main e5f357434899 cached
103 files
193.5 KB
50.7k tokens
197 symbols
1 requests
Download .txt
Showing preview only (216K chars total). Download the full file or copy to clipboard to get everything.
Repository: jazzband/django-cookie-consent
Branch: main
Commit: e5f357434899
Files: 103
Total size: 193.5 KB

Directory structure:
gitextract_18qginr2/

├── .editorconfig
├── .git-blame-ignore-revs
├── .github/
│   ├── actions/
│   │   └── build-js/
│   │       └── action.yml
│   └── workflows/
│       ├── ci.yml
│       └── code_quality.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .readthedocs.yaml
├── AUTHORS
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.rst
├── LICENSE
├── MANIFEST.in
├── README.md
├── __init__.py
├── cookie_consent/
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── cache.py
│   ├── conf.py
│   ├── fixtures/
│   │   └── common_cookies.json
│   ├── forms.py
│   ├── locale/
│   │   ├── en/
│   │   │   └── LC_MESSAGES/
│   │   │       ├── django.mo
│   │   │       └── django.po
│   │   ├── nl/
│   │   │   └── LC_MESSAGES/
│   │   │       ├── django.mo
│   │   │       └── django.po
│   │   ├── oc/
│   │   │   └── LC_MESSAGES/
│   │   │       ├── django.mo
│   │   │       └── django.po
│   │   ├── pt_BR/
│   │   │   └── LC_MESSAGES/
│   │   │       ├── django.mo
│   │   │       └── django.po
│   │   └── sl/
│   │       └── LC_MESSAGES/
│   │           ├── django.mo
│   │           └── django.po
│   ├── management/
│   │   ├── __init__.py
│   │   └── commands/
│   │       ├── __init__.py
│   │       └── prune_cookie_consent_logs.py
│   ├── middleware.py
│   ├── migrations/
│   │   ├── 0001_initial.py
│   │   ├── 0002_auto__add_logitem.py
│   │   ├── 0003_alter_cookiegroup_varname.py
│   │   ├── 0004_cookie_natural_key.py
│   │   └── __init__.py
│   ├── models.py
│   ├── processor.py
│   ├── py.typed
│   ├── templates/
│   │   └── cookie_consent/
│   │       ├── _cookie_group.html
│   │       ├── base.html
│   │       └── cookiegroup_list.html
│   ├── templatetags/
│   │   ├── __init__.py
│   │   └── cookie_consent_tags.py
│   ├── urls.py
│   ├── util.py
│   └── views.py
├── docs/
│   ├── Makefile
│   ├── changelog.rst
│   ├── check_sphinx.py
│   ├── concept.rst
│   ├── conf.py
│   ├── contributing.rst
│   ├── example_app.rst
│   ├── index.rst
│   ├── javascript.rst
│   ├── make.bat
│   ├── migrating-1.0.rst
│   ├── quickstart.rst
│   ├── reference/
│   │   ├── api_middleware.rst
│   │   ├── api_models.rst
│   │   ├── api_templatetags.rst
│   │   ├── api_util.rst
│   │   ├── api_views.rst
│   │   ├── index.rst
│   │   └── management_commands.rst
│   ├── settings.rst
│   └── usage.rst
├── js/
│   ├── .nvmrc
│   ├── README.md
│   ├── package.json
│   ├── src/
│   │   ├── cookiebar.ts
│   │   └── index.ts
│   └── tsconfig.json
├── pyproject.toml
├── testapp/
│   ├── __init__.py
│   ├── fixture.json
│   ├── settings.py
│   ├── static/
│   │   └── styles.css
│   ├── templates/
│   │   ├── show-cookie-bar-script.html
│   │   └── test_page.html
│   ├── urls.py
│   └── views.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── test_admin.py
│   ├── test_cache.py
│   ├── test_cookie_group_model.py
│   ├── test_cookie_model.py
│   ├── test_javascript_cookiebar.py
│   ├── test_middleware.py
│   ├── test_models.py
│   ├── test_prune_cookie_consent_logs.py
│   ├── test_settings.py
│   ├── test_templatetags.py
│   ├── test_util.py
│   └── test_views.py
└── tox.ini

================================================
FILE CONTENTS
================================================

================================================
FILE: .editorconfig
================================================
root = true

[*]
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.{scss,sass}]
indent_size = 2

[*.{yml,yaml}]
indent_size = 2

[*.{js,ts}]
indent_size = 2

[*.json]
indent_size = 2

[Makefile]
indent_style = tab


================================================
FILE: .git-blame-ignore-revs
================================================
# black and isort on the codebase
938a25cb45b672b65673053b3521d04f737e121a


================================================
FILE: .github/actions/build-js/action.yml
================================================
---

name: 'Build JS'
description: 'Compile the TS source code'

inputs:
  npm-package:
    description: Build NPM package
    required: false
    default: 'false'

  django-staticfiles:
    description: Bundle Django staticfiles
    required: false
    default: 'false'

runs:
  using: 'composite'

  steps:

    - uses: actions/setup-node@v4
      with:
        node-version-file: 'js/.nvmrc'
        cache: npm
        cache-dependency-path: js/package-lock.json

    - name: Install dependencies
      run: npm ci
      shell: bash
      working-directory: js

    - name: Build NPM package
      if: ${{ inputs.npm-package == 'true' }}
      run: npm run build
      shell: bash
      working-directory: js

    - name: Build Django assets package
      if: ${{ inputs.django-staticfiles == 'true' }}
      run: npm run build:django-static
      shell: bash
      working-directory: js


================================================
FILE: .github/workflows/ci.yml
================================================
name: Run CI

# Run this workflow every time a new commit pushed to your repository
on:
  push:
    branches:
      - main
    tags:
      - '*'
  pull_request:
  workflow_dispatch:

jobs:
  build-js:
    runs-on: ubuntu-latest
    name: Compile the frontend code

    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/build-js
        with:
          npm-package: 'true'
          django-staticfiles: 'false'

  tests:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python: ['3.10', '3.11', '3.12', '3.13', '3.14']
        django: ['4.2', '5.2', '6.0']
        exclude:
          - python: '3.13'
            django: '4.2'
          - python: '3.14'
            django: '4.2'

    name: Run the test suite (Python ${{ matrix.python }}, Django ${{ matrix.django }})

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python }}

      - name: Install dependencies
        run: pip install tox tox-gh-actions

      - name: Run tests
        run: tox
        env:
          DJANGO: ${{ matrix.django }}

      - name: Publish coverage report
        uses: codecov/codecov-action@v5
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          directory: reports/

  e2e_tests:
    runs-on: ubuntu-latest
    name: Run the end-to-end tests

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - uses: ./.github/actions/build-js
        with:
          npm-package: 'false'
          django-staticfiles: 'true'

      - name: Install dependencies
        run: |
          pip install tox tox-gh-actions pytest-playwright
          playwright install --with-deps chromium

      - name: Run tests
        run: tox -e e2e

      - name: Publish coverage report
        uses: codecov/codecov-action@v3
        with:
          directory: reports/


  publish:
    name: Publish packages to PyPI and NPM
    runs-on: ubuntu-latest
    needs:
      - tests
      - e2e_tests

    if: github.repository == 'django-commons/django-cookie-consent' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags')

    environment:
      name: release
      url: https://pypi.org/project/django-cookie-consent/
    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - uses: actions/setup-node@v4
        with:
          node-version-file: 'js/.nvmrc'
          cache: npm
          cache-dependency-path: js/package-lock.json
          registry-url: 'https://registry.npmjs.org'

      # Required to ensure the build artifacts are in the packages.
      - uses: ./.github/actions/build-js
        with:
          npm-package: 'true'
          django-staticfiles: 'true'

      - name: Build sdist and wheel
        run: |
          pip install build --upgrade
          python -m build

      # TODO: enable verified publishing!
      - name: Publish a Python distribution to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1

      - name: Publish NPM package
        run: |
          version=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
          npm publish --new-version="$version"
        working-directory: js
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}


================================================
FILE: .github/workflows/code_quality.yml
================================================
name: Code quality checks

# Run this workflow every time a new commit pushed to your repository
on:
  push:
    branches:
      - main
    tags:
      - '*'
  pull_request:
  workflow_dispatch:

jobs:
  linting:
    name: Code-quality checks
    runs-on: ubuntu-latest
    strategy:
      matrix:
        toxenv:
          - ruff
          - docs
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Install dependencies
        run: pip install tox
      - run: tox
        env:
          TOXENV: ${{ matrix.toxenv }}
          FORCE_COLOR: '1'


================================================
FILE: .gitignore
================================================
*.log
*.pot
*.pyc
local_settings.py
env/

# build artifacts
docs/_build
build/
dist/
*.egg-info/

# editors
.vscode
.history
.sonarlint

# tests and coverage
.tox/
.pytest_cache
.coverage
htmlcov/
reports/
testapp/*.db
.hypothesis

# frontend tooling / builds
js/node_modules/
js/lib/
cookie_consent/static/cookie_consent/cookiebar.module.*


================================================
FILE: .pre-commit-config.yaml
================================================
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: check-merge-conflict

  - repo: https://github.com/adamchainz/django-upgrade
    rev: 1.29.1
    hooks:
      - id: django-upgrade


================================================
FILE: .readthedocs.yaml
================================================
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details

version: 2

sphinx:
  configuration: docs/conf.py

build:
  os: 'ubuntu-22.04'
  tools:
    python: '3.10'

python:
  install:
    - method: pip
      path: .
      extra_requirements:
        - docs


================================================
FILE: AUTHORS
================================================
The django-cookie-consent was created by Bojan Mihelac (bmihelac).


The following is a list of much appreciated contributors:

* Jonathan L Herr (JonHerr)
* Jasper Koops (Jasper-Koops)
* Fernando Cordeiro (MrCordeiro)
* Mejans
* Sergei Maertens (sergei-maertens)
* Abdullah Alahdal (alahdal)
* some1ataplace
* binoyudayan
* adilhussain540
* Johanan Oppong Amoateng (JohananOppongAmoateng)


================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Code of Conduct

The django-cookie-consent project utilizes the [Django Commons Code of Conduct](https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md).


================================================
FILE: CONTRIBUTING.rst
================================================
By contributing you agree to abide by the
`Contributor Code of Conduct <https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md>`_.

How can you contribute?
=======================

Contributions in all forms are welcome, not only code patches. You can contribute by:

* reporting bugs/issues
* triaging reported issues
* improving the documentation
* suggesting enhancements

Of course, if you report a bug or have a feature request, a pull request implementing
the fix or feature is much appreciated.

Contributing code
-----------------

If you decide to submit a patch or implement a feature, please adhere to the quality
checks:

* Pull requests must be accompanied by tests. We use ``pytest`` and prefer using this
  testing style over Django's ``django.test.TestCase``.
* Ideally, documentation updates are included in a pull request.
* Code formatting and linting is done with ``ruff``. There are tox environments and CI
  checks in place to check/enforce this.
* Follow Django's code style where possible.
* Keep commits atomic - one commit should only concern one topic. Bugfixes typically
  have one commit for the regression test and one commit with the fix.

Setting up the project for local development
--------------------------------------------

After checking out the project (through ``git clone``), it's advised to set up a
virtualenv with the lowest supported python and Django version.

You can then install the project with all the dev-tools:

.. code-block:: bash

   pip install -e .[tests,docs,release]

Some frontend tooling is needed too:

* NodeJS (for the version, see ``.nvmrc``, you can use ``nvm``)

.. code-block:: bash

    cd js
    nvm use
    npm install
    npm run build:django-static
    npm run build  # optional, but a nice check

**Running the testapp as dev environment**

In Django project's, you are typically expecting a ``manage.py`` file. This is less
common in libraries, but it's fairly straightforward to emulate this:

.. code-block:: bash

    export DJANGO_SETTINGS_MODULE=testapp.settings PYTHONPATH=.

    django-admin migrate
    django-admin createsuperuser
    django-admin runserver

You can now start working on your contribution!

**Running the tests**

Run the tests locally using ``tox`` to verify everything is okay:

.. code-block:: bash

   tox

Cheat sheet
-----------

This cheat sheet provides some quick tooling/commands lookup while working on the
project.

**Running tests**

In your current environment

.. code-block:: bash

   pytest

or to build the full test matrix

.. code-block:: bash

   tox

**Formatting the code for check-in**

.. code-block:: bash

   ruff format .
   ruff check --fix .

Should be sufficient. Consider using a pre-commit hook to automate this.

**Building the docs**

.. code-block:: bash

   cd docs
   make html

You can now open the file ``_build/html/index.html`` in your browser.

**Generating message catalogs**

.. code-block:: bash

    export DJANGO_SETTINGS_MODULE=testapp.settings
    cd cookie_consent
    django-admin makemessages --all
    cd ..

After translating the message, you need to compile the message catalogs:

.. code-block:: bash

    django-admin compilemessages

**Bumping the version/releasing**

After updating changelogs etc.

.. code-block:: bash

    bump-my-version bump major|minor|patch
    bump-my-version bump pre_l
    git commit -am ":bookmark: Bump to version <X.Y.Z>"
    git tag -s X.Y.Z
    git push origin main --tags


================================================
FILE: LICENSE
================================================
Copyright (c) Bojan Mihelac and individual contributors.
All rights reserved.

Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

    1. Redistributions of source code must retain the above copyright notice, 
       this list of conditions and the following disclaimer.
    
    2. Redistributions in binary form must reproduce the above copyright 
       notice, this list of conditions and the following disclaimer in the
       documentation and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


================================================
FILE: MANIFEST.in
================================================
include LICENSE
include AUTHORS
include README.md
include cookie_consent/py.typed
recursive-include cookie_consent/templates *
recursive-include cookie_consent/static *
recursive-include cookie_consent/fixtures *


================================================
FILE: README.md
================================================
Django cookie consent
=====================

Manage cookie information and let visitors give or reject consent for them.

![License](https://img.shields.io/pypi/l/django-cookie-consent)
[![Build status][badge:GithubActions:CI]][GithubActions:CI]
[![Code Quality][badge:GithubActions:CQ]][GithubActions:CQ]
[![Code style: ruff][badge:ruff]][ruff]
[![Test coverage][badge:codecov]][codecov]
[![Documentation][badge:docs]][docs]

![Supported python versions](https://img.shields.io/pypi/pyversions/django-cookie-consent)
![Supported Django versions](https://img.shields.io/pypi/djversions/django-cookie-consent)
[![PyPI version][badge:pypi]][pypi]
[![NPM version][badge:npm]][npm]

**Features**

* cookies and cookie groups are stored in models for easy management
  through Django admin interface
* support for both opt-in and opt-out cookie consent schemes
* removing declined cookies (or non accepted when opt-in scheme is used)
* logging user actions when they accept and decline various cookies
* easy adding new cookies and seamlessly re-asking for consent for new cookies

Documentation
-------------

The documentation is hosted on [readthedocs][docs] and contains all instructions
to get started.

Alternatively, if the documentation is not available, you can consult or build the docs
from the `docs` directory in this repository.

[GithubActions:CI]: https://github.com/django-commons/django-cookie-consent/actions?query=workflow%3A%22Run+CI%22
[badge:GithubActions:CI]: https://github.com/django-commons/django-cookie-consent/workflows/Run%20CI/badge.svg
[GithubActions:CQ]: https://github.com/django-commons/django-cookie-consent/actions?query=workflow%3A%22Code+quality+checks%22
[badge:GithubActions:CQ]: https://github.com/django-commons/django-cookie-consent/workflows/Code%20quality%20checks/badge.svg
[ruff]: https://github.com/astral-sh/ruff
[badge:ruff]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
[codecov]: https://codecov.io/gh/django-commons/django-cookie-consent
[badge:codecov]: https://codecov.io/gh/django-commons/django-cookie-consent/branch/main/graph/badge.svg
[docs]: https://django-cookie-consent.readthedocs.io/en/latest/?badge=latest
[badge:docs]: https://readthedocs.org/projects/django-cookie-consent/badge/?version=latest
[pypi]: https://pypi.org/project/django-cookie-consent/
[badge:pypi]: https://img.shields.io/pypi/v/django-cookie-consent.svg
[npm]: https://www.npmjs.com/package/django-cookie-consent
[badge:npm]: https://img.shields.io/npm/v/django-cookie-consent


================================================
FILE: __init__.py
================================================


================================================
FILE: cookie_consent/__init__.py
================================================
__version__ = "1.0.0"


================================================
FILE: cookie_consent/admin.py
================================================
from django.contrib import admin
from django.db.models import Count
from django.http.request import HttpRequest
from django.templatetags.l10n import localize
from django.templatetags.static import static
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _

from .conf import settings
from .models import Cookie, CookieGroup, LogItem


@admin.register(Cookie)
class CookieAdmin(admin.ModelAdmin):
    list_display = ("varname", "name", "cookiegroup", "path", "domain", "get_version")
    search_fields = ("name", "domain", "cookiegroup__varname", "cookiegroup__name")
    readonly_fields = ("varname",)
    list_filter = ("cookiegroup",)


@admin.register(CookieGroup)
class CookieGroupAdmin(admin.ModelAdmin):
    list_display = (
        "varname",
        "name",
        "is_required",
        "is_deletable",
        "num_cookies",
        "get_version",
    )
    search_fields = (
        "varname",
        "name",
    )
    list_filter = (
        "is_required",
        "is_deletable",
    )

    def get_queryset(self, request: HttpRequest):
        qs = super().get_queryset(request)
        return qs.annotate(num_cookies=Count("cookie"))

    @admin.display(ordering="num_cookies", description=_("# cookies"))
    def num_cookies(self, obj: CookieGroup):
        if (count := obj.num_cookies) > 0:
            return localize(count)

        return format_html(
            '{count} <img src="{src}" alt="{alt}">',
            count=localize(count),
            src=static("admin/img/icon-alert.svg"),
            alt=_("Warning icon for missing cookies in cookie group."),
        )


class LogItemAdmin(admin.ModelAdmin):
    list_display = ("action", "cookiegroup", "version", "created")
    list_filter = ("action", "cookiegroup")
    readonly_fields = ("action", "cookiegroup", "version", "created")
    date_hierarchy = "created"


if settings.COOKIE_CONSENT_LOG_ENABLED:
    admin.site.register(LogItem, LogItemAdmin)


================================================
FILE: cookie_consent/apps.py
================================================
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _


class CookieConsentConf(AppConfig):
    name = "cookie_consent"
    verbose_name = _("cookie consent")
    default_auto_field = "django.db.models.AutoField"


================================================
FILE: cookie_consent/cache.py
================================================
from collections.abc import Mapping

from django.core.cache import caches

from .conf import settings
from .models import Cookie, CookieGroup

CACHE_KEY = "cookie_consent_cache"
CACHE_TIMEOUT = 60 * 60  # 60 minutes


def _get_cache():
    """
    Lazily wrap around django.core.cache.

    This prevents the cache object to be resolved at import-time, which breaks the
    `django.test.override_settings` functionality for projects adding tests for this
    package, see https://github.com/bmihelac/django-cookie-consent/issues/41.
    """
    return caches[settings.COOKIE_CONSENT_CACHE_BACKEND]


def delete_cache() -> None:
    cache = _get_cache()
    cache.delete(CACHE_KEY)


def _get_cookie_groups_from_db() -> Mapping[str, CookieGroup]:
    qs = CookieGroup.objects.filter(is_required=False).prefetch_related("cookie_set")
    return qs.in_bulk(field_name="varname")


def all_cookie_groups() -> Mapping[str, CookieGroup]:
    """
    Get all cookie groups that are optional.

    Reads from the cache where possible, sets the value in the cache if there's a
    cache miss.
    """
    cache = _get_cache()
    result = cache.get_or_set(
        CACHE_KEY, _get_cookie_groups_from_db, timeout=CACHE_TIMEOUT
    )
    assert result is not None
    return result


def get_cookie_group(varname: str) -> CookieGroup | None:
    return all_cookie_groups().get(varname)


def get_cookie(cookie_group: CookieGroup, name: str, domain: str) -> Cookie | None:
    # loop over cookie set relation instead of doing a lookup query, as this should
    # come from the cache and avoid hitting the database
    for cookie in cookie_group.cookie_set.all():
        if cookie.name == name and cookie.domain == domain:
            return cookie
    return None


================================================
FILE: cookie_consent/conf.py
================================================
from typing import Literal

from django.conf import settings
from django.urls import reverse_lazy
from django.utils.functional import Promise

from appconf import AppConf

__all__ = ["settings"]


class CookieConsentConf(AppConf):
    # django-cookie-consent cookie settings that store the configuration
    NAME: str = "cookie_consent"
    # TODO: rename to AGE for parity with django settings
    MAX_AGE: int = 60 * 60 * 24 * 365 * 1  # 1 year,
    DOMAIN: str | None = None
    SECURE: bool = False
    HTTPONLY: bool = True
    SAMESITE: Literal["Strict", "Lax", "None", False] = "Lax"

    DECLINE: str = "-1"

    ENABLED: bool = True

    OPT_OUT: bool = False

    CACHE_BACKEND: str = "default"

    LOG_ENABLED: bool = True
    """
    DeprecationWarning: in future versions the default may switch to log disabled.
    """

    SUCCESS_URL: str | Promise = reverse_lazy("cookie_consent_cookie_group_list")


================================================
FILE: cookie_consent/fixtures/common_cookies.json
================================================
[{"pk": 1, "model": "cookie_consent.cookiegroup", "fields": {"is_deletable": false, "name": "Required cookies", "created": "2013-06-03T12:54:15.809", "ordering": 0, "varname": "required", "is_required": true, "description": "This cookies are required for website."}}, {"pk": 2, "model": "cookie_consent.cookiegroup", "fields": {"is_deletable": true, "name": "Google Analytics", "created": "2013-06-03T12:58:11.616", "ordering": 0, "varname": "analytics", "is_required": false, "description": "These cookies are used to collect information about how visitors use our site. We use the information to compile reports and to help us improve the site. The cookies collect information in an anonymous form, including the number of visitors to the site, where vistors have come to the site from and the pages they visited."}}, {"pk": 3, "model": "cookie_consent.cookiegroup", "fields": {"is_deletable": true, "name": "Social media", "created": "2013-06-04T03:16:45.089", "ordering": 0, "varname": "social", "is_required": false, "description": "Facebook, Twitter and other social websites need to know who you are to work properly."}}, {"pk": 1, "model": "cookie_consent.cookie", "fields": {"domain": "", "name": "_utma", "created": "2013-06-03T12:59:03.467", "cookiegroup": 2, "path": "/", "description": null}}, {"pk": 2, "model": "cookie_consent.cookie", "fields": {"domain": "", "name": "_utmb", "created": "2013-06-03T12:59:36.710", "cookiegroup": 2, "path": "/", "description": null}}, {"pk": 3, "model": "cookie_consent.cookie", "fields": {"domain": "", "name": "_utmc", "created": "2013-06-03T12:59:45.074", "cookiegroup": 2, "path": "/", "description": null}}, {"pk": 4, "model": "cookie_consent.cookie", "fields": {"domain": "", "name": "_utmz", "created": "2013-06-03T12:59:55.465", "cookiegroup": 2, "path": "/", "description": null}}, {"pk": 5, "model": "cookie_consent.cookie", "fields": {"domain": "", "name": "sessionid", "created": "2013-06-03T13:00:15.592", "cookiegroup": 1, "path": "/", "description": null}}, {"pk": 6, "model": "cookie_consent.cookie", "fields": {"domain": "", "name": "csrftoken", "created": "2013-06-03T13:00:49.878", "cookiegroup": 1, "path": "/", "description": null}}, {"pk": 7, "model": "cookie_consent.cookie", "fields": {"domain": ".facebook.com", "name": "*", "created": "2013-06-04T03:17:01.421", "cookiegroup": 3, "path": "/", "description": "This cookies are used by Facebook to allow sharing content. "}}]

================================================
FILE: cookie_consent/forms.py
================================================
from collections.abc import Collection, Iterator

from django import forms
from django.utils.translation import gettext_lazy as _

from .cache import all_cookie_groups
from .models import CookieGroup


def iter_cookie_group_choices() -> Iterator[tuple[str, str]]:
    """
    Use the cached cookie group instances to get a list of choices.
    """
    for varname, cookie_group in all_cookie_groups().items():
        yield varname, cookie_group.name


class CookieGroupsChoiceField(forms.TypedMultipleChoiceField):
    def __init__(self, **kwargs):
        kwargs["coerce"] = self._coerce_choice
        kwargs["choices"] = iter_cookie_group_choices
        super().__init__(**kwargs)

    def _coerce_choice(self, varname: str) -> CookieGroup:
        all_groups = all_cookie_groups()
        return all_groups[varname]


class ProcessCookiesForm(forms.Form):
    all_groups = forms.BooleanField(
        label=_("Apply to all cookie groups"),
        required=False,
    )
    cookie_groups = CookieGroupsChoiceField(
        label=_("Cookie group varnames"),
        choices=iter_cookie_group_choices,
        required=False,
    )

    def get_cookie_groups(self) -> Collection[CookieGroup]:
        """
        Build the collection of specified cookies.
        """
        match self.cleaned_data:
            case {"all_groups": True}:
                return all_cookie_groups().values()
            case {"cookie_groups": [*groups]}:
                return groups
            case _:
                return []


================================================
FILE: cookie_consent/locale/en/LC_MESSAGES/django.po
================================================
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2013-06-09 10:12+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"

#: models.py:16
msgid ""
"Enter a valid 'varname' consisting of letters, numbers, underscores or "
"hyphens."
msgstr ""

#: models.py:23
msgid "Variable name"
msgstr ""

#: models.py:26 models.py:66
msgid "Name"
msgstr ""

#: models.py:27 models.py:67
msgid "Description"
msgstr ""

#: models.py:29
msgid "Is required"
msgstr ""

#: models.py:30
msgid "Are cookies in this group required."
msgstr ""

#: models.py:33
msgid "Is deletable?"
msgstr ""

#: models.py:34
msgid "Can cookies in this group be deleted."
msgstr ""

#: models.py:36
msgid "Ordering"
msgstr ""

#: models.py:37 models.py:70 models.py:106
msgid "Created"
msgstr ""

#: models.py:40
msgid "Cookie Group"
msgstr ""

#: models.py:41
msgid "Cookie Groups"
msgstr ""

#: models.py:68
msgid "Path"
msgstr ""

#: models.py:69
msgid "Domain"
msgstr ""

#: models.py:73
msgid "Cookie"
msgstr ""

#: models.py:74
msgid "Cookies"
msgstr ""

#: models.py:95 templates/cookie_consent/_cookie_group.html:22
msgid "Declined"
msgstr ""

#: models.py:96 templates/cookie_consent/_cookie_group.html:13
msgid "Accepted"
msgstr ""

#: models.py:101
msgid "Action"
msgstr ""

#: models.py:105
msgid "Version"
msgstr ""

#: models.py:109
msgid "Log item"
msgstr ""

#: models.py:110
msgid "Log items"
msgstr ""

#: templates/cookie_consent/_cookie_group.html:17
msgid "Accept"
msgstr ""

#: templates/cookie_consent/_cookie_group.html:26
msgid "Decline"
msgstr ""


================================================
FILE: cookie_consent/locale/nl/LC_MESSAGES/django.po
================================================
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: 0.4.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-09-04 15:38-0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Sergei Maertens <sergei@maykinmedia.nl>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: cookie_consent/models.py:14
msgid ""
"Enter a valid 'varname' consisting of letters, numbers, underscores or "
"hyphens."
msgstr ""
"Geef een geldige 'variabelenaam' op die bestaat uit letters, getallen, liggende "
"streepjes of koppeltekens."

#: cookie_consent/models.py:23
msgid "Variable name"
msgstr "Variabelenaam"

#: cookie_consent/models.py:25 cookie_consent/models.py:69
msgid "Name"
msgstr "Naam"

#: cookie_consent/models.py:26 cookie_consent/models.py:70
msgid "Description"
msgstr "Omschrijving"

#: cookie_consent/models.py:28
msgid "Is required"
msgstr "Is verplicht"

#: cookie_consent/models.py:29
msgid "Are cookies in this group required."
msgstr "Of cookies in deze groep verplicht zijn."

#: cookie_consent/models.py:33
msgid "Is deletable?"
msgstr "Kan worden verwijderd?"

#: cookie_consent/models.py:34
msgid "Can cookies in this group be deleted."
msgstr "Of cookies in deze groep verwijderd kunnen worden."

#: cookie_consent/models.py:37
msgid "Ordering"
msgstr "Volgorde"

#: cookie_consent/models.py:38 cookie_consent/models.py:73
#: cookie_consent/models.py:115
msgid "Created"
msgstr "Aangemaakt"

#: cookie_consent/models.py:41
msgid "Cookie Group"
msgstr "Cookiegroep"

#: cookie_consent/models.py:42
msgid "Cookie Groups"
msgstr "Cookiegroepen"

#: cookie_consent/models.py:71
msgid "Path"
msgstr "Pad"

#: cookie_consent/models.py:72
msgid "Domain"
msgstr "Domein"

#: cookie_consent/models.py:76
msgid "Cookie"
msgstr "Cookie"

#: cookie_consent/models.py:77
msgid "Cookies"
msgstr "Cookies"

#: cookie_consent/models.py:102
#: cookie_consent/templates/cookie_consent/_cookie_group.html:21
msgid "Declined"
msgstr "Afgewezen"

#: cookie_consent/models.py:103
#: cookie_consent/templates/cookie_consent/_cookie_group.html:12
msgid "Accepted"
msgstr "Aanvaard"

#: cookie_consent/models.py:108
msgid "Action"
msgstr "Actie"

#: cookie_consent/models.py:114
msgid "Version"
msgstr "Versie"

#: cookie_consent/models.py:121
msgid "Log item"
msgstr "Logitem"

#: cookie_consent/models.py:122
msgid "Log items"
msgstr "Logitems"

#: cookie_consent/templates/cookie_consent/_cookie_group.html:16
msgid "Accept"
msgstr "Aanvaarden"

#: cookie_consent/templates/cookie_consent/_cookie_group.html:25
msgid "Decline"
msgstr "Weigeren"


================================================
FILE: cookie_consent/locale/oc/LC_MESSAGES/django.po
================================================
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2013-06-09 10:12+0200\n"
"PO-Revision-Date: 2021-07-22 21:55+0200\n"
"Language: oc\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Last-Translator: Quentin PAGÈS\n"
"Language-Team: \n"
"X-Generator: Poedit 3.0\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"

#: models.py:16
msgid ""
"Enter a valid 'varname' consisting of letters, numbers, underscores or "
"hyphens."
msgstr ""
"Picatz un « varname » valid compausat de letras, nombres, jonhents "
"basses e jonhents."

#: models.py:23
msgid "Variable name"
msgstr "Nom de la variabla"

#: models.py:26 models.py:66
msgid "Name"
msgstr "Nom"

#: models.py:27 models.py:67
msgid "Description"
msgstr "Descripcion"

#: models.py:29
msgid "Is required"
msgstr "Es requesit"

#: models.py:30
msgid "Are cookies in this group required."
msgstr "Se los cookies d’aqueste grop son requesits."

#: models.py:33
msgid "Is deletable?"
msgstr "Se pòt suprimir ?"

#: models.py:34
msgid "Can cookies in this group be deleted."
msgstr "Se los cookies d’aqueste grop se pòdon suprimir."

#: models.py:36
msgid "Ordering"
msgstr "Òrdre"

#: models.py:37 models.py:70 models.py:106
msgid "Created"
msgstr "Creat lo"

#: models.py:40
msgid "Cookie Group"
msgstr "Grop de cookies"

#: models.py:41
msgid "Cookie Groups"
msgstr "Grops de cookies"

#: models.py:68
msgid "Path"
msgstr "Camin d'accès"

#: models.py:69
msgid "Domain"
msgstr "Domeni"

#: models.py:73
msgid "Cookie"
msgstr "Cookie"

#: models.py:74
msgid "Cookies"
msgstr "Cookies"

#: models.py:95 templates/cookie_consent/_cookie_group.html:22
msgid "Declined"
msgstr "Refusat"

#: models.py:96 templates/cookie_consent/_cookie_group.html:13
msgid "Accepted"
msgstr "Acceptat"

#: models.py:101
msgid "Action"
msgstr "Accion"

#: models.py:105
msgid "Version"
msgstr "Version"

#: models.py:109
msgid "Log item"
msgstr "Auditar element"

#: models.py:110
msgid "Log items"
msgstr "Auditar element"

#: templates/cookie_consent/_cookie_group.html:17
msgid "Accept"
msgstr "Acceptar"

#: templates/cookie_consent/_cookie_group.html:26
msgid "Decline"
msgstr "Refusar"


================================================
FILE: cookie_consent/locale/pt_BR/LC_MESSAGES/django.po
================================================
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-06-16 08:40+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: .\django-cookie-consent\cookie_consent\models.py:16
msgid ""
"Enter a valid 'varname' consisting of letters, numbers, underscores or "
"hyphens."
msgstr ""
"Insira um 'varname' válido, composto por letras, números, underscores ou "
"hífens."

#: .\django-cookie-consent\cookie_consent\models.py:23
msgid "Variable name"
msgstr "Nome da variável"

#: .\django-cookie-consent\cookie_consent\models.py:26
#: .\django-cookie-consent\cookie_consent\models.py:67
msgid "Name"
msgstr "Nome"

#: .\django-cookie-consent\cookie_consent\models.py:27
#: .\django-cookie-consent\cookie_consent\models.py:68
msgid "Description"
msgstr "Descrição"

#: .\django-cookie-consent\cookie_consent\models.py:29
msgid "Is required"
msgstr ""

#: .\django-cookie-consent\cookie_consent\models.py:30
msgid "Are cookies in this group required."
msgstr "Se os cookies deste grupo são necessários."

#: .\django-cookie-consent\cookie_consent\models.py:33
msgid "Is deletable?"
msgstr ""

#: .\django-cookie-consent\cookie_consent\models.py:34
msgid "Can cookies in this group be deleted."
msgstr "Se os cookies deste grupo podem serem apagados."

#: .\django-cookie-consent\cookie_consent\models.py:36
msgid "Ordering"
msgstr "Ordem"

#: .\django-cookie-consent\cookie_consent\models.py:37
#: .\django-cookie-consent\cookie_consent\models.py:71
#: .\django-cookie-consent\cookie_consent\models.py:112
msgid "Created"
msgstr "Criado em"

#: .\django-cookie-consent\cookie_consent\models.py:40
msgid "Cookie Group"
msgstr "Grupo de Cookies"

#: .\django-cookie-consent\cookie_consent\models.py:41
msgid "Cookie Groups"
msgstr "Grupos de Cookies"

#: .\django-cookie-consent\cookie_consent\models.py:69
msgid "Path"
msgstr ""

#: .\django-cookie-consent\cookie_consent\models.py:70
msgid "Domain"
msgstr "Domínio"

#: .\django-cookie-consent\cookie_consent\models.py:74
msgid "Cookie"
msgstr ""

#: .\django-cookie-consent\cookie_consent\models.py:75
msgid "Cookies"
msgstr ""

#: .\django-cookie-consent\cookie_consent\models.py:100
#: .\django-cookie-consent\cookie_consent\templates\cookie_consent\_cookie_group.html:21
msgid "Declined"
msgstr "Rejeitado"

#: .\django-cookie-consent\cookie_consent\models.py:101
#: .\django-cookie-consent\cookie_consent\templates\cookie_consent\_cookie_group.html:12
msgid "Accepted"
msgstr "Aceito"

#: .\django-cookie-consent\cookie_consent\models.py:106
msgid "Action"
msgstr "Ação"

#: .\django-cookie-consent\cookie_consent\models.py:111
msgid "Version"
msgstr "Versão"

#: .\django-cookie-consent\cookie_consent\models.py:117
msgid "Log item"
msgstr ""

#: .\django-cookie-consent\cookie_consent\models.py:118
msgid "Log items"
msgstr ""

#: .\django-cookie-consent\cookie_consent\templates\cookie_consent\_cookie_group.html:16
msgid "Accept"
msgstr "Aceitar"

#: .\django-cookie-consent\cookie_consent\templates\cookie_consent\_cookie_group.html:25
msgid "Decline"
msgstr "Recusar"


================================================
FILE: cookie_consent/locale/sl/LC_MESSAGES/django.po
================================================
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2013-06-09 10:26+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n"
"%100==4 ? 2 : 3);\n"

#: models.py:16
msgid ""
"Enter a valid 'varname' consisting of letters, numbers, underscores or "
"hyphens."
msgstr ""

#: models.py:23
msgid "Variable name"
msgstr ""

#: models.py:26 models.py:66
msgid "Name"
msgstr ""

#: models.py:27 models.py:67
msgid "Description"
msgstr ""

#: models.py:29
msgid "Is required"
msgstr ""

#: models.py:30
msgid "Are cookies in this group required."
msgstr ""

#: models.py:33
msgid "Is deletable?"
msgstr ""

#: models.py:34
msgid "Can cookies in this group be deleted."
msgstr ""

#: models.py:36
msgid "Ordering"
msgstr ""

#: models.py:37 models.py:70 models.py:106
msgid "Created"
msgstr ""

#: models.py:40
msgid "Cookie Group"
msgstr ""

#: models.py:41
msgid "Cookie Groups"
msgstr ""

#: models.py:68
msgid "Path"
msgstr ""

#: models.py:69
msgid "Domain"
msgstr ""

#: models.py:73
msgid "Cookie"
msgstr "Piškot"

#: models.py:74
msgid "Cookies"
msgstr "Piškotki"

#: models.py:95 templates/cookie_consent/_cookie_group.html:22
msgid "Declined"
msgstr "Zavrnjeno"

#: models.py:96 templates/cookie_consent/_cookie_group.html:13
msgid "Accepted"
msgstr "Sprejeto"

#: models.py:101
msgid "Action"
msgstr "Ukrep"

#: models.py:105
msgid "Version"
msgstr "Različica"

#: models.py:109
msgid "Log item"
msgstr ""

#: models.py:110
msgid "Log items"
msgstr ""

#: templates/cookie_consent/_cookie_group.html:17
msgid "Accept"
msgstr "Strinjam se"

#: templates/cookie_consent/_cookie_group.html:26
msgid "Decline"
msgstr "Ne strinjam se"


================================================
FILE: cookie_consent/management/__init__.py
================================================


================================================
FILE: cookie_consent/management/commands/__init__.py
================================================


================================================
FILE: cookie_consent/management/commands/prune_cookie_consent_logs.py
================================================
from datetime import timedelta

from django.core.management.base import BaseCommand
from django.utils import timezone

from ...models import LogItem


class Command(BaseCommand):
    help = "Prune old LogItem records older than a given number of days."

    def add_arguments(self, parser):
        parser.add_argument(
            "--days",
            type=int,
            default=90,
            help="Delete log items older than this many days (default: 90).",
        )

    def handle(self, *args, **options):
        days = options["days"]
        cutoff = timezone.now() - timedelta(days=days)
        deleted, _ = LogItem.objects.filter(created__lt=cutoff).delete()
        self.stdout.write(
            self.style.SUCCESS(f"Deleted {deleted} log item(s) older than {days} days.")
        )


================================================
FILE: cookie_consent/middleware.py
================================================
from collections.abc import Callable

from django.http import HttpRequest, HttpResponseBase

from .cache import all_cookie_groups
from .conf import settings
from .util import get_cookie_dict_from_request, is_cookie_consent_enabled


def _should_delete_cookie(group_version: str | None) -> bool:
    # declined after it was accepted (and set) before
    if group_version == settings.COOKIE_CONSENT_DECLINE:
        return True

    # if you need to opt-out instead of opt-in, then we only delete the cookie in the
    # above scenario -> when the group is explicitly declined
    if settings.COOKIE_CONSENT_OPT_OUT:
        return False

    # when we are opt-in and have no information whether the cookie group was accepted
    # or declined, delete the cookie(s).
    if group_version is None:
        return True

    return False


class CleanCookiesMiddleware:
    """
    Clean declined or non-accepted cookies.

    Note that this only applies if COOKIE_CONSENT_OPT_OUT is not set.
    """

    def __init__(self, get_response: Callable[[HttpRequest], HttpResponseBase]):
        self.get_response = get_response

    def __call__(self, request: HttpRequest):
        response = self.get_response(request)
        if is_cookie_consent_enabled(request):
            self.process_response(request, response)
        return response

    def process_response(self, request: HttpRequest, response: HttpResponseBase):
        cookie_dic = get_cookie_dict_from_request(request)

        cookies_to_delete = []
        for cookie_group in all_cookie_groups().values():
            if not cookie_group.is_deletable:
                continue

            group_version = cookie_dic.get(cookie_group.varname, None)
            for cookie in cookie_group.cookie_set.all():
                if cookie.name not in request.COOKIES:
                    continue
                if _should_delete_cookie(group_version):
                    cookies_to_delete.append(cookie)

        for cookie in cookies_to_delete:
            response.delete_cookie(cookie.name, cookie.path, cookie.domain)

        return response


================================================
FILE: cookie_consent/migrations/0001_initial.py
================================================
# Generated by Django 2.1 on 2019-02-08 14:14

import re

import django.core.validators
import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
    initial = True

    dependencies = []

    operations = [
        migrations.CreateModel(
            name="Cookie",
            fields=[
                (
                    "id",
                    models.AutoField(
                        auto_created=True,
                        primary_key=True,
                        serialize=False,
                        verbose_name="ID",
                    ),
                ),
                ("name", models.CharField(max_length=250, verbose_name="Name")),
                (
                    "description",
                    models.TextField(blank=True, verbose_name="Description"),
                ),
                (
                    "path",
                    models.TextField(blank=True, default="/", verbose_name="Path"),
                ),
                (
                    "domain",
                    models.CharField(blank=True, max_length=250, verbose_name="Domain"),
                ),
                (
                    "created",
                    models.DateTimeField(auto_now_add=True, verbose_name="Created"),
                ),
            ],
            options={
                "verbose_name": "Cookie",
                "verbose_name_plural": "Cookies",
                "ordering": ["-created"],
            },
        ),
        migrations.CreateModel(
            name="CookieGroup",
            fields=[
                (
                    "id",
                    models.AutoField(
                        auto_created=True,
                        primary_key=True,
                        serialize=False,
                        verbose_name="ID",
                    ),
                ),
                (
                    "varname",
                    models.CharField(
                        max_length=32,
                        validators=[
                            django.core.validators.RegexValidator(
                                re.compile("^[-_a-zA-Z0-9]+$"),
                                "Enter a valid 'varname' consisting of letters, "
                                "numbers, underscores or hyphens.",
                                "invalid",
                            )
                        ],
                        verbose_name="Variable name",
                    ),
                ),
                (
                    "name",
                    models.CharField(blank=True, max_length=100, verbose_name="Name"),
                ),
                (
                    "description",
                    models.TextField(blank=True, verbose_name="Description"),
                ),
                (
                    "is_required",
                    models.BooleanField(
                        default=False,
                        help_text="Are cookies in this group required.",
                        verbose_name="Is required",
                    ),
                ),
                (
                    "is_deletable",
                    models.BooleanField(
                        default=True,
                        help_text="Can cookies in this group be deleted.",
                        verbose_name="Is deletable?",
                    ),
                ),
                ("ordering", models.IntegerField(default=0, verbose_name="Ordering")),
                (
                    "created",
                    models.DateTimeField(auto_now_add=True, verbose_name="Created"),
                ),
            ],
            options={
                "verbose_name": "Cookie Group",
                "verbose_name_plural": "Cookie Groups",
                "ordering": ["ordering"],
            },
        ),
        migrations.AddField(
            model_name="cookie",
            name="cookiegroup",
            field=models.ForeignKey(
                on_delete=django.db.models.deletion.CASCADE,
                to="cookie_consent.CookieGroup",
                verbose_name="Cookie Group",
            ),
        ),
    ]


================================================
FILE: cookie_consent/migrations/0002_auto__add_logitem.py
================================================
# Generated by Django 2.1 on 2019-02-08 14:16

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
    dependencies = [
        ("cookie_consent", "0001_initial"),
    ]

    operations = [
        migrations.CreateModel(
            name="LogItem",
            fields=[
                (
                    "id",
                    models.AutoField(
                        auto_created=True,
                        primary_key=True,
                        serialize=False,
                        verbose_name="ID",
                    ),
                ),
                (
                    "action",
                    models.IntegerField(
                        choices=[(-1, "Declined"), (1, "Accepted")],
                        verbose_name="Action",
                    ),
                ),
                ("version", models.CharField(max_length=32, verbose_name="Version")),
                (
                    "created",
                    models.DateTimeField(auto_now_add=True, verbose_name="Created"),
                ),
                (
                    "cookiegroup",
                    models.ForeignKey(
                        on_delete=django.db.models.deletion.CASCADE,
                        to="cookie_consent.CookieGroup",
                        verbose_name="Cookie Group",
                    ),
                ),
            ],
            options={
                "verbose_name": "Log item",
                "verbose_name_plural": "Log items",
                "ordering": ["-created"],
            },
        ),
    ]


================================================
FILE: cookie_consent/migrations/0003_alter_cookiegroup_varname.py
================================================
# Generated by Django 4.2.13 on 2024-05-09 19:01

import re

import django.core.validators
from django.db import migrations, models


class Migration(migrations.Migration):
    dependencies = [
        ("cookie_consent", "0002_auto__add_logitem"),
    ]

    operations = [
        migrations.AlterField(
            model_name="cookiegroup",
            name="varname",
            field=models.CharField(
                max_length=32,
                unique=True,
                validators=[
                    django.core.validators.RegexValidator(
                        re.compile("^[-_a-zA-Z0-9]+$"),
                        "Enter a valid 'varname' consisting of letters, numbers, "
                        "underscores or hyphens.",
                        "invalid",
                    )
                ],
                verbose_name="Variable name",
            ),
        ),
    ]


================================================
FILE: cookie_consent/migrations/0004_cookie_natural_key.py
================================================
# Generated by Django 4.2.13 on 2024-05-09 20:22

from django.db import migrations, models


class Migration(migrations.Migration):
    dependencies = [
        ("cookie_consent", "0003_alter_cookiegroup_varname"),
    ]

    operations = [
        migrations.AddConstraint(
            model_name="cookie",
            constraint=models.UniqueConstraint(
                fields=("cookiegroup", "name", "domain"), name="natural_key"
            ),
        ),
    ]


================================================
FILE: cookie_consent/migrations/__init__.py
================================================


================================================
FILE: cookie_consent/models.py
================================================
from __future__ import annotations

import re
from collections.abc import Callable
from typing import ClassVar, ParamSpec, TypedDict, TypeVar

from django.core.validators import RegexValidator
from django.db import models
from django.utils.translation import gettext_lazy as _

COOKIE_NAME_RE = re.compile(r"^[-_a-zA-Z0-9]+$")
validate_cookie_name = RegexValidator(
    COOKIE_NAME_RE,
    _(
        "Enter a valid 'varname' consisting of letters, numbers"
        ", underscores or hyphens."
    ),
    "invalid",
)

P = ParamSpec("P")
T = TypeVar("T")


def clear_cache_after(func: Callable[P, T]) -> Callable[P, T]:
    def wrapper(*args: P.args, **kwargs: P.kwargs):
        from .cache import delete_cache

        return_value = func(*args, **kwargs)
        delete_cache()
        return return_value

    return wrapper


class CookieGroupDict(TypedDict):
    varname: str
    name: str
    description: str
    is_required: bool
    # The version is deliberately not included because it requires page/view cache
    # busting if a new cookie gets added to the group, which we don't control.


class BaseQueryset(models.query.QuerySet):
    @clear_cache_after
    def delete(self):
        return super().delete()

    @clear_cache_after
    def update(self, **kwargs):
        return super().update(**kwargs)


class CookieGroupManager(models.Manager.from_queryset(BaseQueryset)):
    def get_by_natural_key(self, varname: str) -> CookieGroup:
        return self.get(varname=varname)


class CookieGroup(models.Model):
    varname = models.CharField(
        _("Variable name"),
        max_length=32,
        unique=True,
        validators=[validate_cookie_name],
    )
    name = models.CharField(_("Name"), max_length=100, blank=True)
    description = models.TextField(_("Description"), blank=True)
    is_required = models.BooleanField(
        _("Is required"),
        help_text=_("Are cookies in this group required."),
        default=False,
    )
    is_deletable = models.BooleanField(
        _("Is deletable?"),
        help_text=_("Can cookies in this group be deleted."),
        default=True,
    )
    ordering = models.IntegerField(_("Ordering"), default=0)
    created = models.DateTimeField(_("Created"), auto_now_add=True, blank=True)

    objects: ClassVar[CookieGroupManager] = CookieGroupManager()  # pyright: ignore[reportIncompatibleVariableOverride]
    cookie_set: ClassVar[CookieManager]

    class Meta:
        verbose_name = _("Cookie Group")
        verbose_name_plural = _("Cookie Groups")
        ordering = ["ordering"]

    def __str__(self):
        return self.name

    @clear_cache_after
    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)

    @clear_cache_after
    def delete(self, *args, **kwargs):
        return super().delete(*args, **kwargs)

    def natural_key(self) -> tuple[str]:
        return (self.varname,)

    def get_version(self) -> str:
        try:
            # this relies on the cookie set being ordered by most-recently created
            # first.
            # Note that we don't use `.first()` as that's a new query and bypasses
            # the cache.
            return str(self.cookie_set.all()[0].get_version())
        except IndexError:
            return ""

    def for_json(self) -> CookieGroupDict:
        return {
            "varname": self.varname,
            "name": self.name,
            "description": self.description,
            "is_required": self.is_required,
            # "version": self.get_version(),
        }


class CookieManager(models.Manager.from_queryset(BaseQueryset)):
    def get_by_natural_key(self, name: str, domain: str, cookiegroup: str) -> Cookie:
        group = CookieGroup.objects.get_by_natural_key(cookiegroup)
        return self.get(cookiegroup=group, name=name, domain=domain)


class Cookie(models.Model):
    cookiegroup = models.ForeignKey(
        CookieGroup,
        verbose_name=CookieGroup._meta.verbose_name,
        on_delete=models.CASCADE,
    )
    name = models.CharField(_("Name"), max_length=250)
    description = models.TextField(_("Description"), blank=True)
    path = models.TextField(_("Path"), blank=True, default="/")
    domain = models.CharField(_("Domain"), max_length=250, blank=True)
    created = models.DateTimeField(_("Created"), auto_now_add=True, blank=True)

    objects = CookieManager()

    class Meta:
        verbose_name = _("Cookie")
        verbose_name_plural = _("Cookies")
        constraints = [
            models.UniqueConstraint(
                fields=("cookiegroup", "name", "domain"),
                name="natural_key",
            ),
        ]
        ordering = ["-created"]

    def __str__(self):
        return f"{self.name} {self.domain}{self.path}"

    @clear_cache_after
    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)

    @clear_cache_after
    def delete(self, *args, **kwargs):
        return super().delete(*args, **kwargs)

    def natural_key(self) -> tuple[str, str, str]:
        return (self.name, self.domain) + self.cookiegroup.natural_key()

    natural_key.dependencies = ["cookie_consent.cookiegroup"]  # pyright: ignore[reportFunctionMemberAccess]

    @property
    def varname(self) -> str:
        group_varname = self.cookiegroup.varname
        return f"{group_varname}={self.name}:{self.domain}"

    def get_version(self) -> str:
        return self.created.isoformat()


ACTION_ACCEPTED = 1
ACTION_DECLINED = -1
ACTION_CHOICES = (
    (ACTION_DECLINED, _("Declined")),
    (ACTION_ACCEPTED, _("Accepted")),
)


class LogItem(models.Model):
    action = models.IntegerField(_("Action"), choices=ACTION_CHOICES)
    cookiegroup = models.ForeignKey(
        CookieGroup,
        verbose_name=CookieGroup._meta.verbose_name,
        on_delete=models.CASCADE,
    )
    version = models.CharField(_("Version"), max_length=32)
    created = models.DateTimeField(_("Created"), auto_now_add=True, blank=True)

    class Meta:
        verbose_name = _("Log item")
        verbose_name_plural = _("Log items")
        ordering = ["-created"]

    def __str__(self):
        return f"{self.cookiegroup.name} {self.version}"


================================================
FILE: cookie_consent/processor.py
================================================
from collections.abc import Collection
from typing import Literal

from django.http import HttpRequest, HttpResponseBase

from .conf import settings
from .models import ACTION_ACCEPTED, ACTION_DECLINED, CookieGroup, LogItem
from .util import get_cookie_dict_from_request, set_cookie_dict_to_response


class CookiesProcessor:
    """
    Process the accept/decline logic for cookie groups.
    """

    def __init__(self, request: HttpRequest, response: HttpResponseBase):
        self.request = request
        self.response = response

    def process(
        self,
        cookie_groups: Collection[CookieGroup],
        action: Literal["accept", "decline"],
    ) -> None:
        """
        Apply ``action`` to the specified ``cookie_groups``.

        Mutates the response by updating the cookie tracking the cookie group status. If
        there are no cookie groups provided, nothing happens.
        """
        if not cookie_groups:
            return

        cookie_dic = get_cookie_dict_from_request(self.request)

        match action:
            case "accept":
                for cookie_group in cookie_groups:
                    cookie_dic[cookie_group.varname] = cookie_group.get_version()
            case "decline":
                self._delete_cookies(cookie_groups)
                for cookie_group in cookie_groups:
                    cookie_dic[cookie_group.varname] = settings.COOKIE_CONSENT_DECLINE

        self._log_action(cookie_groups, action)
        set_cookie_dict_to_response(self.response, cookie_dic)

    def _log_action(
        self,
        cookie_groups: Collection[CookieGroup],
        action: Literal["accept", "decline"],
    ) -> None:
        if not settings.COOKIE_CONSENT_LOG_ENABLED:
            return
        # TODO: replace with stdlib logging call/helper instead of creating DB records
        # directly.

        action_map: dict[Literal["accept", "decline"], int] = {
            "accept": ACTION_ACCEPTED,
            "decline": ACTION_DECLINED,
        }
        log_items: list[LogItem] = [
            LogItem(
                action=action_map[action],
                cookiegroup=cookie_group,
                version=cookie_group.get_version(),
            )
            for cookie_group in cookie_groups
        ]
        LogItem.objects.bulk_create(log_items)

    def _delete_cookies(self, cookie_groups: Collection[CookieGroup]) -> None:
        for cookie_group in cookie_groups:
            if not cookie_group.is_deletable:
                continue
            for cookie in cookie_group.cookie_set.all():
                self.response.delete_cookie(cookie.name, cookie.path, cookie.domain)


================================================
FILE: cookie_consent/py.typed
================================================


================================================
FILE: cookie_consent/templates/cookie_consent/_cookie_group.html
================================================
{% load i18n %}
{% load cookie_consent_tags %}


<div class="cookie-group">
  <div class="cookie-group-title">
    <h3>{{ cookie_group.name }}</h3>

    {% if not cookie_group.is_required %}
      <div class="cookie-group-form">
       {% if request|cookie_group_accepted:cookie_group.varname %}
         <span class="cookie-consent-accepted">{% trans "Accepted" %}</span>
       {% else %}
         <form class="cookie-consent-accept" action="{% url "cookie_consent_accept" %}" method="post">
           {% csrf_token %}
           <input type="hidden" name="cookie_groups" value="{{ cookie_group.varname }}">
           <input type="submit" value="{% trans "Accept" %}">
         </form>
       {% endif %}

       {% if request|cookie_group_declined:cookie_group.varname %}
         <span class="cookie-consent-declined">{% trans "Declined" %}</span>
       {% else %}
         <form class="cookie-consent-decline" action="{% url "cookie_consent_decline" %}" method="post">
           {% csrf_token %}
           <input type="hidden" name="cookie_groups" value="{{ cookie_group.varname }}">
           <input type="submit" value="{% trans "Decline" %}">
         </form>
       {% endif %}
      </div>
    {% endif %}

  </div>

  <p>
    {{ cookie_group.description }}
  </p>


  <table>
  {% for cookie in cookie_group.cookie_set.all %}
   <tr>
     <th>
        {{ cookie.name }}
        {% if cookie.domain %}
          ({{ cookie.domain }})
        {% endif %}
     </th>
     <td>
       {% if cookie.description %}
        {{ cookie.description }}
       {% endif %}
     </td>
   </tr>
  {% endfor %}
  </table>

</div>


================================================
FILE: cookie_consent/templates/cookie_consent/base.html
================================================
<html>
 <head></head>
 <body>
   {% block body %}
   {% endblock %}
 </body>
</html>


================================================
FILE: cookie_consent/templates/cookie_consent/cookiegroup_list.html
================================================
{% extends "cookie_consent/base.html" %}

{% block body %}
  <h1>Cookies</h1>

  <p>
    This is a list of the categories of cookies used in our website and why we use them.
  </p>

  {% for cookie_group in object_list  %}
    {% include "cookie_consent/_cookie_group.html" %}
  {% endfor %}

{% endblock %}


================================================
FILE: cookie_consent/templatetags/__init__.py
================================================
#!/usr/bin/env python


================================================
FILE: cookie_consent/templatetags/cookie_consent_tags.py
================================================
from collections.abc import Collection

from django import template
from django.http import HttpRequest
from django.utils.html import json_script

from ..cache import all_cookie_groups as get_all_cookie_groups
from ..models import CookieGroup
from ..util import (
    are_all_cookies_accepted,
    get_cookie_value_from_request,
    get_not_accepted_or_declined_cookie_groups,
    is_cookie_consent_enabled,
)

register = template.Library()


@register.filter
def cookie_group_accepted(request: HttpRequest, group_or_cookie: str) -> bool:
    """
    Return ``True`` if the cookie group/cookie is accepted.

    Examples:

    .. code-block:: django

        {{ request|cookie_group_accepted:"analytics" }}
        {{ request|cookie_group_accepted:"analytics=*:.google.com" }}
    """
    value = get_cookie_value_from_request(request, *group_or_cookie.split("="))
    return value is True


@register.filter
def cookie_group_declined(request: HttpRequest, group_or_cookie: str) -> bool:
    """
    Return ``True`` if the cookie group/cookie is declined.

    Examples:

    .. code-block:: django

        {{ request|cookie_group_declined:"analytics" }}
        {{ request|cookie_group_declined:"analytics=*:.google.com" }}
    """
    value = get_cookie_value_from_request(request, *group_or_cookie.split("="))
    return value is False


@register.filter
def all_cookies_accepted(request: HttpRequest) -> bool:
    """
    Filter returns if all cookies are accepted.
    """
    return are_all_cookies_accepted(request)


@register.simple_tag
def not_accepted_or_declined_cookie_groups(
    request: HttpRequest,
) -> Collection[CookieGroup]:
    """
    Return the cookie groups for which no explicit accept or decline has been given.
    """
    return get_not_accepted_or_declined_cookie_groups(request)


@register.filter
def cookie_consent_enabled(request: HttpRequest) -> bool:
    """
    Indicate whether the cookie-consent app is enabled or not.
    """
    return is_cookie_consent_enabled(request)


@register.simple_tag
def all_cookie_groups(element_id: str):
    """
    Serialize all cookie groups to JSON and output them in a script tag.

    :param element_id: The ID for the script tag so you can look it up in JS later.

    This uses Django's core json_script filter under the hood.
    """
    groups = get_all_cookie_groups()
    value = [group.for_json() for group in groups.values()]
    return json_script(value, element_id)


================================================
FILE: cookie_consent/urls.py
================================================
from django.urls import path

from .views import (
    CookieGroupAcceptView,
    CookieGroupDeclineView,
    CookieGroupListView,
    CookieStatusView,
)

urlpatterns = [
    path("accept/", CookieGroupAcceptView.as_view(), name="cookie_consent_accept"),
    path("decline/", CookieGroupDeclineView.as_view(), name="cookie_consent_decline"),
    path("status/", CookieStatusView.as_view(), name="cookie_consent_status"),
    path("", CookieGroupListView.as_view(), name="cookie_consent_cookie_group_list"),
]


================================================
FILE: cookie_consent/util.py
================================================
import logging
from collections.abc import Callable, Collection, Iterator

from django.http import HttpRequest, HttpResponseBase

from .cache import all_cookie_groups, get_cookie, get_cookie_group
from .conf import settings
from .models import Cookie, CookieGroup

logger = logging.getLogger(__name__)

COOKIE_GROUP_SEP = "|"
KEY_VALUE_SEP = "="


def parse_cookie_str(cookie: str) -> dict[str, str]:
    if not cookie:
        return {}

    bits = cookie.split(COOKIE_GROUP_SEP)

    def _gen_pairs() -> Iterator[tuple[str, str]]:
        for possible_pair in bits:
            parts = possible_pair.split(KEY_VALUE_SEP)
            if len(parts) == 2:
                varname, cookie = parts
                yield varname, cookie
            else:
                logger.debug("cookie_value_discarded", extra={"value": possible_pair})

    return dict(_gen_pairs())


def _contains_invalid_characters(*inputs: str) -> bool:
    # = and | are special separators. They are unexpected characters in both
    # keys and values.
    for separator in (COOKIE_GROUP_SEP, KEY_VALUE_SEP):
        for value in inputs:
            if separator in value:
                logger.debug("skip_separator", extra={"value": value, "sep": separator})
                return True
    return False


def dict_to_cookie_str(dic: dict[str, str]) -> str:
    """
    Serialize a dictionary of cookie-group metadata to a string.

    The result is stored in a cookie itself. Note that the dictionary keys are expected
    to be cookie group ``varname`` fields, which are validated against a slug regex. The
    values are supposed to be ISO-8601 timestamps.

    Invalid key/value pairs are dropped.
    """

    def _gen_pairs() -> Iterator[str]:
        for key, value in dic.items():
            if _contains_invalid_characters(key, value):
                continue
            yield f"{key}={value}"

    return "|".join(_gen_pairs())


def get_cookie_dict_from_request(request: HttpRequest) -> dict[str, str]:
    cookie_str = request.COOKIES.get(settings.COOKIE_CONSENT_NAME, "")
    return parse_cookie_str(cookie_str)


def set_cookie_dict_to_response(
    response: HttpResponseBase, dic: dict[str, str]
) -> None:
    response.set_cookie(
        settings.COOKIE_CONSENT_NAME,
        dict_to_cookie_str(dic),
        max_age=settings.COOKIE_CONSENT_MAX_AGE,
        domain=settings.COOKIE_CONSENT_DOMAIN,
        secure=settings.COOKIE_CONSENT_SECURE,
        httponly=settings.COOKIE_CONSENT_HTTPONLY,
        samesite=settings.COOKIE_CONSENT_SAMESITE,
    )


def get_cookie_value_from_request(
    request: HttpRequest, varname: str, cookie: str = ""
) -> bool | None:
    """
    Returns if cookie group or its specific cookie has been accepted.

    Returns True or False when cookie is accepted or declined or None
    if cookie is not set.
    """
    if not (cookie_dic := get_cookie_dict_from_request(request)):
        return None
    if not (cookie_group := get_cookie_group(varname=varname)):
        return None

    _cookie: Cookie | None = None
    if cookie:
        name, domain = cookie.split(":")
        _cookie = get_cookie(cookie_group, name, domain)

    match version := cookie_dic.get(varname, None):
        case None:
            return None
        case str() if version == settings.COOKIE_CONSENT_DECLINE:
            return False

    reference_version = _cookie.get_version() if _cookie else cookie_group.get_version()
    if version >= reference_version:
        return True
    return None


def get_cookie_groups(varname: str = "") -> Collection[CookieGroup]:
    if not varname:
        return all_cookie_groups().values()
    keys = varname.split(",")
    return [g for k, g in all_cookie_groups().items() if k in keys]


def are_all_cookies_accepted(request: HttpRequest) -> bool:
    """
    Returns if all cookies are accepted.
    """
    return all(
        [
            get_cookie_value_from_request(request, cookie_group.varname)
            for cookie_group in get_cookie_groups()
        ]
    )


def _get_cookie_groups_by_state(request, state: bool | None) -> Collection[CookieGroup]:
    return [
        cookie_group
        for cookie_group in get_cookie_groups()
        if get_cookie_value_from_request(request, cookie_group.varname) is state
    ]


def get_not_accepted_or_declined_cookie_groups(
    request: HttpRequest,
) -> Collection[CookieGroup]:
    """
    Returns all cookie groups that are neither accepted or declined.
    """
    return _get_cookie_groups_by_state(request, state=None)


def get_accepted_cookie_groups(request: HttpRequest) -> Collection[CookieGroup]:
    """
    Returns all cookie groups that are accepted.
    """
    return _get_cookie_groups_by_state(request, state=True)


def get_declined_cookie_groups(request: HttpRequest) -> Collection[CookieGroup]:
    """
    Returns all cookie groups that are declined.
    """
    return _get_cookie_groups_by_state(request, state=False)


def is_cookie_consent_enabled(request: HttpRequest) -> bool:
    """
    Returns if django-cookie-consent is enabled for given request.
    """
    enabled: bool | Callable[[HttpRequest], bool] = settings.COOKIE_CONSENT_ENABLED
    return enabled(request) if callable(enabled) else enabled


================================================
FILE: cookie_consent/views.py
================================================
from typing import Literal

from django.contrib.auth.views import RedirectURLMixin
from django.http import (
    HttpRequest,
    HttpResponse,
    HttpResponseBase,
    HttpResponseRedirect,
    JsonResponse,
)
from django.middleware.csrf import get_token as get_csrf_token
from django.urls import reverse
from django.views.generic import ListView, View

from .conf import settings
from .forms import ProcessCookiesForm
from .models import CookieGroup
from .processor import CookiesProcessor
from .util import (
    get_accepted_cookie_groups,
    get_declined_cookie_groups,
    get_not_accepted_or_declined_cookie_groups,
)


def is_ajax_like(request: HttpRequest) -> bool:
    # legacy ajax, removed in Django 4.0 (used to be request.is_ajax())
    ajax_header = request.headers.get("X-Requested-With")
    if ajax_header == "XMLHttpRequest":
        return True

    # module-js uses fetch and a custom header
    return bool(request.headers.get("X-Cookie-Consent-Fetch"))


class CookieGroupListView(ListView):
    """
    Display all cookies.
    """

    model = CookieGroup


class CookieGroupBaseProcessView(RedirectURLMixin, View):
    """
    Process the cookie groups submitted in the POST request (or URL parameters).

    :class:`RedirectURLMixin` takes care of the hardening against open redirects.
    """

    cookie_process_action: Literal["accept", "decline"]
    """
    Processing action to apply, must be set on the subclasses.
    """

    def get_default_redirect_url(self) -> str:
        return settings.COOKIE_CONSENT_SUCCESS_URL

    def post(self, request: HttpRequest, *args, **kwargs):
        form = ProcessCookiesForm(data=request.POST)

        if not form.is_valid():
            if is_ajax_like(request):
                return JsonResponse(form.errors.get_json_data())
            else:
                return HttpResponse(form.errors.render())

        cookie_groups = form.get_cookie_groups()

        response: HttpResponseBase
        if is_ajax_like(request):
            response = HttpResponse()
        else:
            response = HttpResponseRedirect(self.get_success_url())

        processor = CookiesProcessor(request, response)
        processor.process(cookie_groups, action=self.cookie_process_action)

        return response


class CookieGroupAcceptView(CookieGroupBaseProcessView):
    """
    View to accept CookieGroup.
    """

    cookie_process_action = "accept"


class CookieGroupDeclineView(CookieGroupBaseProcessView):
    """
    View to decline CookieGroup.
    """

    cookie_process_action = "decline"


class CookieStatusView(View):
    """
    Check the current accept/decline status for cookies.

    The returned accept and decline URLs are specific to this user and include the
    cookie groups that weren't accepted or declined yet.

    Note that this endpoint also returns a CSRF Token to be used by the frontend,
    as baking a CSRFToken into a cached page will not reliably work.
    """

    def get(self, request: HttpRequest) -> JsonResponse:
        accepted = get_accepted_cookie_groups(request)
        declined = get_declined_cookie_groups(request)
        not_accepted_or_declined = get_not_accepted_or_declined_cookie_groups(request)
        data = {
            "csrftoken": get_csrf_token(request),
            "acceptUrl": reverse("cookie_consent_accept"),
            "declineUrl": reverse("cookie_consent_decline"),
            "acceptedCookieGroups": [group.varname for group in accepted],
            "declinedCookieGroups": [group.varname for group in declined],
            "notAcceptedOrDeclinedCookieGroups": [
                group.varname for group in not_accepted_or_declined
            ],
        }
        return JsonResponse(data)


================================================
FILE: docs/Makefile
================================================
# Makefile for Sphinx documentation
#

# You can set these variables from the command line.
SPHINXOPTS    =
SPHINXBUILD   = sphinx-build
PAPER         =
BUILDDIR      = _build

# Internal variables.
PAPEROPT_a4     = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
ALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .

.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext

help:
	@echo "Please use \`make <target>' where <target> is one of"
	@echo "  html       to make standalone HTML files"
	@echo "  dirhtml    to make HTML files named index.html in directories"
	@echo "  singlehtml to make a single large HTML file"
	@echo "  pickle     to make pickle files"
	@echo "  json       to make JSON files"
	@echo "  htmlhelp   to make HTML files and a HTML help project"
	@echo "  qthelp     to make HTML files and a qthelp project"
	@echo "  devhelp    to make HTML files and a Devhelp project"
	@echo "  epub       to make an epub"
	@echo "  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
	@echo "  latexpdf   to make LaTeX files and run them through pdflatex"
	@echo "  text       to make text files"
	@echo "  man        to make manual pages"
	@echo "  texinfo    to make Texinfo files"
	@echo "  info       to make Texinfo files and run them through makeinfo"
	@echo "  gettext    to make PO message catalogs"
	@echo "  changes    to make an overview of all changed/added/deprecated items"
	@echo "  linkcheck  to check all external links for integrity"
	@echo "  doctest    to run all doctests embedded in the documentation (if enabled)"

clean:
	-rm -rf $(BUILDDIR)/*

html:
	$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
	@echo
	@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."

dirhtml:
	$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
	@echo
	@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."

singlehtml:
	$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
	@echo
	@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."

pickle:
	$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
	@echo
	@echo "Build finished; now you can process the pickle files."

json:
	$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
	@echo
	@echo "Build finished; now you can process the JSON files."

htmlhelp:
	$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
	@echo
	@echo "Build finished; now you can run HTML Help Workshop with the" \
	      ".hhp project file in $(BUILDDIR)/htmlhelp."

qthelp:
	$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
	@echo
	@echo "Build finished; now you can run "qcollectiongenerator" with the" \
	      ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
	@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-shop-discounts.qhcp"
	@echo "To view the help file:"
	@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-shop-discounts.qhc"

devhelp:
	$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
	@echo
	@echo "Build finished."
	@echo "To view the help file:"
	@echo "# mkdir -p $$HOME/.local/share/devhelp/django-shop-discounts"
	@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-shop-discounts"
	@echo "# devhelp"

epub:
	$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
	@echo
	@echo "Build finished. The epub file is in $(BUILDDIR)/epub."

latex:
	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
	@echo
	@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
	@echo "Run \`make' in that directory to run these through (pdf)latex" \
	      "(use \`make latexpdf' here to do that automatically)."

latexpdf:
	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
	@echo "Running LaTeX files through pdflatex..."
	make -C $(BUILDDIR)/latex all-pdf
	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."

text:
	$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
	@echo
	@echo "Build finished. The text files are in $(BUILDDIR)/text."

man:
	$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
	@echo
	@echo "Build finished. The manual pages are in $(BUILDDIR)/man."

texinfo:
	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
	@echo
	@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
	@echo "Run \`make' in that directory to run these through makeinfo" \
	      "(use \`make info' here to do that automatically)."

info:
	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
	@echo "Running Texinfo files through makeinfo..."
	make -C $(BUILDDIR)/texinfo info
	@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."

gettext:
	$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
	@echo
	@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."

changes:
	$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
	@echo
	@echo "The overview file is in $(BUILDDIR)/changes."

linkcheck:
	$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
	@echo
	@echo "Link check complete; look for any errors in the above output " \
	      "or in $(BUILDDIR)/linkcheck/output.txt."

doctest:
	$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
	@echo "Testing of doctests in the sources finished, look at the " \
	      "results in $(BUILDDIR)/doctest/output.txt."


================================================
FILE: docs/changelog.rst
================================================
=========
Changelog
=========

1.0.0 (2026-02-04)
------------------

The long-awaited 1.0 version of django-cookie-consent is finally here!

The project has seen numerous cleanups, clarifications and (hopefully) simplifications
over the past few releases, but now the public API is considered stable and any future
breaking changes will result in a major version bump.

The documentation :ref:`Migrating to 1.0 <migrating_10>` is there to help you updating
your projects, but don't hesitate to open a Github issue if you run into problems.

**💥 Breaking changes**

*Now* was the time to perform necessary cleanups that break the existing API/features.
The upgrade documentation covers these in more detail.

* [#106] Views and URLs rework:

  * Removed the URL patterns ``accept/<varname>/`` and ``decline/<varname>/``.
  * Renamed the ``cookie_consent_accept_all`` view name to ``cookie_consent_accept``.
  * Renamed the ``cookie_consent_decline_all`` view name to ``cookie_consent_decline``.
  * Replaced the ``CookieGroupBaseProcessView.process`` method with the new
    ``CookiesProcessor`` utility class.
  * The views no longer take the cookie groups/cookie group varnames from the
    ``varname`` URL parameter - it has been removed.
  * The cookie accept/decline views are no longer ``csrf_exempt`` and require a CRSF
    token now.
  * The ``cookie_consent_accept_url`` and ``cookie_consent_decline_url`` template
    tags are removed due to the URL structure changes.
  * Removed the ``DELETE`` method support for the cookie group decline view.

* [#108] Legacy cookiebar Javascript removal:

  * The legacy implementation is removed.
  * Removed the ``get_accept_cookie_groups_cookie_string``,
    ``get_decline_cookie_groups_cookie_string``, ``js_type_for_cookie_consent`` and
    ``accepted_cookies`` template tags due to being incompatible with template/view
    caching.

* Dropped support for the end-of-life Django 5.1.

**New features**

* Confirmed support for Django 6.0.
* Confirmed support for Python 3.14.
* [#106] You can now use plain HTTP POST form semantics to submit the cookie groups to
  accept/decline.
* Added static type annotations to the project and added the ``py.typed`` package marker.

**Project maintenance**

* Added django-upgrade to pre-commit hooks.
* Converted more tests to pytest style and refactored accept/decline views.
* Cleaned up/updated the documentation.
* Renamed the master branch to main.
* Switch the version management tool from tbump to bump-my-version.

0.9.0 (2025-09-28)
------------------

Maintenance and bugfix release.

**💥 Breaking changes**

* Dropped support for Python 3.8 (end-of-life).
* Dropped support for Python 3.9 (soon end-of-life).

**New features**

* [#114] You can now customize the redirect behaviour after accepting/declining cookies,
  using the new ``COOKIE_CONSENT_SUCCESS_URL`` setting.
* The admin now displays a warning for cookie groups that have no cookies in them.

**Bugfixes**

* [#135] Make cookie parsing and serialization more robust.

**Project maintenance**

* Transferred the package from the jazzband Github organization to django-commons, and
  updated all community-related documents like the code of conduct.
* Updated the package metadata to latest format.
* Bumped minimum required setuptools version to build the package.
* Replaced black and isort with Ruff for linting and code-formatting duties.
* Improved the test suite.
* The PyPI and NPM packages are now automatically published with Trusted Publishing,
  enhancing supply-chain security.

0.8.0 (2025-05-30)
------------------

Small feature release

**New features**

* The Javascript (package) now exports some more utilities (#126).

**Project maintenance**

* Fixed some test flakiness.

0.7.0 (2025-04-26)
------------------

Bugfix and Django supported versions release.

**New features**

* Confirmed Python 3.13 support.
* Confirmed Django 5.1 and 5.2 support.

**Project maintenance**

* Fixed incorrect JS example in the documentation.
* Removed Django 5.0 from CI pipeline (it still works, but Django 5.0 is end of life).
* Upgraded esbuild for the Javascript module building.

0.6.0 (2024-05-10)
------------------

Feature release with improved JS support.

**💥 Breaking changes**

Some (database) unique constraints have been added to the model fields. If you have
duplicate values in those fields, the migrations will crash. You should check for
duplicates before upgrading, and fix those:

.. code-block:: py

    from django.db.models import Count
    from cookie_consent.models import CookieGroup, Cookie

    # duplicated cookie groups (by varname)
    CookieGroup.objects.values("varname").annotate(n=Count("varname")).filter(n__gt=1)
    # <QuerySet []>

    # duplicated cookies
    Cookie.objects.values("cookiegroup", "name", "domain").annotate(n=Count("id")).filter(n__gt=1)
    # <QuerySet []>

Additionally, support for unmaintained Django versions (3.2, 4.1) is dropped.

**New features**

* The JS for the cookiebar module is rewritten in TypeScript and published as an
  `npm package`_ for people wishing to integrate this functionality in their own
  frontend stack. ``cookie_consent/cookiebar.module.js`` is still in the Python package,
  and it's generated from the same source code.

* Added support for natural keys in dumpdata/loaddata management commands.

**Deprecations**

None.

**Bugfixes**

* Fixed cache not being cleared after queryset (bulk) update/deletes
* Swapped the order of ``onShow`` and ``doInsert`` in the cookiebar JS. ``onShow`` is
  now called after the cookiebar is inserted into the document.
* Added missing unique constraint to ``CookieGroup.varname`` field
* Added missing unique constraint on ``Cookie`` fields ``cookiegroup``, ``name`` and
  ``domain``.

**Project maintenance**

* Add missing templatetag instruction to docs
* Removed Django < 4.2 compatibility shims
* Formatted code with latest black version
* Dropped Django 3.2 & 4.1 from the supported versions
* Removed unused dependencies
* Bumped github actions to latest versions
* Updated to modern packaging tooling with ``pyproject.toml``

.. _npm package: https://www.npmjs.com/package/django-cookie-consent

0.5.0b0 (2023-09-24)
--------------------

A django-cookie-consent version to test the new Javascript integration.

You can install this using:

.. code-block:: bash

    pip install django-cookie-consent --pre

The new cookiebar JS uses a modern approach and should resolve issues with page caches
and Content Security Policies. Please try it out and report any issues or suggestion on
Github!

**Breaking changes**

None

**New features**

* Implemented ``cookie_consent/cookiebar.module.js`` as a new Javascript integration.
  Please review the updated documentation for usage instructions. (#15, #49, #99)

**Deprecations**

Deprecated functionality is scheduled for removal in django-cookie-consent 1.0.

* Deprecated ``cookie_consent/cookiebar.js`` and added an alias ``legacyShowCookieBar``.
  Existing users are advised to upgrade to the new module approach, or at the very
  least substitute ``showCookieBar`` with ``window.legacyShowCookieBar`` to better keep
  track of this deprecation.

* Deprecated template tags that build up cookie strings suitable for Javascript.

**Bugfixes**

None

**Project maintenance**

* Extensively documented the new cookiebar JS usage.
* Added Playwright for end-to-end testing (covers both the new and legacy cookie bar)
* Removed unnecessary ``smart_str`` usage - thanks @some1ataplace
* Test app and tests themselves are now excluded from coverage measuring for more a
  more accurate reflection of the coverage status.

0.4.0 (2023-06-11)
------------------

.. note::

    The 0.4.0 release mainly has had a project management overhaul. The project has
    transferred to the Jazzband organization. This release mostly focuses on Python/Django
    version compatibility and organization of tests, CI etc.

    Many thanks for people who reported bugs, and especially, your patience for getting
    this release on PyPI.


**Breaking changes**

* Dropped support for Django 2.2, 3.0, 3.1 and 4.0
* Dropped support for Python 3.6 and 3.7

These versions are (nearly) end-of-life and no longer supported by their upstream teams.

**New features**

* Implemented settings for cookie flags: SameSite, HttpOnly, Secure, domain (#27, #60,
  #36, #88)
* Added Dutch translations

**Bugfixes**

* Cache instance resolution is now lazy (#41)
* Fixed support for Django 4.1 (#73) - thanks @alahdal
* Fixed default settings being bytestrings (#24, #55, #69)
* Fixed the middleware to clean cookies (#13) - thanks @some1ataplace
* Fixed bug in JS ``beforeDeclined`` attribute

**Project maintenance**

* Transferred project to Jazzband (#38, #64, #75)
* Replaced Travis CI with Github Actions (#64, #75)
* Set up correct test matrix for python/django versions (#75)
* Code is now ``isort`` and ``black`` formatted (#75)
* Set up ``tox`` and ``pytest`` for testing (#64, #75)
* 'Removed' the example app - the ``testapp`` in the repository is still a good example
* Configured tbump for the release flow
* Confirmed support for Python 3.11 and Django 4.2
* Added explicit template tag tests (#39)

**Documentation**

Did some initial restructuring to make the docs easier to digest, more to come.

* Added documentation on how to contribute
* Corrected settings documentation (#53, #14)
* Documented ``cookiebar.js`` usage (#90) - thanks @MrCordeiro
* Added better contributor documentation and example app documentation based on the
  ``testapp`` in the repository.

0.3.1 (2022-02-17)
------------------

- Protect against open redirect after accepting cookies (#48)


0.3.0 (2021-12-08)
------------------

* support ranges from django 2.2 to 4.0 and python 3.6 to 3.9


0.2.6 (2020-06-17)
------------------

* fix: setup for python 2.7


0.2.5 (2020-06-17)
------------------

* chore: add package descriptions


0.2.4 (2020-06-17)
------------------

* Cookie Bar Choosing Decline Not Disappearing Right Away (#22)

* 📦 NEW: pt_BR (#23)

0.2.3 (2020-06-15)
------------------

* Update package classifiers


0.2.2 (2020-06-15)
------------------

* 8732949 Remove jquery (#20)


0.2.1 (2020-06-02)
------------------

* fix: Set max version for django-appconf (#18)

* fix: Views ignore 'next' url parameter (#12)

* Update configuration.rst


0.2.0 (2020-02-11)
------------------

* support ranges from django 1.9 to 3.0 and python 2.7 to 3.7 (JonHerr)

0.1.1
-----

* tweak admin

* Add accepted_cookies template filter

* Add varname property to Cookie model

* Add translation catalog

0.1.0
-----

* Initial release


================================================
FILE: docs/check_sphinx.py
================================================
import subprocess


def test_linkcheck(tmpdir):
    doctrees = tmpdir.join("doctrees")
    htmldir = tmpdir.join("html")
    subprocess.check_call(
        ["sphinx-build", "-W", "-blinkcheck", "-d", str(doctrees), ".", str(htmldir)],
    )


def test_build_docs(tmpdir):
    doctrees = tmpdir.join("doctrees")
    htmldir = tmpdir.join("html")
    subprocess.check_call(
        ["sphinx-build", "-W", "-bhtml", "-d", str(doctrees), ".", str(htmldir)],
    )


================================================
FILE: docs/concept.rst
================================================
=============
Main concepts
=============

Cookie Group
------------

The :class:`CookieGroup <cookie_consent.models.CookieGroup>` model represents a group
of related cookies. Cookie groups can be either required or not. Users can accept or
decline the use of the cookies in the non-required cookie groups.

Versions
^^^^^^^^

Each cookie group has a "current version" set to the timestamp of the last added
Cookie in it. When a user accepts a cookie group, the current version (at the time of
consent) is :ref:`saved <concept_storing_consent>`.

Versions allow django-cookie-consent to check if new cookies have been introduced after
the user has given consent for a cookie group, so that they can be prompted again to
accept the new cookie(s).

Important attributes:
^^^^^^^^^^^^^^^^^^^^^

``varname``
  The variable name that acts as unique identifier for the cookie group. You can use
  the value in template tags and filters to refer to a particular cookie group.

``is_required``
  Required cookies are never deleted and users cannot accept or decline them. For
  example, Django's default ``sessionid`` and ``csrftoken`` cookies are required for
  the correct functioning of your project. Without these cookies, the website will not
  work properly - so users can't opt-out.

``is_deletable``
  If a cookie group is marked as deletable, django-cookie-consent will try to delete
  the cookies in this group when the user declines the group, or through the
  :class:`cookie_consent.middleware.CleanCookiesMiddleware`.

Cookie
------

The :class:`Cookie<cookie_consent.models.Cookie>` model represents as single cookie.

.. admonition:: Domain and path
   :collapsible: closed

   The ``domain`` and ``path`` fields are important to be able to delete the
   cookies programmatically. Keep in mind that only cookies of your own domain can
   be deleted.

.. _concept_storing_consent:

Saving user selection
---------------------

A bit ironically, django-cookie-consent uses a cookie itself to store the user consent.
By default, the name ``cookie_consent`` is used.

An example value of such a cookie could be:

.. code-block:: none

    optional=-1|social=2013-06-04T03:17:01.421395Z

The meaning of this is:

* the user declined the cookie group with varname ``optional``
* the user accepted the cookie group with varname ``social``, and specifically only the
  cookies that were created before the stated timestamp

Caching
-------

django-cookie-consent keeps the non-required cookie groups and cookies in cache, to
avoid hitting the database for each request. By default, the ``default`` Django cache
is used. You can modify this, see :ref:`settings`.

.. note:: Django's default cache is a local-memory cache. Cache invalidation in one
   wsgi-server process will not propagate to other instances/processes, so you can
   temporarily see inconsistent results. It's recommended to use a shared cache like
   Redis/Valkey or Memcache.


================================================
FILE: docs/conf.py
================================================
import os
import sys
from pathlib import Path

import django

# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
root_dir = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(root_dir))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings")

django.setup()

from cookie_consent import __version__  # noqa: E402

# -- General configuration -----------------------------------------------------

# If your documentation needs a minimal Sphinx version, state it here.
# needs_sphinx = '1.0'

# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = ["sphinx.ext.autodoc"]

# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]

# The suffix of source filenames.
source_suffix = ".rst"

# The encoding of source files.
# source_encoding = 'utf-8-sig'

# The master toctree document.
master_doc = "index"

# General information about the project.
project = "django-cookie-consent"
copyright = "2013, Bojan Mihelac"

release = __version__

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
# language = None

# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
# today = ''
# Else, today_fmt is used as the format for a strftime call.
# today_fmt = '%B %d, %Y'

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ["_build"]

# The reST default role (used for this markup: `text`) to use for all documents.
# default_role = None

# If true, '()' will be appended to :func: etc. cross-reference text.
# add_function_parentheses = True

# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
# add_module_names = True

# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
# show_authors = False

# The name of the Pygments (syntax highlighting) style to use.
pygments_style = "sphinx"

# A list of ignored prefixes for module index sorting.
# modindex_common_prefix = []


# -- Options for HTML output ---------------------------------------------------

# The theme to use for HTML and HTML Help pages.  See the documentation for
# a list of builtin themes.
html_theme = "sphinx_rtd_theme"

# Theme options are theme-specific and customize the look and feel of a theme
# further.  For a list of options available for each theme, see the
# documentation.
# html_theme_options = {}

# Add any paths that contain custom themes here, relative to this directory.
# html_theme_path = []

# The name for this set of Sphinx documents.  If None, it defaults to
# "<project> v<release> documentation".
# html_title = None

# A shorter title for the navigation bar.  Default is the same as html_title.
# html_short_title = None

# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
# html_logo = None

# The name of an image file (within the static path) to use as favicon of the
# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
# html_favicon = None

# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = []

# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
# html_last_updated_fmt = '%b %d, %Y'

# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
# html_use_smartypants = True

# Custom sidebar templates, maps document names to template names.
# html_sidebars = {}

# Additional templates that should be rendered to pages, maps page names to
# template names.
# html_additional_pages = {}

# If false, no module index is generated.
# html_domain_indices = True

# If false, no index is generated.
# html_use_index = True

# If true, the index is split into individual pages for each letter.
# html_split_index = False

# If true, links to the reST sources are added to the pages.
# html_show_sourcelink = True

# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
# html_show_sphinx = True

# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
# html_show_copyright = True

# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it.  The value of this option must be the
# base URL from which the finished HTML is served.
# html_use_opensearch = ''

# This is the file name suffix for HTML files (e.g. ".xhtml").
# html_file_suffix = None

# Output file base name for HTML help builder.
htmlhelp_basename = "django-cookie-consent"


# -- Options for LaTeX output --------------------------------------------------

# The paper size ('letter' or 'a4').
# latex_paper_size = 'letter'

# The font size ('10pt', '11pt' or '12pt').
# latex_font_size = '10pt'

# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
    (
        "index",
        "django-cookie-consent.tex",
        "django-cookie-consent Documentation",
        "Bojan Mihelac",
        "manual",
    ),
]

# The name of an image file (relative to this directory) to place at the top of
# the title page.
# latex_logo = None

# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
# latex_use_parts = False

# If true, show page references after internal links.
# latex_show_pagerefs = False

# If true, show URL addresses after external links.
# latex_show_urls = False

# Additional stuff for the LaTeX preamble.
# latex_preamble = ''

# Documents to append as an appendix to all manuals.
# latex_appendices = []

# If false, no module index is generated.
# latex_domain_indices = True


# -- Options for manual page output --------------------------------------------

# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
    (
        "index",
        "django-cookie-consent",
        "django-cookie-consent Documentation",
        ["Bojan Mihelac"],
        1,
    )
]

# If true, show URL addresses after external links.
# man_show_urls = False

# -- Options for Texinfo output ------------------------------------------------

# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
#  dir menu entry, description, category)
texinfo_documents = [
    (
        "index",
        "django-cookie-consent",
        "django-cookie-consent Documentation",
        "Bojan Mihelac",
        "django-cookie-consent",
        "",
        "Miscellaneous",
    ),
]

# Documents to append as an appendix to all manuals.
texinfo_appendices = []

linkcheck_ignore = [
    r"https://(www\.)?npmjs\.com.*",  # IP/UA blocking...
]


================================================
FILE: docs/contributing.rst
================================================
.. _contributing:

=============
Contributing
=============

.. include:: ../CONTRIBUTING.rst


================================================
FILE: docs/example_app.rst
================================================
===========
Example app
===========

The ``testapp`` project is both an example of how you could use this library and serves
as the reference for our test suite.

Running the testapp
-------------------

The testapp is essentially a standard django project, however there is no ``manage.py``
file. Instead, you have to use the ``django-admin`` command (``manage.py`` is only
a wrapper around this anyway).

#. First, clone the repository to get all the necessary files:

   .. code-block:: bash

       git clone https://github.com/django-commons/django-cookie-consent.git
       cd django-cookie-consent

#. Create a virtual environment for the project, using any supported Python version
   (3.10+) and activate it

   .. code-block:: bash

       python3.12 -m venv ./env
       source ./env/bin/activate

#. Install the application and dependencies

   .. code-block:: bash

       pip install .

#. Prepare your settings and local project instance

   .. code-block:: bash

       export DJANGO_SETTINGS_MODULE=testapp.settings PYTHONPATH=.
       django-admin migrate
       django-admin loaddata testapp/fixture.json
       django-admin createsuperuser

#. Start the development server

   .. code-block:: bash

       django-admin runserver

You can now navigate to ``http://127.0.0.1:8000`` and ``http://127.0.0.1:8000/admin/``.


================================================
FILE: docs/index.rst
================================================
=====================
Django cookie consent
=====================

Manage cookie information and let visitors give or reject consent for them.

|build-status| |code-quality| |ruff| |coverage| |docs|

|python-versions| |django-versions| |pypi-version|

Features
========

* cookies and cookie groups are stored in models for easy management
  through Django admin interface
* support for both opt-in and opt-out cookie consent schemes
* removing declined cookies (or non accepted when opt-in scheme is used)
* logging user actions when they accept and decline various cookies
* easy adding new cookies and seamlessly re-asking for consent for new cookies

You can find the source code and development progress on https://github.com/django-commons/django-cookie-consent/.

User Guide
----------

.. toctree::
   :maxdepth: 2

   quickstart
   concept
   usage
   javascript
   settings
   example_app
   reference/index
   contributing
   migrating-1.0
   changelog

Indices and tables
==================

* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

.. |build-status| image:: https://github.com/django-commons/django-cookie-consent/workflows/Run%20CI/badge.svg
    :alt: Build status
    :target: https://github.com/django-commons/django-cookie-consent/actions?query=workflow%3A%22Run+CI%22

.. |code-quality| image:: https://github.com/django-commons/django-cookie-consent/workflows/Code%20quality%20checks/badge.svg
     :alt: Code quality checks
     :target: https://github.com/django-commons/django-cookie-consent/actions?query=workflow%3A%22Code+quality+checks%22

.. |ruff| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
    :target: https://github.com/astral-sh/ruff

.. |coverage| image:: https://codecov.io/gh/django-commons/django-cookie-consent/branch/main/graph/badge.svg
    :target: https://codecov.io/gh/django-commons/django-cookie-consent
    :alt: Coverage status

.. |docs| image:: https://readthedocs.org/projects/django-cookie-consent/badge/?version=latest
    :target: https://django-cookie-consent.readthedocs.io/en/latest/?badge=latest
    :alt: Documentation Status

.. |python-versions| image:: https://img.shields.io/pypi/pyversions/django-cookie-consent.svg

.. |django-versions| image:: https://img.shields.io/pypi/djversions/django-cookie-consent.svg

.. |pypi-version| image:: https://img.shields.io/pypi/v/django-cookie-consent.svg
    :target: https://pypi.org/project/django-cookie-consent/


================================================
FILE: docs/javascript.rst
================================================
.. _javascript:

======================
Javascript integration
======================

Cookie consent supports "classic" pages where you submit the accept/decline form and
then it performs a full page reload. This strategy is simple and straight-forward, as
any dynamic scripts tied to the cookie groups are then automatically initialized.

However, this does not lead to the best user-experience. Consider a user filling out a
long form and half-way they decide to get rid of the "annoying cookie bar". Either
accepting or declining will make them lose their changes, providing a frustrating
experience.

Using the scripts we ship, you can provide a better user experience, at the cost of
more development work.

.. _showcookiebar_getting_started:

Getting started
===============

Requirements
------------

The new script is designed for modern Javascript and modern browsers. It should also
integrate with JS tooling like Webpack/Rollup/ESBuild... but we are not actively testing
this. Please let us know via Github issues what your issues and/or wishes are!

As such, the target browsers must support:

* ``<script type="module">`` OR you must process the source code with a compiler (like
  Babel_).
* ``window.fetch``
* ``async``/``await`` syntax OR use a compiler like Babel.
* ES2020 (features like optional chaining are used)

In your Django template
-----------------------

**Add a template element for your content**

The ``<template>`` node is cloned and injected in the configured location. For example:

.. code-block:: django

    {% url "cookie_consent_cookie_group_list" as url_cookies %}

    <template id="cookie-consent__cookie-bar">
        <div class="cookie-bar">
            This site uses cookies for better performance and user experience.
            Do you agree to use these cookies?
            {# Button is the more accessible role, but an anchor tag would also work #}
            <button type="button" class="cookie-consent__accept">Accept</button>
            <button type="button" class="cookie-consent__decline">Decline</button>
            <a href="{{ url_cookies }}">Cookies info</a>
        </div>
    </template>

This lets you, the developer, control the exact layout, styling and content of the
cookie notice.

.. note:: Avoid using (most) of the built in template tags if you want to use
   template/view caching. For more background information, see:
   :ref:`javascript_design_considerations`.

**Emit the cookie groups for the Javascript**

The cookiebar module needs to know which cookie groups exist to decide whether a bar
has to be shown at all. A template tag exists which emits this as JSON serialized
data (in a page-cache compatible manner):

.. code-block:: django

    {# Set up the data and template for dynamic JS cookie bar #}
    {% all_cookie_groups 'cookie-consent__cookie-groups' %}
    {# Emits a <script type="application/json" id="cookie-consent__cookie-groups">...</script> tag #}

**Include a script that calls the showCookieBar function**

The most straight-forward way is to include this in your Django template:

.. code-block:: django

    {% load static cookie_consent_tags %}
    {% static "cookie_consent/cookiebar.module.js" as cookiebar_src %}
    {% url 'cookie_consent_status' as status_url %}
    <script type="module">
        import {showCookieBar} from '{{ cookiebar_src }}';
        showCookieBar({
          statusUrl: '{{ status_url|escapejs }}',
          templateSelector: '#cookie-consent__cookie-bar',
          cookieGroupsSelector: '#cookie-consent__cookie-groups',
          onShow: () => document.querySelector('body').classList.add('with-cookie-bar'),
          onAccept: () => document.querySelector('body').classList.remove('with-cookie-bar'),
          onDecline: () => document.querySelector('body').classList.remove('with-cookie-bar'),
        });
    </script>

You call the function with the necessary options, and on page-load the cookie bar will
be properly initialized.

The ``status_url`` is special - it points to a backend view which returns the
user-specific cookie consent status, returning the appropriate accept and decline URLs
and other details relevant to cookie consent.

.. note::

    If you prefer the include the cookiebar module in your own Javascript entrypoint,
    the easiest way is to install our `published package`_.

    This package should work with TypeScript, Webpack, ESBuild, Vite... and other popular
    bundlers and toolchains.

    Just be careful to install the same (minor) version as the backend package to avoid
    weird bugs.

.. _published package: hhttps://www.npmjs.com/package/django-cookie-consent

Options
=======

The ``showCookieBar`` function takes a few required options and many optional options to
tweak the behaviour to your wishes.

**Required options**

* ``statusUrl``: URL to the ``CookieStatusView`` - essential to determine the
  accept/decline URLs and CSRF token. Use ``{% url 'cookie_consent_status' as status_url %}``
  for the correct value, irrespective of your urlconf.

**Recommended options**

These options have default values, but to prevent surprises and maximum flexibility, you
should provide them. Please check the source code for their default values.

* ``templateSelector`` - CSS selector to find the template element of the cookie bar.
  This element will be cloned and ultimately added to the page.

* ``cookieGroupsSelector`` - CSS selector to the element produced by
  ``{% all_cookie_groups 'cookie-consent__cookie-groups' %}``. This provides all
  configured cookie groups in a JSON script tag and is read by ``showCookieBar`` to
  determine if a bar should be shown at all (e.g. if there are no cookie groups,
  nothing is done).

* ``acceptSelector`` - CSS selector to the element to accept all cookies. A ``click``
  event listener is bound to this element to register the cookies accept action.

* ``declineSelector`` - CSS selector to the element to decline all cookies. A ``click``
  event listener is bound to this element to register the cookies decline action.

**Optional**

* ``insertBefore`` - A CSS selector, DOM node or ``null``. If provided, the cookie bar
  is prepended before this node, otherwise it is appended to the body element.

* ``onShow`` - an optional callback function, called right before the cookie bar is
  added to the document.

* ``onAccept`` - an optional callback, called when the "cookies accept" element is
  clicked and when the cookie status is initially loaded. It receives the list of
  all cookie groups that are (now) accepted and the click event (if there was one).

* ``onDecline`` - an optional callback, called when the "cookies decline" element is
  clicked and when the cookie status is initially loaded. It receives the list of
  all cookie groups that are (now) declined and the click event (if there was one).

* ``csrfHeaderName`` - HTTP header name for the CSRF Token. Defaults to Django's default
  value, so if you have a non-default ``settings.CSRF_HEADER_NAME``, you must provide
  this.

.. _javascript_enable_scripts:

Enabling other scripts after cookies were accepted
==================================================

The legacy version of ``showCookieBar`` supported emitting scripts with a custom type
in the Django templates, which where then changed to ``type="text/javascript"`` to make
them execute without a full page reload. The new version does not support this out of
the box, as it may interfere with page caches, Content Security Policies and was poorly
documented.

We recommend hooking into the ``onAccept`` and ``onDecline`` hooks to perform these
actions.

E.g. in the django template:

.. code-block:: django

    <template id="analytics-scripts">
        <script type="text/javascript">
            // lots of interesting code
        </script>
        <script type="module" src="..."></script>
    </template>

and the Javascript function:

.. code-block:: javascript

    function onAccept(cookieGroups) {
        const analyticsEnabled = cookieGroups.find(group => group.varname === 'analytics') != undefined;
        if (analyticsEnabled) {
            const template = document.getElementById('analytics-scripts').content;
            const analyticsScripts = templateNode.content.cloneNode(true);
            document.body.appendChild(analyticsScripts);
        }
    }

Passing this ``onAccept`` callback then adds the scripts after the user accepted the
cookies, causing them to execute. This way, there's no reliance on ``unsafe-eval``.

.. _javascript_design_considerations:

Considerations and design decisions made for the JS integration
===============================================================

We realize there is quite a bit of work to do to use this functionality. We've aimed for
a trade-off where the simple things are easy to do and the complex set-ups are
achievable.

The :ref:`showcookiebar_getting_started` section should be close to plug-and-play by
integrating well with Django's static files. Especially on modern browsers, we intend
to have a working solution without intricate Javascript knowledge.

For more advanced Javascript usage/developers, we expose hooks and options to tap into
the life-cycle. The code may also serve as a reference for your own implementation.

HttpOnly and CSRF
-----------------

The cookie-consent cookie itself can safely be set to ``HttpOnly`` so it cannot be
tampered with (or even read) from Javascript. This follows security best practices. The
new script no longer touches ``document.cookie``.

Accepting and declining cookies must be CSRF-protected and use ``POST`` requests. This
works out of the box with the async calls we make - the status endpoint provides the
CSRF token to the Javascript so that it can include this via an HTTP header.

This means that you can mark your CSRF cookies ``HttpOnly`` in Django.

Content Security Policy (CSP)
-----------------------------

Content Security Policies aim to lock down which scripts, styles... can run in the
browser. They are a good tool in helping prevent Cross-Site-Scripting attacks, by
specifying from which sources scripts are allowed to run and usually by blocking
``eval`` (which should be the bare minimum of what you block).

The new scripts play well with this - you can include your analytics scripts inside
``<template>`` nodes and inject them dynamically without resorting to ``eval``.
Additionally, they are held against the configured CSP. Including these in the template
also provide the option to set a ``nonce`` (e.g. when using django-csp).

For more advanced setups, it's even possible a nonce is injected by a reverse proxy -
with creative Javascript you can read this nonce (typically from a ``<meta>`` tag) and
included it in the scripts you add in the ``onAccept`` hook.

Page caches
-----------

You should now be able to use Django's page cache which caches the entire response for
a given URL. The new script fetches the user-specific cookie status via an async call
which bypasses the cache (or you configure it to ``Vary`` on the cookies).

Localization
------------

The template element approach allows you to use Django's built in translation machinery,
keeping your templates readable and properly HTML-escaped.

Hooks
-----

The ``onShow``, ``onAccept`` and ``onDecline`` hooks allow you to perform additional
actions on the main events. You can add your own markup and Javascript for more advanced
user experiences.

.. _Babel: https://babeljs.io/


================================================
FILE: docs/make.bat
================================================
@ECHO OFF

REM Command file for Sphinx documentation

if "%SPHINXBUILD%" == "" (
	set SPHINXBUILD=sphinx-build
)
set BUILDDIR=_build
set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
set I18NSPHINXOPTS=%SPHINXOPTS% .
if NOT "%PAPER%" == "" (
	set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
	set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
)

if "%1" == "" goto help

if "%1" == "help" (
	:help
	echo.Please use `make ^<target^>` where ^<target^> is one of
	echo.  html       to make standalone HTML files
	echo.  dirhtml    to make HTML files named index.html in directories
	echo.  singlehtml to make a single large HTML file
	echo.  pickle     to make pickle files
	echo.  json       to make JSON files
	echo.  htmlhelp   to make HTML files and a HTML help project
	echo.  qthelp     to make HTML files and a qthelp project
	echo.  devhelp    to make HTML files and a Devhelp project
	echo.  epub       to make an epub
	echo.  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter
	echo.  text       to make text files
	echo.  man        to make manual pages
	echo.  texinfo    to make Texinfo files
	echo.  gettext    to make PO message catalogs
	echo.  changes    to make an overview over all changed/added/deprecated items
	echo.  linkcheck  to check all external links for integrity
	echo.  doctest    to run all doctests embedded in the documentation if enabled
	goto end
)

if "%1" == "clean" (
	for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
	del /q /s %BUILDDIR%\*
	goto end
)

if "%1" == "html" (
	%SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished. The HTML pages are in %BUILDDIR%/html.
	goto end
)

if "%1" == "dirhtml" (
	%SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
	goto end
)

if "%1" == "singlehtml" (
	%SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
	goto end
)

if "%1" == "pickle" (
	%SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished; now you can process the pickle files.
	goto end
)

if "%1" == "json" (
	%SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished; now you can process the JSON files.
	goto end
)

if "%1" == "htmlhelp" (
	%SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished; now you can run HTML Help Workshop with the ^
.hhp project file in %BUILDDIR%/htmlhelp.
	goto end
)

if "%1" == "qthelp" (
	%SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished; now you can run "qcollectiongenerator" with the ^
.qhcp project file in %BUILDDIR%/qthelp, like this:
	echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-shop-discounts.qhcp
	echo.To view the help file:
	echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-shop-discounts.ghc
	goto end
)

if "%1" == "devhelp" (
	%SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished.
	goto end
)

if "%1" == "epub" (
	%SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished. The epub file is in %BUILDDIR%/epub.
	goto end
)

if "%1" == "latex" (
	%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
	goto end
)

if "%1" == "text" (
	%SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished. The text files are in %BUILDDIR%/text.
	goto end
)

if "%1" == "man" (
	%SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished. The manual pages are in %BUILDDIR%/man.
	goto end
)

if "%1" == "texinfo" (
	%SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
	goto end
)

if "%1" == "gettext" (
	%SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
	if errorlevel 1 exit /b 1
	echo.
	echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
	goto end
)

if "%1" == "changes" (
	%SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
	if errorlevel 1 exit /b 1
	echo.
	echo.The overview file is in %BUILDDIR%/changes.
	goto end
)

if "%1" == "linkcheck" (
	%SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
	if errorlevel 1 exit /b 1
	echo.
	echo.Link check complete; look for any errors in the above output ^
or in %BUILDDIR%/linkcheck/output.txt.
	goto end
)

if "%1" == "doctest" (
	%SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
	if errorlevel 1 exit /b 1
	echo.
	echo.Testing of doctests in the sources finished, look at the ^
results in %BUILDDIR%/doctest/output.txt.
	goto end
)

:end


================================================
FILE: docs/migrating-1.0.rst
================================================
.. _migrating_10:

================
Migrating to 1.0
================

After more than 12 years since it's initial inception, django-cookie-consent finally
has a 1.0 version number. It means that the public API is stable, and breaking changes
will be reflected in a new major version number.

Some breaking changes compared to version 0.9 and earlier may affect you. This document
helps you upgrade from the earlier versions to 1.0.

Legacy cookiebar removal
========================

The largest breaking change is the removal of ``cookie_consent/cookiebar.js`` in favour
of the new ``cookie_consent/cookiebar.module.js``. It was deprecated in version 0.5.0b0.

Before
------

Before, you'd have code along the lines of:

.. code-block:: django

    {% load cookie_consent_tags %}

    <script type="text/javascript" src="{% static 'cookie_consent/cookiebar.js' %}"></script>

    <script type="{% js_type_for_cookie_consent request "analytics" "*:example.com" %}" data-varname="analytics">
      console.log('Analytics script activated.');
    </script>

    <script>
        showCookieBar({
          content: '<div class="cookie-bar"> <p>We use cookies to improve your browsing experience. By continuing to use our site, you agree to our use of cookies.</p> <a href="/accept_cookies" class="cc-cookie-accept">Accept</a> <a href="/decline_cookies" class="cc-cookie-decline">Decline</a> </div>',
          cookie_groups: ['analytics'],
          cookie_decline: '{% get_decline_cookie_groups_cookie_string request analytics %}',
          beforeDeclined: function () {
            console.log('User is about to decline cookies');
          },
        });
    </script>

Note the requirement of the ``cc-cookie-accept`` and ``cc-cookie-decline`` class names.

After
-----

.. code-block:: django

    {% load static cookie_consent_tags %}

    {% static "cookie_consent/cookiebar.module.js" as cookiebar_src %}
    {% url "cookie_consent_cookie_group_list" as url_cookies %}
    {% url 'cookie_consent_status' as status_url %}

    {% all_cookie_groups 'cookie-consent__cookie-groups' %}
    {# Emits a <script type="application/json" id="cookie-consent__cookie-groups">...</script> tag #}

    <template id="cookie-consent__cookie-bar">
        <div class="cookie-bar">
            <p>We use cookies to improve your browsing experience. By continuing to use
            our site, you agree to our use of cookies.</p>
            <a href="#" class="cc-cookie-accept">Accept</button>
            <a href="#" class="cc-cookie-decline">Decline</button>
        </div>
    </template>

    <template data-varname="analytics">
        <script>
          console.log('Analytics script activated.');
        </script>
    </template>

    <script type="module">
        import {showCookieBar} from '{{ cookiebar_src }}';
        showCookieBar({
          statusUrl: '{{ status_url|escapejs }}',
          templateSelector: '#cookie-consent__cookie-bar',
          cookieGroupsSelector: '#cookie-consent__cookie-groups',
          acceptSelector: '.cc-cookie-accept',
          declineSelector: '.cc-cookie-decline',
          onAccept: (acceptedGroups) => {
            document.querySelector('.cookie-bar').style.display = 'none';
            document.querySelector('body').classList.remove('with-cookie-bar');

            acceptedGroups
              .filter(group => ['analytics'].includes(group.varname))
              .forEach(group => {
                const scriptTemplate = document.querySelector(
                  `template[data-varname="${group.varname}"]`
                ).content;
                const scripts = templateNode.content.cloneNode(true);
                document.body.appendChild(scripts);
              })
            ;
          },
          onDecline: (declinedGroups) => {
            document.querySelector('.cookie-bar').style.display = 'none';
            document.querySelector('body').classList.remove('with-cookie-bar');

            console.log('User is about to decline cookies', declinedGroups);
          },
        });
    </script>

.. note::

    There is no replacement for the ``cookie_decline`` option, since the backend
    already results in the cookie being updated.

Removed template tags
---------------------

**Cookie string builders**

The tags below have been obsoleted due to the cookie not being updated in Javascript
any longer:

* ``get_accept_cookie_groups_cookie_string``
* ``get_decline_cookie_groups_cookie_string``

Instead, make an Ajax or Fetch call to the ``cookie_consent_accept`` or
``cookie_consent_decline`` views, e.g.:

.. code-block:: django

    {% url 'cookie_consent_accept' as accept_url %}

    {{ accept_url|json_script:'cc-accept-url' }}

.. code-block:: javascript

    const acceptUrl = JSON.parse(document.getElementById('cc-accept-url').content);

    const formData = new FormData();
    formData.append('cookie_groups', 'analytics');

    window.fetch(acceptUrl, {
      method: 'POST',
      body: formData,
      credentials: 'same-origin',
      headers: {
        'X-Cookie-Consent-Fetch': '1',
        'X-CSRFToken': csrftoken, // get this from the template or the status endpoint
      }
    });

The view response updates the cookie for the browser.

**Script tag helper**

Tag: ``js_type_for_cookie_consent``

Use the ``template`` approach to enable scripts without full page reloads, see above.

**Get accepted cookie varnames**

Tag: ``accepted_cookies``

This would cause view/page cache issues, as it outputs the cookie varnames for the
user.

Instead, use Javascript for dynamic cookie banner behaviour, ideally the new cookiebar
module which uses the ``cookie_consent_status`` endpoint under the hood.

Alternatively, you can write your own implementation and fetch the current cookie
consent status of the user through the ``cookie_consent_status`` view.

Accept/decline views now use form data and only support POST requests
=====================================================================

Before 1.0, the accept and decline URLs would (optionally) take a comma-separated
URL path variable for the cookie group varnames:

* ``.../accept/social,analytics/``
* ``.../decline/social,analytics/``

while the URLs without the varnames would imply that *all* cookie groups are being
accepted or declined.

Instead of using ``{% url 'cookie_consent_accept' varname='social,analytics' %}``, in 1.0, use proper form semantics instead:

.. code-block:: django

    <form action="{% url 'cookie_consent_accept' %}" method="post">
        {% csrf_token %}
        <input type="hidden" name="cookie_groups" value="social">
        <input type="hidden" name="cookie_groups" value="analytics">
        <button type="submit">Accept</button>
    </form>

or, to accept/decline all cookie groups, instead of
``{% url 'cookie_consent_accept_all' %}``:

.. code-block:: django

    <form action="{% url 'cookie_consent_accept' %}" method="post">
        {% csrf_token %}
        <input type="hidden" name="all_groups" value="true">
        <button type="submit">Accept all</button>
    </form>

.. warning:: The accept/decline views now **require** a CSRF token to be provided. The
   new cookiebar module already handles this.

.. warning:: The ``DELETE`` support to decline cookies is removed. Use ``POST`` instead.

Enable safety/strictness features
=================================

The legacy situation may have prevented you from applying some security hardening or
improvements in your project(s). Below, we point out some things that are possible now.

**Enable the httpOnly flag for cookie consent's own cookie**

Since the legacy Javascript that was writing directly to ``document.cookie`` is removed,
you can block access to this cookie from Javascript entirely now.

The default settings already enable this.

**Use a strict(er) Content-Security-Policy**

The legacy cookiebar required ``unsave-eval``, which is a serious weakening of
cross-site scripting protections. If you were using it because of django-cookie-consent,
you can now remove it after upgrading to the new cookiebar module.

**Site/view cache can be enabled**

By relying on ``template`` nodes, Javascript and JSON-based endpoints for the dynamic
per-user information, your main views/page templates can now be safely cached using
Django's cache framework without serving cookie consent information from the wrong
user.

The future
==========

Future major versions will primarily be caused by dropping support for old Python and/or
Django version, notably when those go end-of-life. The Python/Django backwards
compatibility has an excellent track record, so we don't expect big impacts from this.

One larger rework that is planned, is the overhaul of the cookie accept/decline
logging. This will likely be another major release, but we expect a smooth upgrade
path.


================================================
FILE: docs/quickstart.rst
================================================
==========
Quickstart
==========

Installation
============

Install django-cookie-consent from PyPI with pip (recommended):

.. code-block:: bash

    pip install django-cookie-consent

Alternatively, you can install directly from Github:

.. code-block:: bash

    pip install git+https://github.com/django-commons/django-cookie-consent@main#egg=django-cookie-consent

.. warning:: Installing from the main branch can be unstable. It is recommended to pin
   your installation to a specific git tag or commit.

Configuration
=============

#. Add ``cookie_consent`` to your ``INSTALLED_APPS``.

#. Add ``django.template.context_processors.request``
   to ``TEMPLATE_CONTEXT_PROCESSORS`` if it is not already added.

#. Include django-cookie-consent urls in ``urls.py``

    .. code-block:: python

        from django.urls import path

        urlpatterns = [
            ...,
            path("cookies/", include("cookie_consent.urls")),
            ...,
        ]

#. Run the ``migrate`` management command to update your database tables:

    .. code-block:: bash

        python manage.py migrate


================================================
FILE: docs/reference/api_middleware.rst
================================================
==========
Middleware
==========

CleanCookiesMiddleware
----------------------

.. code-block:: python

    MIDDLEWARE = [
        "cookie_consent.middleware.CleanCookiesMiddleware",
    ]


This middleware will automatically delete previously accepted first party cookies when
they are declined or not accepted/declined.

If you have enabled the ``COOKIE_CONSENT_OPT_OUT`` setting, then the cookies will only
be deleted if they are explicitly rejected.

.. note:: First party cookies are created by the host domain, while third party cookies
   are created by *other domains* than the one the user is visiting. For security
   reasons, browsers only allow your server to set first-party cookies.

   This gets even more confusing because third parties (such as analytics providers,
   ad-services...) DO set first party cookies rather than third-party, and store/read
   the information to then send it via another transport mechanism.

**Reference**

.. autoclass:: cookie_consent.middleware.CleanCookiesMiddleware
   :members:


================================================
FILE: docs/reference/api_models.rst
================================================
======
Models
======

.. automodule:: cookie_consent.models
   :members:


================================================
FILE: docs/reference/api_templatetags.rst
================================================
.. _api_templatetags:

=============
Template tags
=============

cookie_consent
--------------

.. automodule:: cookie_consent.templatetags.cookie_consent_tags
   :members:


================================================
FILE: docs/reference/api_util.rst
================================================
====
Util
====

.. automodule:: cookie_consent.util
   :members:


================================================
FILE: docs/reference/api_views.rst
================================================
=====
Views
=====

.. automodule:: cookie_consent.views
   :members:


================================================
FILE: docs/reference/index.rst
================================================
=============
API Reference
=============

.. toctree::
   :maxdepth: 2

   api_models
   api_views
   api_util
   api_templatetags
   api_middleware
   management_commands


================================================
FILE: docs/reference/management_commands.rst
================================================
====================
Management commands
====================

prune_cookie_consent_logs
=========================

.. code-block:: bash

   python manage.py prune_cookie_consent_logs [--days DAYS]

Deletes :class:`~cookie_consent.models.LogItem` records older than the
specified number of days.

**Options**

``--days DAYS``
    Number of days to use as the cutoff. Log items created more than this
    many days ago will be deleted. Defaults to ``90``.

**Example** — delete log items older than 30 days:

.. code-block:: bash

   python manage.py prune_cookie_consent_logs --days 30

This command is safe to run repeatedly.


================================================
FILE: docs/settings.rst
================================================
.. _settings:

========
Settings
========

The cookie settings (name, max-age, domain...) follow the same principles like
Django's built-in session cookie. For more details, please check that documenation
for more details about the meaning.

``COOKIE_CONSENT_NAME``
  name of consent cookie that remembers user choice

  Default: ``cookie_consent``.

``COOKIE_CONSENT_MAX_AGE``
  max-age of consent cookie, in seconds

  Default: 1 year

``COOKIE_CONSENT_DOMAIN``
  Domain to restrict the cookie to.

  Default: ``None``

``COOKIE_CONSENT_SECURE``
  Whether to only set the cookie in an HTTPS context.

  Default: ``False``

``COOKIE_CONSENT_HTTPONLY``
  Whether access from Javascript is blocked.

  Default: ``True``

``COOKIE_CONSENT_SAMESITE``
  The SameSite policy. Possible values are ``"Strict"``, ``"Lax"``, ``"None"`` or
  ``False`` to disable setting the flag.

  Default: ``"Lax"``

``COOKIE_CONSENT_DECLINE``
  decline value

  Default: ``-1``

``COOKIE_CONSENT_ENABLED``
  boolean or callable that receives request and returns a boolean.

  For example, if you want to enable cookie consent for debug or staff only:

  .. code-block:: python

      COOKIE_CONSENT_ENABLED = lambda r: DEBUG or (r.user.is_authenticated and r.user.is_staff)

  Default: ``True``

``COOKIE_CONSENT_OPT_OUT``
  Boolean value represents if cookies are opt-in (``False``) or opt-out (``True``).
  Opt-out cookies are set until declined.
  Opt-in cookies are set only if accepted.

  Default: ``False``

``COOKIE_CONSENT_CACHE_BACKEND``
  Alias for backend to use for caching.

  Default: ``default``

``COOKIE_CONSENT_LOG_ENABLED``
  Boolean value represents if the accept/decline user actions will be logged to the
  database. Turning it off might be useful for preventing your database from getting
  filled up with log items.

  Default: ``True`` 

``COOKIE_CONSENT_SUCCESS_URL``
  The success URL to redirect the user to after a successful accept/decline action. If
  a ``?next`` parameter is present in the request, then it takes priority over this
  setting.

  Default: the URL of the built-in cookie list view.


================================================
FILE: docs/usage.rst
================================================
=====
Usage
=====

Managing cookie groups and cookies
----------------------------------

Typically you manage the cookie groups and associated cookies through the admin
interface. You can of course integrate your own user interface if you prefer.

Cache invalidation is wired up at the model layer.

Checking for cookie consent in templates
----------------------------------------

django-cookie-consent provides some :ref:`template tags and filters <api_templatetags>`.
Most notable, you'll want to use:

.. currentmodule:: cookie_consent.templatetags.cookie_consent_tags

* :func:`cookie_group_accepted`
* :func:`cookie_group_declined`

to test whether a cookie group and/or specific cookie have been accepted or declined.

For example:

.. code-block:: django

  {% load cookie_consent_tags %}
  {% if request|cookie_group_accepted:"analytics" %}
    {# load 3rd party analytics #}
  {% endif %}

  {% if request|cookie_group_accepted:"analytics=_ga:example.com" %}
    {# load google analytics #}
  {% endif %}

Both filters takes the cookie group ``varname`` and an optional cookie name with
domain. If the cookie name with domain is used, the format is
``VARNAME=COOKIENAME:DOMAIN``.

Asking users for cookie consent in templates
--------------------------------------------

See :ref:`javascript`.

Checking for cookie consent in Python code
------------------------------------------

.. currentmodule:: cookie_consent.util

You can use the :func:`get_cookie_value_from_request` utility function to check consent
status in views and other Python code. This function powers the template filters from
above.

.. code-block:: python

    from cookie_consent.util import get_cookie_value_from_request

    def myview(request, *args, **kwargs):
        cc = get_cookie_value_from_request(request, "mycookies")
        if cc:
            # add cookie

You can check if a particular cookie in the group is accepted:

.. code-block:: python

    cc = get_cookie_value_from_request(request, "mycookies", "mycookie1:example.com")


Checking for 3rd party cookies dynamically
------------------------------------------

See :ref:`javascript_enable_scripts`.

.. versionremoved:: 1.0

    The ``js_type_for_cookie_consent`` tag was removed due to its reliance on
    ``unsave-eval`` (`MDN <https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src#unsafe_eval_expressions>`_).
    Instead, use the modern ``cookiebar.module.js`` and hook into the ``onAccept`` event
    and use ``template`` nodes.


================================================
FILE: js/.nvmrc
================================================
v24


================================================
FILE: js/README.md
================================================
# django-cookie-consent

Package containing the JS code for django-cookie-consent.

The cookiebar module is shipped in the Python package itself and available through
django's staticfiles mechanism. This package is aimed at users wishing to include the
assets in their own Javascript bundle through webpack/vite/...

[![PyPI version][badge:pypi]][pypi]

## Installation

```bash
npm install django-cookie-consent
```

You can now import the public API in your own bundle:

```ts
import {showCookieBar} from 'django-cookie-consent';
````

## TypeScript and ESM

The source code is written in TypeScript. The type declarations are shipped in the
published package.

We only publish ES modules and do not offer CommonJS.

## Building

Use ``nvm`` or your tool of choice to select the right NodeJS version:

```bash
nvm use
```

Building the NPM package:

```bash
npm run build
```

Lastly, the frontend toolchain also builds the cookiebar module bundle that's included
in the Python package:

```bash
npm run build:django-static
```

[pypi]: https://pypi.org/project/django-cookie-consent/
[badge:pypi]: https://img.shields.io/pypi/v/django-cookie-consent.svg


================================================
FILE: js/package.json
================================================
{
  "name": "django-cookie-consent",
  "version": "1.0.0",
  "description": "Frontend code for django-cookie-consent",
  "main": "lib/index.js",
  "type": "module",
  "files": [
    "lib/"
  ],
  "scripts": {
    "build": "tsc",
    "build:django-static": "esbuild --bundle src/cookiebar.ts --outfile=../cookie_consent/static/cookie_consent/cookiebar.module.js --target=es2018 --format=esm --sourcemap",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/django-commons/django-cookie-consent.git"
  },
  "keywords": [
    "django",
    "typescript",
    "cookie",
    "consent"
  ],
  "author": "Sergei Maertens",
  "license": "BSD-2-Clause",
  "bugs": {
    "url": "https://github.com/django-commons/django-cookie-consent/issues"
  },
  "homepage": "https://github.com/django-commons/django-cookie-consent#readme",
  "devDependencies": {
    "esbuild": "^0.25.3",
    "typescript": "^5.4.5"
  }
}


================================================
FILE: js/src/cookiebar.ts
================================================
/**
 * Cookiebar functionality, as a TS/JS module.
 *
 * About modules: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
 *
 * The code is organized here in a way to make the templates work with Django's page
 * cache. This means that anything user-specific (so different django session and even
 * cookie consent cookies) cannot be baked into the templates, as that breaks caches.
 *
 * The cookie bar operates on the following principles:
 *
 * - The developer using the library includes the desired template in their django
 *   templates, using the HTML <template> element. This contains the content for the
 *   cookie bar.
 * - The developer is responsible for loading some Javascript that loads this script.
 * - The main export of this script needs to be called (showCookieBar), with the
 *   appropriate options.
 * - The options include the backend URLs where the retrieve data, which selectors/DOM
 *   nodes to use for various functionality and the hooks to tap into the accept/decline
 *   life-cycle.
 * - When a user accepts or declines (all) cookies, the call to the backend is made via
 *   a fetch request, bypassing any page caches and preventing full-page reloads.
 */

/**
 * A serialized cookie group.
 *
 * See the backend model method `CookieGroup.as_json()`.
 */
export interface CookieGroup {
  varname: string;
  name: string;
  description: string;
  is_required: boolean;
}

export interface Options {
  statusUrl: string;
  // TODO: also accept element rather than selector?
  templateSelector: string;
  /**
   * DOM selector to the (script) tag holding the JSON-serialized cookie groups.
   *
   * This is typically rendered in a template with a template tag, e.g.
   *
   * ```django
   * {% all_cookie_groups 'cookie-consent__cookie-groups' %}
   * ```
   *
   * resulting in the selector: `'#cookie-consent__cookie-groups'`.
   */
  cookieGroupsSelector: string;
  acceptSelector: string;
  declineSelector: string;
  /**
   * Either a string (selector), DOMNode or null.
   *
   * If null, the bar is appended to the body. If provided, the node is used or looked
   * up.
   */
  insertBefore: string | HTMLElement | null;
  /**
   * Optional callback for when the cookie bar is being shown.
   *
   * You can use this to add a CSS class name to the body, for example.
   */
  onShow?: () => void;
  /**
   * Optional callback called when cookies are accepted.
   */
  onAccept?: (acceptedGroups: CookieGroup[], event?: MouseEvent) => void;
  /**
   * Optional callback called when cookies are accepted.
   */
  onDecline?: (declinedGroups: CookieGroup[], event?: MouseEvent) => void;
  /**
   * Name of the header to use for the CSRF token.
   *
   * If needed, this can be read/set via `settings.CSRF_HEADER_NAME` in the backend.
   */
  csrfHeaderName: string;
};

export interface CookieStatus {
  csrftoken: string;
  /**
   * Backend endpoint to POST to to accept the cookie groups.
   */
  acceptUrl: string;
  /**
   * Backend endpoint to POST to to decline the cookie groups.
   */
  declineUrl: string;
  /**
   * Array of accepted cookie group varnames.
   */
  acceptedCookieGroups: string[];
  /**
   * Array of declined cookie group varnames.
   */
  declinedCookieGroups: string[];
  /**
   * Array of undecided cookie group varnames.
   */
  notAcceptedOrDeclinedCookieGroups: string[];
}

const DEFAULT_FETCH_HEADERS: Record<string, string> = {
  'X-Cookie-Consent-Fetch': '1'
};

/**
 * A simple wrapper around window.fetch that understands the django-cookie-consent
 * backend endpoints.
 *
 * @private - while exported, use at your own risk. This class is not part of the
 * public API covered by SemVer.
 */
export class FetchClient {
  protected statusUrl: string;
  protected csrfHeaderName: string;
  protected cookieStatus: CookieStatus | null;

  constructor(statusUrl: string, csrfHeaderName: string) {
    this.statusUrl = statusUrl;
    this.csrfHeaderName = csrfHeaderName;
    this.cookieStatus = null;
  }

  async getCookieStatus(): Promise<CookieStatus> {
    if (this.cookieStatus === null) {
      const response = await window.fetch(
        this.statusUrl,
        {
          method: 'GET',
          credentials: 'same-origin',
          headers: DEFAULT_FETCH_HEADERS,
        }
      );
      this.cookieStatus = await response.json();
    }

    // type checker sanity check
    if (this.cookieStatus === null) {
      throw new Error('Unexpectedly received null cookie status');
    }
    return this.cookieStatus;
  };

  async saveCookiesStatusBackend (
    urlProperty: 'acceptUrl' | 'declineUrl',
    cookieGroups: CookieGroup[],
  ) {
    const cookieStatus = await this.getCookieStatus();
    const url = cookieStatus[urlProperty];
    if (!url) {
      throw new Error(`Missing url for ${urlProperty} - was the cookie status not loaded properly?`);
    }

    const formData = new FormData();
    for (const group of cookieGroups) {
      formData.append('cookie_groups', group.varname);
    }

    await window.fetch(url, {
      method: 'POST',
      body: formData,
      credentials: 'same-origin',
      headers: {
        ...DEFAULT_FETCH_HEADERS,
        [this.csrfHeaderName]: cookieStatus.csrftoken
      }
    });
  }
}

/**
 * Read the JSON script node contents and parse the content as JSON.
 *
 * The result is the list of available/configured cookie groups.
 * Use the status URL to get the accepted/declined status for an individual user.
 */
export const loadCookieGroups = (selector: string): CookieGroup[] => {
  const node = document.querySelector<HTMLScriptElement>(selector);
  if (!node) {
    throw new Error(`No cookie groups (script) tag found, using selector: '${selector}'`);
  }
  return JSON.parse(node.innerText);
};

const doInsertBefore = (beforeNode: HTMLElement, newNode: Node): void => {
  const parent = beforeNode.parentNode;
  if (parent === null) throw new Error('Reference node doesn\'t have a parent.');
  parent.insertBefore(newNode, beforeNode);
}

type RegisterEventsOptions = Pick<
  Options,
  'acceptSelector' | 'onAccept' | 'declineSelector' | 'onDecline'
> & Pick<
  CookieStatus,
  'acceptedCookieGroups' | 'declinedCookieGroups' | 'notAcceptedOrDeclinedCookieGroups'
> & {
  client: FetchClient,
  cookieBarNode: Element;
  cookieGroups: CookieGroup[];
}

/**
 * Register the accept/decline event handlers.
 *
 * Note that we can't just set the decline or accept cookie purely client-side, as the
 * cookie possibly has the httpOnly flag set.
 */
const registerEvents = ({
  client,
  cookieBarNode,
  cookieGroups,
  acceptSelector,
  onAccept,
  declineSelector,
  onDecline,
  acceptedCookieGroups: accepted,
  declinedCookieGroups: declined,
  notAcceptedOrDeclinedCookieGroups: undecided,
}: RegisterEventsOptions): void => {

  const acceptNode = cookieBarNode.querySelector<HTMLElement>(acceptSelector);
  if (acceptNode) {
    acceptNode.addEventListener('click', event => {
      event.preventDefault();
      const acceptedGroups = filterCookieGroups(cookieGroups, accepted.concat(undecided));
      onAccept?.(acceptedGroups, event);
      // trigger async action, but don't wait for completion
      client.saveCookiesStatusBackend('acceptUrl', acceptedGroups);
      cookieBarNode.parentNode!.removeChild(cookieBarNode);
    });
  }

  const declineNode = cookieBarNode.querySelector<HTMLElement>(declineSelector);
  if (declineNode) {
    declineNode.addEventListener('click', event => {
      event.preventDefault();
      const declinedGroups = filterCookieGroups(cookieGroups, declined.concat(undecided));
      onDecline?.(declinedGroups, event);
      // trigger async action, but don't wait for completion
      client.saveCookiesStatusBackend('declineUrl', declinedGroups);
      cookieBarNode.parentNode!.removeChild(cookieBarNode);
    });
  }
};

/**
 * Filter the cookie groups down to a subset of specified varnames.
 */
const filterCookieGroups = (cookieGroups: CookieGroup[], varNames: string[]) => {
  return cookieGroups.filter(group => varNames.includes(group.varname));
};

// See https://github.com/microsoft/TypeScript/issues/283
function cloneNode<T extends Node>(node: T) {
  return <T>node.cloneNode(true);
}

export const showCookieBar = async (options: Partial<Options> = {}): Promise<void> => {
  const {
    templateSelector = '#cookie-consent__cookie-bar',
    cookieGroupsSelector = '#cookie-consent__cookie-groups',
    acceptSelector = '.cookie-consent__accept',
    declineSelector = '.cookie-consent__decline',
    insertBefore = null,
    onShow,
    onAccept,
    onDecline,
    statusUrl = '',
    csrfHeaderName = 'X-CSRFToken', // Django's default, can be overridden with settings.CSRF_HEADER_NAME
  } = options;

  const cookieGroups = loadCookieGroups(cookieGroupsSelector);

  // no cookie groups -> abort, nothing to do
  if (!cookieGroups.length) return;

  const templateNode = document.querySelector<HTMLTemplateElement>(templateSelector);
  if (!templateNode) {
    throw new Error(`No (template) element found for selector '${templateSelector}'.`)
  }

  // insert before a given node, if specified, or append to the body as default behaviour
  const doInsert = insertBefore === null
    ? (cookieBarNode: Node) => document.querySelector('body')!.appendChild(cookieBarNode)
    : typeof insertBefore === 'string'
      ? (cookieBarNode: Node) => {
        const referenceNode = document.querySelector<HTMLElement>(insertBefore);
        if (referenceNode === null) throw new Error(`No element found for selector '${insertBefore}'.`)
        doInsertBefore(referenceNode, cookieBarNode);
      }
      : (cookieBarNode: Node) => doInsertBefore(insertBefore, cookieBarNode)
  ;

  if (!statusUrl) throw new Error('Missing status URL option, did you forget to pass the `statusUrl` option?');

  const client = new FetchClient(statusUrl, csrfHeaderName);
  const cookieStatus = await client.getCookieStatus();

  // calculate the cookie groups to invoke the callbacks. We deliberately fire those
  // without awaiting so that our cookie bar is shown/hidden as soon as possible.
  const {
    acceptedCookieGroups,
    declinedCookieGroups,
    notAcceptedOrDeclinedCookieGroups
  } = cookieStatus;

  const acceptedGroups = filterCookieGroups(cookieGroups, acceptedCookieGroups);
  if (acceptedGroups.length) onAccept?.(acceptedGroups);
  const declinedGroups = filterCookieGroups(cookieGroups, declinedCookieGroups);
  if (declinedGroups.length) onDecline?.(declinedGroups);

  // there are no (more) cookie groups to accept, don't show the bar
  if (!notAcceptedOrDeclinedCookieGroups.length) return;

  // grab the contents from the template node and add them to the DOM, optionally
  // calling the onShow callback
  const childToClone = templateNode.content.firstElementChild;
  if (childToClone === null) throw new Error('The cookie bar template element may not be empty.');
  const cookieBarNode = cloneNode(childToClone);
  registerEvents({
    client,
    cookieBarNode,
    cookieGroups,
    acceptSelector,
    onAccept,
    declineSelector,
    onDecline,
    acceptedCookieGroups,
    declinedCookieGroups,
    notAcceptedOrDeclinedCookieGroups,
  });
  doInsert(cookieBarNode);
  onShow?.();
};


================================================
FILE: js/src/index.ts
================================================
export {loadCookieGroups, showCookieBar} from './cookiebar.js';


================================================
FILE: js/tsconfig.json
================================================
{
  "compilerOptions": {
    "baseUrl": "src",
    "target": "es2017",
    "module": "esnext",
    "outDir": "lib",
    "declaration": true,
    "noErrorTruncation": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noImplicitAny": true,
    "strictBindCallApply": true,
    "strictNullChecks": true,
  },
  "include": ["src"],
  "exclude": ["node_modules", "lib"],
}


================================================
FILE: pyproject.toml
================================================
[build-system]
requires = ["setuptools>=77.0.3"]
build-backend = "setuptools.build_meta"

[project]
name = "django-cookie-consent"
description = "Django cookie consent application"
authors = [
    {name = "Informatika Mihelac", email = "bmihelac@mihelac.org"}
]
readme = "README.md"
license = "BSD-2-Clause-first-lines"
license-files = ["LICENSE"]
keywords = ["cookies", "cookie-consent", "cookie bar"]
classifiers = [
    "Development Status :: 5 - Production/Stable",
    "Framework :: Django",
    "Framework :: Django :: 4.2",
    "Framework :: Django :: 5.2",
    "Framework :: Django :: 6.0",
    "Intended Audience :: Developers",
    "Operating System :: Unix",
    "Operating System :: MacOS",
    "Operating System :: Microsoft :: Windows",
    "Operating System :: OS Independent",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
    "Programming Language :: Python :: 3.14",
    "Topic :: Software Development :: Libraries :: Python Modules",
]
requires-python = ">=3.10"
dependencies = [
    "django>=4.2",
    "django-appconf",
]
dynamic = ["version"]

[project.urls]
Documentation = "https://django-cookie-consent.readthedocs.io/en/latest/"
Changelog = "https://github.com/django-commons/django-cookie-consent/blob/main/docs/changelog.rst"
"Bug Tracker" = "https://github.com/django-commons/django-cookie-consent/issues"
"Source Code" = "https://github.com/django-commons/django-cookie-consent"

[project.optional-dependencies]
tests = [
    "pytest",
    "pytest-cov",
    "pytest-django",
    "pytest-playwright",
    "hypothesis",
    "tox",
    "ruff",
]
docs = [
    "sphinx",
    "sphinx-rtd-theme",
]
release = [
    "bump-my-version",
]

[tool.setuptools.dynamic]
version = {attr = "cookie_consent.__version__"}

[tool.setuptools.packages.find]
include = ["cookie_consent*"]
namespaces = true

[tool.pytest.ini_options]
testpaths = ["tests"]
DJANGO_SETTINGS_MODULE = "testapp.settings"
markers = [
    "e2e: mark tests as end-to-end tests, using playwright (deselect with '-m \"not e2e\"')",
]

[tool.coverage.run]
branch = true
source = ["cookie_consent"]
omit = [
    # migrations run while django initializes the test db
    "*/migrations/*",
]

[tool.coverage.report]
skip_covered = true
exclude_also = [
    "if (typing\\.)?TYPE_CHECKING:",
    "@(typing\\.)?overload",
    "class .*\\(.*Protocol.*\\):",
    "@(abc\\.)?abstractmethod",
    "raise NotImplementedError",
    "\\.\\.\\.",
    "\\bpass$",
]

[tool.ruff.lint]
extend-select = [
    "UP",  # pyupgrade
    "DJ",  # django
    "LOG", # logging
    "G",
    "I",   # isort
    "E",   # pycodestyle
    "F",   # pyflakes
    "PERF",# perflint
    "B",   # flake8-bugbear
]

[tool.ruff.lint.isort]
combine-as-imports = true
section-order = [
    "future",
    "standard-library",
    "django",
    "third-party",
    "first-party",
    "local-folder",
]

[tool.ruff.lint.isort.sections]
"django" = ["django"]

[tool.bumpversion]
current_version = "1.0.0"
parse = """(?x)
    (?P<major>0|[1-9]\\d*)\\.
    (?P<minor>0|[1-9]\\d*)\\.
    (?P<patch>0|[1-9]\\d*)
    (?:
        -                             # dash separator for pre-release section
        (?P<pre_l>[a-zA-Z-]+)\\.      # pre-release label
        (?P<pre_n>0|[1-9]\\d*)        # pre-release version number
    )?                                # pre-release section is optional
"""
serialize = [
    "{major}.{minor}.{patch}-{pre_l}.{pre_n}",
    "{major}.{minor}.{patch}",
]
search = "{current_version}"
replace = "{new_version}"
regex = false
ignore_missing_version = false
ignore_missing_files = false
tag = false
sign_tags = false
tag_name = "{new_version}"
tag_message = ":bookmark: Bump version to {new_version} and update changelog"
allow_dirty = false
commit = false
message = ":bookmark: Bump version to {new_version} and update changelog"
commit_args = ""
setup_hooks = []
pre_commit_hooks = [
    "cd js && npm i",  # ensure that package-lock.json is updated
]
post_commit_hooks = []

[tool.bumpversion.parts.pre_l]
values = ["beta", "final"]
optional_value = "final"

[[tool.bumpversion.files]]
filename = "cookie_consent/__init__.py"

[[tool.bumpversion.files]]
filename = "js/package.json"
search = "  \"version\": \"{current_version}\""
replace = "  \"version\": \"{new_version}\""


================================================
FILE: testapp/__init__.py
================================================


================================================
FILE: testapp/fixture.json
================================================
[
{
    "model": "cookie_consent.cookiegroup",
    "pk": 1,
    "fields": {
        "varname": "social",
        "name": "Social",
        "description": "",
        "is_required": false,
        "is_deletable": true,
        "ordering": 1,
        "created": "2023-06-11T13:31:03.677Z"
    }
},
{
    "model": "cookie_consent.cookiegroup",
    "pk": 2,
    "fields": {
        "varname": "optional",
        "name": "Optional",
        "description": "",
        "is_required": false,
        "is_deletable": true,
        "ordering": 2,
        "created": "2023-06-11T13:40:26.842Z"
    }
},
{
    "model": "cookie_consent.cookiegroup",
    "pk": 3,
    "fields": {
        "varname": "required",
        "name": "Required",
        "description": "",
        "is_required": true,
        "is_deletable": false,
        "ordering": 0,
        "created": "2023-06-11T13:40:58.798Z"
    }
},
{
    "model": "cookie_consent.cookie",
    "pk": 1,
    "fields": {
        "cookiegroup": 3,
        "name": "sessionid",
        "description": "Session ID to stay logged in.",
        "path": "/",
        "domain": "",
        "created": "2023-06-11T13:41:39.982Z"
    }
},
{
    "model": "cookie_consent.cookie",
    "pk": 2,
    "fields": {
        "cookiegroup": 1,
        "name": "dummy",
        "description": "",
        "path": "/",
        "domain": ".google.com",
        "created": "2023-06-11T13:43:26.360Z"
    }
},
{
    "model": "cookie_consent.cookie",
    "pk": 3,
    "fields": {
        "cookiegroup": 2,
        "name": "sample",
        "description": "",
        "path": "/",
        "domain": "",
        "created": "2023-06-11T13:43:43.888Z"
    }
}
]


================================================
FILE: testapp/settings.py
================================================
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.sites",
    "django.contrib.staticfiles",
    "django.contrib.messages",
    "cookie_consent",
    "testapp",
]
SITE_ID = 1

ROOT_URLCONF = "testapp.urls"

DEBUG = True

USE_TZ = True

STATIC_URL = "/static/"
STATICFILES_DIRS = [
    BASE_DIR / "static",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.contrib.auth.context_processors.auth",
                "django.template.context_processors.debug",
                "django.template.context_processors.i18n",
                "django.template.context_processors.media",
                "django.template.context_processors.static",
                "django.template.context_processors.tz",
                "django.template.context_processors.request",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

SECRET_KEY = "2n6)=vnp8@bu0om9d05vwf7@=5vpn%)97-!d*t4zq1mku%0-@j"

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": os.path.join(os.path.dirname(__file__), "database.db"),
    }
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"

MIDDLEWARE_CLASSES = MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
    "cookie_consent.middleware.CleanCookiesMiddleware",
]

# Use the default appconf settings. In tests, use @override_settings if you need
# some specific setting values.
# COOKIE_CONSENT_NAME = "cookie_consent"


================================================
FILE: testapp/static/styles.css
================================================
body.with-cookie-bar {
  padding-top: 35px;
}

.cookie-bar {
  position: fixed;
  width: 100%;
  top: 0;
  text-align: center;
  height: 25px;
  line-height: 25px;
  background: #eee;
}


================================================
FILE: testapp/templates/show-cookie-bar-script.html
================================================
{% load static cookie_consent_tags %}
{% static "cookie_consent/cookiebar.module.js" as cookiebar_src %}
<script type="module">
    import {showCookieBar} from '{{ cookiebar_src }}';

    const showShareButton = () => {
        const template = document.getElementById('show-share-button')
        const showButtonScript = template.content.cloneNode(true);
        document.body.appendChild(showButtonScript);
    };

    showCookieBar({
      statusUrl: '{{ status_url|escapejs }}',
      templateSelector: '#cookie-consent__cookie-bar',
      cookieGroupsSelector: '#cookie-consent__cookie-groups',
      onShow: () => document.querySelector('body').classList.add('with-cookie-bar'),
      onAccept: (cookieGroups) => {
        document.querySelector('body').classList.remove('with-cookie-bar');
        const hasSocial = cookieGroups.find(g => g.varname == 'social') !== undefined;
        hasSocial && showShareButton();
      },
      onDecline: () => document.querySelector('body').classList.remove('with-cookie-bar'),
    });

    document.getElementById('loading-marker').style.display = 'inline';
</script>

<template id="show-share-button">
    <script type="text/javascript">
      document.getElementById('share-button').style.display = 'block';
    </script>
</template>


================================================
FILE: testapp/templates/test_page.html
================================================
{% load static %}
{% load cookie_consent_tags %}
{% url "cookie_consent_cookie_group_list" as url_cookies %}
<!DOCTYPE html>
<html>
    <head>
        <link href="{% static 'styles.css' %}" rel="stylesheet" />
    </head>

  <body>
    <h1>Test page</h1>

    <span id="loading-marker" style="display:none">page-done-loading</span>

    <h2>Social cookies</h2>
    <p>
        sharing button is displayed below only if "Social" cookies are accepted.
        <button id="share-button" type="button" style="display:none">SHARE</button>
    </p>

    {# NOTE - this section is not compatible with django's full page cache #}
    <h2>Optional cookies</h2>
    {% if request|cookie_group_accepted:"optional" %}
        <p>"optional" cookies accepted</p>
    {% elif request|cookie_group_declined:"optional" %}
        <p>"optional" cookies declined</p>
    {% else %}
        <p>"optional" cookies not accepted or declined</p>
    {% endif %}

    {# not existing cookie group #}
    {% if request|cookie_group_accepted:"foo=*:.foo.com" %}
        <p>None existing cookies</p>
    {% endif %}
    {# END of section not compatible with page cache #}

    <p>
        <a href="{{ url_cookies }}">Cookies policy</a>
    </p>

    {% if request|cookie_consent_enabled %}
        {% not_accepted_or_declined_cookie_groups request as cookie_groups %}

        {# Set up the data and template for dynamic JS cookie bar #}
        {% all_cookie_groups 'cookie-consent__cookie-groups' %}
        {% comment %}
            NOTE: to make this work with page caches, you'd typically leave out the
            dynamic parts (such as {{ cookie_groups }}) and handle that dynamically
            in JS.

            For example, by getting the information dynamically from a template, putting
            that in the template fragment and eventually calling the code from
            cookiebar.module.js.

            FIXME: add this to the docs
        {% endcomment %}
        <template id="cookie-consent__cookie-bar">
            {% with cookie_groups=cookie_groups|join:", " %}
            <div class="cookie-bar">
                This site uses {{ cookie_groups }} cookies for better performance and user experience.
                Do you agree to use these cookies?
                {# Button is the more accessible role, but an anchor tag would also work #}
                <button type="button" class="cookie-consent__accept">Accept</button>
                <button type="button" class="cookie-consent__decline">Decline</button>
                <a href="{{ url_cookies }}">Cookies info</a>
            </div>
            {% endwith %}
        </template>
        {% url 'cookie_consent_status' as status_url %}
        {% include "./show-cookie-bar-script.html" with status_url=status_url %}

    {% endif %}

  </body>
</html>


================================================
FILE: testapp/urls.py
================================================
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path

from .views import TestPageView

urlpatterns = [
    path("admin/", admin.site.urls),
    path("cookies/", include("cookie_consent.urls")),
    path("", TestPageView.as_view(), name="test_page"),
]

urlpatterns += staticfiles_urlpatterns()


================================================
FILE: testapp/views.py
================================================
from django.views.generic import TemplateView

from cookie_consent.util import get_cookie_value_from_request


class TestPageView(TemplateView):
    template_name = "test_page.html"

    def _should_set_cookie(self) -> bool:
        if "force" in self.request.GET:
            return True

        cookie_value = get_cookie_value_from_request(self.request, "optional")
        return cookie_value is True

    def get(self, request, *args, **kwargs):
        response = super().get(request, *args, **kwargs)
        if self._should_set_cookie():
            val = "optional cookie set from django"
            response.set_cookie("optional_test_cookie", val)
        return response


================================================
FILE: tests/__init__.py
================================================


================================================
FILE: tests/conftest.py
================================================
import os
from io import StringIO
from pathlib import Path

from django.core.management import call_command

import pytest

from cookie_consent.cache import delete_cache
from cookie_consent.models import Cookie, CookieGroup

TEST_APP_DIR = Path(__file__).parent.parent.resolve() / "testapp"

# otherwise pytest-playwright and pytest-django don't play nice :(
# See https://github.com/microsoft/playwright-pytest/issues/46
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "1"


@pytest.fixture
def required_cookiegroup(db):
    group = CookieGroup.objects.create(
        varname="required",
        name="Functional cookies",
        is_required=True,
        is_deletable=False,
    )
    Cookie.objects.create(cookiegroup=group, name="sessionid")
    return group


@pytest.fixture
def optional_cookiegroup(db):
    group = CookieGroup.objects.create(
        varname="optional",
        name="Optional cookies",
        is_required=False,
        is_deletable=True,
    )
    Cookie.objects.create(cookiegroup=group, name="evil-tracking")
    return group


@pytest.fixture
def load_testapp_fixture(transactional_db):
    fixture = str(TEST_APP_DIR / "fixture.json")
    call_command("loaddata", fixture, stdout=StringIO())


@pytest.fixture(scope="function", autouse=True)
def before_each_after_each():
    yield
    delete_cache()


================================================
FILE: tests/test_admin.py
================================================
from django.test import Client
from django.urls import reverse

import pytest
from pytest_django.asserts import assertContains

from cookie_consent.models import CookieGroup

pytestmark = pytest.mark.django_db


def test_warning_icon_for_missing_cookies(
    admin_client: Client,
    required_cookiegroup: CookieGroup,
    optional_cookiegroup: CookieGroup,
):
    optional_cookiegroup.cookie_set.all().delete()

    admin_list_response = admin_client.get(
        reverse("admin:cookie_consent_cookiegroup_changelist")
    )

    assert admin_list_response.status_code == 200
    assertContains(admin_list_response, "admin/img/icon-alert", count=1)


================================================
FILE: tests/test_cache.py
================================================
from django.test import TestCase, override_settings

from cookie_consent.cache import delete_cache, get_cookie, get_cookie_group
from cookie_consent.models import Cookie, CookieGroup


class CacheTest(TestCase):
    def setUp(self):
        super().setUp()
        self.addCleanup(delete_cache)

        self.cookie_group = CookieGroup.objects.create(
            varname="optional",
            name="Optional",
        )
        self.cookie = Cookie.objects.create(
            cookiegroup=self.cookie_group,
            name="foo",
        )

    def test_get_cookie_group(self):
        self.assertEqual(get_cookie_group("optional"), self.cookie_group)

    def test_get_cookie(self):
        cookie_group = get_cookie_group("optional")
        self.assertEqual(get_cookie(cookie_group, "foo", ""), self.cookie)

    def test_caching(self):
        CookieGroup.objects.create(
            varname="foo",
            name="Foo",
        )
        with self.assertNumQueries(2):
            cookie_group = get_cookie_group("optional")
            get_cookie_group("foo")
            get_cookie(cookie_group, "foo", "")

    def test_caching_expire(self):
        with self.assertNumQueries(2):
            cookie_group = get_cookie_group("optional")

        self.cookie_group.name = "Bar"
        self.cookie_group.save()

        with self.assertNumQueries(2):
            cookie_group = get_cookie_group("optional")
        self.assertEqual(cookie_group.name, "Bar")

    @override_settings(
        CACHES={"tests": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}},
        COOKIE_CONSENT_CACHE_BACKEND="tests",
    )
    def test_can_override_cache_settings(self):
        """
        Assert that the cache backend/settings can be swapped out in tests.

        Regression test for #41
        """
        CookieGroup.objects.create(
            varname="foo",
            name="Foo",
        )
        # expect multiple calls to not be cached because of the no-op cache
        with self.assertNumQueries(2 + 2):
            get_cookie_group("optional")
            get_cookie_group("foo")


================================================
FILE: tests/test_cookie_group_model.py
================================================
import pytest

from cookie_consent.models import CookieGroup


def test_natural_key():
    group = CookieGroup(varname="social")

    assert group.natural_key() == ("social",)


@pytest.mark.django_db
def test_load_by_natural_key():
    social_group = CookieGroup.objects.create(varname="social")
    CookieGroup.objects.create(varname="other")

    loaded_group = CookieGroup.objects.get_by_natural_key("social")

    assert loaded_group == social_group


================================================
FILE: tests/test_cookie_model.py
================================================
import pytest

from cookie_consent.models import Cookie, CookieGroup


def test_natural_key():
    cookie = Cookie(
        cookiegroup=CookieGroup(varname="analytics"), name="trck", domain="example.com"
    )

    assert cookie.natural_key() == ("trck", "example.com", "analytics")


@pytest.mark.django_db
def test_load_by_natural_key():
    social_group = CookieGroup.objects.create(varname="social")
    cookie = Cookie.objects.create(
        cookiegroup=social_group, name="trck", domain="example.com"
    )
    Cookie.objects.create(cookiegroup=social_group, name="other", domain="example.com")

    loaded_cookie = Cookie.objects.get_by_natural_key("trck", "example.com", "social")

    assert loaded_cookie == cookie


================================================
FILE: tests/test_javascript_cookiebar.py
================================================
"""
Test the behaviour of the dynamic (JS based) cookiebar module.

See docs: https://playwright.dev/python/docs/test-runners for CLI options.
"""

from django.urls import reverse

import pytest
from playwright.sync_api import Page, expect

pytestmark = [pytest.mark.django_db, pytest.mark.e2e]


COOKIE_BAR_CONTENT = """
This site uses Social, Optional cookies for better performance and user experience.
Do you agree to use these cookies?
"""


@pytest.fixture(scope="function", autouse=True)
def before_each_after_each(live_server, page: Page, load_testapp_fixture):
    test_page_url = f"{live_server.url}{reverse('test_page')}"
    page.goto(test_page_url)
    marker = page.get_by_text("page-done-loading")
    expect(marker).to_be_visible()
    yield


def test_cookiebar_shows_initially(page: Page):
    cookiebar = page.get_by_text(COOKIE_BAR_CONTENT)
    expect(cookiebar).to_be_visible()


def test_cookiebar_accept_all(page: Page):
    accept_button = page.get_by_role("button", name="Accept")
    expect(accept_button).to_be_visible()

    accept_button.click()

    expect(page.get_by_text(COOKIE_BAR_CONTENT)).not_to_be_visible()
    share_button = page.get_by_role("button", name="SHARE")
    expect(share_button).to_be_visible()


def test_cookiebar_decline_all(page: Page):
    decline_button = page.get_by_role("button", name="Decline")
    expect(decline_button).to_be_visible()

    decline_button.click()

    expect(page.get_by_text(COOKIE_BAR_CONTENT)).not_to_be_visible()
    share_button = page.get_by_role("button", name="SHARE")
    expect(share_button).not_to_be_visible()


@pytest.mark.parametrize("btn_text", ["Accept", "Decline"])
def test_cookiebar_not_shown_anymore_after_accept_or_decline(btn_text: str, page: Page):
    expect(page.get_by_text(COOKIE_BAR_CONTENT)).to_be_visible()

    button = page.get_by_role("button", name=btn_text)
    expect(button).to_be_visible()

    button.click()
    expect(page.get_by_text(COOKIE_BAR_CONTENT)).not_to_be_visible()

    page.reload()
    expect(page.get_by_text(COOKIE_BAR_CONTENT)).not_to_be_visible()


def test_on_accept_handler_runs_on_load(page: Page, live_server):
    accept_button = page.get_by_role("button", name="Accept")
    accept_button.click()
    page.wait_for_load_state("networkidle")
    # wait for fetch calls to complete & avoid test race conditions...
    share_button = page.get_by_role("button", name="SHARE")
    expect(share_button).to_be_visible()

    test_page_url = f"{live_server.url}{reverse('test_page')}"
    page.goto(test_page_url)
    marker = page.get_by_text("page-done-loading")
    expect(marker).to_be_visible()

    share_button = page.get_by_role("button", name="SHARE")
    expect(share_button).to_be_visible()


================================================
FILE: tests/test_middleware.py
================================================
from django.test import TestCase, override_settings
from django.test.client import RequestFactory
from django.urls import reverse

from cookie_consent.cache import delete_cache
from cookie_consent.models import Cookie, CookieGroup

factory = RequestFactory()


@override_settings(COOKIE_CONSENT_OPT_OUT=False)
class CleanCookiesMiddlewareTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        super().setUpTestData()

        cls.cookie_group = CookieGroup.objects.create(
            varname="optional",
            name="Optional (test) cookies",
        )
        cls.cookie = Cookie.objects.create(
            cookiegroup=cls.cookie_group,
            name="optional_test_cookie",
            domain="127.0.0.1",
            path="/",
        )

    def setUp(self):
        super().setUp()
        self.addCleanup(delete_cache)

    def _accept_and_set_cookie(self):
        with self.subTest("initial setup"):
            # ensure we start with cookies first being accepted
            endpoint = reverse("cookie_consent_accept")

            consent_response = self.client.post(
                endpoint,
                data={"cookie_groups": ["optional"]},
                follow=True,
                headers={"x-requested-with": "XMLHttpRequest"},
            )
            self.assertEqual(consent_response.status_code, 200)

            # hit the test page to set the cookie
            self.client.get(reverse("test_page"))
            self.assertIn("optional_test_cookie", self.client.cookies)

    def assertCookieDeleted(self, name: str):
        if name not in self.client.cookies:
            self.fail(
                "Cookie not present in client cookies, which is required to delete it"
            )

        # deleting a cookie is done by setting the expiry date to the past/max-age 0
        # so that the browser effectively is instructed to delete the cookie
        cookie = self.client.cookies[name]
        cookie_dict = dict(cookie)
        self.assertEqual(cookie_dict["max-age"], 0)
        self.assertIn("1970", cookie_dict["expires"])
        self.assertEqual(cookie.value, "")

    def test_middleware_decline_previously_accepted_cookiegroup_cookies_are_deleted(
        self,
    ):
        self._accept_and_set_cookie()

        with self.subTest("decline prevously accepted group"):
            url = reverse("cookie_consent_decline")

            decline_response = self.client.post(
                url, data={"cookie_groups": ["optional"]}, follow=True
            )

            self.assertEqual(decline_response.status_code, 200)

        # fetch the test page and assert the middleware deleted the cookie
        self.client.get(reverse("test_page"))

        self.assertCookieDeleted("optional_test_cookie")

    def test_middleware_no_cookie_consent_cookie_present_cookies_are_deleted(self):
        self._accept_and_set_cookie()
        # Delete cookie_consent cookie
        del self.client.cookies["cookie_consent"]

        # fetch the test page and assert the middleware deleted the cookie
        self.client.get(reverse("test_page"))

        # Check if cookie_consent cookie is deleted
        self.assertCookieDeleted("optional_test_cookie")

    def test_cookie_consent_disabled(self):
        self._accept_and_set_cookie()

        with override_settings(COOKIE_CONSENT_ENABLED=False):
            self.client.get(reverse("test_page"))

        cookie = self.client.cookies["optional_test_cookie"]
        self.assertEqual(cookie.value, "optional cookie set from django")

    def test_cookie_group_not_deletable(self):
        self.cookie_group.is_deletable = False
        self.cookie_group.save()
        self._accept_and_set_cookie()

        self.client.get(reverse("test_page"))

        cookie = self.client.cookies["optional_test_cookie"]
        self.assertEqual(cookie.value, "optional cookie set from django")

    @override_settings(COOKIE_CONSENT_OPT_OUT=True)
    def test_with_opt_out_behaviour(self):
        # set the cookie
        self.client.get(reverse("test_page"), {"force": "1"})

        # call view to run middleware
        self.client.get(reverse("test_page"))

        cookie = self.client.cookies["optional_test_cookie"]
        self.assertEqual(cookie.value, "optional cookie set from django")


================================================
FILE: tests/test_models.py
================================================
import string
from copy import deepcopy

from django.conf import settings
from django.core.cache import caches
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings

import pytest
from hypothesis import given, strategies as st

from cookie_consent.cache import CACHE_KEY, delete_cache
from cookie_consent.models import Cookie, CookieGroup, validate_cookie_name

patch_caches = override_settings(
    COOKIE_CONSENT_CACHE_BACKEND="tests",
    CACHES={
        **deepcopy(settings.CACHES),
        "tests": {
            "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
        },
    },
)


class CacheMixin:
    def populateCache(self):
        cache = caches["tests"]
        cache.set(CACHE_KEY, {}, timeout=3600)

    def assertCacheNotPopulated(self):
        cache = caches["tests"]
        has_key = cache.has_key(CACHE_KEY)
        self.assertFalse(has_key)


@patch_caches
class CookieGroupTest(CacheMixin, TestCase):
    def setUp(self):
        self.addCleanup(delete_cache)

        self.cookie_group = CookieGroup.objects.create(
            varname="optional",
            name="Optional",
        )
        self.cookie = Cookie.objects.create(
            cookiegroup=self.cookie_group,
            name="foo",
        )

    def test_get_version(self):
        self.assertEqual(
            self.cookie_group.get_version(), self.cookie.created.isoformat()
        )

    def test_bulk_delete(self):
        self.populateCache()

        deleted_objs_count, _ = CookieGroup.objects.filter(
            id=self.cookie_group.id
        ).delete()

        # Deleting a CookieGroup also deletes the associated Cookies, that's why we
        # expect a count of 2.
        self.assertEqual(deleted_objs_count, 2)
        self.assertCacheNotPopulated()

    def test_bulk_update(self):
        self.populateCache()

        updated_objs_count = CookieGroup.objects.filter(id=self.cookie_group.id).update(
            name="Optional2"
        )

        self.assertEqual(updated_objs_count, 1)
        self.assertCacheNotPopulated()


@patch_caches
class CookieTest(CacheMixin, TestCase):
    def setUp(self):
        self.addCleanup(delete_cache)

        self.cookie_group = CookieGroup.objects.create(
            varname="optional",
            name="Optional",
        )
        self.cookie = Cookie.objects.create(
            cookiegroup=self.cookie_group,
            name="foo",
            domain=".example.com",
        )

    def test_varname(self):
        self.assertEqual(self.cookie.varname, "optional=foo:.example.com")

    def test_bulk_delete(self):
        self.populateCache()

        deleted_objs_count, _ = Cookie.objects.filter(id=self.cookie.id).delete()

        self.assertEqual(deleted_objs_count, 1)
        self.assertCacheNotPopulated()

    def test_bulk_update(self):
        self.populateCache()

        updated_objs_count = Cookie.objects.filter(id=self.cookie.id).update(
            name="foo2"
        )

        self.assertEqual(updated_objs_count, 1)
        self.assertCacheNotPopulated()


@given(
    name=st.text(
        alphabet=string.ascii_letters + string.digits + "-_",
        min_size=1,
    )
)
def test_valid_cookie_name_does_not_raise(name):
    try:
        validate_cookie_name(name)
    except ValidationError:
        pytest.fail(reason=f"Expected {name} to be valid")


@pytest.mark.parametrize(
    "name",
    (
        "space inside",
        "a!b",
        "$",
    ),
)
def test_invalid_cookie_name_raises(name: str):
    with pytest.raises(ValidationError):
        validate_cookie_name(name)


================================================
FILE: tests/test_prune_cookie_consent_logs.py
================================================
from datetime import timedelta
from io import StringIO

from django.core.management import call_command
from django.utils import timezone

import pytest

from cookie_consent.models import ACTION_ACCEPTED, LogItem


@pytest.fixture
def make_log_item(optional_cookiegroup):
    def _make(days_old: int = 0, **kwargs) -> LogItem:
        item = LogItem.objects.create(
            action=ACTION_ACCEPTED,
            cookiegroup=optional_cookiegroup,
            version="1",
        )
        created = timezone.now() - timedelta(days=days_old, **kwargs)
        LogItem.objects.filter(pk=item.pk).update(created=created)
        return item

    return _make


def test_prunes_old_items_with_default_days(make_log_item):
    old = make_log_item(days_old=91)
    recent = make_log_item(days_old=10)

    call_command("prune_cookie_consent_logs", stdout=StringIO(), stderr=StringIO())

    assert not LogItem.objects.filter(pk=old.pk).exists()
    assert LogItem.objects.filter(pk=recent.pk).exists()


def test_prunes_items_older_than_custom_days(make_log_item):
    old = make_log_item(days_old=31)
    recent = make_log_item(days_old=5)

    call_command(
        "prune_cookie_consent_logs", days=30, stdout=StringIO(), stderr=StringIO()
    )

    assert not LogItem.objects.filter(pk=old.pk).exists()
    assert LogItem.objects.filter(pk=recent.pk).exists()


def test_no_items_deleted_when_all_recent(make_log_item):
    item = make_log_item(days_old=1)

    call_command("prune_cookie_consent_logs", stdout=StringIO(), stderr=StringIO())

    assert LogItem.objects.filter(pk=item.pk).exists()


def test_output_reports_deleted_count(make_log_item):
    make_log_item(days_old=91)
    make_log_item(days_old=91)

    out = StringIO()
    call_command("prune_cookie_consent_logs", stdout=out)

    assert out.getvalue().strip() == "Deleted 2 log item(s) older than 90 days."


def test_strict_days_cutoff(make_log_item):
    """
    Test that the cutoff is strict and doesn't just look at the date part.
    """
    # Item created exactly 90 days ago + 4 hour (outside the prune window, so deleted)
    old = make_log_item(days_old=90, hours=4)
    # Item created exactly 90 days ago - 1 hour (inside the prune window, so kept)
    recent = make_log_item(days_old=89, hours=23)

    call_command("prune_cookie_consent_logs", stdout=StringIO(), stderr=StringIO())

    assert not LogItem.objects.filter(pk=old.pk).exists()
    assert LogItem.objects.filter(pk=recent.pk).exists()


================================================
FILE: tests/test_settings.py
================================================
from django.urls import reverse

import pytest

pytestmark = pytest.mark.django_db


@pytest.mark.parametrize(
    ("setting", "value", "assertion"),
    [
        ("MAX_AGE", 3600, {"max-age": 3600}),
        ("DOMAIN", None, {"domain": ""}),
        ("DOMAIN", "example.com", {"domain": "example.com"}),
        ("SECURE", True, {"secure": True}),
        ("SECURE", False, {"secure": ""}),
        ("SECURE", None, {"secure": ""}),
        ("HTTPONLY", True, {"httponly": True}),
        ("HTTPONLY", None, {"httponly": ""}),
        ("HTTPONLY", False, {"httponly": ""}),
        ("SAMESITE", "Lax", {"samesite": "Lax"}),
        ("SAMESITE", None, {"samesite": ""}),
        ("SAMESITE", False, {"samesite": ""}),
        ("SAMESITE", "None", {"samesite": "None"}),
        ("SAMESITE", "Strict", {"samesite": "Strict"}),
    ],
)
@pytest.mark.django_db
def test_cookie_consent_cookie_options(
    settings, client, optional_cookiegroup, setting, value, assertion
):
    accept_url = reverse("cookie_consent_accept")
    setattr(settings, f"COOKIE_CONSENT_{setting}", value)

    client.post(accept_url, data={"all_groups": "true"})

    cookie = client.cookies[settings.COOKIE_CONSENT_NAME]
    for key, expected in assertion.items():
        assert cookie[key] == expected


================================================
FILE: tests/test_templatetags.py
================================================
from textwrap import dedent
from typing import Any

from django.template import Context, Template

import pytest


def render(tpl: str, context: dict[str, Any] | None = None) -> str:
    template = Template(dedent(tpl).strip())
    return template.render(Context(context))


NOT_ACCEPT_OR_DECLINED_TEMPLATE = """
{% load cookie_consent_tags %}
{% not_accepted_or_declined_cookie_groups request as cookie_groups %}
{% if cookie_groups %}FOUND COOKIES{% else %}NO COOKIES{% endif %}
"""


@pytest.mark.django_db
def test_not_accepted_or_declined_cookie_groups_only_required_cookies(
    required_cookiegroup, rf
):
    context = {"request": rf.get("/")}

    output = render(NOT_ACCEPT_OR_DECLINED_TEMPLATE, context).strip()

    assert output == "NO COOKIES"


@pytest.mark.django_db
def test_not_accepted_or_declined_cookie_groups_only_optional_cookies(
    optional_cookiegroup, rf
):
    context = {"request": rf.get("/")}

    output = render(NOT_ACCEPT_OR_DECLINED_TEMPLATE, context).strip()

    assert output == "FOUND COOKIES"


def test_not_accepted_or_declined_cookie_groups_required_and_optional_cookies(
    required_cookiegroup, optional_cookiegroup, rf
):
    context = {"request": rf.get("/")}

    output = render(NOT_ACCEPT_OR_DECLINED_TEMPLATE, context).strip()

    assert output == "FOUND COOKIES"


================================================
FILE: tests/test_util.py
================================================
from datetime import datetime

from django.test import TestCase
from django.test.client import RequestFactory
from django.test.utils import override_settings

from hypothesis import example, given, strategies as st

from cookie_consent.conf import settings
from cookie_consent.models import Cookie, CookieGroup
from cookie_consent.util import (
    dict_to_cookie_str,
    get_cookie_groups,
    get_cookie_value_from_request,
    is_cookie_consent_enabled,
    parse_cookie_str,
)


class UtilTest(TestCase):
    def setUp(self):
        self.cookie_group = CookieGroup.objects.create(
            varname="optional",
            name="Optional",
        )
        self.cookie = Cookie.objects.create(
            cookiegroup=self.cookie_group,
            name="foo",
        )
        self.factory = RequestFactory()
        self.request = self.factory.get("")

    def test_parse_cookie_str(self):
        cookie_str = "foo=2013-06-04T01:08:58.262162|bar=2013-06-04T01:08:58"
        res = parse_cookie_str(cookie_str)
        dic = {
            "foo": "2013-06-04T01:08:58.262162",
            "bar": "2013-06-04T01:08:58",
        }
        self.assertEqual(res, dic)

    def test_dict_to_cookie_str(self):
        cookie_str = "|"
        dic = {
            "foo": "2013-06-04T01:08:58.262162",
            "bar": "2013-06-04T01:08:58",
        }
        cookie_str = dict_to_cookie_str(dic)
        self.assertEqual(parse_cookie_str(cookie_str), dic)

    def test_get_cookie_value_from_request(self):
        cookie_str = dict_to_cookie_str({"optional": self.cookie_group.get_version()})
        self.request.COOKIES[settings.COOKIE_CONSENT_NAME] = cookie_str
        res = get_cookie_value_from_request(self.request, "optional")
        self.assertTrue(res)

    def test_get_cookie_value_from_request_declined(self):
        cookie_str = dict_to_cookie_str({"optional": datetime(1999, 1, 1).isoformat()})
        self.request.COOKIES[settings.COOKIE_CONSENT_NAME] = cookie_str
        res = get_cookie_value_from_request(self.request, "optional")
        self.assertFalse(res)

    def test_get_cookie_value_from_request_empty(self):
        res = get_cookie_value_from_request(self.request, "optional")
        self.assertIsNone(res)

    def test_get_cookie_value_from_request_added_cookies(self):
        cookie_str = dict_to_cookie_str(
            {
                "optional": self.cookie_group.get_version(),
            }
        )
        Cookie.objects.create(
            cookiegroup=self.cookie_group,
            name="bar",
            domain=".example.com",
        )
        self.request.COOKIES[settings.COOKIE_CONSENT_NAME] = cookie_str
        res = get_cookie_value_from_request(self.request, "optional")
        self.assertIsNone(res)

    def test_get_cookie_value_from_request_specific_cookie(self):
        cookie_str = dict_to_cookie_str({"optional": self.cookie_group.get_version()})
        self.request.COOKIES[settings.COOKIE_CONSENT_NAME] = cookie_str
        res = get_cookie_value_from_request(self.request, "optional", "foo:")
        self.assertTrue(res)

        Cookie.objects.create(
            cookiegroup=self.cookie_group,
            name="bar",
            domain=".example.com",
        )
        res = get_cookie_value_from_request(
            self.request, "optional", "bar:.example.com"
        )
        self.assertFalse(res)

        res = get_cookie_value_from_request(self.request, "optional", "foo:")
        self.assertTrue(res)

        cookie_str = dict_to_cookie_str({"optional": self.cookie_group.get_version()})
        self.request.COOKIES[settings.COOKIE_CONSENT_NAME] = cookie_str
        res = get_cookie_value_from_request(
            self.request, "optional", "bar:.example.com"
        )
        self.assertTrue(res)

    def test_is_cookie_consent_enabled(self):
        self.assertTrue(is_cookie_consent_enabled(None))

    @override_settings(COOKIE_CONSENT_ENABLED=lambda r: False)
    def test_is_cookie_consent_enabled_callable(self):
        self.assertFalse(is_cookie_consent_enabled(None))

    def test_get_cookie_groups(self):
        self.assertIn(self.cookie_group, get_cookie_groups("optional"))

        cookie_group2 = CookieGroup.objects.create(
            varname="foo",
            name="foo",
        )
        self.assertIn(self.cookie_group, get_cookie_groups("foo,optional"))
        self.assertIn(cookie_group2, get_cookie_groups("foo,optional"))


@example({"": "|"})
@example({"": "="})
@given(
    cookie_dict=st.dictionaries(
        keys=st.text(min_size=0),
        values=st.text(min_size=0),
    )
)
def test_serialize_and_parse_cookie_str(cookie_dict):
    serialized = dict_to_cookie_str(cookie_dict)
    parsed = parse_cookie_str(serialized)

    assert len(parsed.keys()) <= len(cookie_dict.keys())


@given(cookie_str=st.text(min_size=0))
def test_parse_cookie_str(co
Download .txt
gitextract_18qginr2/

├── .editorconfig
├── .git-blame-ignore-revs
├── .github/
│   ├── actions/
│   │   └── build-js/
│   │       └── action.yml
│   └── workflows/
│       ├── ci.yml
│       └── code_quality.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .readthedocs.yaml
├── AUTHORS
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.rst
├── LICENSE
├── MANIFEST.in
├── README.md
├── __init__.py
├── cookie_consent/
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── cache.py
│   ├── conf.py
│   ├── fixtures/
│   │   └── common_cookies.json
│   ├── forms.py
│   ├── locale/
│   │   ├── en/
│   │   │   └── LC_MESSAGES/
│   │   │       ├── django.mo
│   │   │       └── django.po
│   │   ├── nl/
│   │   │   └── LC_MESSAGES/
│   │   │       ├── django.mo
│   │   │       └── django.po
│   │   ├── oc/
│   │   │   └── LC_MESSAGES/
│   │   │       ├── django.mo
│   │   │       └── django.po
│   │   ├── pt_BR/
│   │   │   └── LC_MESSAGES/
│   │   │       ├── django.mo
│   │   │       └── django.po
│   │   └── sl/
│   │       └── LC_MESSAGES/
│   │           ├── django.mo
│   │           └── django.po
│   ├── management/
│   │   ├── __init__.py
│   │   └── commands/
│   │       ├── __init__.py
│   │       └── prune_cookie_consent_logs.py
│   ├── middleware.py
│   ├── migrations/
│   │   ├── 0001_initial.py
│   │   ├── 0002_auto__add_logitem.py
│   │   ├── 0003_alter_cookiegroup_varname.py
│   │   ├── 0004_cookie_natural_key.py
│   │   └── __init__.py
│   ├── models.py
│   ├── processor.py
│   ├── py.typed
│   ├── templates/
│   │   └── cookie_consent/
│   │       ├── _cookie_group.html
│   │       ├── base.html
│   │       └── cookiegroup_list.html
│   ├── templatetags/
│   │   ├── __init__.py
│   │   └── cookie_consent_tags.py
│   ├── urls.py
│   ├── util.py
│   └── views.py
├── docs/
│   ├── Makefile
│   ├── changelog.rst
│   ├── check_sphinx.py
│   ├── concept.rst
│   ├── conf.py
│   ├── contributing.rst
│   ├── example_app.rst
│   ├── index.rst
│   ├── javascript.rst
│   ├── make.bat
│   ├── migrating-1.0.rst
│   ├── quickstart.rst
│   ├── reference/
│   │   ├── api_middleware.rst
│   │   ├── api_models.rst
│   │   ├── api_templatetags.rst
│   │   ├── api_util.rst
│   │   ├── api_views.rst
│   │   ├── index.rst
│   │   └── management_commands.rst
│   ├── settings.rst
│   └── usage.rst
├── js/
│   ├── .nvmrc
│   ├── README.md
│   ├── package.json
│   ├── src/
│   │   ├── cookiebar.ts
│   │   └── index.ts
│   └── tsconfig.json
├── pyproject.toml
├── testapp/
│   ├── __init__.py
│   ├── fixture.json
│   ├── settings.py
│   ├── static/
│   │   └── styles.css
│   ├── templates/
│   │   ├── show-cookie-bar-script.html
│   │   └── test_page.html
│   ├── urls.py
│   └── views.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── test_admin.py
│   ├── test_cache.py
│   ├── test_cookie_group_model.py
│   ├── test_cookie_model.py
│   ├── test_javascript_cookiebar.py
│   ├── test_middleware.py
│   ├── test_models.py
│   ├── test_prune_cookie_consent_logs.py
│   ├── test_settings.py
│   ├── test_templatetags.py
│   ├── test_util.py
│   └── test_views.py
└── tox.ini
Download .txt
SYMBOL INDEX (197 symbols across 32 files)

FILE: cookie_consent/admin.py
  class CookieAdmin (line 14) | class CookieAdmin(admin.ModelAdmin):
  class CookieGroupAdmin (line 22) | class CookieGroupAdmin(admin.ModelAdmin):
    method get_queryset (line 40) | def get_queryset(self, request: HttpRequest):
    method num_cookies (line 45) | def num_cookies(self, obj: CookieGroup):
  class LogItemAdmin (line 57) | class LogItemAdmin(admin.ModelAdmin):

FILE: cookie_consent/apps.py
  class CookieConsentConf (line 5) | class CookieConsentConf(AppConfig):

FILE: cookie_consent/cache.py
  function _get_cache (line 12) | def _get_cache():
  function delete_cache (line 23) | def delete_cache() -> None:
  function _get_cookie_groups_from_db (line 28) | def _get_cookie_groups_from_db() -> Mapping[str, CookieGroup]:
  function all_cookie_groups (line 33) | def all_cookie_groups() -> Mapping[str, CookieGroup]:
  function get_cookie_group (line 48) | def get_cookie_group(varname: str) -> CookieGroup | None:
  function get_cookie (line 52) | def get_cookie(cookie_group: CookieGroup, name: str, domain: str) -> Coo...

FILE: cookie_consent/conf.py
  class CookieConsentConf (line 12) | class CookieConsentConf(AppConf):

FILE: cookie_consent/forms.py
  function iter_cookie_group_choices (line 10) | def iter_cookie_group_choices() -> Iterator[tuple[str, str]]:
  class CookieGroupsChoiceField (line 18) | class CookieGroupsChoiceField(forms.TypedMultipleChoiceField):
    method __init__ (line 19) | def __init__(self, **kwargs):
    method _coerce_choice (line 24) | def _coerce_choice(self, varname: str) -> CookieGroup:
  class ProcessCookiesForm (line 29) | class ProcessCookiesForm(forms.Form):
    method get_cookie_groups (line 40) | def get_cookie_groups(self) -> Collection[CookieGroup]:

FILE: cookie_consent/management/commands/prune_cookie_consent_logs.py
  class Command (line 9) | class Command(BaseCommand):
    method add_arguments (line 12) | def add_arguments(self, parser):
    method handle (line 20) | def handle(self, *args, **options):

FILE: cookie_consent/middleware.py
  function _should_delete_cookie (line 10) | def _should_delete_cookie(group_version: str | None) -> bool:
  class CleanCookiesMiddleware (line 28) | class CleanCookiesMiddleware:
    method __init__ (line 35) | def __init__(self, get_response: Callable[[HttpRequest], HttpResponseB...
    method __call__ (line 38) | def __call__(self, request: HttpRequest):
    method process_response (line 44) | def process_response(self, request: HttpRequest, response: HttpRespons...

FILE: cookie_consent/migrations/0001_initial.py
  class Migration (line 10) | class Migration(migrations.Migration):

FILE: cookie_consent/migrations/0002_auto__add_logitem.py
  class Migration (line 7) | class Migration(migrations.Migration):

FILE: cookie_consent/migrations/0003_alter_cookiegroup_varname.py
  class Migration (line 9) | class Migration(migrations.Migration):

FILE: cookie_consent/migrations/0004_cookie_natural_key.py
  class Migration (line 6) | class Migration(migrations.Migration):

FILE: cookie_consent/models.py
  function clear_cache_after (line 25) | def clear_cache_after(func: Callable[P, T]) -> Callable[P, T]:
  class CookieGroupDict (line 36) | class CookieGroupDict(TypedDict):
  class BaseQueryset (line 45) | class BaseQueryset(models.query.QuerySet):
    method delete (line 47) | def delete(self):
    method update (line 51) | def update(self, **kwargs):
  class CookieGroupManager (line 55) | class CookieGroupManager(models.Manager.from_queryset(BaseQueryset)):
    method get_by_natural_key (line 56) | def get_by_natural_key(self, varname: str) -> CookieGroup:
  class CookieGroup (line 60) | class CookieGroup(models.Model):
    class Meta (line 85) | class Meta:
    method __str__ (line 90) | def __str__(self):
    method save (line 94) | def save(self, *args, **kwargs):
    method delete (line 98) | def delete(self, *args, **kwargs):
    method natural_key (line 101) | def natural_key(self) -> tuple[str]:
    method get_version (line 104) | def get_version(self) -> str:
    method for_json (line 114) | def for_json(self) -> CookieGroupDict:
  class CookieManager (line 124) | class CookieManager(models.Manager.from_queryset(BaseQueryset)):
    method get_by_natural_key (line 125) | def get_by_natural_key(self, name: str, domain: str, cookiegroup: str)...
  class Cookie (line 130) | class Cookie(models.Model):
    class Meta (line 144) | class Meta:
    method __str__ (line 155) | def __str__(self):
    method save (line 159) | def save(self, *args, **kwargs):
    method delete (line 163) | def delete(self, *args, **kwargs):
    method natural_key (line 166) | def natural_key(self) -> tuple[str, str, str]:
    method varname (line 172) | def varname(self) -> str:
    method get_version (line 176) | def get_version(self) -> str:
  class LogItem (line 188) | class LogItem(models.Model):
    class Meta (line 198) | class Meta:
    method __str__ (line 203) | def __str__(self):

FILE: cookie_consent/processor.py
  class CookiesProcessor (line 11) | class CookiesProcessor:
    method __init__ (line 16) | def __init__(self, request: HttpRequest, response: HttpResponseBase):
    method process (line 20) | def process(
    method _log_action (line 48) | def _log_action(
    method _delete_cookies (line 72) | def _delete_cookies(self, cookie_groups: Collection[CookieGroup]) -> N...

FILE: cookie_consent/templatetags/cookie_consent_tags.py
  function cookie_group_accepted (line 20) | def cookie_group_accepted(request: HttpRequest, group_or_cookie: str) ->...
  function cookie_group_declined (line 36) | def cookie_group_declined(request: HttpRequest, group_or_cookie: str) ->...
  function all_cookies_accepted (line 52) | def all_cookies_accepted(request: HttpRequest) -> bool:
  function not_accepted_or_declined_cookie_groups (line 60) | def not_accepted_or_declined_cookie_groups(
  function cookie_consent_enabled (line 70) | def cookie_consent_enabled(request: HttpRequest) -> bool:
  function all_cookie_groups (line 78) | def all_cookie_groups(element_id: str):

FILE: cookie_consent/util.py
  function parse_cookie_str (line 16) | def parse_cookie_str(cookie: str) -> dict[str, str]:
  function _contains_invalid_characters (line 34) | def _contains_invalid_characters(*inputs: str) -> bool:
  function dict_to_cookie_str (line 45) | def dict_to_cookie_str(dic: dict[str, str]) -> str:
  function get_cookie_dict_from_request (line 65) | def get_cookie_dict_from_request(request: HttpRequest) -> dict[str, str]:
  function set_cookie_dict_to_response (line 70) | def set_cookie_dict_to_response(
  function get_cookie_value_from_request (line 84) | def get_cookie_value_from_request(
  function get_cookie_groups (line 115) | def get_cookie_groups(varname: str = "") -> Collection[CookieGroup]:
  function are_all_cookies_accepted (line 122) | def are_all_cookies_accepted(request: HttpRequest) -> bool:
  function _get_cookie_groups_by_state (line 134) | def _get_cookie_groups_by_state(request, state: bool | None) -> Collecti...
  function get_not_accepted_or_declined_cookie_groups (line 142) | def get_not_accepted_or_declined_cookie_groups(
  function get_accepted_cookie_groups (line 151) | def get_accepted_cookie_groups(request: HttpRequest) -> Collection[Cooki...
  function get_declined_cookie_groups (line 158) | def get_declined_cookie_groups(request: HttpRequest) -> Collection[Cooki...
  function is_cookie_consent_enabled (line 165) | def is_cookie_consent_enabled(request: HttpRequest) -> bool:

FILE: cookie_consent/views.py
  function is_ajax_like (line 26) | def is_ajax_like(request: HttpRequest) -> bool:
  class CookieGroupListView (line 36) | class CookieGroupListView(ListView):
  class CookieGroupBaseProcessView (line 44) | class CookieGroupBaseProcessView(RedirectURLMixin, View):
    method get_default_redirect_url (line 56) | def get_default_redirect_url(self) -> str:
    method post (line 59) | def post(self, request: HttpRequest, *args, **kwargs):
  class CookieGroupAcceptView (line 82) | class CookieGroupAcceptView(CookieGroupBaseProcessView):
  class CookieGroupDeclineView (line 90) | class CookieGroupDeclineView(CookieGroupBaseProcessView):
  class CookieStatusView (line 98) | class CookieStatusView(View):
    method get (line 109) | def get(self, request: HttpRequest) -> JsonResponse:

FILE: docs/check_sphinx.py
  function test_linkcheck (line 4) | def test_linkcheck(tmpdir):
  function test_build_docs (line 12) | def test_build_docs(tmpdir):

FILE: js/src/cookiebar.ts
  type CookieGroup (line 30) | interface CookieGroup {
  type Options (line 37) | interface Options {
  type CookieStatus (line 84) | interface CookieStatus {
  constant DEFAULT_FETCH_HEADERS (line 108) | const DEFAULT_FETCH_HEADERS: Record<string, string> = {
  class FetchClient (line 119) | class FetchClient {
    method constructor (line 124) | constructor(statusUrl: string, csrfHeaderName: string) {
    method getCookieStatus (line 130) | async getCookieStatus(): Promise<CookieStatus> {
    method saveCookiesStatusBackend (line 150) | async saveCookiesStatusBackend (
  type RegisterEventsOptions (line 197) | type RegisterEventsOptions = Pick<
  function cloneNode (line 261) | function cloneNode<T extends Node>(node: T) {

FILE: testapp/views.py
  class TestPageView (line 6) | class TestPageView(TemplateView):
    method _should_set_cookie (line 9) | def _should_set_cookie(self) -> bool:
    method get (line 16) | def get(self, request, *args, **kwargs):

FILE: tests/conftest.py
  function required_cookiegroup (line 20) | def required_cookiegroup(db):
  function optional_cookiegroup (line 32) | def optional_cookiegroup(db):
  function load_testapp_fixture (line 44) | def load_testapp_fixture(transactional_db):
  function before_each_after_each (line 50) | def before_each_after_each():

FILE: tests/test_admin.py
  function test_warning_icon_for_missing_cookies (line 12) | def test_warning_icon_for_missing_cookies(

FILE: tests/test_cache.py
  class CacheTest (line 7) | class CacheTest(TestCase):
    method setUp (line 8) | def setUp(self):
    method test_get_cookie_group (line 21) | def test_get_cookie_group(self):
    method test_get_cookie (line 24) | def test_get_cookie(self):
    method test_caching (line 28) | def test_caching(self):
    method test_caching_expire (line 38) | def test_caching_expire(self):
    method test_can_override_cache_settings (line 53) | def test_can_override_cache_settings(self):

FILE: tests/test_cookie_group_model.py
  function test_natural_key (line 6) | def test_natural_key():
  function test_load_by_natural_key (line 13) | def test_load_by_natural_key():

FILE: tests/test_cookie_model.py
  function test_natural_key (line 6) | def test_natural_key():
  function test_load_by_natural_key (line 15) | def test_load_by_natural_key():

FILE: tests/test_javascript_cookiebar.py
  function before_each_after_each (line 22) | def before_each_after_each(live_server, page: Page, load_testapp_fixture):
  function test_cookiebar_shows_initially (line 30) | def test_cookiebar_shows_initially(page: Page):
  function test_cookiebar_accept_all (line 35) | def test_cookiebar_accept_all(page: Page):
  function test_cookiebar_decline_all (line 46) | def test_cookiebar_decline_all(page: Page):
  function test_cookiebar_not_shown_anymore_after_accept_or_decline (line 58) | def test_cookiebar_not_shown_anymore_after_accept_or_decline(btn_text: s...
  function test_on_accept_handler_runs_on_load (line 71) | def test_on_accept_handler_runs_on_load(page: Page, live_server):

FILE: tests/test_middleware.py
  class CleanCookiesMiddlewareTests (line 12) | class CleanCookiesMiddlewareTests(TestCase):
    method setUpTestData (line 14) | def setUpTestData(cls):
    method setUp (line 28) | def setUp(self):
    method _accept_and_set_cookie (line 32) | def _accept_and_set_cookie(self):
    method assertCookieDeleted (line 49) | def assertCookieDeleted(self, name: str):
    method test_middleware_decline_previously_accepted_cookiegroup_cookies_are_deleted (line 63) | def test_middleware_decline_previously_accepted_cookiegroup_cookies_ar...
    method test_middleware_no_cookie_consent_cookie_present_cookies_are_deleted (line 82) | def test_middleware_no_cookie_consent_cookie_present_cookies_are_delet...
    method test_cookie_consent_disabled (line 93) | def test_cookie_consent_disabled(self):
    method test_cookie_group_not_deletable (line 102) | def test_cookie_group_not_deletable(self):
    method test_with_opt_out_behaviour (line 113) | def test_with_opt_out_behaviour(self):

FILE: tests/test_models.py
  class CacheMixin (line 26) | class CacheMixin:
    method populateCache (line 27) | def populateCache(self):
    method assertCacheNotPopulated (line 31) | def assertCacheNotPopulated(self):
  class CookieGroupTest (line 38) | class CookieGroupTest(CacheMixin, TestCase):
    method setUp (line 39) | def setUp(self):
    method test_get_version (line 51) | def test_get_version(self):
    method test_bulk_delete (line 56) | def test_bulk_delete(self):
    method test_bulk_update (line 68) | def test_bulk_update(self):
  class CookieTest (line 80) | class CookieTest(CacheMixin, TestCase):
    method setUp (line 81) | def setUp(self):
    method test_varname (line 94) | def test_varname(self):
    method test_bulk_delete (line 97) | def test_bulk_delete(self):
    method test_bulk_update (line 105) | def test_bulk_update(self):
  function test_valid_cookie_name_does_not_raise (line 122) | def test_valid_cookie_name_does_not_raise(name):
  function test_invalid_cookie_name_raises (line 137) | def test_invalid_cookie_name_raises(name: str):

FILE: tests/test_prune_cookie_consent_logs.py
  function make_log_item (line 13) | def make_log_item(optional_cookiegroup):
  function test_prunes_old_items_with_default_days (line 27) | def test_prunes_old_items_with_default_days(make_log_item):
  function test_prunes_items_older_than_custom_days (line 37) | def test_prunes_items_older_than_custom_days(make_log_item):
  function test_no_items_deleted_when_all_recent (line 49) | def test_no_items_deleted_when_all_recent(make_log_item):
  function test_output_reports_deleted_count (line 57) | def test_output_reports_deleted_count(make_log_item):
  function test_strict_days_cutoff (line 67) | def test_strict_days_cutoff(make_log_item):

FILE: tests/test_settings.py
  function test_cookie_consent_cookie_options (line 28) | def test_cookie_consent_cookie_options(

FILE: tests/test_templatetags.py
  function render (line 9) | def render(tpl: str, context: dict[str, Any] | None = None) -> str:
  function test_not_accepted_or_declined_cookie_groups_only_required_cookies (line 22) | def test_not_accepted_or_declined_cookie_groups_only_required_cookies(
  function test_not_accepted_or_declined_cookie_groups_only_optional_cookies (line 33) | def test_not_accepted_or_declined_cookie_groups_only_optional_cookies(
  function test_not_accepted_or_declined_cookie_groups_required_and_optional_cookies (line 43) | def test_not_accepted_or_declined_cookie_groups_required_and_optional_co...

FILE: tests/test_util.py
  class UtilTest (line 20) | class UtilTest(TestCase):
    method setUp (line 21) | def setUp(self):
    method test_parse_cookie_str (line 33) | def test_parse_cookie_str(self):
    method test_dict_to_cookie_str (line 42) | def test_dict_to_cookie_str(self):
    method test_get_cookie_value_from_request (line 51) | def test_get_cookie_value_from_request(self):
    method test_get_cookie_value_from_request_declined (line 57) | def test_get_cookie_value_from_request_declined(self):
    method test_get_cookie_value_from_request_empty (line 63) | def test_get_cookie_value_from_request_empty(self):
    method test_get_cookie_value_from_request_added_cookies (line 67) | def test_get_cookie_value_from_request_added_cookies(self):
    method test_get_cookie_value_from_request_specific_cookie (line 82) | def test_get_cookie_value_from_request_specific_cookie(self):
    method test_is_cookie_consent_enabled (line 108) | def test_is_cookie_consent_enabled(self):
    method test_is_cookie_consent_enabled_callable (line 112) | def test_is_cookie_consent_enabled_callable(self):
    method test_get_cookie_groups (line 115) | def test_get_cookie_groups(self):
  function test_serialize_and_parse_cookie_str (line 134) | def test_serialize_and_parse_cookie_str(cookie_dict):
  function test_parse_cookie_str (line 142) | def test_parse_cookie_str(cookie_str: str):

FILE: tests/test_views.py
  function test_cookiegroup_list_view (line 19) | def test_cookiegroup_list_view(client: Client, optional_cookiegroup: Coo...
  function assertAcceptedCookieGroups (line 28) | def assertAcceptedCookieGroups(client: Client, varnames: Collection[str]):
  function assertDeclinedCookieGroups (line 33) | def assertDeclinedCookieGroups(client: Client, varnames: Collection[str]):
  function test_processing_get_success_url (line 46) | def test_processing_get_success_url(
  function test_alternative_redirect_fallback (line 65) | def test_alternative_redirect_fallback(client: Client, settings):
  function test_accept_multiple_cookiegroups_submitted_via_post_body (line 77) | def test_accept_multiple_cookiegroups_submitted_via_post_body(client: Cl...
  function test_accept_all_cookiegroups (line 96) | def test_accept_all_cookiegroups(client: Client, optional_cookiegroup: C...
  function test_accept_cookie_view_ajax (line 110) | def test_accept_cookie_view_ajax(client: Client, optional_cookiegroup: C...
  function test_accept_cookie_invalid_varname (line 122) | def test_accept_cookie_invalid_varname(
  function test_ajax_like_accept_cookie_invalid_varname (line 136) | def test_ajax_like_accept_cookie_invalid_varname(
  function test_decline_multiple_cookiegroups_submitted_via_post_body (line 154) | def test_decline_multiple_cookiegroups_submitted_via_post_body(client: C...
  function test_decline_all_cookiegroups (line 173) | def test_decline_all_cookiegroups(client: Client, optional_cookiegroup: ...
  function test_decline_cookie_view_ajax (line 187) | def test_decline_cookie_view_ajax(client: Client, optional_cookiegroup: ...
  function test_decline_cookie_invalid_varname (line 199) | def test_decline_cookie_invalid_varname(
  function test_ajax_like_decline_cookie_invalid_varname (line 213) | def test_ajax_like_decline_cookie_invalid_varname(
  function test_logging_enabled (line 231) | def test_logging_enabled(client: Client, optional_cookiegroup: CookieGro...
  function test_logging_disabled (line 252) | def test_logging_disabled(client: Client, optional_cookiegroup: CookieGr...
  function test_integration_test_page_works (line 262) | def test_integration_test_page_works(client: Client, optional_cookiegrou...
Condensed preview — 103 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (214K chars).
[
  {
    "path": ".editorconfig",
    "chars": 282,
    "preview": "root = true\n\n[*]\nindent_style = space\nindent_size = 4\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitesp"
  },
  {
    "path": ".git-blame-ignore-revs",
    "chars": 75,
    "preview": "# black and isort on the codebase\n938a25cb45b672b65673053b3521d04f737e121a\n"
  },
  {
    "path": ".github/actions/build-js/action.yml",
    "chars": 891,
    "preview": "---\n\nname: 'Build JS'\ndescription: 'Compile the TS source code'\n\ninputs:\n  npm-package:\n    description: Build NPM packa"
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 3448,
    "preview": "name: Run CI\n\n# Run this workflow every time a new commit pushed to your repository\non:\n  push:\n    branches:\n      - ma"
  },
  {
    "path": ".github/workflows/code_quality.yml",
    "chars": 638,
    "preview": "name: Code quality checks\n\n# Run this workflow every time a new commit pushed to your repository\non:\n  push:\n    branche"
  },
  {
    "path": ".gitignore",
    "chars": 341,
    "preview": "*.log\n*.pot\n*.pyc\nlocal_settings.py\nenv/\n\n# build artifacts\ndocs/_build\nbuild/\ndist/\n*.egg-info/\n\n# editors\n.vscode\n.his"
  },
  {
    "path": ".pre-commit-config.yaml",
    "chars": 234,
    "preview": "repos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v5.0.0\n    hooks:\n      - id: check-merge-confl"
  },
  {
    "path": ".readthedocs.yaml",
    "chars": 332,
    "preview": "# .readthedocs.yaml\n# Read the Docs configuration file\n# See https://docs.readthedocs.io/en/stable/config-file/v2.html f"
  },
  {
    "path": "AUTHORS",
    "chars": 390,
    "preview": "The django-cookie-consent was created by Bojan Mihelac (bmihelac).\n\n\nThe following is a list of much appreciated contrib"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "chars": 175,
    "preview": "# Code of Conduct\n\nThe django-cookie-consent project utilizes the [Django Commons Code of Conduct](https://github.com/dj"
  },
  {
    "path": "CONTRIBUTING.rst",
    "chars": 3487,
    "preview": "By contributing you agree to abide by the\n`Contributor Code of Conduct <https://github.com/django-commons/membership/blo"
  },
  {
    "path": "LICENSE",
    "chars": 1349,
    "preview": "Copyright (c) Bojan Mihelac and individual contributors.\nAll rights reserved.\n\nRedistribution and use in source and bina"
  },
  {
    "path": "MANIFEST.in",
    "chars": 213,
    "preview": "include LICENSE\ninclude AUTHORS\ninclude README.md\ninclude cookie_consent/py.typed\nrecursive-include cookie_consent/templ"
  },
  {
    "path": "README.md",
    "chars": 2575,
    "preview": "Django cookie consent\n=====================\n\nManage cookie information and let visitors give or reject consent for them."
  },
  {
    "path": "__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "cookie_consent/__init__.py",
    "chars": 22,
    "preview": "__version__ = \"1.0.0\"\n"
  },
  {
    "path": "cookie_consent/admin.py",
    "chars": 1983,
    "preview": "from django.contrib import admin\nfrom django.db.models import Count\nfrom django.http.request import HttpRequest\nfrom dja"
  },
  {
    "path": "cookie_consent/apps.py",
    "chars": 248,
    "preview": "from django.apps import AppConfig\nfrom django.utils.translation import gettext_lazy as _\n\n\nclass CookieConsentConf(AppCo"
  },
  {
    "path": "cookie_consent/cache.py",
    "chars": 1753,
    "preview": "from collections.abc import Mapping\n\nfrom django.core.cache import caches\n\nfrom .conf import settings\nfrom .models impor"
  },
  {
    "path": "cookie_consent/conf.py",
    "chars": 917,
    "preview": "from typing import Literal\n\nfrom django.conf import settings\nfrom django.urls import reverse_lazy\nfrom django.utils.func"
  },
  {
    "path": "cookie_consent/fixtures/common_cookies.json",
    "chars": 2449,
    "preview": "[{\"pk\": 1, \"model\": \"cookie_consent.cookiegroup\", \"fields\": {\"is_deletable\": false, \"name\": \"Required cookies\", \"created"
  },
  {
    "path": "cookie_consent/forms.py",
    "chars": 1519,
    "preview": "from collections.abc import Collection, Iterator\n\nfrom django import forms\nfrom django.utils.translation import gettext_"
  },
  {
    "path": "cookie_consent/locale/en/LC_MESSAGES/django.po",
    "chars": 1994,
    "preview": "# SOME DESCRIPTIVE TITLE.\n# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\n# This file is distributed under the same "
  },
  {
    "path": "cookie_consent/locale/nl/LC_MESSAGES/django.po",
    "chars": 2895,
    "preview": "# SOME DESCRIPTIVE TITLE.\n# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\n# This file is distributed under the same "
  },
  {
    "path": "cookie_consent/locale/oc/LC_MESSAGES/django.po",
    "chars": 2404,
    "preview": "# SOME DESCRIPTIVE TITLE.\n# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\n# This file is distributed under the same "
  },
  {
    "path": "cookie_consent/locale/pt_BR/LC_MESSAGES/django.po",
    "chars": 3526,
    "preview": "# SOME DESCRIPTIVE TITLE.\n# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\n# This file is distributed under the same "
  },
  {
    "path": "cookie_consent/locale/sl/LC_MESSAGES/django.po",
    "chars": 2166,
    "preview": "# SOME DESCRIPTIVE TITLE.\n# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\n# This file is distributed under the same "
  },
  {
    "path": "cookie_consent/management/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "cookie_consent/management/commands/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "cookie_consent/management/commands/prune_cookie_consent_logs.py",
    "chars": 802,
    "preview": "from datetime import timedelta\n\nfrom django.core.management.base import BaseCommand\nfrom django.utils import timezone\n\nf"
  },
  {
    "path": "cookie_consent/middleware.py",
    "chars": 2105,
    "preview": "from collections.abc import Callable\n\nfrom django.http import HttpRequest, HttpResponseBase\n\nfrom .cache import all_cook"
  },
  {
    "path": "cookie_consent/migrations/0001_initial.py",
    "chars": 4215,
    "preview": "# Generated by Django 2.1 on 2019-02-08 14:14\n\nimport re\n\nimport django.core.validators\nimport django.db.models.deletion"
  },
  {
    "path": "cookie_consent/migrations/0002_auto__add_logitem.py",
    "chars": 1633,
    "preview": "# Generated by Django 2.1 on 2019-02-08 14:16\n\nimport django.db.models.deletion\nfrom django.db import migrations, models"
  },
  {
    "path": "cookie_consent/migrations/0003_alter_cookiegroup_varname.py",
    "chars": 899,
    "preview": "# Generated by Django 4.2.13 on 2024-05-09 19:01\n\nimport re\n\nimport django.core.validators\nfrom django.db import migrati"
  },
  {
    "path": "cookie_consent/migrations/0004_cookie_natural_key.py",
    "chars": 465,
    "preview": "# Generated by Django 4.2.13 on 2024-05-09 20:22\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
  },
  {
    "path": "cookie_consent/migrations/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "cookie_consent/models.py",
    "chars": 6185,
    "preview": "from __future__ import annotations\n\nimport re\nfrom collections.abc import Callable\nfrom typing import ClassVar, ParamSpe"
  },
  {
    "path": "cookie_consent/processor.py",
    "chars": 2667,
    "preview": "from collections.abc import Collection\nfrom typing import Literal\n\nfrom django.http import HttpRequest, HttpResponseBase"
  },
  {
    "path": "cookie_consent/py.typed",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "cookie_consent/templates/cookie_consent/_cookie_group.html",
    "chars": 1632,
    "preview": "{% load i18n %}\n{% load cookie_consent_tags %}\n\n\n<div class=\"cookie-group\">\n  <div class=\"cookie-group-title\">\n    <h3>{"
  },
  {
    "path": "cookie_consent/templates/cookie_consent/base.html",
    "chars": 85,
    "preview": "<html>\n <head></head>\n <body>\n   {% block body %}\n   {% endblock %}\n </body>\n</html>\n"
  },
  {
    "path": "cookie_consent/templates/cookie_consent/cookiegroup_list.html",
    "chars": 308,
    "preview": "{% extends \"cookie_consent/base.html\" %}\n\n{% block body %}\n  <h1>Cookies</h1>\n\n  <p>\n    This is a list of the categorie"
  },
  {
    "path": "cookie_consent/templatetags/__init__.py",
    "chars": 22,
    "preview": "#!/usr/bin/env python\n"
  },
  {
    "path": "cookie_consent/templatetags/cookie_consent_tags.py",
    "chars": 2453,
    "preview": "from collections.abc import Collection\n\nfrom django import template\nfrom django.http import HttpRequest\nfrom django.util"
  },
  {
    "path": "cookie_consent/urls.py",
    "chars": 510,
    "preview": "from django.urls import path\n\nfrom .views import (\n    CookieGroupAcceptView,\n    CookieGroupDeclineView,\n    CookieGrou"
  },
  {
    "path": "cookie_consent/util.py",
    "chars": 5252,
    "preview": "import logging\nfrom collections.abc import Callable, Collection, Iterator\n\nfrom django.http import HttpRequest, HttpResp"
  },
  {
    "path": "cookie_consent/views.py",
    "chars": 3742,
    "preview": "from typing import Literal\n\nfrom django.contrib.auth.views import RedirectURLMixin\nfrom django.http import (\n    HttpReq"
  },
  {
    "path": "docs/Makefile",
    "chars": 5621,
    "preview": "# Makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line.\nSPHINXOPTS    =\nSPHINXBUILD "
  },
  {
    "path": "docs/changelog.rst",
    "chars": 10669,
    "preview": "=========\nChangelog\n=========\n\n1.0.0 (2026-02-04)\n------------------\n\nThe long-awaited 1.0 version of django-cookie-cons"
  },
  {
    "path": "docs/check_sphinx.py",
    "chars": 460,
    "preview": "import subprocess\n\n\ndef test_linkcheck(tmpdir):\n    doctrees = tmpdir.join(\"doctrees\")\n    htmldir = tmpdir.join(\"html\")"
  },
  {
    "path": "docs/concept.rst",
    "chars": 2943,
    "preview": "=============\nMain concepts\n=============\n\nCookie Group\n------------\n\nThe :class:`CookieGroup <cookie_consent.models.Coo"
  },
  {
    "path": "docs/conf.py",
    "chars": 7448,
    "preview": "import os\nimport sys\nfrom pathlib import Path\n\nimport django\n\n# If extensions (or modules to document with autodoc) are "
  },
  {
    "path": "docs/contributing.rst",
    "chars": 94,
    "preview": ".. _contributing:\n\n=============\nContributing\n=============\n\n.. include:: ../CONTRIBUTING.rst\n"
  },
  {
    "path": "docs/example_app.rst",
    "chars": 1338,
    "preview": "===========\nExample app\n===========\n\nThe ``testapp`` project is both an example of how you could use this library and se"
  },
  {
    "path": "docs/index.rst",
    "chars": 2505,
    "preview": "=====================\nDjango cookie consent\n=====================\n\nManage cookie information and let visitors give or re"
  },
  {
    "path": "docs/javascript.rst",
    "chars": 11479,
    "preview": ".. _javascript:\n\n======================\nJavascript integration\n======================\n\nCookie consent supports \"classic\""
  },
  {
    "path": "docs/make.bat",
    "chars": 5126,
    "preview": "@ECHO OFF\n\nREM Command file for Sphinx documentation\n\nif \"%SPHINXBUILD%\" == \"\" (\n\tset SPHINXBUILD=sphinx-build\n)\nset BUI"
  },
  {
    "path": "docs/migrating-1.0.rst",
    "chars": 8834,
    "preview": ".. _migrating_10:\n\n================\nMigrating to 1.0\n================\n\nAfter more than 12 years since it's initial incep"
  },
  {
    "path": "docs/quickstart.rst",
    "chars": 1103,
    "preview": "==========\nQuickstart\n==========\n\nInstallation\n============\n\nInstall django-cookie-consent from PyPI with pip (recommend"
  },
  {
    "path": "docs/reference/api_middleware.rst",
    "chars": 1031,
    "preview": "==========\nMiddleware\n==========\n\nCleanCookiesMiddleware\n----------------------\n\n.. code-block:: python\n\n    MIDDLEWARE "
  },
  {
    "path": "docs/reference/api_models.rst",
    "chars": 73,
    "preview": "======\nModels\n======\n\n.. automodule:: cookie_consent.models\n   :members:\n"
  },
  {
    "path": "docs/reference/api_templatetags.rst",
    "chars": 174,
    "preview": ".. _api_templatetags:\n\n=============\nTemplate tags\n=============\n\ncookie_consent\n--------------\n\n.. automodule:: cookie_"
  },
  {
    "path": "docs/reference/api_util.rst",
    "chars": 65,
    "preview": "====\nUtil\n====\n\n.. automodule:: cookie_consent.util\n   :members:\n"
  },
  {
    "path": "docs/reference/api_views.rst",
    "chars": 69,
    "preview": "=====\nViews\n=====\n\n.. automodule:: cookie_consent.views\n   :members:\n"
  },
  {
    "path": "docs/reference/index.rst",
    "chars": 173,
    "preview": "=============\nAPI Reference\n=============\n\n.. toctree::\n   :maxdepth: 2\n\n   api_models\n   api_views\n   api_util\n   api_t"
  },
  {
    "path": "docs/reference/management_commands.rst",
    "chars": 627,
    "preview": "====================\nManagement commands\n====================\n\nprune_cookie_consent_logs\n=========================\n\n.. c"
  },
  {
    "path": "docs/settings.rst",
    "chars": 2109,
    "preview": ".. _settings:\n\n========\nSettings\n========\n\nThe cookie settings (name, max-age, domain...) follow the same principles lik"
  },
  {
    "path": "docs/usage.rst",
    "chars": 2541,
    "preview": "=====\nUsage\n=====\n\nManaging cookie groups and cookies\n----------------------------------\n\nTypically you manage the cooki"
  },
  {
    "path": "js/.nvmrc",
    "chars": 4,
    "preview": "v24\n"
  },
  {
    "path": "js/README.md",
    "chars": 1157,
    "preview": "# django-cookie-consent\n\nPackage containing the JS code for django-cookie-consent.\n\nThe cookiebar module is shipped in t"
  },
  {
    "path": "js/package.json",
    "chars": 982,
    "preview": "{\n  \"name\": \"django-cookie-consent\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Frontend code for django-cookie-consent\",\n "
  },
  {
    "path": "js/src/cookiebar.ts",
    "chars": 11225,
    "preview": "/**\n * Cookiebar functionality, as a TS/JS module.\n *\n * About modules: https://developer.mozilla.org/en-US/docs/Web/Jav"
  },
  {
    "path": "js/src/index.ts",
    "chars": 64,
    "preview": "export {loadCookieGroups, showCookieBar} from './cookiebar.js';\n"
  },
  {
    "path": "js/tsconfig.json",
    "chars": 543,
    "preview": "{\n  \"compilerOptions\": {\n    \"baseUrl\": \"src\",\n    \"target\": \"es2017\",\n    \"module\": \"esnext\",\n    \"outDir\": \"lib\",\n    "
  },
  {
    "path": "pyproject.toml",
    "chars": 4383,
    "preview": "[build-system]\nrequires = [\"setuptools>=77.0.3\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"django-cooki"
  },
  {
    "path": "testapp/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "testapp/fixture.json",
    "chars": 1673,
    "preview": "[\n{\n    \"model\": \"cookie_consent.cookiegroup\",\n    \"pk\": 1,\n    \"fields\": {\n        \"varname\": \"social\",\n        \"name\":"
  },
  {
    "path": "testapp/settings.py",
    "chars": 2142,
    "preview": "import os\nfrom pathlib import Path\n\nBASE_DIR = Path(__file__).resolve().parent\n\nINSTALLED_APPS = [\n    \"django.contrib.a"
  },
  {
    "path": "testapp/static/styles.css",
    "chars": 186,
    "preview": "body.with-cookie-bar {\n  padding-top: 35px;\n}\n\n.cookie-bar {\n  position: fixed;\n  width: 100%;\n  top: 0;\n  text-align: c"
  },
  {
    "path": "testapp/templates/show-cookie-bar-script.html",
    "chars": 1284,
    "preview": "{% load static cookie_consent_tags %}\n{% static \"cookie_consent/cookiebar.module.js\" as cookiebar_src %}\n<script type=\"m"
  },
  {
    "path": "testapp/templates/test_page.html",
    "chars": 2817,
    "preview": "{% load static %}\n{% load cookie_consent_tags %}\n{% url \"cookie_consent_cookie_group_list\" as url_cookies %}\n<!DOCTYPE h"
  },
  {
    "path": "testapp/urls.py",
    "chars": 380,
    "preview": "from django.contrib import admin\nfrom django.contrib.staticfiles.urls import staticfiles_urlpatterns\nfrom django.urls im"
  },
  {
    "path": "testapp/views.py",
    "chars": 683,
    "preview": "from django.views.generic import TemplateView\n\nfrom cookie_consent.util import get_cookie_value_from_request\n\n\nclass Tes"
  },
  {
    "path": "tests/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/conftest.py",
    "chars": 1329,
    "preview": "import os\nfrom io import StringIO\nfrom pathlib import Path\n\nfrom django.core.management import call_command\n\nimport pyte"
  },
  {
    "path": "tests/test_admin.py",
    "chars": 651,
    "preview": "from django.test import Client\nfrom django.urls import reverse\n\nimport pytest\nfrom pytest_django.asserts import assertCo"
  },
  {
    "path": "tests/test_cache.py",
    "chars": 2109,
    "preview": "from django.test import TestCase, override_settings\n\nfrom cookie_consent.cache import delete_cache, get_cookie, get_cook"
  },
  {
    "path": "tests/test_cookie_group_model.py",
    "chars": 455,
    "preview": "import pytest\n\nfrom cookie_consent.models import CookieGroup\n\n\ndef test_natural_key():\n    group = CookieGroup(varname=\""
  },
  {
    "path": "tests/test_cookie_model.py",
    "chars": 726,
    "preview": "import pytest\n\nfrom cookie_consent.models import Cookie, CookieGroup\n\n\ndef test_natural_key():\n    cookie = Cookie(\n    "
  },
  {
    "path": "tests/test_javascript_cookiebar.py",
    "chars": 2739,
    "preview": "\"\"\"\nTest the behaviour of the dynamic (JS based) cookiebar module.\n\nSee docs: https://playwright.dev/python/docs/test-ru"
  },
  {
    "path": "tests/test_middleware.py",
    "chars": 4300,
    "preview": "from django.test import TestCase, override_settings\nfrom django.test.client import RequestFactory\nfrom django.urls impor"
  },
  {
    "path": "tests/test_models.py",
    "chars": 3630,
    "preview": "import string\nfrom copy import deepcopy\n\nfrom django.conf import settings\nfrom django.core.cache import caches\nfrom djan"
  },
  {
    "path": "tests/test_prune_cookie_consent_logs.py",
    "chars": 2483,
    "preview": "from datetime import timedelta\nfrom io import StringIO\n\nfrom django.core.management import call_command\nfrom django.util"
  },
  {
    "path": "tests/test_settings.py",
    "chars": 1280,
    "preview": "from django.urls import reverse\n\nimport pytest\n\npytestmark = pytest.mark.django_db\n\n\n@pytest.mark.parametrize(\n    (\"set"
  },
  {
    "path": "tests/test_templatetags.py",
    "chars": 1317,
    "preview": "from textwrap import dedent\nfrom typing import Any\n\nfrom django.template import Context, Template\n\nimport pytest\n\n\ndef r"
  },
  {
    "path": "tests/test_util.py",
    "chars": 4980,
    "preview": "from datetime import datetime\n\nfrom django.test import TestCase\nfrom django.test.client import RequestFactory\nfrom djang"
  },
  {
    "path": "tests/test_views.py",
    "chars": 9672,
    "preview": "from collections.abc import Collection\n\nfrom django.test import Client\nfrom django.urls import reverse\n\nimport pytest\nfr"
  },
  {
    "path": "tox.ini",
    "chars": 1154,
    "preview": "[tox]\nenvlist =\n    py{310,311}-django{42,52}\n    py{312,313,314}-django{52,60}\n    ruff\n    docs\nskip_missing_interpret"
  }
]

// ... and 5 more files (download for full content)

About this extraction

This page contains the full source code of the jazzband/django-cookie-consent GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 103 files (193.5 KB), approximately 50.7k tokens, and a symbol index with 197 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!