[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nindent_style = space\nindent_size = 4\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.{scss,sass}]\nindent_size = 2\n\n[*.{yml,yaml}]\nindent_size = 2\n\n[*.{js,ts}]\nindent_size = 2\n\n[*.json]\nindent_size = 2\n\n[Makefile]\nindent_style = tab\n"
  },
  {
    "path": ".git-blame-ignore-revs",
    "content": "# black and isort on the codebase\n938a25cb45b672b65673053b3521d04f737e121a\n"
  },
  {
    "path": ".github/actions/build-js/action.yml",
    "content": "---\n\nname: 'Build JS'\ndescription: 'Compile the TS source code'\n\ninputs:\n  npm-package:\n    description: Build NPM package\n    required: false\n    default: 'false'\n\n  django-staticfiles:\n    description: Bundle Django staticfiles\n    required: false\n    default: 'false'\n\nruns:\n  using: 'composite'\n\n  steps:\n\n    - uses: actions/setup-node@v4\n      with:\n        node-version-file: 'js/.nvmrc'\n        cache: npm\n        cache-dependency-path: js/package-lock.json\n\n    - name: Install dependencies\n      run: npm ci\n      shell: bash\n      working-directory: js\n\n    - name: Build NPM package\n      if: ${{ inputs.npm-package == 'true' }}\n      run: npm run build\n      shell: bash\n      working-directory: js\n\n    - name: Build Django assets package\n      if: ${{ inputs.django-staticfiles == 'true' }}\n      run: npm run build:django-static\n      shell: bash\n      working-directory: js\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: Run CI\n\n# Run this workflow every time a new commit pushed to your repository\non:\n  push:\n    branches:\n      - main\n    tags:\n      - '*'\n  pull_request:\n  workflow_dispatch:\n\njobs:\n  build-js:\n    runs-on: ubuntu-latest\n    name: Compile the frontend code\n\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/build-js\n        with:\n          npm-package: 'true'\n          django-staticfiles: 'false'\n\n  tests:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python: ['3.10', '3.11', '3.12', '3.13', '3.14']\n        django: ['4.2', '5.2', '6.0']\n        exclude:\n          - python: '3.13'\n            django: '4.2'\n          - python: '3.14'\n            django: '4.2'\n\n    name: Run the test suite (Python ${{ matrix.python }}, Django ${{ matrix.django }})\n\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: ${{ matrix.python }}\n\n      - name: Install dependencies\n        run: pip install tox tox-gh-actions\n\n      - name: Run tests\n        run: tox\n        env:\n          DJANGO: ${{ matrix.django }}\n\n      - name: Publish coverage report\n        uses: codecov/codecov-action@v5\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          directory: reports/\n\n  e2e_tests:\n    runs-on: ubuntu-latest\n    name: Run the end-to-end tests\n\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.12'\n\n      - uses: ./.github/actions/build-js\n        with:\n          npm-package: 'false'\n          django-staticfiles: 'true'\n\n      - name: Install dependencies\n        run: |\n          pip install tox tox-gh-actions pytest-playwright\n          playwright install --with-deps chromium\n\n      - name: Run tests\n        run: tox -e e2e\n\n      - name: Publish coverage report\n        uses: codecov/codecov-action@v3\n        with:\n          directory: reports/\n\n\n  publish:\n    name: Publish packages to PyPI and NPM\n    runs-on: ubuntu-latest\n    needs:\n      - tests\n      - e2e_tests\n\n    if: github.repository == 'django-commons/django-cookie-consent' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags')\n\n    environment:\n      name: release\n      url: https://pypi.org/project/django-cookie-consent/\n    permissions:\n      id-token: write\n      contents: read\n\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.12'\n      - uses: actions/setup-node@v4\n        with:\n          node-version-file: 'js/.nvmrc'\n          cache: npm\n          cache-dependency-path: js/package-lock.json\n          registry-url: 'https://registry.npmjs.org'\n\n      # Required to ensure the build artifacts are in the packages.\n      - uses: ./.github/actions/build-js\n        with:\n          npm-package: 'true'\n          django-staticfiles: 'true'\n\n      - name: Build sdist and wheel\n        run: |\n          pip install build --upgrade\n          python -m build\n\n      # TODO: enable verified publishing!\n      - name: Publish a Python distribution to PyPI\n        uses: pypa/gh-action-pypi-publish@release/v1\n\n      - name: Publish NPM package\n        run: |\n          version=$(echo \"${{ github.ref }}\" | sed -e 's,.*/\\(.*\\),\\1,')\n          npm publish --new-version=\"$version\"\n        working-directory: js\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/code_quality.yml",
    "content": "name: Code quality checks\n\n# Run this workflow every time a new commit pushed to your repository\non:\n  push:\n    branches:\n      - main\n    tags:\n      - '*'\n  pull_request:\n  workflow_dispatch:\n\njobs:\n  linting:\n    name: Code-quality checks\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        toxenv:\n          - ruff\n          - docs\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.12'\n      - name: Install dependencies\n        run: pip install tox\n      - run: tox\n        env:\n          TOXENV: ${{ matrix.toxenv }}\n          FORCE_COLOR: '1'\n"
  },
  {
    "path": ".gitignore",
    "content": "*.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.history\n.sonarlint\n\n# tests and coverage\n.tox/\n.pytest_cache\n.coverage\nhtmlcov/\nreports/\ntestapp/*.db\n.hypothesis\n\n# frontend tooling / builds\njs/node_modules/\njs/lib/\ncookie_consent/static/cookie_consent/cookiebar.module.*\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v5.0.0\n    hooks:\n      - id: check-merge-conflict\n\n  - repo: https://github.com/adamchainz/django-upgrade\n    rev: 1.29.1\n    hooks:\n      - id: django-upgrade\n"
  },
  {
    "path": ".readthedocs.yaml",
    "content": "# .readthedocs.yaml\n# Read the Docs configuration file\n# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details\n\nversion: 2\n\nsphinx:\n  configuration: docs/conf.py\n\nbuild:\n  os: 'ubuntu-22.04'\n  tools:\n    python: '3.10'\n\npython:\n  install:\n    - method: pip\n      path: .\n      extra_requirements:\n        - docs\n"
  },
  {
    "path": "AUTHORS",
    "content": "The django-cookie-consent was created by Bojan Mihelac (bmihelac).\n\n\nThe following is a list of much appreciated contributors:\n\n* Jonathan L Herr (JonHerr)\n* Jasper Koops (Jasper-Koops)\n* Fernando Cordeiro (MrCordeiro)\n* Mejans\n* Sergei Maertens (sergei-maertens)\n* Abdullah Alahdal (alahdal)\n* some1ataplace\n* binoyudayan\n* adilhussain540\n* Johanan Oppong Amoateng (JohananOppongAmoateng)\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Code of Conduct\n\nThe django-cookie-consent project utilizes the [Django Commons Code of Conduct](https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md).\n"
  },
  {
    "path": "CONTRIBUTING.rst",
    "content": "By contributing you agree to abide by the\n`Contributor Code of Conduct <https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md>`_.\n\nHow can you contribute?\n=======================\n\nContributions in all forms are welcome, not only code patches. You can contribute by:\n\n* reporting bugs/issues\n* triaging reported issues\n* improving the documentation\n* suggesting enhancements\n\nOf course, if you report a bug or have a feature request, a pull request implementing\nthe fix or feature is much appreciated.\n\nContributing code\n-----------------\n\nIf you decide to submit a patch or implement a feature, please adhere to the quality\nchecks:\n\n* Pull requests must be accompanied by tests. We use ``pytest`` and prefer using this\n  testing style over Django's ``django.test.TestCase``.\n* Ideally, documentation updates are included in a pull request.\n* Code formatting and linting is done with ``ruff``. There are tox environments and CI\n  checks in place to check/enforce this.\n* Follow Django's code style where possible.\n* Keep commits atomic - one commit should only concern one topic. Bugfixes typically\n  have one commit for the regression test and one commit with the fix.\n\nSetting up the project for local development\n--------------------------------------------\n\nAfter checking out the project (through ``git clone``), it's advised to set up a\nvirtualenv with the lowest supported python and Django version.\n\nYou can then install the project with all the dev-tools:\n\n.. code-block:: bash\n\n   pip install -e .[tests,docs,release]\n\nSome frontend tooling is needed too:\n\n* NodeJS (for the version, see ``.nvmrc``, you can use ``nvm``)\n\n.. code-block:: bash\n\n    cd js\n    nvm use\n    npm install\n    npm run build:django-static\n    npm run build  # optional, but a nice check\n\n**Running the testapp as dev environment**\n\nIn Django project's, you are typically expecting a ``manage.py`` file. This is less\ncommon in libraries, but it's fairly straightforward to emulate this:\n\n.. code-block:: bash\n\n    export DJANGO_SETTINGS_MODULE=testapp.settings PYTHONPATH=.\n\n    django-admin migrate\n    django-admin createsuperuser\n    django-admin runserver\n\nYou can now start working on your contribution!\n\n**Running the tests**\n\nRun the tests locally using ``tox`` to verify everything is okay:\n\n.. code-block:: bash\n\n   tox\n\nCheat sheet\n-----------\n\nThis cheat sheet provides some quick tooling/commands lookup while working on the\nproject.\n\n**Running tests**\n\nIn your current environment\n\n.. code-block:: bash\n\n   pytest\n\nor to build the full test matrix\n\n.. code-block:: bash\n\n   tox\n\n**Formatting the code for check-in**\n\n.. code-block:: bash\n\n   ruff format .\n   ruff check --fix .\n\nShould be sufficient. Consider using a pre-commit hook to automate this.\n\n**Building the docs**\n\n.. code-block:: bash\n\n   cd docs\n   make html\n\nYou can now open the file ``_build/html/index.html`` in your browser.\n\n**Generating message catalogs**\n\n.. code-block:: bash\n\n    export DJANGO_SETTINGS_MODULE=testapp.settings\n    cd cookie_consent\n    django-admin makemessages --all\n    cd ..\n\nAfter translating the message, you need to compile the message catalogs:\n\n.. code-block:: bash\n\n    django-admin compilemessages\n\n**Bumping the version/releasing**\n\nAfter updating changelogs etc.\n\n.. code-block:: bash\n\n    bump-my-version bump major|minor|patch\n    bump-my-version bump pre_l\n    git commit -am \":bookmark: Bump to version <X.Y.Z>\"\n    git tag -s X.Y.Z\n    git push origin main --tags\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) Bojan Mihelac and individual contributors.\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without modification,\nare permitted provided that the following conditions are met:\n\n    1. Redistributions of source code must retain the above copyright notice, \n       this list of conditions and the following disclaimer.\n    \n    2. Redistributions in binary form must reproduce the above copyright \n       notice, this list of conditions and the following disclaimer in the\n       documentation and/or other materials provided with the distribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND\nANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\nWARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR\nANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\n(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\nLOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON\nANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\nSOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "include LICENSE\ninclude AUTHORS\ninclude README.md\ninclude cookie_consent/py.typed\nrecursive-include cookie_consent/templates *\nrecursive-include cookie_consent/static *\nrecursive-include cookie_consent/fixtures *\n"
  },
  {
    "path": "README.md",
    "content": "Django cookie consent\n=====================\n\nManage cookie information and let visitors give or reject consent for them.\n\n![License](https://img.shields.io/pypi/l/django-cookie-consent)\n[![Build status][badge:GithubActions:CI]][GithubActions:CI]\n[![Code Quality][badge:GithubActions:CQ]][GithubActions:CQ]\n[![Code style: ruff][badge:ruff]][ruff]\n[![Test coverage][badge:codecov]][codecov]\n[![Documentation][badge:docs]][docs]\n\n![Supported python versions](https://img.shields.io/pypi/pyversions/django-cookie-consent)\n![Supported Django versions](https://img.shields.io/pypi/djversions/django-cookie-consent)\n[![PyPI version][badge:pypi]][pypi]\n[![NPM version][badge:npm]][npm]\n\n**Features**\n\n* cookies and cookie groups are stored in models for easy management\n  through Django admin interface\n* support for both opt-in and opt-out cookie consent schemes\n* removing declined cookies (or non accepted when opt-in scheme is used)\n* logging user actions when they accept and decline various cookies\n* easy adding new cookies and seamlessly re-asking for consent for new cookies\n\nDocumentation\n-------------\n\nThe documentation is hosted on [readthedocs][docs] and contains all instructions\nto get started.\n\nAlternatively, if the documentation is not available, you can consult or build the docs\nfrom the `docs` directory in this repository.\n\n[GithubActions:CI]: https://github.com/django-commons/django-cookie-consent/actions?query=workflow%3A%22Run+CI%22\n[badge:GithubActions:CI]: https://github.com/django-commons/django-cookie-consent/workflows/Run%20CI/badge.svg\n[GithubActions:CQ]: https://github.com/django-commons/django-cookie-consent/actions?query=workflow%3A%22Code+quality+checks%22\n[badge:GithubActions:CQ]: https://github.com/django-commons/django-cookie-consent/workflows/Code%20quality%20checks/badge.svg\n[ruff]: https://github.com/astral-sh/ruff\n[badge:ruff]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json\n[codecov]: https://codecov.io/gh/django-commons/django-cookie-consent\n[badge:codecov]: https://codecov.io/gh/django-commons/django-cookie-consent/branch/main/graph/badge.svg\n[docs]: https://django-cookie-consent.readthedocs.io/en/latest/?badge=latest\n[badge:docs]: https://readthedocs.org/projects/django-cookie-consent/badge/?version=latest\n[pypi]: https://pypi.org/project/django-cookie-consent/\n[badge:pypi]: https://img.shields.io/pypi/v/django-cookie-consent.svg\n[npm]: https://www.npmjs.com/package/django-cookie-consent\n[badge:npm]: https://img.shields.io/npm/v/django-cookie-consent\n"
  },
  {
    "path": "__init__.py",
    "content": ""
  },
  {
    "path": "cookie_consent/__init__.py",
    "content": "__version__ = \"1.0.0\"\n"
  },
  {
    "path": "cookie_consent/admin.py",
    "content": "from django.contrib import admin\nfrom django.db.models import Count\nfrom django.http.request import HttpRequest\nfrom django.templatetags.l10n import localize\nfrom django.templatetags.static import static\nfrom django.utils.html import format_html\nfrom django.utils.translation import gettext_lazy as _\n\nfrom .conf import settings\nfrom .models import Cookie, CookieGroup, LogItem\n\n\n@admin.register(Cookie)\nclass CookieAdmin(admin.ModelAdmin):\n    list_display = (\"varname\", \"name\", \"cookiegroup\", \"path\", \"domain\", \"get_version\")\n    search_fields = (\"name\", \"domain\", \"cookiegroup__varname\", \"cookiegroup__name\")\n    readonly_fields = (\"varname\",)\n    list_filter = (\"cookiegroup\",)\n\n\n@admin.register(CookieGroup)\nclass CookieGroupAdmin(admin.ModelAdmin):\n    list_display = (\n        \"varname\",\n        \"name\",\n        \"is_required\",\n        \"is_deletable\",\n        \"num_cookies\",\n        \"get_version\",\n    )\n    search_fields = (\n        \"varname\",\n        \"name\",\n    )\n    list_filter = (\n        \"is_required\",\n        \"is_deletable\",\n    )\n\n    def get_queryset(self, request: HttpRequest):\n        qs = super().get_queryset(request)\n        return qs.annotate(num_cookies=Count(\"cookie\"))\n\n    @admin.display(ordering=\"num_cookies\", description=_(\"# cookies\"))\n    def num_cookies(self, obj: CookieGroup):\n        if (count := obj.num_cookies) > 0:\n            return localize(count)\n\n        return format_html(\n            '{count} <img src=\"{src}\" alt=\"{alt}\">',\n            count=localize(count),\n            src=static(\"admin/img/icon-alert.svg\"),\n            alt=_(\"Warning icon for missing cookies in cookie group.\"),\n        )\n\n\nclass LogItemAdmin(admin.ModelAdmin):\n    list_display = (\"action\", \"cookiegroup\", \"version\", \"created\")\n    list_filter = (\"action\", \"cookiegroup\")\n    readonly_fields = (\"action\", \"cookiegroup\", \"version\", \"created\")\n    date_hierarchy = \"created\"\n\n\nif settings.COOKIE_CONSENT_LOG_ENABLED:\n    admin.site.register(LogItem, LogItemAdmin)\n"
  },
  {
    "path": "cookie_consent/apps.py",
    "content": "from django.apps import AppConfig\nfrom django.utils.translation import gettext_lazy as _\n\n\nclass CookieConsentConf(AppConfig):\n    name = \"cookie_consent\"\n    verbose_name = _(\"cookie consent\")\n    default_auto_field = \"django.db.models.AutoField\"\n"
  },
  {
    "path": "cookie_consent/cache.py",
    "content": "from collections.abc import Mapping\n\nfrom django.core.cache import caches\n\nfrom .conf import settings\nfrom .models import Cookie, CookieGroup\n\nCACHE_KEY = \"cookie_consent_cache\"\nCACHE_TIMEOUT = 60 * 60  # 60 minutes\n\n\ndef _get_cache():\n    \"\"\"\n    Lazily wrap around django.core.cache.\n\n    This prevents the cache object to be resolved at import-time, which breaks the\n    `django.test.override_settings` functionality for projects adding tests for this\n    package, see https://github.com/bmihelac/django-cookie-consent/issues/41.\n    \"\"\"\n    return caches[settings.COOKIE_CONSENT_CACHE_BACKEND]\n\n\ndef delete_cache() -> None:\n    cache = _get_cache()\n    cache.delete(CACHE_KEY)\n\n\ndef _get_cookie_groups_from_db() -> Mapping[str, CookieGroup]:\n    qs = CookieGroup.objects.filter(is_required=False).prefetch_related(\"cookie_set\")\n    return qs.in_bulk(field_name=\"varname\")\n\n\ndef all_cookie_groups() -> Mapping[str, CookieGroup]:\n    \"\"\"\n    Get all cookie groups that are optional.\n\n    Reads from the cache where possible, sets the value in the cache if there's a\n    cache miss.\n    \"\"\"\n    cache = _get_cache()\n    result = cache.get_or_set(\n        CACHE_KEY, _get_cookie_groups_from_db, timeout=CACHE_TIMEOUT\n    )\n    assert result is not None\n    return result\n\n\ndef get_cookie_group(varname: str) -> CookieGroup | None:\n    return all_cookie_groups().get(varname)\n\n\ndef get_cookie(cookie_group: CookieGroup, name: str, domain: str) -> Cookie | None:\n    # loop over cookie set relation instead of doing a lookup query, as this should\n    # come from the cache and avoid hitting the database\n    for cookie in cookie_group.cookie_set.all():\n        if cookie.name == name and cookie.domain == domain:\n            return cookie\n    return None\n"
  },
  {
    "path": "cookie_consent/conf.py",
    "content": "from typing import Literal\n\nfrom django.conf import settings\nfrom django.urls import reverse_lazy\nfrom django.utils.functional import Promise\n\nfrom appconf import AppConf\n\n__all__ = [\"settings\"]\n\n\nclass CookieConsentConf(AppConf):\n    # django-cookie-consent cookie settings that store the configuration\n    NAME: str = \"cookie_consent\"\n    # TODO: rename to AGE for parity with django settings\n    MAX_AGE: int = 60 * 60 * 24 * 365 * 1  # 1 year,\n    DOMAIN: str | None = None\n    SECURE: bool = False\n    HTTPONLY: bool = True\n    SAMESITE: Literal[\"Strict\", \"Lax\", \"None\", False] = \"Lax\"\n\n    DECLINE: str = \"-1\"\n\n    ENABLED: bool = True\n\n    OPT_OUT: bool = False\n\n    CACHE_BACKEND: str = \"default\"\n\n    LOG_ENABLED: bool = True\n    \"\"\"\n    DeprecationWarning: in future versions the default may switch to log disabled.\n    \"\"\"\n\n    SUCCESS_URL: str | Promise = reverse_lazy(\"cookie_consent_cookie_group_list\")\n"
  },
  {
    "path": "cookie_consent/fixtures/common_cookies.json",
    "content": "[{\"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. \"}}]"
  },
  {
    "path": "cookie_consent/forms.py",
    "content": "from collections.abc import Collection, Iterator\n\nfrom django import forms\nfrom django.utils.translation import gettext_lazy as _\n\nfrom .cache import all_cookie_groups\nfrom .models import CookieGroup\n\n\ndef iter_cookie_group_choices() -> Iterator[tuple[str, str]]:\n    \"\"\"\n    Use the cached cookie group instances to get a list of choices.\n    \"\"\"\n    for varname, cookie_group in all_cookie_groups().items():\n        yield varname, cookie_group.name\n\n\nclass CookieGroupsChoiceField(forms.TypedMultipleChoiceField):\n    def __init__(self, **kwargs):\n        kwargs[\"coerce\"] = self._coerce_choice\n        kwargs[\"choices\"] = iter_cookie_group_choices\n        super().__init__(**kwargs)\n\n    def _coerce_choice(self, varname: str) -> CookieGroup:\n        all_groups = all_cookie_groups()\n        return all_groups[varname]\n\n\nclass ProcessCookiesForm(forms.Form):\n    all_groups = forms.BooleanField(\n        label=_(\"Apply to all cookie groups\"),\n        required=False,\n    )\n    cookie_groups = CookieGroupsChoiceField(\n        label=_(\"Cookie group varnames\"),\n        choices=iter_cookie_group_choices,\n        required=False,\n    )\n\n    def get_cookie_groups(self) -> Collection[CookieGroup]:\n        \"\"\"\n        Build the collection of specified cookies.\n        \"\"\"\n        match self.cleaned_data:\n            case {\"all_groups\": True}:\n                return all_cookie_groups().values()\n            case {\"cookie_groups\": [*groups]}:\n                return groups\n            case _:\n                return []\n"
  },
  {
    "path": "cookie_consent/locale/en/LC_MESSAGES/django.po",
    "content": "# SOME DESCRIPTIVE TITLE.\n# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\n# This file is distributed under the same license as the PACKAGE package.\n# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.\n#\n#, fuzzy\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: PACKAGE VERSION\\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"POT-Creation-Date: 2013-06-09 10:12+0200\\n\"\n\"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n\"\n\"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n\"\n\"Language-Team: LANGUAGE <LL@li.org>\\n\"\n\"Language: \\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\n#: models.py:16\nmsgid \"\"\n\"Enter a valid 'varname' consisting of letters, numbers, underscores or \"\n\"hyphens.\"\nmsgstr \"\"\n\n#: models.py:23\nmsgid \"Variable name\"\nmsgstr \"\"\n\n#: models.py:26 models.py:66\nmsgid \"Name\"\nmsgstr \"\"\n\n#: models.py:27 models.py:67\nmsgid \"Description\"\nmsgstr \"\"\n\n#: models.py:29\nmsgid \"Is required\"\nmsgstr \"\"\n\n#: models.py:30\nmsgid \"Are cookies in this group required.\"\nmsgstr \"\"\n\n#: models.py:33\nmsgid \"Is deletable?\"\nmsgstr \"\"\n\n#: models.py:34\nmsgid \"Can cookies in this group be deleted.\"\nmsgstr \"\"\n\n#: models.py:36\nmsgid \"Ordering\"\nmsgstr \"\"\n\n#: models.py:37 models.py:70 models.py:106\nmsgid \"Created\"\nmsgstr \"\"\n\n#: models.py:40\nmsgid \"Cookie Group\"\nmsgstr \"\"\n\n#: models.py:41\nmsgid \"Cookie Groups\"\nmsgstr \"\"\n\n#: models.py:68\nmsgid \"Path\"\nmsgstr \"\"\n\n#: models.py:69\nmsgid \"Domain\"\nmsgstr \"\"\n\n#: models.py:73\nmsgid \"Cookie\"\nmsgstr \"\"\n\n#: models.py:74\nmsgid \"Cookies\"\nmsgstr \"\"\n\n#: models.py:95 templates/cookie_consent/_cookie_group.html:22\nmsgid \"Declined\"\nmsgstr \"\"\n\n#: models.py:96 templates/cookie_consent/_cookie_group.html:13\nmsgid \"Accepted\"\nmsgstr \"\"\n\n#: models.py:101\nmsgid \"Action\"\nmsgstr \"\"\n\n#: models.py:105\nmsgid \"Version\"\nmsgstr \"\"\n\n#: models.py:109\nmsgid \"Log item\"\nmsgstr \"\"\n\n#: models.py:110\nmsgid \"Log items\"\nmsgstr \"\"\n\n#: templates/cookie_consent/_cookie_group.html:17\nmsgid \"Accept\"\nmsgstr \"\"\n\n#: templates/cookie_consent/_cookie_group.html:26\nmsgid \"Decline\"\nmsgstr \"\"\n"
  },
  {
    "path": "cookie_consent/locale/nl/LC_MESSAGES/django.po",
    "content": "# SOME DESCRIPTIVE TITLE.\n# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\n# This file is distributed under the same license as the PACKAGE package.\n# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.\n#\n#, fuzzy\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: 0.4.0\\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"POT-Creation-Date: 2022-09-04 15:38-0500\\n\"\n\"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n\"\n\"Last-Translator: Sergei Maertens <sergei@maykinmedia.nl>\\n\"\n\"Language-Team: LANGUAGE <LL@li.org>\\n\"\n\"Language: \\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n#: cookie_consent/models.py:14\nmsgid \"\"\n\"Enter a valid 'varname' consisting of letters, numbers, underscores or \"\n\"hyphens.\"\nmsgstr \"\"\n\"Geef een geldige 'variabelenaam' op die bestaat uit letters, getallen, liggende \"\n\"streepjes of koppeltekens.\"\n\n#: cookie_consent/models.py:23\nmsgid \"Variable name\"\nmsgstr \"Variabelenaam\"\n\n#: cookie_consent/models.py:25 cookie_consent/models.py:69\nmsgid \"Name\"\nmsgstr \"Naam\"\n\n#: cookie_consent/models.py:26 cookie_consent/models.py:70\nmsgid \"Description\"\nmsgstr \"Omschrijving\"\n\n#: cookie_consent/models.py:28\nmsgid \"Is required\"\nmsgstr \"Is verplicht\"\n\n#: cookie_consent/models.py:29\nmsgid \"Are cookies in this group required.\"\nmsgstr \"Of cookies in deze groep verplicht zijn.\"\n\n#: cookie_consent/models.py:33\nmsgid \"Is deletable?\"\nmsgstr \"Kan worden verwijderd?\"\n\n#: cookie_consent/models.py:34\nmsgid \"Can cookies in this group be deleted.\"\nmsgstr \"Of cookies in deze groep verwijderd kunnen worden.\"\n\n#: cookie_consent/models.py:37\nmsgid \"Ordering\"\nmsgstr \"Volgorde\"\n\n#: cookie_consent/models.py:38 cookie_consent/models.py:73\n#: cookie_consent/models.py:115\nmsgid \"Created\"\nmsgstr \"Aangemaakt\"\n\n#: cookie_consent/models.py:41\nmsgid \"Cookie Group\"\nmsgstr \"Cookiegroep\"\n\n#: cookie_consent/models.py:42\nmsgid \"Cookie Groups\"\nmsgstr \"Cookiegroepen\"\n\n#: cookie_consent/models.py:71\nmsgid \"Path\"\nmsgstr \"Pad\"\n\n#: cookie_consent/models.py:72\nmsgid \"Domain\"\nmsgstr \"Domein\"\n\n#: cookie_consent/models.py:76\nmsgid \"Cookie\"\nmsgstr \"Cookie\"\n\n#: cookie_consent/models.py:77\nmsgid \"Cookies\"\nmsgstr \"Cookies\"\n\n#: cookie_consent/models.py:102\n#: cookie_consent/templates/cookie_consent/_cookie_group.html:21\nmsgid \"Declined\"\nmsgstr \"Afgewezen\"\n\n#: cookie_consent/models.py:103\n#: cookie_consent/templates/cookie_consent/_cookie_group.html:12\nmsgid \"Accepted\"\nmsgstr \"Aanvaard\"\n\n#: cookie_consent/models.py:108\nmsgid \"Action\"\nmsgstr \"Actie\"\n\n#: cookie_consent/models.py:114\nmsgid \"Version\"\nmsgstr \"Versie\"\n\n#: cookie_consent/models.py:121\nmsgid \"Log item\"\nmsgstr \"Logitem\"\n\n#: cookie_consent/models.py:122\nmsgid \"Log items\"\nmsgstr \"Logitems\"\n\n#: cookie_consent/templates/cookie_consent/_cookie_group.html:16\nmsgid \"Accept\"\nmsgstr \"Aanvaarden\"\n\n#: cookie_consent/templates/cookie_consent/_cookie_group.html:25\nmsgid \"Decline\"\nmsgstr \"Weigeren\"\n"
  },
  {
    "path": "cookie_consent/locale/oc/LC_MESSAGES/django.po",
    "content": "# SOME DESCRIPTIVE TITLE.\n# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\n# This file is distributed under the same license as the PACKAGE package.\n# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.\n#\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: \\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"POT-Creation-Date: 2013-06-09 10:12+0200\\n\"\n\"PO-Revision-Date: 2021-07-22 21:55+0200\\n\"\n\"Language: oc\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Last-Translator: Quentin PAGÈS\\n\"\n\"Language-Team: \\n\"\n\"X-Generator: Poedit 3.0\\n\"\n\"Plural-Forms: nplurals=2; plural=n > 1;\\n\"\n\n#: models.py:16\nmsgid \"\"\n\"Enter a valid 'varname' consisting of letters, numbers, underscores or \"\n\"hyphens.\"\nmsgstr \"\"\n\"Picatz un « varname » valid compausat de letras, nombres, jonhents \"\n\"basses e jonhents.\"\n\n#: models.py:23\nmsgid \"Variable name\"\nmsgstr \"Nom de la variabla\"\n\n#: models.py:26 models.py:66\nmsgid \"Name\"\nmsgstr \"Nom\"\n\n#: models.py:27 models.py:67\nmsgid \"Description\"\nmsgstr \"Descripcion\"\n\n#: models.py:29\nmsgid \"Is required\"\nmsgstr \"Es requesit\"\n\n#: models.py:30\nmsgid \"Are cookies in this group required.\"\nmsgstr \"Se los cookies d’aqueste grop son requesits.\"\n\n#: models.py:33\nmsgid \"Is deletable?\"\nmsgstr \"Se pòt suprimir ?\"\n\n#: models.py:34\nmsgid \"Can cookies in this group be deleted.\"\nmsgstr \"Se los cookies d’aqueste grop se pòdon suprimir.\"\n\n#: models.py:36\nmsgid \"Ordering\"\nmsgstr \"Òrdre\"\n\n#: models.py:37 models.py:70 models.py:106\nmsgid \"Created\"\nmsgstr \"Creat lo\"\n\n#: models.py:40\nmsgid \"Cookie Group\"\nmsgstr \"Grop de cookies\"\n\n#: models.py:41\nmsgid \"Cookie Groups\"\nmsgstr \"Grops de cookies\"\n\n#: models.py:68\nmsgid \"Path\"\nmsgstr \"Camin d'accès\"\n\n#: models.py:69\nmsgid \"Domain\"\nmsgstr \"Domeni\"\n\n#: models.py:73\nmsgid \"Cookie\"\nmsgstr \"Cookie\"\n\n#: models.py:74\nmsgid \"Cookies\"\nmsgstr \"Cookies\"\n\n#: models.py:95 templates/cookie_consent/_cookie_group.html:22\nmsgid \"Declined\"\nmsgstr \"Refusat\"\n\n#: models.py:96 templates/cookie_consent/_cookie_group.html:13\nmsgid \"Accepted\"\nmsgstr \"Acceptat\"\n\n#: models.py:101\nmsgid \"Action\"\nmsgstr \"Accion\"\n\n#: models.py:105\nmsgid \"Version\"\nmsgstr \"Version\"\n\n#: models.py:109\nmsgid \"Log item\"\nmsgstr \"Auditar element\"\n\n#: models.py:110\nmsgid \"Log items\"\nmsgstr \"Auditar element\"\n\n#: templates/cookie_consent/_cookie_group.html:17\nmsgid \"Accept\"\nmsgstr \"Acceptar\"\n\n#: templates/cookie_consent/_cookie_group.html:26\nmsgid \"Decline\"\nmsgstr \"Refusar\"\n"
  },
  {
    "path": "cookie_consent/locale/pt_BR/LC_MESSAGES/django.po",
    "content": "# SOME DESCRIPTIVE TITLE.\n# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\n# This file is distributed under the same license as the PACKAGE package.\n# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.\n#\n#, fuzzy\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: PACKAGE VERSION\\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"POT-Creation-Date: 2020-06-16 08:40+0100\\n\"\n\"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n\"\n\"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n\"\n\"Language-Team: LANGUAGE <LL@li.org>\\n\"\n\"Language: \\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Plural-Forms: nplurals=2; plural=(n > 1);\\n\"\n#: .\\django-cookie-consent\\cookie_consent\\models.py:16\nmsgid \"\"\n\"Enter a valid 'varname' consisting of letters, numbers, underscores or \"\n\"hyphens.\"\nmsgstr \"\"\n\"Insira um 'varname' válido, composto por letras, números, underscores ou \"\n\"hífens.\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:23\nmsgid \"Variable name\"\nmsgstr \"Nome da variável\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:26\n#: .\\django-cookie-consent\\cookie_consent\\models.py:67\nmsgid \"Name\"\nmsgstr \"Nome\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:27\n#: .\\django-cookie-consent\\cookie_consent\\models.py:68\nmsgid \"Description\"\nmsgstr \"Descrição\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:29\nmsgid \"Is required\"\nmsgstr \"\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:30\nmsgid \"Are cookies in this group required.\"\nmsgstr \"Se os cookies deste grupo são necessários.\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:33\nmsgid \"Is deletable?\"\nmsgstr \"\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:34\nmsgid \"Can cookies in this group be deleted.\"\nmsgstr \"Se os cookies deste grupo podem serem apagados.\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:36\nmsgid \"Ordering\"\nmsgstr \"Ordem\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:37\n#: .\\django-cookie-consent\\cookie_consent\\models.py:71\n#: .\\django-cookie-consent\\cookie_consent\\models.py:112\nmsgid \"Created\"\nmsgstr \"Criado em\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:40\nmsgid \"Cookie Group\"\nmsgstr \"Grupo de Cookies\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:41\nmsgid \"Cookie Groups\"\nmsgstr \"Grupos de Cookies\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:69\nmsgid \"Path\"\nmsgstr \"\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:70\nmsgid \"Domain\"\nmsgstr \"Domínio\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:74\nmsgid \"Cookie\"\nmsgstr \"\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:75\nmsgid \"Cookies\"\nmsgstr \"\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:100\n#: .\\django-cookie-consent\\cookie_consent\\templates\\cookie_consent\\_cookie_group.html:21\nmsgid \"Declined\"\nmsgstr \"Rejeitado\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:101\n#: .\\django-cookie-consent\\cookie_consent\\templates\\cookie_consent\\_cookie_group.html:12\nmsgid \"Accepted\"\nmsgstr \"Aceito\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:106\nmsgid \"Action\"\nmsgstr \"Ação\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:111\nmsgid \"Version\"\nmsgstr \"Versão\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:117\nmsgid \"Log item\"\nmsgstr \"\"\n\n#: .\\django-cookie-consent\\cookie_consent\\models.py:118\nmsgid \"Log items\"\nmsgstr \"\"\n\n#: .\\django-cookie-consent\\cookie_consent\\templates\\cookie_consent\\_cookie_group.html:16\nmsgid \"Accept\"\nmsgstr \"Aceitar\"\n\n#: .\\django-cookie-consent\\cookie_consent\\templates\\cookie_consent\\_cookie_group.html:25\nmsgid \"Decline\"\nmsgstr \"Recusar\"\n"
  },
  {
    "path": "cookie_consent/locale/sl/LC_MESSAGES/django.po",
    "content": "# SOME DESCRIPTIVE TITLE.\n# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\n# This file is distributed under the same license as the PACKAGE package.\n# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.\n#\n#, fuzzy\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: PACKAGE VERSION\\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"POT-Creation-Date: 2013-06-09 10:26+0200\\n\"\n\"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n\"\n\"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n\"\n\"Language-Team: LANGUAGE <LL@li.org>\\n\"\n\"Language: \\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Plural-Forms: nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n\"\n\"%100==4 ? 2 : 3);\\n\"\n\n#: models.py:16\nmsgid \"\"\n\"Enter a valid 'varname' consisting of letters, numbers, underscores or \"\n\"hyphens.\"\nmsgstr \"\"\n\n#: models.py:23\nmsgid \"Variable name\"\nmsgstr \"\"\n\n#: models.py:26 models.py:66\nmsgid \"Name\"\nmsgstr \"\"\n\n#: models.py:27 models.py:67\nmsgid \"Description\"\nmsgstr \"\"\n\n#: models.py:29\nmsgid \"Is required\"\nmsgstr \"\"\n\n#: models.py:30\nmsgid \"Are cookies in this group required.\"\nmsgstr \"\"\n\n#: models.py:33\nmsgid \"Is deletable?\"\nmsgstr \"\"\n\n#: models.py:34\nmsgid \"Can cookies in this group be deleted.\"\nmsgstr \"\"\n\n#: models.py:36\nmsgid \"Ordering\"\nmsgstr \"\"\n\n#: models.py:37 models.py:70 models.py:106\nmsgid \"Created\"\nmsgstr \"\"\n\n#: models.py:40\nmsgid \"Cookie Group\"\nmsgstr \"\"\n\n#: models.py:41\nmsgid \"Cookie Groups\"\nmsgstr \"\"\n\n#: models.py:68\nmsgid \"Path\"\nmsgstr \"\"\n\n#: models.py:69\nmsgid \"Domain\"\nmsgstr \"\"\n\n#: models.py:73\nmsgid \"Cookie\"\nmsgstr \"Piškot\"\n\n#: models.py:74\nmsgid \"Cookies\"\nmsgstr \"Piškotki\"\n\n#: models.py:95 templates/cookie_consent/_cookie_group.html:22\nmsgid \"Declined\"\nmsgstr \"Zavrnjeno\"\n\n#: models.py:96 templates/cookie_consent/_cookie_group.html:13\nmsgid \"Accepted\"\nmsgstr \"Sprejeto\"\n\n#: models.py:101\nmsgid \"Action\"\nmsgstr \"Ukrep\"\n\n#: models.py:105\nmsgid \"Version\"\nmsgstr \"Različica\"\n\n#: models.py:109\nmsgid \"Log item\"\nmsgstr \"\"\n\n#: models.py:110\nmsgid \"Log items\"\nmsgstr \"\"\n\n#: templates/cookie_consent/_cookie_group.html:17\nmsgid \"Accept\"\nmsgstr \"Strinjam se\"\n\n#: templates/cookie_consent/_cookie_group.html:26\nmsgid \"Decline\"\nmsgstr \"Ne strinjam se\"\n"
  },
  {
    "path": "cookie_consent/management/__init__.py",
    "content": ""
  },
  {
    "path": "cookie_consent/management/commands/__init__.py",
    "content": ""
  },
  {
    "path": "cookie_consent/management/commands/prune_cookie_consent_logs.py",
    "content": "from datetime import timedelta\n\nfrom django.core.management.base import BaseCommand\nfrom django.utils import timezone\n\nfrom ...models import LogItem\n\n\nclass Command(BaseCommand):\n    help = \"Prune old LogItem records older than a given number of days.\"\n\n    def add_arguments(self, parser):\n        parser.add_argument(\n            \"--days\",\n            type=int,\n            default=90,\n            help=\"Delete log items older than this many days (default: 90).\",\n        )\n\n    def handle(self, *args, **options):\n        days = options[\"days\"]\n        cutoff = timezone.now() - timedelta(days=days)\n        deleted, _ = LogItem.objects.filter(created__lt=cutoff).delete()\n        self.stdout.write(\n            self.style.SUCCESS(f\"Deleted {deleted} log item(s) older than {days} days.\")\n        )\n"
  },
  {
    "path": "cookie_consent/middleware.py",
    "content": "from collections.abc import Callable\n\nfrom django.http import HttpRequest, HttpResponseBase\n\nfrom .cache import all_cookie_groups\nfrom .conf import settings\nfrom .util import get_cookie_dict_from_request, is_cookie_consent_enabled\n\n\ndef _should_delete_cookie(group_version: str | None) -> bool:\n    # declined after it was accepted (and set) before\n    if group_version == settings.COOKIE_CONSENT_DECLINE:\n        return True\n\n    # if you need to opt-out instead of opt-in, then we only delete the cookie in the\n    # above scenario -> when the group is explicitly declined\n    if settings.COOKIE_CONSENT_OPT_OUT:\n        return False\n\n    # when we are opt-in and have no information whether the cookie group was accepted\n    # or declined, delete the cookie(s).\n    if group_version is None:\n        return True\n\n    return False\n\n\nclass CleanCookiesMiddleware:\n    \"\"\"\n    Clean declined or non-accepted cookies.\n\n    Note that this only applies if COOKIE_CONSENT_OPT_OUT is not set.\n    \"\"\"\n\n    def __init__(self, get_response: Callable[[HttpRequest], HttpResponseBase]):\n        self.get_response = get_response\n\n    def __call__(self, request: HttpRequest):\n        response = self.get_response(request)\n        if is_cookie_consent_enabled(request):\n            self.process_response(request, response)\n        return response\n\n    def process_response(self, request: HttpRequest, response: HttpResponseBase):\n        cookie_dic = get_cookie_dict_from_request(request)\n\n        cookies_to_delete = []\n        for cookie_group in all_cookie_groups().values():\n            if not cookie_group.is_deletable:\n                continue\n\n            group_version = cookie_dic.get(cookie_group.varname, None)\n            for cookie in cookie_group.cookie_set.all():\n                if cookie.name not in request.COOKIES:\n                    continue\n                if _should_delete_cookie(group_version):\n                    cookies_to_delete.append(cookie)\n\n        for cookie in cookies_to_delete:\n            response.delete_cookie(cookie.name, cookie.path, cookie.domain)\n\n        return response\n"
  },
  {
    "path": "cookie_consent/migrations/0001_initial.py",
    "content": "# Generated by Django 2.1 on 2019-02-08 14:14\n\nimport re\n\nimport django.core.validators\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    initial = True\n\n    dependencies = []\n\n    operations = [\n        migrations.CreateModel(\n            name=\"Cookie\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"name\", models.CharField(max_length=250, verbose_name=\"Name\")),\n                (\n                    \"description\",\n                    models.TextField(blank=True, verbose_name=\"Description\"),\n                ),\n                (\n                    \"path\",\n                    models.TextField(blank=True, default=\"/\", verbose_name=\"Path\"),\n                ),\n                (\n                    \"domain\",\n                    models.CharField(blank=True, max_length=250, verbose_name=\"Domain\"),\n                ),\n                (\n                    \"created\",\n                    models.DateTimeField(auto_now_add=True, verbose_name=\"Created\"),\n                ),\n            ],\n            options={\n                \"verbose_name\": \"Cookie\",\n                \"verbose_name_plural\": \"Cookies\",\n                \"ordering\": [\"-created\"],\n            },\n        ),\n        migrations.CreateModel(\n            name=\"CookieGroup\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\n                    \"varname\",\n                    models.CharField(\n                        max_length=32,\n                        validators=[\n                            django.core.validators.RegexValidator(\n                                re.compile(\"^[-_a-zA-Z0-9]+$\"),\n                                \"Enter a valid 'varname' consisting of letters, \"\n                                \"numbers, underscores or hyphens.\",\n                                \"invalid\",\n                            )\n                        ],\n                        verbose_name=\"Variable name\",\n                    ),\n                ),\n                (\n                    \"name\",\n                    models.CharField(blank=True, max_length=100, verbose_name=\"Name\"),\n                ),\n                (\n                    \"description\",\n                    models.TextField(blank=True, verbose_name=\"Description\"),\n                ),\n                (\n                    \"is_required\",\n                    models.BooleanField(\n                        default=False,\n                        help_text=\"Are cookies in this group required.\",\n                        verbose_name=\"Is required\",\n                    ),\n                ),\n                (\n                    \"is_deletable\",\n                    models.BooleanField(\n                        default=True,\n                        help_text=\"Can cookies in this group be deleted.\",\n                        verbose_name=\"Is deletable?\",\n                    ),\n                ),\n                (\"ordering\", models.IntegerField(default=0, verbose_name=\"Ordering\")),\n                (\n                    \"created\",\n                    models.DateTimeField(auto_now_add=True, verbose_name=\"Created\"),\n                ),\n            ],\n            options={\n                \"verbose_name\": \"Cookie Group\",\n                \"verbose_name_plural\": \"Cookie Groups\",\n                \"ordering\": [\"ordering\"],\n            },\n        ),\n        migrations.AddField(\n            model_name=\"cookie\",\n            name=\"cookiegroup\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE,\n                to=\"cookie_consent.CookieGroup\",\n                verbose_name=\"Cookie Group\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "cookie_consent/migrations/0002_auto__add_logitem.py",
    "content": "# Generated by Django 2.1 on 2019-02-08 14:16\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"cookie_consent\", \"0001_initial\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"LogItem\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\n                    \"action\",\n                    models.IntegerField(\n                        choices=[(-1, \"Declined\"), (1, \"Accepted\")],\n                        verbose_name=\"Action\",\n                    ),\n                ),\n                (\"version\", models.CharField(max_length=32, verbose_name=\"Version\")),\n                (\n                    \"created\",\n                    models.DateTimeField(auto_now_add=True, verbose_name=\"Created\"),\n                ),\n                (\n                    \"cookiegroup\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"cookie_consent.CookieGroup\",\n                        verbose_name=\"Cookie Group\",\n                    ),\n                ),\n            ],\n            options={\n                \"verbose_name\": \"Log item\",\n                \"verbose_name_plural\": \"Log items\",\n                \"ordering\": [\"-created\"],\n            },\n        ),\n    ]\n"
  },
  {
    "path": "cookie_consent/migrations/0003_alter_cookiegroup_varname.py",
    "content": "# Generated by Django 4.2.13 on 2024-05-09 19:01\n\nimport re\n\nimport django.core.validators\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"cookie_consent\", \"0002_auto__add_logitem\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"cookiegroup\",\n            name=\"varname\",\n            field=models.CharField(\n                max_length=32,\n                unique=True,\n                validators=[\n                    django.core.validators.RegexValidator(\n                        re.compile(\"^[-_a-zA-Z0-9]+$\"),\n                        \"Enter a valid 'varname' consisting of letters, numbers, \"\n                        \"underscores or hyphens.\",\n                        \"invalid\",\n                    )\n                ],\n                verbose_name=\"Variable name\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "cookie_consent/migrations/0004_cookie_natural_key.py",
    "content": "# Generated by Django 4.2.13 on 2024-05-09 20:22\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"cookie_consent\", \"0003_alter_cookiegroup_varname\"),\n    ]\n\n    operations = [\n        migrations.AddConstraint(\n            model_name=\"cookie\",\n            constraint=models.UniqueConstraint(\n                fields=(\"cookiegroup\", \"name\", \"domain\"), name=\"natural_key\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "cookie_consent/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "cookie_consent/models.py",
    "content": "from __future__ import annotations\n\nimport re\nfrom collections.abc import Callable\nfrom typing import ClassVar, ParamSpec, TypedDict, TypeVar\n\nfrom django.core.validators import RegexValidator\nfrom django.db import models\nfrom django.utils.translation import gettext_lazy as _\n\nCOOKIE_NAME_RE = re.compile(r\"^[-_a-zA-Z0-9]+$\")\nvalidate_cookie_name = RegexValidator(\n    COOKIE_NAME_RE,\n    _(\n        \"Enter a valid 'varname' consisting of letters, numbers\"\n        \", underscores or hyphens.\"\n    ),\n    \"invalid\",\n)\n\nP = ParamSpec(\"P\")\nT = TypeVar(\"T\")\n\n\ndef clear_cache_after(func: Callable[P, T]) -> Callable[P, T]:\n    def wrapper(*args: P.args, **kwargs: P.kwargs):\n        from .cache import delete_cache\n\n        return_value = func(*args, **kwargs)\n        delete_cache()\n        return return_value\n\n    return wrapper\n\n\nclass CookieGroupDict(TypedDict):\n    varname: str\n    name: str\n    description: str\n    is_required: bool\n    # The version is deliberately not included because it requires page/view cache\n    # busting if a new cookie gets added to the group, which we don't control.\n\n\nclass BaseQueryset(models.query.QuerySet):\n    @clear_cache_after\n    def delete(self):\n        return super().delete()\n\n    @clear_cache_after\n    def update(self, **kwargs):\n        return super().update(**kwargs)\n\n\nclass CookieGroupManager(models.Manager.from_queryset(BaseQueryset)):\n    def get_by_natural_key(self, varname: str) -> CookieGroup:\n        return self.get(varname=varname)\n\n\nclass CookieGroup(models.Model):\n    varname = models.CharField(\n        _(\"Variable name\"),\n        max_length=32,\n        unique=True,\n        validators=[validate_cookie_name],\n    )\n    name = models.CharField(_(\"Name\"), max_length=100, blank=True)\n    description = models.TextField(_(\"Description\"), blank=True)\n    is_required = models.BooleanField(\n        _(\"Is required\"),\n        help_text=_(\"Are cookies in this group required.\"),\n        default=False,\n    )\n    is_deletable = models.BooleanField(\n        _(\"Is deletable?\"),\n        help_text=_(\"Can cookies in this group be deleted.\"),\n        default=True,\n    )\n    ordering = models.IntegerField(_(\"Ordering\"), default=0)\n    created = models.DateTimeField(_(\"Created\"), auto_now_add=True, blank=True)\n\n    objects: ClassVar[CookieGroupManager] = CookieGroupManager()  # pyright: ignore[reportIncompatibleVariableOverride]\n    cookie_set: ClassVar[CookieManager]\n\n    class Meta:\n        verbose_name = _(\"Cookie Group\")\n        verbose_name_plural = _(\"Cookie Groups\")\n        ordering = [\"ordering\"]\n\n    def __str__(self):\n        return self.name\n\n    @clear_cache_after\n    def save(self, *args, **kwargs):\n        super().save(*args, **kwargs)\n\n    @clear_cache_after\n    def delete(self, *args, **kwargs):\n        return super().delete(*args, **kwargs)\n\n    def natural_key(self) -> tuple[str]:\n        return (self.varname,)\n\n    def get_version(self) -> str:\n        try:\n            # this relies on the cookie set being ordered by most-recently created\n            # first.\n            # Note that we don't use `.first()` as that's a new query and bypasses\n            # the cache.\n            return str(self.cookie_set.all()[0].get_version())\n        except IndexError:\n            return \"\"\n\n    def for_json(self) -> CookieGroupDict:\n        return {\n            \"varname\": self.varname,\n            \"name\": self.name,\n            \"description\": self.description,\n            \"is_required\": self.is_required,\n            # \"version\": self.get_version(),\n        }\n\n\nclass CookieManager(models.Manager.from_queryset(BaseQueryset)):\n    def get_by_natural_key(self, name: str, domain: str, cookiegroup: str) -> Cookie:\n        group = CookieGroup.objects.get_by_natural_key(cookiegroup)\n        return self.get(cookiegroup=group, name=name, domain=domain)\n\n\nclass Cookie(models.Model):\n    cookiegroup = models.ForeignKey(\n        CookieGroup,\n        verbose_name=CookieGroup._meta.verbose_name,\n        on_delete=models.CASCADE,\n    )\n    name = models.CharField(_(\"Name\"), max_length=250)\n    description = models.TextField(_(\"Description\"), blank=True)\n    path = models.TextField(_(\"Path\"), blank=True, default=\"/\")\n    domain = models.CharField(_(\"Domain\"), max_length=250, blank=True)\n    created = models.DateTimeField(_(\"Created\"), auto_now_add=True, blank=True)\n\n    objects = CookieManager()\n\n    class Meta:\n        verbose_name = _(\"Cookie\")\n        verbose_name_plural = _(\"Cookies\")\n        constraints = [\n            models.UniqueConstraint(\n                fields=(\"cookiegroup\", \"name\", \"domain\"),\n                name=\"natural_key\",\n            ),\n        ]\n        ordering = [\"-created\"]\n\n    def __str__(self):\n        return f\"{self.name} {self.domain}{self.path}\"\n\n    @clear_cache_after\n    def save(self, *args, **kwargs):\n        super().save(*args, **kwargs)\n\n    @clear_cache_after\n    def delete(self, *args, **kwargs):\n        return super().delete(*args, **kwargs)\n\n    def natural_key(self) -> tuple[str, str, str]:\n        return (self.name, self.domain) + self.cookiegroup.natural_key()\n\n    natural_key.dependencies = [\"cookie_consent.cookiegroup\"]  # pyright: ignore[reportFunctionMemberAccess]\n\n    @property\n    def varname(self) -> str:\n        group_varname = self.cookiegroup.varname\n        return f\"{group_varname}={self.name}:{self.domain}\"\n\n    def get_version(self) -> str:\n        return self.created.isoformat()\n\n\nACTION_ACCEPTED = 1\nACTION_DECLINED = -1\nACTION_CHOICES = (\n    (ACTION_DECLINED, _(\"Declined\")),\n    (ACTION_ACCEPTED, _(\"Accepted\")),\n)\n\n\nclass LogItem(models.Model):\n    action = models.IntegerField(_(\"Action\"), choices=ACTION_CHOICES)\n    cookiegroup = models.ForeignKey(\n        CookieGroup,\n        verbose_name=CookieGroup._meta.verbose_name,\n        on_delete=models.CASCADE,\n    )\n    version = models.CharField(_(\"Version\"), max_length=32)\n    created = models.DateTimeField(_(\"Created\"), auto_now_add=True, blank=True)\n\n    class Meta:\n        verbose_name = _(\"Log item\")\n        verbose_name_plural = _(\"Log items\")\n        ordering = [\"-created\"]\n\n    def __str__(self):\n        return f\"{self.cookiegroup.name} {self.version}\"\n"
  },
  {
    "path": "cookie_consent/processor.py",
    "content": "from collections.abc import Collection\nfrom typing import Literal\n\nfrom django.http import HttpRequest, HttpResponseBase\n\nfrom .conf import settings\nfrom .models import ACTION_ACCEPTED, ACTION_DECLINED, CookieGroup, LogItem\nfrom .util import get_cookie_dict_from_request, set_cookie_dict_to_response\n\n\nclass CookiesProcessor:\n    \"\"\"\n    Process the accept/decline logic for cookie groups.\n    \"\"\"\n\n    def __init__(self, request: HttpRequest, response: HttpResponseBase):\n        self.request = request\n        self.response = response\n\n    def process(\n        self,\n        cookie_groups: Collection[CookieGroup],\n        action: Literal[\"accept\", \"decline\"],\n    ) -> None:\n        \"\"\"\n        Apply ``action`` to the specified ``cookie_groups``.\n\n        Mutates the response by updating the cookie tracking the cookie group status. If\n        there are no cookie groups provided, nothing happens.\n        \"\"\"\n        if not cookie_groups:\n            return\n\n        cookie_dic = get_cookie_dict_from_request(self.request)\n\n        match action:\n            case \"accept\":\n                for cookie_group in cookie_groups:\n                    cookie_dic[cookie_group.varname] = cookie_group.get_version()\n            case \"decline\":\n                self._delete_cookies(cookie_groups)\n                for cookie_group in cookie_groups:\n                    cookie_dic[cookie_group.varname] = settings.COOKIE_CONSENT_DECLINE\n\n        self._log_action(cookie_groups, action)\n        set_cookie_dict_to_response(self.response, cookie_dic)\n\n    def _log_action(\n        self,\n        cookie_groups: Collection[CookieGroup],\n        action: Literal[\"accept\", \"decline\"],\n    ) -> None:\n        if not settings.COOKIE_CONSENT_LOG_ENABLED:\n            return\n        # TODO: replace with stdlib logging call/helper instead of creating DB records\n        # directly.\n\n        action_map: dict[Literal[\"accept\", \"decline\"], int] = {\n            \"accept\": ACTION_ACCEPTED,\n            \"decline\": ACTION_DECLINED,\n        }\n        log_items: list[LogItem] = [\n            LogItem(\n                action=action_map[action],\n                cookiegroup=cookie_group,\n                version=cookie_group.get_version(),\n            )\n            for cookie_group in cookie_groups\n        ]\n        LogItem.objects.bulk_create(log_items)\n\n    def _delete_cookies(self, cookie_groups: Collection[CookieGroup]) -> None:\n        for cookie_group in cookie_groups:\n            if not cookie_group.is_deletable:\n                continue\n            for cookie in cookie_group.cookie_set.all():\n                self.response.delete_cookie(cookie.name, cookie.path, cookie.domain)\n"
  },
  {
    "path": "cookie_consent/py.typed",
    "content": ""
  },
  {
    "path": "cookie_consent/templates/cookie_consent/_cookie_group.html",
    "content": "{% load i18n %}\n{% load cookie_consent_tags %}\n\n\n<div class=\"cookie-group\">\n  <div class=\"cookie-group-title\">\n    <h3>{{ cookie_group.name }}</h3>\n\n    {% if not cookie_group.is_required %}\n      <div class=\"cookie-group-form\">\n       {% if request|cookie_group_accepted:cookie_group.varname %}\n         <span class=\"cookie-consent-accepted\">{% trans \"Accepted\" %}</span>\n       {% else %}\n         <form class=\"cookie-consent-accept\" action=\"{% url \"cookie_consent_accept\" %}\" method=\"post\">\n           {% csrf_token %}\n           <input type=\"hidden\" name=\"cookie_groups\" value=\"{{ cookie_group.varname }}\">\n           <input type=\"submit\" value=\"{% trans \"Accept\" %}\">\n         </form>\n       {% endif %}\n\n       {% if request|cookie_group_declined:cookie_group.varname %}\n         <span class=\"cookie-consent-declined\">{% trans \"Declined\" %}</span>\n       {% else %}\n         <form class=\"cookie-consent-decline\" action=\"{% url \"cookie_consent_decline\" %}\" method=\"post\">\n           {% csrf_token %}\n           <input type=\"hidden\" name=\"cookie_groups\" value=\"{{ cookie_group.varname }}\">\n           <input type=\"submit\" value=\"{% trans \"Decline\" %}\">\n         </form>\n       {% endif %}\n      </div>\n    {% endif %}\n\n  </div>\n\n  <p>\n    {{ cookie_group.description }}\n  </p>\n\n\n  <table>\n  {% for cookie in cookie_group.cookie_set.all %}\n   <tr>\n     <th>\n        {{ cookie.name }}\n        {% if cookie.domain %}\n          ({{ cookie.domain }})\n        {% endif %}\n     </th>\n     <td>\n       {% if cookie.description %}\n        {{ cookie.description }}\n       {% endif %}\n     </td>\n   </tr>\n  {% endfor %}\n  </table>\n\n</div>\n"
  },
  {
    "path": "cookie_consent/templates/cookie_consent/base.html",
    "content": "<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",
    "content": "{% extends \"cookie_consent/base.html\" %}\n\n{% block body %}\n  <h1>Cookies</h1>\n\n  <p>\n    This is a list of the categories of cookies used in our website and why we use them.\n  </p>\n\n  {% for cookie_group in object_list  %}\n    {% include \"cookie_consent/_cookie_group.html\" %}\n  {% endfor %}\n\n{% endblock %}\n"
  },
  {
    "path": "cookie_consent/templatetags/__init__.py",
    "content": "#!/usr/bin/env python\n"
  },
  {
    "path": "cookie_consent/templatetags/cookie_consent_tags.py",
    "content": "from collections.abc import Collection\n\nfrom django import template\nfrom django.http import HttpRequest\nfrom django.utils.html import json_script\n\nfrom ..cache import all_cookie_groups as get_all_cookie_groups\nfrom ..models import CookieGroup\nfrom ..util import (\n    are_all_cookies_accepted,\n    get_cookie_value_from_request,\n    get_not_accepted_or_declined_cookie_groups,\n    is_cookie_consent_enabled,\n)\n\nregister = template.Library()\n\n\n@register.filter\ndef cookie_group_accepted(request: HttpRequest, group_or_cookie: str) -> bool:\n    \"\"\"\n    Return ``True`` if the cookie group/cookie is accepted.\n\n    Examples:\n\n    .. code-block:: django\n\n        {{ request|cookie_group_accepted:\"analytics\" }}\n        {{ request|cookie_group_accepted:\"analytics=*:.google.com\" }}\n    \"\"\"\n    value = get_cookie_value_from_request(request, *group_or_cookie.split(\"=\"))\n    return value is True\n\n\n@register.filter\ndef cookie_group_declined(request: HttpRequest, group_or_cookie: str) -> bool:\n    \"\"\"\n    Return ``True`` if the cookie group/cookie is declined.\n\n    Examples:\n\n    .. code-block:: django\n\n        {{ request|cookie_group_declined:\"analytics\" }}\n        {{ request|cookie_group_declined:\"analytics=*:.google.com\" }}\n    \"\"\"\n    value = get_cookie_value_from_request(request, *group_or_cookie.split(\"=\"))\n    return value is False\n\n\n@register.filter\ndef all_cookies_accepted(request: HttpRequest) -> bool:\n    \"\"\"\n    Filter returns if all cookies are accepted.\n    \"\"\"\n    return are_all_cookies_accepted(request)\n\n\n@register.simple_tag\ndef not_accepted_or_declined_cookie_groups(\n    request: HttpRequest,\n) -> Collection[CookieGroup]:\n    \"\"\"\n    Return the cookie groups for which no explicit accept or decline has been given.\n    \"\"\"\n    return get_not_accepted_or_declined_cookie_groups(request)\n\n\n@register.filter\ndef cookie_consent_enabled(request: HttpRequest) -> bool:\n    \"\"\"\n    Indicate whether the cookie-consent app is enabled or not.\n    \"\"\"\n    return is_cookie_consent_enabled(request)\n\n\n@register.simple_tag\ndef all_cookie_groups(element_id: str):\n    \"\"\"\n    Serialize all cookie groups to JSON and output them in a script tag.\n\n    :param element_id: The ID for the script tag so you can look it up in JS later.\n\n    This uses Django's core json_script filter under the hood.\n    \"\"\"\n    groups = get_all_cookie_groups()\n    value = [group.for_json() for group in groups.values()]\n    return json_script(value, element_id)\n"
  },
  {
    "path": "cookie_consent/urls.py",
    "content": "from django.urls import path\n\nfrom .views import (\n    CookieGroupAcceptView,\n    CookieGroupDeclineView,\n    CookieGroupListView,\n    CookieStatusView,\n)\n\nurlpatterns = [\n    path(\"accept/\", CookieGroupAcceptView.as_view(), name=\"cookie_consent_accept\"),\n    path(\"decline/\", CookieGroupDeclineView.as_view(), name=\"cookie_consent_decline\"),\n    path(\"status/\", CookieStatusView.as_view(), name=\"cookie_consent_status\"),\n    path(\"\", CookieGroupListView.as_view(), name=\"cookie_consent_cookie_group_list\"),\n]\n"
  },
  {
    "path": "cookie_consent/util.py",
    "content": "import logging\nfrom collections.abc import Callable, Collection, Iterator\n\nfrom django.http import HttpRequest, HttpResponseBase\n\nfrom .cache import all_cookie_groups, get_cookie, get_cookie_group\nfrom .conf import settings\nfrom .models import Cookie, CookieGroup\n\nlogger = logging.getLogger(__name__)\n\nCOOKIE_GROUP_SEP = \"|\"\nKEY_VALUE_SEP = \"=\"\n\n\ndef parse_cookie_str(cookie: str) -> dict[str, str]:\n    if not cookie:\n        return {}\n\n    bits = cookie.split(COOKIE_GROUP_SEP)\n\n    def _gen_pairs() -> Iterator[tuple[str, str]]:\n        for possible_pair in bits:\n            parts = possible_pair.split(KEY_VALUE_SEP)\n            if len(parts) == 2:\n                varname, cookie = parts\n                yield varname, cookie\n            else:\n                logger.debug(\"cookie_value_discarded\", extra={\"value\": possible_pair})\n\n    return dict(_gen_pairs())\n\n\ndef _contains_invalid_characters(*inputs: str) -> bool:\n    # = and | are special separators. They are unexpected characters in both\n    # keys and values.\n    for separator in (COOKIE_GROUP_SEP, KEY_VALUE_SEP):\n        for value in inputs:\n            if separator in value:\n                logger.debug(\"skip_separator\", extra={\"value\": value, \"sep\": separator})\n                return True\n    return False\n\n\ndef dict_to_cookie_str(dic: dict[str, str]) -> str:\n    \"\"\"\n    Serialize a dictionary of cookie-group metadata to a string.\n\n    The result is stored in a cookie itself. Note that the dictionary keys are expected\n    to be cookie group ``varname`` fields, which are validated against a slug regex. The\n    values are supposed to be ISO-8601 timestamps.\n\n    Invalid key/value pairs are dropped.\n    \"\"\"\n\n    def _gen_pairs() -> Iterator[str]:\n        for key, value in dic.items():\n            if _contains_invalid_characters(key, value):\n                continue\n            yield f\"{key}={value}\"\n\n    return \"|\".join(_gen_pairs())\n\n\ndef get_cookie_dict_from_request(request: HttpRequest) -> dict[str, str]:\n    cookie_str = request.COOKIES.get(settings.COOKIE_CONSENT_NAME, \"\")\n    return parse_cookie_str(cookie_str)\n\n\ndef set_cookie_dict_to_response(\n    response: HttpResponseBase, dic: dict[str, str]\n) -> None:\n    response.set_cookie(\n        settings.COOKIE_CONSENT_NAME,\n        dict_to_cookie_str(dic),\n        max_age=settings.COOKIE_CONSENT_MAX_AGE,\n        domain=settings.COOKIE_CONSENT_DOMAIN,\n        secure=settings.COOKIE_CONSENT_SECURE,\n        httponly=settings.COOKIE_CONSENT_HTTPONLY,\n        samesite=settings.COOKIE_CONSENT_SAMESITE,\n    )\n\n\ndef get_cookie_value_from_request(\n    request: HttpRequest, varname: str, cookie: str = \"\"\n) -> bool | None:\n    \"\"\"\n    Returns if cookie group or its specific cookie has been accepted.\n\n    Returns True or False when cookie is accepted or declined or None\n    if cookie is not set.\n    \"\"\"\n    if not (cookie_dic := get_cookie_dict_from_request(request)):\n        return None\n    if not (cookie_group := get_cookie_group(varname=varname)):\n        return None\n\n    _cookie: Cookie | None = None\n    if cookie:\n        name, domain = cookie.split(\":\")\n        _cookie = get_cookie(cookie_group, name, domain)\n\n    match version := cookie_dic.get(varname, None):\n        case None:\n            return None\n        case str() if version == settings.COOKIE_CONSENT_DECLINE:\n            return False\n\n    reference_version = _cookie.get_version() if _cookie else cookie_group.get_version()\n    if version >= reference_version:\n        return True\n    return None\n\n\ndef get_cookie_groups(varname: str = \"\") -> Collection[CookieGroup]:\n    if not varname:\n        return all_cookie_groups().values()\n    keys = varname.split(\",\")\n    return [g for k, g in all_cookie_groups().items() if k in keys]\n\n\ndef are_all_cookies_accepted(request: HttpRequest) -> bool:\n    \"\"\"\n    Returns if all cookies are accepted.\n    \"\"\"\n    return all(\n        [\n            get_cookie_value_from_request(request, cookie_group.varname)\n            for cookie_group in get_cookie_groups()\n        ]\n    )\n\n\ndef _get_cookie_groups_by_state(request, state: bool | None) -> Collection[CookieGroup]:\n    return [\n        cookie_group\n        for cookie_group in get_cookie_groups()\n        if get_cookie_value_from_request(request, cookie_group.varname) is state\n    ]\n\n\ndef get_not_accepted_or_declined_cookie_groups(\n    request: HttpRequest,\n) -> Collection[CookieGroup]:\n    \"\"\"\n    Returns all cookie groups that are neither accepted or declined.\n    \"\"\"\n    return _get_cookie_groups_by_state(request, state=None)\n\n\ndef get_accepted_cookie_groups(request: HttpRequest) -> Collection[CookieGroup]:\n    \"\"\"\n    Returns all cookie groups that are accepted.\n    \"\"\"\n    return _get_cookie_groups_by_state(request, state=True)\n\n\ndef get_declined_cookie_groups(request: HttpRequest) -> Collection[CookieGroup]:\n    \"\"\"\n    Returns all cookie groups that are declined.\n    \"\"\"\n    return _get_cookie_groups_by_state(request, state=False)\n\n\ndef is_cookie_consent_enabled(request: HttpRequest) -> bool:\n    \"\"\"\n    Returns if django-cookie-consent is enabled for given request.\n    \"\"\"\n    enabled: bool | Callable[[HttpRequest], bool] = settings.COOKIE_CONSENT_ENABLED\n    return enabled(request) if callable(enabled) else enabled\n"
  },
  {
    "path": "cookie_consent/views.py",
    "content": "from typing import Literal\n\nfrom django.contrib.auth.views import RedirectURLMixin\nfrom django.http import (\n    HttpRequest,\n    HttpResponse,\n    HttpResponseBase,\n    HttpResponseRedirect,\n    JsonResponse,\n)\nfrom django.middleware.csrf import get_token as get_csrf_token\nfrom django.urls import reverse\nfrom django.views.generic import ListView, View\n\nfrom .conf import settings\nfrom .forms import ProcessCookiesForm\nfrom .models import CookieGroup\nfrom .processor import CookiesProcessor\nfrom .util import (\n    get_accepted_cookie_groups,\n    get_declined_cookie_groups,\n    get_not_accepted_or_declined_cookie_groups,\n)\n\n\ndef is_ajax_like(request: HttpRequest) -> bool:\n    # legacy ajax, removed in Django 4.0 (used to be request.is_ajax())\n    ajax_header = request.headers.get(\"X-Requested-With\")\n    if ajax_header == \"XMLHttpRequest\":\n        return True\n\n    # module-js uses fetch and a custom header\n    return bool(request.headers.get(\"X-Cookie-Consent-Fetch\"))\n\n\nclass CookieGroupListView(ListView):\n    \"\"\"\n    Display all cookies.\n    \"\"\"\n\n    model = CookieGroup\n\n\nclass CookieGroupBaseProcessView(RedirectURLMixin, View):\n    \"\"\"\n    Process the cookie groups submitted in the POST request (or URL parameters).\n\n    :class:`RedirectURLMixin` takes care of the hardening against open redirects.\n    \"\"\"\n\n    cookie_process_action: Literal[\"accept\", \"decline\"]\n    \"\"\"\n    Processing action to apply, must be set on the subclasses.\n    \"\"\"\n\n    def get_default_redirect_url(self) -> str:\n        return settings.COOKIE_CONSENT_SUCCESS_URL\n\n    def post(self, request: HttpRequest, *args, **kwargs):\n        form = ProcessCookiesForm(data=request.POST)\n\n        if not form.is_valid():\n            if is_ajax_like(request):\n                return JsonResponse(form.errors.get_json_data())\n            else:\n                return HttpResponse(form.errors.render())\n\n        cookie_groups = form.get_cookie_groups()\n\n        response: HttpResponseBase\n        if is_ajax_like(request):\n            response = HttpResponse()\n        else:\n            response = HttpResponseRedirect(self.get_success_url())\n\n        processor = CookiesProcessor(request, response)\n        processor.process(cookie_groups, action=self.cookie_process_action)\n\n        return response\n\n\nclass CookieGroupAcceptView(CookieGroupBaseProcessView):\n    \"\"\"\n    View to accept CookieGroup.\n    \"\"\"\n\n    cookie_process_action = \"accept\"\n\n\nclass CookieGroupDeclineView(CookieGroupBaseProcessView):\n    \"\"\"\n    View to decline CookieGroup.\n    \"\"\"\n\n    cookie_process_action = \"decline\"\n\n\nclass CookieStatusView(View):\n    \"\"\"\n    Check the current accept/decline status for cookies.\n\n    The returned accept and decline URLs are specific to this user and include the\n    cookie groups that weren't accepted or declined yet.\n\n    Note that this endpoint also returns a CSRF Token to be used by the frontend,\n    as baking a CSRFToken into a cached page will not reliably work.\n    \"\"\"\n\n    def get(self, request: HttpRequest) -> JsonResponse:\n        accepted = get_accepted_cookie_groups(request)\n        declined = get_declined_cookie_groups(request)\n        not_accepted_or_declined = get_not_accepted_or_declined_cookie_groups(request)\n        data = {\n            \"csrftoken\": get_csrf_token(request),\n            \"acceptUrl\": reverse(\"cookie_consent_accept\"),\n            \"declineUrl\": reverse(\"cookie_consent_decline\"),\n            \"acceptedCookieGroups\": [group.varname for group in accepted],\n            \"declinedCookieGroups\": [group.varname for group in declined],\n            \"notAcceptedOrDeclinedCookieGroups\": [\n                group.varname for group in not_accepted_or_declined\n            ],\n        }\n        return JsonResponse(data)\n"
  },
  {
    "path": "docs/Makefile",
    "content": "# Makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line.\nSPHINXOPTS    =\nSPHINXBUILD   = sphinx-build\nPAPER         =\nBUILDDIR      = _build\n\n# Internal variables.\nPAPEROPT_a4     = -D latex_paper_size=a4\nPAPEROPT_letter = -D latex_paper_size=letter\nALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .\n# the i18n builder cannot share the environment and doctrees with the others\nI18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .\n\n.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext\n\nhelp:\n\t@echo \"Please use \\`make <target>' where <target> is one of\"\n\t@echo \"  html       to make standalone HTML files\"\n\t@echo \"  dirhtml    to make HTML files named index.html in directories\"\n\t@echo \"  singlehtml to make a single large HTML file\"\n\t@echo \"  pickle     to make pickle files\"\n\t@echo \"  json       to make JSON files\"\n\t@echo \"  htmlhelp   to make HTML files and a HTML help project\"\n\t@echo \"  qthelp     to make HTML files and a qthelp project\"\n\t@echo \"  devhelp    to make HTML files and a Devhelp project\"\n\t@echo \"  epub       to make an epub\"\n\t@echo \"  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter\"\n\t@echo \"  latexpdf   to make LaTeX files and run them through pdflatex\"\n\t@echo \"  text       to make text files\"\n\t@echo \"  man        to make manual pages\"\n\t@echo \"  texinfo    to make Texinfo files\"\n\t@echo \"  info       to make Texinfo files and run them through makeinfo\"\n\t@echo \"  gettext    to make PO message catalogs\"\n\t@echo \"  changes    to make an overview of all changed/added/deprecated items\"\n\t@echo \"  linkcheck  to check all external links for integrity\"\n\t@echo \"  doctest    to run all doctests embedded in the documentation (if enabled)\"\n\nclean:\n\t-rm -rf $(BUILDDIR)/*\n\nhtml:\n\t$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html\n\t@echo\n\t@echo \"Build finished. The HTML pages are in $(BUILDDIR)/html.\"\n\ndirhtml:\n\t$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml\n\t@echo\n\t@echo \"Build finished. The HTML pages are in $(BUILDDIR)/dirhtml.\"\n\nsinglehtml:\n\t$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml\n\t@echo\n\t@echo \"Build finished. The HTML page is in $(BUILDDIR)/singlehtml.\"\n\npickle:\n\t$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle\n\t@echo\n\t@echo \"Build finished; now you can process the pickle files.\"\n\njson:\n\t$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json\n\t@echo\n\t@echo \"Build finished; now you can process the JSON files.\"\n\nhtmlhelp:\n\t$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp\n\t@echo\n\t@echo \"Build finished; now you can run HTML Help Workshop with the\" \\\n\t      \".hhp project file in $(BUILDDIR)/htmlhelp.\"\n\nqthelp:\n\t$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp\n\t@echo\n\t@echo \"Build finished; now you can run \"qcollectiongenerator\" with the\" \\\n\t      \".qhcp project file in $(BUILDDIR)/qthelp, like this:\"\n\t@echo \"# qcollectiongenerator $(BUILDDIR)/qthelp/django-shop-discounts.qhcp\"\n\t@echo \"To view the help file:\"\n\t@echo \"# assistant -collectionFile $(BUILDDIR)/qthelp/django-shop-discounts.qhc\"\n\ndevhelp:\n\t$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp\n\t@echo\n\t@echo \"Build finished.\"\n\t@echo \"To view the help file:\"\n\t@echo \"# mkdir -p $$HOME/.local/share/devhelp/django-shop-discounts\"\n\t@echo \"# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-shop-discounts\"\n\t@echo \"# devhelp\"\n\nepub:\n\t$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub\n\t@echo\n\t@echo \"Build finished. The epub file is in $(BUILDDIR)/epub.\"\n\nlatex:\n\t$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex\n\t@echo\n\t@echo \"Build finished; the LaTeX files are in $(BUILDDIR)/latex.\"\n\t@echo \"Run \\`make' in that directory to run these through (pdf)latex\" \\\n\t      \"(use \\`make latexpdf' here to do that automatically).\"\n\nlatexpdf:\n\t$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex\n\t@echo \"Running LaTeX files through pdflatex...\"\n\tmake -C $(BUILDDIR)/latex all-pdf\n\t@echo \"pdflatex finished; the PDF files are in $(BUILDDIR)/latex.\"\n\ntext:\n\t$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text\n\t@echo\n\t@echo \"Build finished. The text files are in $(BUILDDIR)/text.\"\n\nman:\n\t$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man\n\t@echo\n\t@echo \"Build finished. The manual pages are in $(BUILDDIR)/man.\"\n\ntexinfo:\n\t$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo\n\t@echo\n\t@echo \"Build finished. The Texinfo files are in $(BUILDDIR)/texinfo.\"\n\t@echo \"Run \\`make' in that directory to run these through makeinfo\" \\\n\t      \"(use \\`make info' here to do that automatically).\"\n\ninfo:\n\t$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo\n\t@echo \"Running Texinfo files through makeinfo...\"\n\tmake -C $(BUILDDIR)/texinfo info\n\t@echo \"makeinfo finished; the Info files are in $(BUILDDIR)/texinfo.\"\n\ngettext:\n\t$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale\n\t@echo\n\t@echo \"Build finished. The message catalogs are in $(BUILDDIR)/locale.\"\n\nchanges:\n\t$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes\n\t@echo\n\t@echo \"The overview file is in $(BUILDDIR)/changes.\"\n\nlinkcheck:\n\t$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck\n\t@echo\n\t@echo \"Link check complete; look for any errors in the above output \" \\\n\t      \"or in $(BUILDDIR)/linkcheck/output.txt.\"\n\ndoctest:\n\t$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest\n\t@echo \"Testing of doctests in the sources finished, look at the \" \\\n\t      \"results in $(BUILDDIR)/doctest/output.txt.\"\n"
  },
  {
    "path": "docs/changelog.rst",
    "content": "=========\nChangelog\n=========\n\n1.0.0 (2026-02-04)\n------------------\n\nThe long-awaited 1.0 version of django-cookie-consent is finally here!\n\nThe project has seen numerous cleanups, clarifications and (hopefully) simplifications\nover the past few releases, but now the public API is considered stable and any future\nbreaking changes will result in a major version bump.\n\nThe documentation :ref:`Migrating to 1.0 <migrating_10>` is there to help you updating\nyour projects, but don't hesitate to open a Github issue if you run into problems.\n\n**💥 Breaking changes**\n\n*Now* was the time to perform necessary cleanups that break the existing API/features.\nThe upgrade documentation covers these in more detail.\n\n* [#106] Views and URLs rework:\n\n  * Removed the URL patterns ``accept/<varname>/`` and ``decline/<varname>/``.\n  * Renamed the ``cookie_consent_accept_all`` view name to ``cookie_consent_accept``.\n  * Renamed the ``cookie_consent_decline_all`` view name to ``cookie_consent_decline``.\n  * Replaced the ``CookieGroupBaseProcessView.process`` method with the new\n    ``CookiesProcessor`` utility class.\n  * The views no longer take the cookie groups/cookie group varnames from the\n    ``varname`` URL parameter - it has been removed.\n  * The cookie accept/decline views are no longer ``csrf_exempt`` and require a CRSF\n    token now.\n  * The ``cookie_consent_accept_url`` and ``cookie_consent_decline_url`` template\n    tags are removed due to the URL structure changes.\n  * Removed the ``DELETE`` method support for the cookie group decline view.\n\n* [#108] Legacy cookiebar Javascript removal:\n\n  * The legacy implementation is removed.\n  * Removed the ``get_accept_cookie_groups_cookie_string``,\n    ``get_decline_cookie_groups_cookie_string``, ``js_type_for_cookie_consent`` and\n    ``accepted_cookies`` template tags due to being incompatible with template/view\n    caching.\n\n* Dropped support for the end-of-life Django 5.1.\n\n**New features**\n\n* Confirmed support for Django 6.0.\n* Confirmed support for Python 3.14.\n* [#106] You can now use plain HTTP POST form semantics to submit the cookie groups to\n  accept/decline.\n* Added static type annotations to the project and added the ``py.typed`` package marker.\n\n**Project maintenance**\n\n* Added django-upgrade to pre-commit hooks.\n* Converted more tests to pytest style and refactored accept/decline views.\n* Cleaned up/updated the documentation.\n* Renamed the master branch to main.\n* Switch the version management tool from tbump to bump-my-version.\n\n0.9.0 (2025-09-28)\n------------------\n\nMaintenance and bugfix release.\n\n**💥 Breaking changes**\n\n* Dropped support for Python 3.8 (end-of-life).\n* Dropped support for Python 3.9 (soon end-of-life).\n\n**New features**\n\n* [#114] You can now customize the redirect behaviour after accepting/declining cookies,\n  using the new ``COOKIE_CONSENT_SUCCESS_URL`` setting.\n* The admin now displays a warning for cookie groups that have no cookies in them.\n\n**Bugfixes**\n\n* [#135] Make cookie parsing and serialization more robust.\n\n**Project maintenance**\n\n* Transferred the package from the jazzband Github organization to django-commons, and\n  updated all community-related documents like the code of conduct.\n* Updated the package metadata to latest format.\n* Bumped minimum required setuptools version to build the package.\n* Replaced black and isort with Ruff for linting and code-formatting duties.\n* Improved the test suite.\n* The PyPI and NPM packages are now automatically published with Trusted Publishing,\n  enhancing supply-chain security.\n\n0.8.0 (2025-05-30)\n------------------\n\nSmall feature release\n\n**New features**\n\n* The Javascript (package) now exports some more utilities (#126).\n\n**Project maintenance**\n\n* Fixed some test flakiness.\n\n0.7.0 (2025-04-26)\n------------------\n\nBugfix and Django supported versions release.\n\n**New features**\n\n* Confirmed Python 3.13 support.\n* Confirmed Django 5.1 and 5.2 support.\n\n**Project maintenance**\n\n* Fixed incorrect JS example in the documentation.\n* Removed Django 5.0 from CI pipeline (it still works, but Django 5.0 is end of life).\n* Upgraded esbuild for the Javascript module building.\n\n0.6.0 (2024-05-10)\n------------------\n\nFeature release with improved JS support.\n\n**💥 Breaking changes**\n\nSome (database) unique constraints have been added to the model fields. If you have\nduplicate values in those fields, the migrations will crash. You should check for\nduplicates before upgrading, and fix those:\n\n.. code-block:: py\n\n    from django.db.models import Count\n    from cookie_consent.models import CookieGroup, Cookie\n\n    # duplicated cookie groups (by varname)\n    CookieGroup.objects.values(\"varname\").annotate(n=Count(\"varname\")).filter(n__gt=1)\n    # <QuerySet []>\n\n    # duplicated cookies\n    Cookie.objects.values(\"cookiegroup\", \"name\", \"domain\").annotate(n=Count(\"id\")).filter(n__gt=1)\n    # <QuerySet []>\n\nAdditionally, support for unmaintained Django versions (3.2, 4.1) is dropped.\n\n**New features**\n\n* The JS for the cookiebar module is rewritten in TypeScript and published as an\n  `npm package`_ for people wishing to integrate this functionality in their own\n  frontend stack. ``cookie_consent/cookiebar.module.js`` is still in the Python package,\n  and it's generated from the same source code.\n\n* Added support for natural keys in dumpdata/loaddata management commands.\n\n**Deprecations**\n\nNone.\n\n**Bugfixes**\n\n* Fixed cache not being cleared after queryset (bulk) update/deletes\n* Swapped the order of ``onShow`` and ``doInsert`` in the cookiebar JS. ``onShow`` is\n  now called after the cookiebar is inserted into the document.\n* Added missing unique constraint to ``CookieGroup.varname`` field\n* Added missing unique constraint on ``Cookie`` fields ``cookiegroup``, ``name`` and\n  ``domain``.\n\n**Project maintenance**\n\n* Add missing templatetag instruction to docs\n* Removed Django < 4.2 compatibility shims\n* Formatted code with latest black version\n* Dropped Django 3.2 & 4.1 from the supported versions\n* Removed unused dependencies\n* Bumped github actions to latest versions\n* Updated to modern packaging tooling with ``pyproject.toml``\n\n.. _npm package: https://www.npmjs.com/package/django-cookie-consent\n\n0.5.0b0 (2023-09-24)\n--------------------\n\nA django-cookie-consent version to test the new Javascript integration.\n\nYou can install this using:\n\n.. code-block:: bash\n\n    pip install django-cookie-consent --pre\n\nThe new cookiebar JS uses a modern approach and should resolve issues with page caches\nand Content Security Policies. Please try it out and report any issues or suggestion on\nGithub!\n\n**Breaking changes**\n\nNone\n\n**New features**\n\n* Implemented ``cookie_consent/cookiebar.module.js`` as a new Javascript integration.\n  Please review the updated documentation for usage instructions. (#15, #49, #99)\n\n**Deprecations**\n\nDeprecated functionality is scheduled for removal in django-cookie-consent 1.0.\n\n* Deprecated ``cookie_consent/cookiebar.js`` and added an alias ``legacyShowCookieBar``.\n  Existing users are advised to upgrade to the new module approach, or at the very\n  least substitute ``showCookieBar`` with ``window.legacyShowCookieBar`` to better keep\n  track of this deprecation.\n\n* Deprecated template tags that build up cookie strings suitable for Javascript.\n\n**Bugfixes**\n\nNone\n\n**Project maintenance**\n\n* Extensively documented the new cookiebar JS usage.\n* Added Playwright for end-to-end testing (covers both the new and legacy cookie bar)\n* Removed unnecessary ``smart_str`` usage - thanks @some1ataplace\n* Test app and tests themselves are now excluded from coverage measuring for more a\n  more accurate reflection of the coverage status.\n\n0.4.0 (2023-06-11)\n------------------\n\n.. note::\n\n    The 0.4.0 release mainly has had a project management overhaul. The project has\n    transferred to the Jazzband organization. This release mostly focuses on Python/Django\n    version compatibility and organization of tests, CI etc.\n\n    Many thanks for people who reported bugs, and especially, your patience for getting\n    this release on PyPI.\n\n\n**Breaking changes**\n\n* Dropped support for Django 2.2, 3.0, 3.1 and 4.0\n* Dropped support for Python 3.6 and 3.7\n\nThese versions are (nearly) end-of-life and no longer supported by their upstream teams.\n\n**New features**\n\n* Implemented settings for cookie flags: SameSite, HttpOnly, Secure, domain (#27, #60,\n  #36, #88)\n* Added Dutch translations\n\n**Bugfixes**\n\n* Cache instance resolution is now lazy (#41)\n* Fixed support for Django 4.1 (#73) - thanks @alahdal\n* Fixed default settings being bytestrings (#24, #55, #69)\n* Fixed the middleware to clean cookies (#13) - thanks @some1ataplace\n* Fixed bug in JS ``beforeDeclined`` attribute\n\n**Project maintenance**\n\n* Transferred project to Jazzband (#38, #64, #75)\n* Replaced Travis CI with Github Actions (#64, #75)\n* Set up correct test matrix for python/django versions (#75)\n* Code is now ``isort`` and ``black`` formatted (#75)\n* Set up ``tox`` and ``pytest`` for testing (#64, #75)\n* 'Removed' the example app - the ``testapp`` in the repository is still a good example\n* Configured tbump for the release flow\n* Confirmed support for Python 3.11 and Django 4.2\n* Added explicit template tag tests (#39)\n\n**Documentation**\n\nDid some initial restructuring to make the docs easier to digest, more to come.\n\n* Added documentation on how to contribute\n* Corrected settings documentation (#53, #14)\n* Documented ``cookiebar.js`` usage (#90) - thanks @MrCordeiro\n* Added better contributor documentation and example app documentation based on the\n  ``testapp`` in the repository.\n\n0.3.1 (2022-02-17)\n------------------\n\n- Protect against open redirect after accepting cookies (#48)\n\n\n0.3.0 (2021-12-08)\n------------------\n\n* support ranges from django 2.2 to 4.0 and python 3.6 to 3.9\n\n\n0.2.6 (2020-06-17)\n------------------\n\n* fix: setup for python 2.7\n\n\n0.2.5 (2020-06-17)\n------------------\n\n* chore: add package descriptions\n\n\n0.2.4 (2020-06-17)\n------------------\n\n* Cookie Bar Choosing Decline Not Disappearing Right Away (#22)\n\n* 📦 NEW: pt_BR (#23)\n\n0.2.3 (2020-06-15)\n------------------\n\n* Update package classifiers\n\n\n0.2.2 (2020-06-15)\n------------------\n\n* 8732949 Remove jquery (#20)\n\n\n0.2.1 (2020-06-02)\n------------------\n\n* fix: Set max version for django-appconf (#18)\n\n* fix: Views ignore 'next' url parameter (#12)\n\n* Update configuration.rst\n\n\n0.2.0 (2020-02-11)\n------------------\n\n* support ranges from django 1.9 to 3.0 and python 2.7 to 3.7 (JonHerr)\n\n0.1.1\n-----\n\n* tweak admin\n\n* Add accepted_cookies template filter\n\n* Add varname property to Cookie model\n\n* Add translation catalog\n\n0.1.0\n-----\n\n* Initial release\n"
  },
  {
    "path": "docs/check_sphinx.py",
    "content": "import subprocess\n\n\ndef test_linkcheck(tmpdir):\n    doctrees = tmpdir.join(\"doctrees\")\n    htmldir = tmpdir.join(\"html\")\n    subprocess.check_call(\n        [\"sphinx-build\", \"-W\", \"-blinkcheck\", \"-d\", str(doctrees), \".\", str(htmldir)],\n    )\n\n\ndef test_build_docs(tmpdir):\n    doctrees = tmpdir.join(\"doctrees\")\n    htmldir = tmpdir.join(\"html\")\n    subprocess.check_call(\n        [\"sphinx-build\", \"-W\", \"-bhtml\", \"-d\", str(doctrees), \".\", str(htmldir)],\n    )\n"
  },
  {
    "path": "docs/concept.rst",
    "content": "=============\nMain concepts\n=============\n\nCookie Group\n------------\n\nThe :class:`CookieGroup <cookie_consent.models.CookieGroup>` model represents a group\nof related cookies. Cookie groups can be either required or not. Users can accept or\ndecline the use of the cookies in the non-required cookie groups.\n\nVersions\n^^^^^^^^\n\nEach cookie group has a \"current version\" set to the timestamp of the last added\nCookie in it. When a user accepts a cookie group, the current version (at the time of\nconsent) is :ref:`saved <concept_storing_consent>`.\n\nVersions allow django-cookie-consent to check if new cookies have been introduced after\nthe user has given consent for a cookie group, so that they can be prompted again to\naccept the new cookie(s).\n\nImportant attributes:\n^^^^^^^^^^^^^^^^^^^^^\n\n``varname``\n  The variable name that acts as unique identifier for the cookie group. You can use\n  the value in template tags and filters to refer to a particular cookie group.\n\n``is_required``\n  Required cookies are never deleted and users cannot accept or decline them. For\n  example, Django's default ``sessionid`` and ``csrftoken`` cookies are required for\n  the correct functioning of your project. Without these cookies, the website will not\n  work properly - so users can't opt-out.\n\n``is_deletable``\n  If a cookie group is marked as deletable, django-cookie-consent will try to delete\n  the cookies in this group when the user declines the group, or through the\n  :class:`cookie_consent.middleware.CleanCookiesMiddleware`.\n\nCookie\n------\n\nThe :class:`Cookie<cookie_consent.models.Cookie>` model represents as single cookie.\n\n.. admonition:: Domain and path\n   :collapsible: closed\n\n   The ``domain`` and ``path`` fields are important to be able to delete the\n   cookies programmatically. Keep in mind that only cookies of your own domain can\n   be deleted.\n\n.. _concept_storing_consent:\n\nSaving user selection\n---------------------\n\nA bit ironically, django-cookie-consent uses a cookie itself to store the user consent.\nBy default, the name ``cookie_consent`` is used.\n\nAn example value of such a cookie could be:\n\n.. code-block:: none\n\n    optional=-1|social=2013-06-04T03:17:01.421395Z\n\nThe meaning of this is:\n\n* the user declined the cookie group with varname ``optional``\n* the user accepted the cookie group with varname ``social``, and specifically only the\n  cookies that were created before the stated timestamp\n\nCaching\n-------\n\ndjango-cookie-consent keeps the non-required cookie groups and cookies in cache, to\navoid hitting the database for each request. By default, the ``default`` Django cache\nis used. You can modify this, see :ref:`settings`.\n\n.. note:: Django's default cache is a local-memory cache. Cache invalidation in one\n   wsgi-server process will not propagate to other instances/processes, so you can\n   temporarily see inconsistent results. It's recommended to use a shared cache like\n   Redis/Valkey or Memcache.\n"
  },
  {
    "path": "docs/conf.py",
    "content": "import os\nimport sys\nfrom pathlib import Path\n\nimport django\n\n# If extensions (or modules to document with autodoc) are in another directory,\n# add these directories to sys.path here. If the directory is relative to the\n# documentation root, use os.path.abspath to make it absolute, like shown here.\nroot_dir = Path(__file__).parent.parent.resolve()\nsys.path.insert(0, str(root_dir))\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"testapp.settings\")\n\ndjango.setup()\n\nfrom cookie_consent import __version__  # noqa: E402\n\n# -- General configuration -----------------------------------------------------\n\n# If your documentation needs a minimal Sphinx version, state it here.\n# needs_sphinx = '1.0'\n\n# Add any Sphinx extension module names here, as strings. They can be extensions\n# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.\nextensions = [\"sphinx.ext.autodoc\"]\n\n# Add any paths that contain templates here, relative to this directory.\ntemplates_path = [\"_templates\"]\n\n# The suffix of source filenames.\nsource_suffix = \".rst\"\n\n# The encoding of source files.\n# source_encoding = 'utf-8-sig'\n\n# The master toctree document.\nmaster_doc = \"index\"\n\n# General information about the project.\nproject = \"django-cookie-consent\"\ncopyright = \"2013, Bojan Mihelac\"\n\nrelease = __version__\n\n# The language for content autogenerated by Sphinx. Refer to documentation\n# for a list of supported languages.\n# language = None\n\n# There are two options for replacing |today|: either, you set today to some\n# non-false value, then it is used:\n# today = ''\n# Else, today_fmt is used as the format for a strftime call.\n# today_fmt = '%B %d, %Y'\n\n# List of patterns, relative to source directory, that match files and\n# directories to ignore when looking for source files.\nexclude_patterns = [\"_build\"]\n\n# The reST default role (used for this markup: `text`) to use for all documents.\n# default_role = None\n\n# If true, '()' will be appended to :func: etc. cross-reference text.\n# add_function_parentheses = True\n\n# If true, the current module name will be prepended to all description\n# unit titles (such as .. function::).\n# add_module_names = True\n\n# If true, sectionauthor and moduleauthor directives will be shown in the\n# output. They are ignored by default.\n# show_authors = False\n\n# The name of the Pygments (syntax highlighting) style to use.\npygments_style = \"sphinx\"\n\n# A list of ignored prefixes for module index sorting.\n# modindex_common_prefix = []\n\n\n# -- Options for HTML output ---------------------------------------------------\n\n# The theme to use for HTML and HTML Help pages.  See the documentation for\n# a list of builtin themes.\nhtml_theme = \"sphinx_rtd_theme\"\n\n# Theme options are theme-specific and customize the look and feel of a theme\n# further.  For a list of options available for each theme, see the\n# documentation.\n# html_theme_options = {}\n\n# Add any paths that contain custom themes here, relative to this directory.\n# html_theme_path = []\n\n# The name for this set of Sphinx documents.  If None, it defaults to\n# \"<project> v<release> documentation\".\n# html_title = None\n\n# A shorter title for the navigation bar.  Default is the same as html_title.\n# html_short_title = None\n\n# The name of an image file (relative to this directory) to place at the top\n# of the sidebar.\n# html_logo = None\n\n# The name of an image file (within the static path) to use as favicon of the\n# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32\n# pixels large.\n# html_favicon = None\n\n# Add any paths that contain custom static files (such as style sheets) here,\n# relative to this directory. They are copied after the builtin static files,\n# so a file named \"default.css\" will overwrite the builtin \"default.css\".\nhtml_static_path = []\n\n# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,\n# using the given strftime format.\n# html_last_updated_fmt = '%b %d, %Y'\n\n# If true, SmartyPants will be used to convert quotes and dashes to\n# typographically correct entities.\n# html_use_smartypants = True\n\n# Custom sidebar templates, maps document names to template names.\n# html_sidebars = {}\n\n# Additional templates that should be rendered to pages, maps page names to\n# template names.\n# html_additional_pages = {}\n\n# If false, no module index is generated.\n# html_domain_indices = True\n\n# If false, no index is generated.\n# html_use_index = True\n\n# If true, the index is split into individual pages for each letter.\n# html_split_index = False\n\n# If true, links to the reST sources are added to the pages.\n# html_show_sourcelink = True\n\n# If true, \"Created using Sphinx\" is shown in the HTML footer. Default is True.\n# html_show_sphinx = True\n\n# If true, \"(C) Copyright ...\" is shown in the HTML footer. Default is True.\n# html_show_copyright = True\n\n# If true, an OpenSearch description file will be output, and all pages will\n# contain a <link> tag referring to it.  The value of this option must be the\n# base URL from which the finished HTML is served.\n# html_use_opensearch = ''\n\n# This is the file name suffix for HTML files (e.g. \".xhtml\").\n# html_file_suffix = None\n\n# Output file base name for HTML help builder.\nhtmlhelp_basename = \"django-cookie-consent\"\n\n\n# -- Options for LaTeX output --------------------------------------------------\n\n# The paper size ('letter' or 'a4').\n# latex_paper_size = 'letter'\n\n# The font size ('10pt', '11pt' or '12pt').\n# latex_font_size = '10pt'\n\n# Grouping the document tree into LaTeX files. List of tuples\n# (source start file, target name, title, author, documentclass [howto/manual]).\nlatex_documents = [\n    (\n        \"index\",\n        \"django-cookie-consent.tex\",\n        \"django-cookie-consent Documentation\",\n        \"Bojan Mihelac\",\n        \"manual\",\n    ),\n]\n\n# The name of an image file (relative to this directory) to place at the top of\n# the title page.\n# latex_logo = None\n\n# For \"manual\" documents, if this is true, then toplevel headings are parts,\n# not chapters.\n# latex_use_parts = False\n\n# If true, show page references after internal links.\n# latex_show_pagerefs = False\n\n# If true, show URL addresses after external links.\n# latex_show_urls = False\n\n# Additional stuff for the LaTeX preamble.\n# latex_preamble = ''\n\n# Documents to append as an appendix to all manuals.\n# latex_appendices = []\n\n# If false, no module index is generated.\n# latex_domain_indices = True\n\n\n# -- Options for manual page output --------------------------------------------\n\n# One entry per manual page. List of tuples\n# (source start file, name, description, authors, manual section).\nman_pages = [\n    (\n        \"index\",\n        \"django-cookie-consent\",\n        \"django-cookie-consent Documentation\",\n        [\"Bojan Mihelac\"],\n        1,\n    )\n]\n\n# If true, show URL addresses after external links.\n# man_show_urls = False\n\n# -- Options for Texinfo output ------------------------------------------------\n\n# Grouping the document tree into Texinfo files. List of tuples\n# (source start file, target name, title, author,\n#  dir menu entry, description, category)\ntexinfo_documents = [\n    (\n        \"index\",\n        \"django-cookie-consent\",\n        \"django-cookie-consent Documentation\",\n        \"Bojan Mihelac\",\n        \"django-cookie-consent\",\n        \"\",\n        \"Miscellaneous\",\n    ),\n]\n\n# Documents to append as an appendix to all manuals.\ntexinfo_appendices = []\n\nlinkcheck_ignore = [\n    r\"https://(www\\.)?npmjs\\.com.*\",  # IP/UA blocking...\n]\n"
  },
  {
    "path": "docs/contributing.rst",
    "content": ".. _contributing:\n\n=============\nContributing\n=============\n\n.. include:: ../CONTRIBUTING.rst\n"
  },
  {
    "path": "docs/example_app.rst",
    "content": "===========\nExample app\n===========\n\nThe ``testapp`` project is both an example of how you could use this library and serves\nas the reference for our test suite.\n\nRunning the testapp\n-------------------\n\nThe testapp is essentially a standard django project, however there is no ``manage.py``\nfile. Instead, you have to use the ``django-admin`` command (``manage.py`` is only\na wrapper around this anyway).\n\n#. First, clone the repository to get all the necessary files:\n\n   .. code-block:: bash\n\n       git clone https://github.com/django-commons/django-cookie-consent.git\n       cd django-cookie-consent\n\n#. Create a virtual environment for the project, using any supported Python version\n   (3.10+) and activate it\n\n   .. code-block:: bash\n\n       python3.12 -m venv ./env\n       source ./env/bin/activate\n\n#. Install the application and dependencies\n\n   .. code-block:: bash\n\n       pip install .\n\n#. Prepare your settings and local project instance\n\n   .. code-block:: bash\n\n       export DJANGO_SETTINGS_MODULE=testapp.settings PYTHONPATH=.\n       django-admin migrate\n       django-admin loaddata testapp/fixture.json\n       django-admin createsuperuser\n\n#. Start the development server\n\n   .. code-block:: bash\n\n       django-admin runserver\n\nYou can now navigate to ``http://127.0.0.1:8000`` and ``http://127.0.0.1:8000/admin/``.\n"
  },
  {
    "path": "docs/index.rst",
    "content": "=====================\nDjango cookie consent\n=====================\n\nManage cookie information and let visitors give or reject consent for them.\n\n|build-status| |code-quality| |ruff| |coverage| |docs|\n\n|python-versions| |django-versions| |pypi-version|\n\nFeatures\n========\n\n* cookies and cookie groups are stored in models for easy management\n  through Django admin interface\n* support for both opt-in and opt-out cookie consent schemes\n* removing declined cookies (or non accepted when opt-in scheme is used)\n* logging user actions when they accept and decline various cookies\n* easy adding new cookies and seamlessly re-asking for consent for new cookies\n\nYou can find the source code and development progress on https://github.com/django-commons/django-cookie-consent/.\n\nUser Guide\n----------\n\n.. toctree::\n   :maxdepth: 2\n\n   quickstart\n   concept\n   usage\n   javascript\n   settings\n   example_app\n   reference/index\n   contributing\n   migrating-1.0\n   changelog\n\nIndices and tables\n==================\n\n* :ref:`genindex`\n* :ref:`modindex`\n* :ref:`search`\n\n.. |build-status| image:: https://github.com/django-commons/django-cookie-consent/workflows/Run%20CI/badge.svg\n    :alt: Build status\n    :target: https://github.com/django-commons/django-cookie-consent/actions?query=workflow%3A%22Run+CI%22\n\n.. |code-quality| image:: https://github.com/django-commons/django-cookie-consent/workflows/Code%20quality%20checks/badge.svg\n     :alt: Code quality checks\n     :target: https://github.com/django-commons/django-cookie-consent/actions?query=workflow%3A%22Code+quality+checks%22\n\n.. |ruff| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json\n    :target: https://github.com/astral-sh/ruff\n\n.. |coverage| image:: https://codecov.io/gh/django-commons/django-cookie-consent/branch/main/graph/badge.svg\n    :target: https://codecov.io/gh/django-commons/django-cookie-consent\n    :alt: Coverage status\n\n.. |docs| image:: https://readthedocs.org/projects/django-cookie-consent/badge/?version=latest\n    :target: https://django-cookie-consent.readthedocs.io/en/latest/?badge=latest\n    :alt: Documentation Status\n\n.. |python-versions| image:: https://img.shields.io/pypi/pyversions/django-cookie-consent.svg\n\n.. |django-versions| image:: https://img.shields.io/pypi/djversions/django-cookie-consent.svg\n\n.. |pypi-version| image:: https://img.shields.io/pypi/v/django-cookie-consent.svg\n    :target: https://pypi.org/project/django-cookie-consent/\n"
  },
  {
    "path": "docs/javascript.rst",
    "content": ".. _javascript:\n\n======================\nJavascript integration\n======================\n\nCookie consent supports \"classic\" pages where you submit the accept/decline form and\nthen it performs a full page reload. This strategy is simple and straight-forward, as\nany dynamic scripts tied to the cookie groups are then automatically initialized.\n\nHowever, this does not lead to the best user-experience. Consider a user filling out a\nlong form and half-way they decide to get rid of the \"annoying cookie bar\". Either\naccepting or declining will make them lose their changes, providing a frustrating\nexperience.\n\nUsing the scripts we ship, you can provide a better user experience, at the cost of\nmore development work.\n\n.. _showcookiebar_getting_started:\n\nGetting started\n===============\n\nRequirements\n------------\n\nThe new script is designed for modern Javascript and modern browsers. It should also\nintegrate with JS tooling like Webpack/Rollup/ESBuild... but we are not actively testing\nthis. Please let us know via Github issues what your issues and/or wishes are!\n\nAs such, the target browsers must support:\n\n* ``<script type=\"module\">`` OR you must process the source code with a compiler (like\n  Babel_).\n* ``window.fetch``\n* ``async``/``await`` syntax OR use a compiler like Babel.\n* ES2020 (features like optional chaining are used)\n\nIn your Django template\n-----------------------\n\n**Add a template element for your content**\n\nThe ``<template>`` node is cloned and injected in the configured location. For example:\n\n.. code-block:: django\n\n    {% url \"cookie_consent_cookie_group_list\" as url_cookies %}\n\n    <template id=\"cookie-consent__cookie-bar\">\n        <div class=\"cookie-bar\">\n            This site uses cookies for better performance and user experience.\n            Do you agree to use these cookies?\n            {# Button is the more accessible role, but an anchor tag would also work #}\n            <button type=\"button\" class=\"cookie-consent__accept\">Accept</button>\n            <button type=\"button\" class=\"cookie-consent__decline\">Decline</button>\n            <a href=\"{{ url_cookies }}\">Cookies info</a>\n        </div>\n    </template>\n\nThis lets you, the developer, control the exact layout, styling and content of the\ncookie notice.\n\n.. note:: Avoid using (most) of the built in template tags if you want to use\n   template/view caching. For more background information, see:\n   :ref:`javascript_design_considerations`.\n\n**Emit the cookie groups for the Javascript**\n\nThe cookiebar module needs to know which cookie groups exist to decide whether a bar\nhas to be shown at all. A template tag exists which emits this as JSON serialized\ndata (in a page-cache compatible manner):\n\n.. code-block:: django\n\n    {# Set up the data and template for dynamic JS cookie bar #}\n    {% all_cookie_groups 'cookie-consent__cookie-groups' %}\n    {# Emits a <script type=\"application/json\" id=\"cookie-consent__cookie-groups\">...</script> tag #}\n\n**Include a script that calls the showCookieBar function**\n\nThe most straight-forward way is to include this in your Django template:\n\n.. code-block:: django\n\n    {% load static cookie_consent_tags %}\n    {% static \"cookie_consent/cookiebar.module.js\" as cookiebar_src %}\n    {% url 'cookie_consent_status' as status_url %}\n    <script type=\"module\">\n        import {showCookieBar} from '{{ cookiebar_src }}';\n        showCookieBar({\n          statusUrl: '{{ status_url|escapejs }}',\n          templateSelector: '#cookie-consent__cookie-bar',\n          cookieGroupsSelector: '#cookie-consent__cookie-groups',\n          onShow: () => document.querySelector('body').classList.add('with-cookie-bar'),\n          onAccept: () => document.querySelector('body').classList.remove('with-cookie-bar'),\n          onDecline: () => document.querySelector('body').classList.remove('with-cookie-bar'),\n        });\n    </script>\n\nYou call the function with the necessary options, and on page-load the cookie bar will\nbe properly initialized.\n\nThe ``status_url`` is special - it points to a backend view which returns the\nuser-specific cookie consent status, returning the appropriate accept and decline URLs\nand other details relevant to cookie consent.\n\n.. note::\n\n    If you prefer the include the cookiebar module in your own Javascript entrypoint,\n    the easiest way is to install our `published package`_.\n\n    This package should work with TypeScript, Webpack, ESBuild, Vite... and other popular\n    bundlers and toolchains.\n\n    Just be careful to install the same (minor) version as the backend package to avoid\n    weird bugs.\n\n.. _published package: hhttps://www.npmjs.com/package/django-cookie-consent\n\nOptions\n=======\n\nThe ``showCookieBar`` function takes a few required options and many optional options to\ntweak the behaviour to your wishes.\n\n**Required options**\n\n* ``statusUrl``: URL to the ``CookieStatusView`` - essential to determine the\n  accept/decline URLs and CSRF token. Use ``{% url 'cookie_consent_status' as status_url %}``\n  for the correct value, irrespective of your urlconf.\n\n**Recommended options**\n\nThese options have default values, but to prevent surprises and maximum flexibility, you\nshould provide them. Please check the source code for their default values.\n\n* ``templateSelector`` - CSS selector to find the template element of the cookie bar.\n  This element will be cloned and ultimately added to the page.\n\n* ``cookieGroupsSelector`` - CSS selector to the element produced by\n  ``{% all_cookie_groups 'cookie-consent__cookie-groups' %}``. This provides all\n  configured cookie groups in a JSON script tag and is read by ``showCookieBar`` to\n  determine if a bar should be shown at all (e.g. if there are no cookie groups,\n  nothing is done).\n\n* ``acceptSelector`` - CSS selector to the element to accept all cookies. A ``click``\n  event listener is bound to this element to register the cookies accept action.\n\n* ``declineSelector`` - CSS selector to the element to decline all cookies. A ``click``\n  event listener is bound to this element to register the cookies decline action.\n\n**Optional**\n\n* ``insertBefore`` - A CSS selector, DOM node or ``null``. If provided, the cookie bar\n  is prepended before this node, otherwise it is appended to the body element.\n\n* ``onShow`` - an optional callback function, called right before the cookie bar is\n  added to the document.\n\n* ``onAccept`` - an optional callback, called when the \"cookies accept\" element is\n  clicked and when the cookie status is initially loaded. It receives the list of\n  all cookie groups that are (now) accepted and the click event (if there was one).\n\n* ``onDecline`` - an optional callback, called when the \"cookies decline\" element is\n  clicked and when the cookie status is initially loaded. It receives the list of\n  all cookie groups that are (now) declined and the click event (if there was one).\n\n* ``csrfHeaderName`` - HTTP header name for the CSRF Token. Defaults to Django's default\n  value, so if you have a non-default ``settings.CSRF_HEADER_NAME``, you must provide\n  this.\n\n.. _javascript_enable_scripts:\n\nEnabling other scripts after cookies were accepted\n==================================================\n\nThe legacy version of ``showCookieBar`` supported emitting scripts with a custom type\nin the Django templates, which where then changed to ``type=\"text/javascript\"`` to make\nthem execute without a full page reload. The new version does not support this out of\nthe box, as it may interfere with page caches, Content Security Policies and was poorly\ndocumented.\n\nWe recommend hooking into the ``onAccept`` and ``onDecline`` hooks to perform these\nactions.\n\nE.g. in the django template:\n\n.. code-block:: django\n\n    <template id=\"analytics-scripts\">\n        <script type=\"text/javascript\">\n            // lots of interesting code\n        </script>\n        <script type=\"module\" src=\"...\"></script>\n    </template>\n\nand the Javascript function:\n\n.. code-block:: javascript\n\n    function onAccept(cookieGroups) {\n        const analyticsEnabled = cookieGroups.find(group => group.varname === 'analytics') != undefined;\n        if (analyticsEnabled) {\n            const template = document.getElementById('analytics-scripts').content;\n            const analyticsScripts = templateNode.content.cloneNode(true);\n            document.body.appendChild(analyticsScripts);\n        }\n    }\n\nPassing this ``onAccept`` callback then adds the scripts after the user accepted the\ncookies, causing them to execute. This way, there's no reliance on ``unsafe-eval``.\n\n.. _javascript_design_considerations:\n\nConsiderations and design decisions made for the JS integration\n===============================================================\n\nWe realize there is quite a bit of work to do to use this functionality. We've aimed for\na trade-off where the simple things are easy to do and the complex set-ups are\nachievable.\n\nThe :ref:`showcookiebar_getting_started` section should be close to plug-and-play by\nintegrating well with Django's static files. Especially on modern browsers, we intend\nto have a working solution without intricate Javascript knowledge.\n\nFor more advanced Javascript usage/developers, we expose hooks and options to tap into\nthe life-cycle. The code may also serve as a reference for your own implementation.\n\nHttpOnly and CSRF\n-----------------\n\nThe cookie-consent cookie itself can safely be set to ``HttpOnly`` so it cannot be\ntampered with (or even read) from Javascript. This follows security best practices. The\nnew script no longer touches ``document.cookie``.\n\nAccepting and declining cookies must be CSRF-protected and use ``POST`` requests. This\nworks out of the box with the async calls we make - the status endpoint provides the\nCSRF token to the Javascript so that it can include this via an HTTP header.\n\nThis means that you can mark your CSRF cookies ``HttpOnly`` in Django.\n\nContent Security Policy (CSP)\n-----------------------------\n\nContent Security Policies aim to lock down which scripts, styles... can run in the\nbrowser. They are a good tool in helping prevent Cross-Site-Scripting attacks, by\nspecifying from which sources scripts are allowed to run and usually by blocking\n``eval`` (which should be the bare minimum of what you block).\n\nThe new scripts play well with this - you can include your analytics scripts inside\n``<template>`` nodes and inject them dynamically without resorting to ``eval``.\nAdditionally, they are held against the configured CSP. Including these in the template\nalso provide the option to set a ``nonce`` (e.g. when using django-csp).\n\nFor more advanced setups, it's even possible a nonce is injected by a reverse proxy -\nwith creative Javascript you can read this nonce (typically from a ``<meta>`` tag) and\nincluded it in the scripts you add in the ``onAccept`` hook.\n\nPage caches\n-----------\n\nYou should now be able to use Django's page cache which caches the entire response for\na given URL. The new script fetches the user-specific cookie status via an async call\nwhich bypasses the cache (or you configure it to ``Vary`` on the cookies).\n\nLocalization\n------------\n\nThe template element approach allows you to use Django's built in translation machinery,\nkeeping your templates readable and properly HTML-escaped.\n\nHooks\n-----\n\nThe ``onShow``, ``onAccept`` and ``onDecline`` hooks allow you to perform additional\nactions on the main events. You can add your own markup and Javascript for more advanced\nuser experiences.\n\n.. _Babel: https://babeljs.io/\n"
  },
  {
    "path": "docs/make.bat",
    "content": "@ECHO OFF\n\nREM Command file for Sphinx documentation\n\nif \"%SPHINXBUILD%\" == \"\" (\n\tset SPHINXBUILD=sphinx-build\n)\nset BUILDDIR=_build\nset ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .\nset I18NSPHINXOPTS=%SPHINXOPTS% .\nif NOT \"%PAPER%\" == \"\" (\n\tset ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%\n\tset I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%\n)\n\nif \"%1\" == \"\" goto help\n\nif \"%1\" == \"help\" (\n\t:help\n\techo.Please use `make ^<target^>` where ^<target^> is one of\n\techo.  html       to make standalone HTML files\n\techo.  dirhtml    to make HTML files named index.html in directories\n\techo.  singlehtml to make a single large HTML file\n\techo.  pickle     to make pickle files\n\techo.  json       to make JSON files\n\techo.  htmlhelp   to make HTML files and a HTML help project\n\techo.  qthelp     to make HTML files and a qthelp project\n\techo.  devhelp    to make HTML files and a Devhelp project\n\techo.  epub       to make an epub\n\techo.  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter\n\techo.  text       to make text files\n\techo.  man        to make manual pages\n\techo.  texinfo    to make Texinfo files\n\techo.  gettext    to make PO message catalogs\n\techo.  changes    to make an overview over all changed/added/deprecated items\n\techo.  linkcheck  to check all external links for integrity\n\techo.  doctest    to run all doctests embedded in the documentation if enabled\n\tgoto end\n)\n\nif \"%1\" == \"clean\" (\n\tfor /d %%i in (%BUILDDIR%\\*) do rmdir /q /s %%i\n\tdel /q /s %BUILDDIR%\\*\n\tgoto end\n)\n\nif \"%1\" == \"html\" (\n\t%SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished. The HTML pages are in %BUILDDIR%/html.\n\tgoto end\n)\n\nif \"%1\" == \"dirhtml\" (\n\t%SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.\n\tgoto end\n)\n\nif \"%1\" == \"singlehtml\" (\n\t%SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.\n\tgoto end\n)\n\nif \"%1\" == \"pickle\" (\n\t%SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished; now you can process the pickle files.\n\tgoto end\n)\n\nif \"%1\" == \"json\" (\n\t%SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished; now you can process the JSON files.\n\tgoto end\n)\n\nif \"%1\" == \"htmlhelp\" (\n\t%SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished; now you can run HTML Help Workshop with the ^\n.hhp project file in %BUILDDIR%/htmlhelp.\n\tgoto end\n)\n\nif \"%1\" == \"qthelp\" (\n\t%SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished; now you can run \"qcollectiongenerator\" with the ^\n.qhcp project file in %BUILDDIR%/qthelp, like this:\n\techo.^> qcollectiongenerator %BUILDDIR%\\qthelp\\django-shop-discounts.qhcp\n\techo.To view the help file:\n\techo.^> assistant -collectionFile %BUILDDIR%\\qthelp\\django-shop-discounts.ghc\n\tgoto end\n)\n\nif \"%1\" == \"devhelp\" (\n\t%SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished.\n\tgoto end\n)\n\nif \"%1\" == \"epub\" (\n\t%SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished. The epub file is in %BUILDDIR%/epub.\n\tgoto end\n)\n\nif \"%1\" == \"latex\" (\n\t%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished; the LaTeX files are in %BUILDDIR%/latex.\n\tgoto end\n)\n\nif \"%1\" == \"text\" (\n\t%SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished. The text files are in %BUILDDIR%/text.\n\tgoto end\n)\n\nif \"%1\" == \"man\" (\n\t%SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished. The manual pages are in %BUILDDIR%/man.\n\tgoto end\n)\n\nif \"%1\" == \"texinfo\" (\n\t%SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.\n\tgoto end\n)\n\nif \"%1\" == \"gettext\" (\n\t%SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Build finished. The message catalogs are in %BUILDDIR%/locale.\n\tgoto end\n)\n\nif \"%1\" == \"changes\" (\n\t%SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.The overview file is in %BUILDDIR%/changes.\n\tgoto end\n)\n\nif \"%1\" == \"linkcheck\" (\n\t%SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Link check complete; look for any errors in the above output ^\nor in %BUILDDIR%/linkcheck/output.txt.\n\tgoto end\n)\n\nif \"%1\" == \"doctest\" (\n\t%SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest\n\tif errorlevel 1 exit /b 1\n\techo.\n\techo.Testing of doctests in the sources finished, look at the ^\nresults in %BUILDDIR%/doctest/output.txt.\n\tgoto end\n)\n\n:end\n"
  },
  {
    "path": "docs/migrating-1.0.rst",
    "content": ".. _migrating_10:\n\n================\nMigrating to 1.0\n================\n\nAfter more than 12 years since it's initial inception, django-cookie-consent finally\nhas a 1.0 version number. It means that the public API is stable, and breaking changes\nwill be reflected in a new major version number.\n\nSome breaking changes compared to version 0.9 and earlier may affect you. This document\nhelps you upgrade from the earlier versions to 1.0.\n\nLegacy cookiebar removal\n========================\n\nThe largest breaking change is the removal of ``cookie_consent/cookiebar.js`` in favour\nof the new ``cookie_consent/cookiebar.module.js``. It was deprecated in version 0.5.0b0.\n\nBefore\n------\n\nBefore, you'd have code along the lines of:\n\n.. code-block:: django\n\n    {% load cookie_consent_tags %}\n\n    <script type=\"text/javascript\" src=\"{% static 'cookie_consent/cookiebar.js' %}\"></script>\n\n    <script type=\"{% js_type_for_cookie_consent request \"analytics\" \"*:example.com\" %}\" data-varname=\"analytics\">\n      console.log('Analytics script activated.');\n    </script>\n\n    <script>\n        showCookieBar({\n          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>',\n          cookie_groups: ['analytics'],\n          cookie_decline: '{% get_decline_cookie_groups_cookie_string request analytics %}',\n          beforeDeclined: function () {\n            console.log('User is about to decline cookies');\n          },\n        });\n    </script>\n\nNote the requirement of the ``cc-cookie-accept`` and ``cc-cookie-decline`` class names.\n\nAfter\n-----\n\n.. code-block:: django\n\n    {% load static cookie_consent_tags %}\n\n    {% static \"cookie_consent/cookiebar.module.js\" as cookiebar_src %}\n    {% url \"cookie_consent_cookie_group_list\" as url_cookies %}\n    {% url 'cookie_consent_status' as status_url %}\n\n    {% all_cookie_groups 'cookie-consent__cookie-groups' %}\n    {# Emits a <script type=\"application/json\" id=\"cookie-consent__cookie-groups\">...</script> tag #}\n\n    <template id=\"cookie-consent__cookie-bar\">\n        <div class=\"cookie-bar\">\n            <p>We use cookies to improve your browsing experience. By continuing to use\n            our site, you agree to our use of cookies.</p>\n            <a href=\"#\" class=\"cc-cookie-accept\">Accept</button>\n            <a href=\"#\" class=\"cc-cookie-decline\">Decline</button>\n        </div>\n    </template>\n\n    <template data-varname=\"analytics\">\n        <script>\n          console.log('Analytics script activated.');\n        </script>\n    </template>\n\n    <script type=\"module\">\n        import {showCookieBar} from '{{ cookiebar_src }}';\n        showCookieBar({\n          statusUrl: '{{ status_url|escapejs }}',\n          templateSelector: '#cookie-consent__cookie-bar',\n          cookieGroupsSelector: '#cookie-consent__cookie-groups',\n          acceptSelector: '.cc-cookie-accept',\n          declineSelector: '.cc-cookie-decline',\n          onAccept: (acceptedGroups) => {\n            document.querySelector('.cookie-bar').style.display = 'none';\n            document.querySelector('body').classList.remove('with-cookie-bar');\n\n            acceptedGroups\n              .filter(group => ['analytics'].includes(group.varname))\n              .forEach(group => {\n                const scriptTemplate = document.querySelector(\n                  `template[data-varname=\"${group.varname}\"]`\n                ).content;\n                const scripts = templateNode.content.cloneNode(true);\n                document.body.appendChild(scripts);\n              })\n            ;\n          },\n          onDecline: (declinedGroups) => {\n            document.querySelector('.cookie-bar').style.display = 'none';\n            document.querySelector('body').classList.remove('with-cookie-bar');\n\n            console.log('User is about to decline cookies', declinedGroups);\n          },\n        });\n    </script>\n\n.. note::\n\n    There is no replacement for the ``cookie_decline`` option, since the backend\n    already results in the cookie being updated.\n\nRemoved template tags\n---------------------\n\n**Cookie string builders**\n\nThe tags below have been obsoleted due to the cookie not being updated in Javascript\nany longer:\n\n* ``get_accept_cookie_groups_cookie_string``\n* ``get_decline_cookie_groups_cookie_string``\n\nInstead, make an Ajax or Fetch call to the ``cookie_consent_accept`` or\n``cookie_consent_decline`` views, e.g.:\n\n.. code-block:: django\n\n    {% url 'cookie_consent_accept' as accept_url %}\n\n    {{ accept_url|json_script:'cc-accept-url' }}\n\n.. code-block:: javascript\n\n    const acceptUrl = JSON.parse(document.getElementById('cc-accept-url').content);\n\n    const formData = new FormData();\n    formData.append('cookie_groups', 'analytics');\n\n    window.fetch(acceptUrl, {\n      method: 'POST',\n      body: formData,\n      credentials: 'same-origin',\n      headers: {\n        'X-Cookie-Consent-Fetch': '1',\n        'X-CSRFToken': csrftoken, // get this from the template or the status endpoint\n      }\n    });\n\nThe view response updates the cookie for the browser.\n\n**Script tag helper**\n\nTag: ``js_type_for_cookie_consent``\n\nUse the ``template`` approach to enable scripts without full page reloads, see above.\n\n**Get accepted cookie varnames**\n\nTag: ``accepted_cookies``\n\nThis would cause view/page cache issues, as it outputs the cookie varnames for the\nuser.\n\nInstead, use Javascript for dynamic cookie banner behaviour, ideally the new cookiebar\nmodule which uses the ``cookie_consent_status`` endpoint under the hood.\n\nAlternatively, you can write your own implementation and fetch the current cookie\nconsent status of the user through the ``cookie_consent_status`` view.\n\nAccept/decline views now use form data and only support POST requests\n=====================================================================\n\nBefore 1.0, the accept and decline URLs would (optionally) take a comma-separated\nURL path variable for the cookie group varnames:\n\n* ``.../accept/social,analytics/``\n* ``.../decline/social,analytics/``\n\nwhile the URLs without the varnames would imply that *all* cookie groups are being\naccepted or declined.\n\nInstead of using ``{% url 'cookie_consent_accept' varname='social,analytics' %}``, in 1.0, use proper form semantics instead:\n\n.. code-block:: django\n\n    <form action=\"{% url 'cookie_consent_accept' %}\" method=\"post\">\n        {% csrf_token %}\n        <input type=\"hidden\" name=\"cookie_groups\" value=\"social\">\n        <input type=\"hidden\" name=\"cookie_groups\" value=\"analytics\">\n        <button type=\"submit\">Accept</button>\n    </form>\n\nor, to accept/decline all cookie groups, instead of\n``{% url 'cookie_consent_accept_all' %}``:\n\n.. code-block:: django\n\n    <form action=\"{% url 'cookie_consent_accept' %}\" method=\"post\">\n        {% csrf_token %}\n        <input type=\"hidden\" name=\"all_groups\" value=\"true\">\n        <button type=\"submit\">Accept all</button>\n    </form>\n\n.. warning:: The accept/decline views now **require** a CSRF token to be provided. The\n   new cookiebar module already handles this.\n\n.. warning:: The ``DELETE`` support to decline cookies is removed. Use ``POST`` instead.\n\nEnable safety/strictness features\n=================================\n\nThe legacy situation may have prevented you from applying some security hardening or\nimprovements in your project(s). Below, we point out some things that are possible now.\n\n**Enable the httpOnly flag for cookie consent's own cookie**\n\nSince the legacy Javascript that was writing directly to ``document.cookie`` is removed,\nyou can block access to this cookie from Javascript entirely now.\n\nThe default settings already enable this.\n\n**Use a strict(er) Content-Security-Policy**\n\nThe legacy cookiebar required ``unsave-eval``, which is a serious weakening of\ncross-site scripting protections. If you were using it because of django-cookie-consent,\nyou can now remove it after upgrading to the new cookiebar module.\n\n**Site/view cache can be enabled**\n\nBy relying on ``template`` nodes, Javascript and JSON-based endpoints for the dynamic\nper-user information, your main views/page templates can now be safely cached using\nDjango's cache framework without serving cookie consent information from the wrong\nuser.\n\nThe future\n==========\n\nFuture major versions will primarily be caused by dropping support for old Python and/or\nDjango version, notably when those go end-of-life. The Python/Django backwards\ncompatibility has an excellent track record, so we don't expect big impacts from this.\n\nOne larger rework that is planned, is the overhaul of the cookie accept/decline\nlogging. This will likely be another major release, but we expect a smooth upgrade\npath.\n"
  },
  {
    "path": "docs/quickstart.rst",
    "content": "==========\nQuickstart\n==========\n\nInstallation\n============\n\nInstall django-cookie-consent from PyPI with pip (recommended):\n\n.. code-block:: bash\n\n    pip install django-cookie-consent\n\nAlternatively, you can install directly from Github:\n\n.. code-block:: bash\n\n    pip install git+https://github.com/django-commons/django-cookie-consent@main#egg=django-cookie-consent\n\n.. warning:: Installing from the main branch can be unstable. It is recommended to pin\n   your installation to a specific git tag or commit.\n\nConfiguration\n=============\n\n#. Add ``cookie_consent`` to your ``INSTALLED_APPS``.\n\n#. Add ``django.template.context_processors.request``\n   to ``TEMPLATE_CONTEXT_PROCESSORS`` if it is not already added.\n\n#. Include django-cookie-consent urls in ``urls.py``\n\n    .. code-block:: python\n\n        from django.urls import path\n\n        urlpatterns = [\n            ...,\n            path(\"cookies/\", include(\"cookie_consent.urls\")),\n            ...,\n        ]\n\n#. Run the ``migrate`` management command to update your database tables:\n\n    .. code-block:: bash\n\n        python manage.py migrate\n"
  },
  {
    "path": "docs/reference/api_middleware.rst",
    "content": "==========\nMiddleware\n==========\n\nCleanCookiesMiddleware\n----------------------\n\n.. code-block:: python\n\n    MIDDLEWARE = [\n        \"cookie_consent.middleware.CleanCookiesMiddleware\",\n    ]\n\n\nThis middleware will automatically delete previously accepted first party cookies when\nthey are declined or not accepted/declined.\n\nIf you have enabled the ``COOKIE_CONSENT_OPT_OUT`` setting, then the cookies will only\nbe deleted if they are explicitly rejected.\n\n.. note:: First party cookies are created by the host domain, while third party cookies\n   are created by *other domains* than the one the user is visiting. For security\n   reasons, browsers only allow your server to set first-party cookies.\n\n   This gets even more confusing because third parties (such as analytics providers,\n   ad-services...) DO set first party cookies rather than third-party, and store/read\n   the information to then send it via another transport mechanism.\n\n**Reference**\n\n.. autoclass:: cookie_consent.middleware.CleanCookiesMiddleware\n   :members:\n"
  },
  {
    "path": "docs/reference/api_models.rst",
    "content": "======\nModels\n======\n\n.. automodule:: cookie_consent.models\n   :members:\n"
  },
  {
    "path": "docs/reference/api_templatetags.rst",
    "content": ".. _api_templatetags:\n\n=============\nTemplate tags\n=============\n\ncookie_consent\n--------------\n\n.. automodule:: cookie_consent.templatetags.cookie_consent_tags\n   :members:\n"
  },
  {
    "path": "docs/reference/api_util.rst",
    "content": "====\nUtil\n====\n\n.. automodule:: cookie_consent.util\n   :members:\n"
  },
  {
    "path": "docs/reference/api_views.rst",
    "content": "=====\nViews\n=====\n\n.. automodule:: cookie_consent.views\n   :members:\n"
  },
  {
    "path": "docs/reference/index.rst",
    "content": "=============\nAPI Reference\n=============\n\n.. toctree::\n   :maxdepth: 2\n\n   api_models\n   api_views\n   api_util\n   api_templatetags\n   api_middleware\n   management_commands\n"
  },
  {
    "path": "docs/reference/management_commands.rst",
    "content": "====================\nManagement commands\n====================\n\nprune_cookie_consent_logs\n=========================\n\n.. code-block:: bash\n\n   python manage.py prune_cookie_consent_logs [--days DAYS]\n\nDeletes :class:`~cookie_consent.models.LogItem` records older than the\nspecified number of days.\n\n**Options**\n\n``--days DAYS``\n    Number of days to use as the cutoff. Log items created more than this\n    many days ago will be deleted. Defaults to ``90``.\n\n**Example** — delete log items older than 30 days:\n\n.. code-block:: bash\n\n   python manage.py prune_cookie_consent_logs --days 30\n\nThis command is safe to run repeatedly.\n"
  },
  {
    "path": "docs/settings.rst",
    "content": ".. _settings:\n\n========\nSettings\n========\n\nThe cookie settings (name, max-age, domain...) follow the same principles like\nDjango's built-in session cookie. For more details, please check that documenation\nfor more details about the meaning.\n\n``COOKIE_CONSENT_NAME``\n  name of consent cookie that remembers user choice\n\n  Default: ``cookie_consent``.\n\n``COOKIE_CONSENT_MAX_AGE``\n  max-age of consent cookie, in seconds\n\n  Default: 1 year\n\n``COOKIE_CONSENT_DOMAIN``\n  Domain to restrict the cookie to.\n\n  Default: ``None``\n\n``COOKIE_CONSENT_SECURE``\n  Whether to only set the cookie in an HTTPS context.\n\n  Default: ``False``\n\n``COOKIE_CONSENT_HTTPONLY``\n  Whether access from Javascript is blocked.\n\n  Default: ``True``\n\n``COOKIE_CONSENT_SAMESITE``\n  The SameSite policy. Possible values are ``\"Strict\"``, ``\"Lax\"``, ``\"None\"`` or\n  ``False`` to disable setting the flag.\n\n  Default: ``\"Lax\"``\n\n``COOKIE_CONSENT_DECLINE``\n  decline value\n\n  Default: ``-1``\n\n``COOKIE_CONSENT_ENABLED``\n  boolean or callable that receives request and returns a boolean.\n\n  For example, if you want to enable cookie consent for debug or staff only:\n\n  .. code-block:: python\n\n      COOKIE_CONSENT_ENABLED = lambda r: DEBUG or (r.user.is_authenticated and r.user.is_staff)\n\n  Default: ``True``\n\n``COOKIE_CONSENT_OPT_OUT``\n  Boolean value represents if cookies are opt-in (``False``) or opt-out (``True``).\n  Opt-out cookies are set until declined.\n  Opt-in cookies are set only if accepted.\n\n  Default: ``False``\n\n``COOKIE_CONSENT_CACHE_BACKEND``\n  Alias for backend to use for caching.\n\n  Default: ``default``\n\n``COOKIE_CONSENT_LOG_ENABLED``\n  Boolean value represents if the accept/decline user actions will be logged to the\n  database. Turning it off might be useful for preventing your database from getting\n  filled up with log items.\n\n  Default: ``True`` \n\n``COOKIE_CONSENT_SUCCESS_URL``\n  The success URL to redirect the user to after a successful accept/decline action. If\n  a ``?next`` parameter is present in the request, then it takes priority over this\n  setting.\n\n  Default: the URL of the built-in cookie list view.\n"
  },
  {
    "path": "docs/usage.rst",
    "content": "=====\nUsage\n=====\n\nManaging cookie groups and cookies\n----------------------------------\n\nTypically you manage the cookie groups and associated cookies through the admin\ninterface. You can of course integrate your own user interface if you prefer.\n\nCache invalidation is wired up at the model layer.\n\nChecking for cookie consent in templates\n----------------------------------------\n\ndjango-cookie-consent provides some :ref:`template tags and filters <api_templatetags>`.\nMost notable, you'll want to use:\n\n.. currentmodule:: cookie_consent.templatetags.cookie_consent_tags\n\n* :func:`cookie_group_accepted`\n* :func:`cookie_group_declined`\n\nto test whether a cookie group and/or specific cookie have been accepted or declined.\n\nFor example:\n\n.. code-block:: django\n\n  {% load cookie_consent_tags %}\n  {% if request|cookie_group_accepted:\"analytics\" %}\n    {# load 3rd party analytics #}\n  {% endif %}\n\n  {% if request|cookie_group_accepted:\"analytics=_ga:example.com\" %}\n    {# load google analytics #}\n  {% endif %}\n\nBoth filters takes the cookie group ``varname`` and an optional cookie name with\ndomain. If the cookie name with domain is used, the format is\n``VARNAME=COOKIENAME:DOMAIN``.\n\nAsking users for cookie consent in templates\n--------------------------------------------\n\nSee :ref:`javascript`.\n\nChecking for cookie consent in Python code\n------------------------------------------\n\n.. currentmodule:: cookie_consent.util\n\nYou can use the :func:`get_cookie_value_from_request` utility function to check consent\nstatus in views and other Python code. This function powers the template filters from\nabove.\n\n.. code-block:: python\n\n    from cookie_consent.util import get_cookie_value_from_request\n\n    def myview(request, *args, **kwargs):\n        cc = get_cookie_value_from_request(request, \"mycookies\")\n        if cc:\n            # add cookie\n\nYou can check if a particular cookie in the group is accepted:\n\n.. code-block:: python\n\n    cc = get_cookie_value_from_request(request, \"mycookies\", \"mycookie1:example.com\")\n\n\nChecking for 3rd party cookies dynamically\n------------------------------------------\n\nSee :ref:`javascript_enable_scripts`.\n\n.. versionremoved:: 1.0\n\n    The ``js_type_for_cookie_consent`` tag was removed due to its reliance on\n    ``unsave-eval`` (`MDN <https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src#unsafe_eval_expressions>`_).\n    Instead, use the modern ``cookiebar.module.js`` and hook into the ``onAccept`` event\n    and use ``template`` nodes.\n"
  },
  {
    "path": "js/.nvmrc",
    "content": "v24\n"
  },
  {
    "path": "js/README.md",
    "content": "# django-cookie-consent\n\nPackage containing the JS code for django-cookie-consent.\n\nThe cookiebar module is shipped in the Python package itself and available through\ndjango's staticfiles mechanism. This package is aimed at users wishing to include the\nassets in their own Javascript bundle through webpack/vite/...\n\n[![PyPI version][badge:pypi]][pypi]\n\n## Installation\n\n```bash\nnpm install django-cookie-consent\n```\n\nYou can now import the public API in your own bundle:\n\n```ts\nimport {showCookieBar} from 'django-cookie-consent';\n````\n\n## TypeScript and ESM\n\nThe source code is written in TypeScript. The type declarations are shipped in the\npublished package.\n\nWe only publish ES modules and do not offer CommonJS.\n\n## Building\n\nUse ``nvm`` or your tool of choice to select the right NodeJS version:\n\n```bash\nnvm use\n```\n\nBuilding the NPM package:\n\n```bash\nnpm run build\n```\n\nLastly, the frontend toolchain also builds the cookiebar module bundle that's included\nin the Python package:\n\n```bash\nnpm run build:django-static\n```\n\n[pypi]: https://pypi.org/project/django-cookie-consent/\n[badge:pypi]: https://img.shields.io/pypi/v/django-cookie-consent.svg\n"
  },
  {
    "path": "js/package.json",
    "content": "{\n  \"name\": \"django-cookie-consent\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Frontend code for django-cookie-consent\",\n  \"main\": \"lib/index.js\",\n  \"type\": \"module\",\n  \"files\": [\n    \"lib/\"\n  ],\n  \"scripts\": {\n    \"build\": \"tsc\",\n    \"build:django-static\": \"esbuild --bundle src/cookiebar.ts --outfile=../cookie_consent/static/cookie_consent/cookiebar.module.js --target=es2018 --format=esm --sourcemap\",\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/django-commons/django-cookie-consent.git\"\n  },\n  \"keywords\": [\n    \"django\",\n    \"typescript\",\n    \"cookie\",\n    \"consent\"\n  ],\n  \"author\": \"Sergei Maertens\",\n  \"license\": \"BSD-2-Clause\",\n  \"bugs\": {\n    \"url\": \"https://github.com/django-commons/django-cookie-consent/issues\"\n  },\n  \"homepage\": \"https://github.com/django-commons/django-cookie-consent#readme\",\n  \"devDependencies\": {\n    \"esbuild\": \"^0.25.3\",\n    \"typescript\": \"^5.4.5\"\n  }\n}\n"
  },
  {
    "path": "js/src/cookiebar.ts",
    "content": "/**\n * Cookiebar functionality, as a TS/JS module.\n *\n * About modules: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules\n *\n * The code is organized here in a way to make the templates work with Django's page\n * cache. This means that anything user-specific (so different django session and even\n * cookie consent cookies) cannot be baked into the templates, as that breaks caches.\n *\n * The cookie bar operates on the following principles:\n *\n * - The developer using the library includes the desired template in their django\n *   templates, using the HTML <template> element. This contains the content for the\n *   cookie bar.\n * - The developer is responsible for loading some Javascript that loads this script.\n * - The main export of this script needs to be called (showCookieBar), with the\n *   appropriate options.\n * - The options include the backend URLs where the retrieve data, which selectors/DOM\n *   nodes to use for various functionality and the hooks to tap into the accept/decline\n *   life-cycle.\n * - When a user accepts or declines (all) cookies, the call to the backend is made via\n *   a fetch request, bypassing any page caches and preventing full-page reloads.\n */\n\n/**\n * A serialized cookie group.\n *\n * See the backend model method `CookieGroup.as_json()`.\n */\nexport interface CookieGroup {\n  varname: string;\n  name: string;\n  description: string;\n  is_required: boolean;\n}\n\nexport interface Options {\n  statusUrl: string;\n  // TODO: also accept element rather than selector?\n  templateSelector: string;\n  /**\n   * DOM selector to the (script) tag holding the JSON-serialized cookie groups.\n   *\n   * This is typically rendered in a template with a template tag, e.g.\n   *\n   * ```django\n   * {% all_cookie_groups 'cookie-consent__cookie-groups' %}\n   * ```\n   *\n   * resulting in the selector: `'#cookie-consent__cookie-groups'`.\n   */\n  cookieGroupsSelector: string;\n  acceptSelector: string;\n  declineSelector: string;\n  /**\n   * Either a string (selector), DOMNode or null.\n   *\n   * If null, the bar is appended to the body. If provided, the node is used or looked\n   * up.\n   */\n  insertBefore: string | HTMLElement | null;\n  /**\n   * Optional callback for when the cookie bar is being shown.\n   *\n   * You can use this to add a CSS class name to the body, for example.\n   */\n  onShow?: () => void;\n  /**\n   * Optional callback called when cookies are accepted.\n   */\n  onAccept?: (acceptedGroups: CookieGroup[], event?: MouseEvent) => void;\n  /**\n   * Optional callback called when cookies are accepted.\n   */\n  onDecline?: (declinedGroups: CookieGroup[], event?: MouseEvent) => void;\n  /**\n   * Name of the header to use for the CSRF token.\n   *\n   * If needed, this can be read/set via `settings.CSRF_HEADER_NAME` in the backend.\n   */\n  csrfHeaderName: string;\n};\n\nexport interface CookieStatus {\n  csrftoken: string;\n  /**\n   * Backend endpoint to POST to to accept the cookie groups.\n   */\n  acceptUrl: string;\n  /**\n   * Backend endpoint to POST to to decline the cookie groups.\n   */\n  declineUrl: string;\n  /**\n   * Array of accepted cookie group varnames.\n   */\n  acceptedCookieGroups: string[];\n  /**\n   * Array of declined cookie group varnames.\n   */\n  declinedCookieGroups: string[];\n  /**\n   * Array of undecided cookie group varnames.\n   */\n  notAcceptedOrDeclinedCookieGroups: string[];\n}\n\nconst DEFAULT_FETCH_HEADERS: Record<string, string> = {\n  'X-Cookie-Consent-Fetch': '1'\n};\n\n/**\n * A simple wrapper around window.fetch that understands the django-cookie-consent\n * backend endpoints.\n *\n * @private - while exported, use at your own risk. This class is not part of the\n * public API covered by SemVer.\n */\nexport class FetchClient {\n  protected statusUrl: string;\n  protected csrfHeaderName: string;\n  protected cookieStatus: CookieStatus | null;\n\n  constructor(statusUrl: string, csrfHeaderName: string) {\n    this.statusUrl = statusUrl;\n    this.csrfHeaderName = csrfHeaderName;\n    this.cookieStatus = null;\n  }\n\n  async getCookieStatus(): Promise<CookieStatus> {\n    if (this.cookieStatus === null) {\n      const response = await window.fetch(\n        this.statusUrl,\n        {\n          method: 'GET',\n          credentials: 'same-origin',\n          headers: DEFAULT_FETCH_HEADERS,\n        }\n      );\n      this.cookieStatus = await response.json();\n    }\n\n    // type checker sanity check\n    if (this.cookieStatus === null) {\n      throw new Error('Unexpectedly received null cookie status');\n    }\n    return this.cookieStatus;\n  };\n\n  async saveCookiesStatusBackend (\n    urlProperty: 'acceptUrl' | 'declineUrl',\n    cookieGroups: CookieGroup[],\n  ) {\n    const cookieStatus = await this.getCookieStatus();\n    const url = cookieStatus[urlProperty];\n    if (!url) {\n      throw new Error(`Missing url for ${urlProperty} - was the cookie status not loaded properly?`);\n    }\n\n    const formData = new FormData();\n    for (const group of cookieGroups) {\n      formData.append('cookie_groups', group.varname);\n    }\n\n    await window.fetch(url, {\n      method: 'POST',\n      body: formData,\n      credentials: 'same-origin',\n      headers: {\n        ...DEFAULT_FETCH_HEADERS,\n        [this.csrfHeaderName]: cookieStatus.csrftoken\n      }\n    });\n  }\n}\n\n/**\n * Read the JSON script node contents and parse the content as JSON.\n *\n * The result is the list of available/configured cookie groups.\n * Use the status URL to get the accepted/declined status for an individual user.\n */\nexport const loadCookieGroups = (selector: string): CookieGroup[] => {\n  const node = document.querySelector<HTMLScriptElement>(selector);\n  if (!node) {\n    throw new Error(`No cookie groups (script) tag found, using selector: '${selector}'`);\n  }\n  return JSON.parse(node.innerText);\n};\n\nconst doInsertBefore = (beforeNode: HTMLElement, newNode: Node): void => {\n  const parent = beforeNode.parentNode;\n  if (parent === null) throw new Error('Reference node doesn\\'t have a parent.');\n  parent.insertBefore(newNode, beforeNode);\n}\n\ntype RegisterEventsOptions = Pick<\n  Options,\n  'acceptSelector' | 'onAccept' | 'declineSelector' | 'onDecline'\n> & Pick<\n  CookieStatus,\n  'acceptedCookieGroups' | 'declinedCookieGroups' | 'notAcceptedOrDeclinedCookieGroups'\n> & {\n  client: FetchClient,\n  cookieBarNode: Element;\n  cookieGroups: CookieGroup[];\n}\n\n/**\n * Register the accept/decline event handlers.\n *\n * Note that we can't just set the decline or accept cookie purely client-side, as the\n * cookie possibly has the httpOnly flag set.\n */\nconst registerEvents = ({\n  client,\n  cookieBarNode,\n  cookieGroups,\n  acceptSelector,\n  onAccept,\n  declineSelector,\n  onDecline,\n  acceptedCookieGroups: accepted,\n  declinedCookieGroups: declined,\n  notAcceptedOrDeclinedCookieGroups: undecided,\n}: RegisterEventsOptions): void => {\n\n  const acceptNode = cookieBarNode.querySelector<HTMLElement>(acceptSelector);\n  if (acceptNode) {\n    acceptNode.addEventListener('click', event => {\n      event.preventDefault();\n      const acceptedGroups = filterCookieGroups(cookieGroups, accepted.concat(undecided));\n      onAccept?.(acceptedGroups, event);\n      // trigger async action, but don't wait for completion\n      client.saveCookiesStatusBackend('acceptUrl', acceptedGroups);\n      cookieBarNode.parentNode!.removeChild(cookieBarNode);\n    });\n  }\n\n  const declineNode = cookieBarNode.querySelector<HTMLElement>(declineSelector);\n  if (declineNode) {\n    declineNode.addEventListener('click', event => {\n      event.preventDefault();\n      const declinedGroups = filterCookieGroups(cookieGroups, declined.concat(undecided));\n      onDecline?.(declinedGroups, event);\n      // trigger async action, but don't wait for completion\n      client.saveCookiesStatusBackend('declineUrl', declinedGroups);\n      cookieBarNode.parentNode!.removeChild(cookieBarNode);\n    });\n  }\n};\n\n/**\n * Filter the cookie groups down to a subset of specified varnames.\n */\nconst filterCookieGroups = (cookieGroups: CookieGroup[], varNames: string[]) => {\n  return cookieGroups.filter(group => varNames.includes(group.varname));\n};\n\n// See https://github.com/microsoft/TypeScript/issues/283\nfunction cloneNode<T extends Node>(node: T) {\n  return <T>node.cloneNode(true);\n}\n\nexport const showCookieBar = async (options: Partial<Options> = {}): Promise<void> => {\n  const {\n    templateSelector = '#cookie-consent__cookie-bar',\n    cookieGroupsSelector = '#cookie-consent__cookie-groups',\n    acceptSelector = '.cookie-consent__accept',\n    declineSelector = '.cookie-consent__decline',\n    insertBefore = null,\n    onShow,\n    onAccept,\n    onDecline,\n    statusUrl = '',\n    csrfHeaderName = 'X-CSRFToken', // Django's default, can be overridden with settings.CSRF_HEADER_NAME\n  } = options;\n\n  const cookieGroups = loadCookieGroups(cookieGroupsSelector);\n\n  // no cookie groups -> abort, nothing to do\n  if (!cookieGroups.length) return;\n\n  const templateNode = document.querySelector<HTMLTemplateElement>(templateSelector);\n  if (!templateNode) {\n    throw new Error(`No (template) element found for selector '${templateSelector}'.`)\n  }\n\n  // insert before a given node, if specified, or append to the body as default behaviour\n  const doInsert = insertBefore === null\n    ? (cookieBarNode: Node) => document.querySelector('body')!.appendChild(cookieBarNode)\n    : typeof insertBefore === 'string'\n      ? (cookieBarNode: Node) => {\n        const referenceNode = document.querySelector<HTMLElement>(insertBefore);\n        if (referenceNode === null) throw new Error(`No element found for selector '${insertBefore}'.`)\n        doInsertBefore(referenceNode, cookieBarNode);\n      }\n      : (cookieBarNode: Node) => doInsertBefore(insertBefore, cookieBarNode)\n  ;\n\n  if (!statusUrl) throw new Error('Missing status URL option, did you forget to pass the `statusUrl` option?');\n\n  const client = new FetchClient(statusUrl, csrfHeaderName);\n  const cookieStatus = await client.getCookieStatus();\n\n  // calculate the cookie groups to invoke the callbacks. We deliberately fire those\n  // without awaiting so that our cookie bar is shown/hidden as soon as possible.\n  const {\n    acceptedCookieGroups,\n    declinedCookieGroups,\n    notAcceptedOrDeclinedCookieGroups\n  } = cookieStatus;\n\n  const acceptedGroups = filterCookieGroups(cookieGroups, acceptedCookieGroups);\n  if (acceptedGroups.length) onAccept?.(acceptedGroups);\n  const declinedGroups = filterCookieGroups(cookieGroups, declinedCookieGroups);\n  if (declinedGroups.length) onDecline?.(declinedGroups);\n\n  // there are no (more) cookie groups to accept, don't show the bar\n  if (!notAcceptedOrDeclinedCookieGroups.length) return;\n\n  // grab the contents from the template node and add them to the DOM, optionally\n  // calling the onShow callback\n  const childToClone = templateNode.content.firstElementChild;\n  if (childToClone === null) throw new Error('The cookie bar template element may not be empty.');\n  const cookieBarNode = cloneNode(childToClone);\n  registerEvents({\n    client,\n    cookieBarNode,\n    cookieGroups,\n    acceptSelector,\n    onAccept,\n    declineSelector,\n    onDecline,\n    acceptedCookieGroups,\n    declinedCookieGroups,\n    notAcceptedOrDeclinedCookieGroups,\n  });\n  doInsert(cookieBarNode);\n  onShow?.();\n};\n"
  },
  {
    "path": "js/src/index.ts",
    "content": "export {loadCookieGroups, showCookieBar} from './cookiebar.js';\n"
  },
  {
    "path": "js/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"baseUrl\": \"src\",\n    \"target\": \"es2017\",\n    \"module\": \"esnext\",\n    \"outDir\": \"lib\",\n    \"declaration\": true,\n    \"noErrorTruncation\": true,\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noImplicitReturns\": true,\n    \"noImplicitThis\": true,\n    \"noImplicitAny\": true,\n    \"strictBindCallApply\": true,\n    \"strictNullChecks\": true,\n  },\n  \"include\": [\"src\"],\n  \"exclude\": [\"node_modules\", \"lib\"],\n}\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"setuptools>=77.0.3\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"django-cookie-consent\"\ndescription = \"Django cookie consent application\"\nauthors = [\n    {name = \"Informatika Mihelac\", email = \"bmihelac@mihelac.org\"}\n]\nreadme = \"README.md\"\nlicense = \"BSD-2-Clause-first-lines\"\nlicense-files = [\"LICENSE\"]\nkeywords = [\"cookies\", \"cookie-consent\", \"cookie bar\"]\nclassifiers = [\n    \"Development Status :: 5 - Production/Stable\",\n    \"Framework :: Django\",\n    \"Framework :: Django :: 4.2\",\n    \"Framework :: Django :: 5.2\",\n    \"Framework :: Django :: 6.0\",\n    \"Intended Audience :: Developers\",\n    \"Operating System :: Unix\",\n    \"Operating System :: MacOS\",\n    \"Operating System :: Microsoft :: Windows\",\n    \"Operating System :: OS Independent\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Programming Language :: Python :: 3.13\",\n    \"Programming Language :: Python :: 3.14\",\n    \"Topic :: Software Development :: Libraries :: Python Modules\",\n]\nrequires-python = \">=3.10\"\ndependencies = [\n    \"django>=4.2\",\n    \"django-appconf\",\n]\ndynamic = [\"version\"]\n\n[project.urls]\nDocumentation = \"https://django-cookie-consent.readthedocs.io/en/latest/\"\nChangelog = \"https://github.com/django-commons/django-cookie-consent/blob/main/docs/changelog.rst\"\n\"Bug Tracker\" = \"https://github.com/django-commons/django-cookie-consent/issues\"\n\"Source Code\" = \"https://github.com/django-commons/django-cookie-consent\"\n\n[project.optional-dependencies]\ntests = [\n    \"pytest\",\n    \"pytest-cov\",\n    \"pytest-django\",\n    \"pytest-playwright\",\n    \"hypothesis\",\n    \"tox\",\n    \"ruff\",\n]\ndocs = [\n    \"sphinx\",\n    \"sphinx-rtd-theme\",\n]\nrelease = [\n    \"bump-my-version\",\n]\n\n[tool.setuptools.dynamic]\nversion = {attr = \"cookie_consent.__version__\"}\n\n[tool.setuptools.packages.find]\ninclude = [\"cookie_consent*\"]\nnamespaces = true\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\nDJANGO_SETTINGS_MODULE = \"testapp.settings\"\nmarkers = [\n    \"e2e: mark tests as end-to-end tests, using playwright (deselect with '-m \\\"not e2e\\\"')\",\n]\n\n[tool.coverage.run]\nbranch = true\nsource = [\"cookie_consent\"]\nomit = [\n    # migrations run while django initializes the test db\n    \"*/migrations/*\",\n]\n\n[tool.coverage.report]\nskip_covered = true\nexclude_also = [\n    \"if (typing\\\\.)?TYPE_CHECKING:\",\n    \"@(typing\\\\.)?overload\",\n    \"class .*\\\\(.*Protocol.*\\\\):\",\n    \"@(abc\\\\.)?abstractmethod\",\n    \"raise NotImplementedError\",\n    \"\\\\.\\\\.\\\\.\",\n    \"\\\\bpass$\",\n]\n\n[tool.ruff.lint]\nextend-select = [\n    \"UP\",  # pyupgrade\n    \"DJ\",  # django\n    \"LOG\", # logging\n    \"G\",\n    \"I\",   # isort\n    \"E\",   # pycodestyle\n    \"F\",   # pyflakes\n    \"PERF\",# perflint\n    \"B\",   # flake8-bugbear\n]\n\n[tool.ruff.lint.isort]\ncombine-as-imports = true\nsection-order = [\n    \"future\",\n    \"standard-library\",\n    \"django\",\n    \"third-party\",\n    \"first-party\",\n    \"local-folder\",\n]\n\n[tool.ruff.lint.isort.sections]\n\"django\" = [\"django\"]\n\n[tool.bumpversion]\ncurrent_version = \"1.0.0\"\nparse = \"\"\"(?x)\n    (?P<major>0|[1-9]\\\\d*)\\\\.\n    (?P<minor>0|[1-9]\\\\d*)\\\\.\n    (?P<patch>0|[1-9]\\\\d*)\n    (?:\n        -                             # dash separator for pre-release section\n        (?P<pre_l>[a-zA-Z-]+)\\\\.      # pre-release label\n        (?P<pre_n>0|[1-9]\\\\d*)        # pre-release version number\n    )?                                # pre-release section is optional\n\"\"\"\nserialize = [\n    \"{major}.{minor}.{patch}-{pre_l}.{pre_n}\",\n    \"{major}.{minor}.{patch}\",\n]\nsearch = \"{current_version}\"\nreplace = \"{new_version}\"\nregex = false\nignore_missing_version = false\nignore_missing_files = false\ntag = false\nsign_tags = false\ntag_name = \"{new_version}\"\ntag_message = \":bookmark: Bump version to {new_version} and update changelog\"\nallow_dirty = false\ncommit = false\nmessage = \":bookmark: Bump version to {new_version} and update changelog\"\ncommit_args = \"\"\nsetup_hooks = []\npre_commit_hooks = [\n    \"cd js && npm i\",  # ensure that package-lock.json is updated\n]\npost_commit_hooks = []\n\n[tool.bumpversion.parts.pre_l]\nvalues = [\"beta\", \"final\"]\noptional_value = \"final\"\n\n[[tool.bumpversion.files]]\nfilename = \"cookie_consent/__init__.py\"\n\n[[tool.bumpversion.files]]\nfilename = \"js/package.json\"\nsearch = \"  \\\"version\\\": \\\"{current_version}\\\"\"\nreplace = \"  \\\"version\\\": \\\"{new_version}\\\"\"\n"
  },
  {
    "path": "testapp/__init__.py",
    "content": ""
  },
  {
    "path": "testapp/fixture.json",
    "content": "[\n{\n    \"model\": \"cookie_consent.cookiegroup\",\n    \"pk\": 1,\n    \"fields\": {\n        \"varname\": \"social\",\n        \"name\": \"Social\",\n        \"description\": \"\",\n        \"is_required\": false,\n        \"is_deletable\": true,\n        \"ordering\": 1,\n        \"created\": \"2023-06-11T13:31:03.677Z\"\n    }\n},\n{\n    \"model\": \"cookie_consent.cookiegroup\",\n    \"pk\": 2,\n    \"fields\": {\n        \"varname\": \"optional\",\n        \"name\": \"Optional\",\n        \"description\": \"\",\n        \"is_required\": false,\n        \"is_deletable\": true,\n        \"ordering\": 2,\n        \"created\": \"2023-06-11T13:40:26.842Z\"\n    }\n},\n{\n    \"model\": \"cookie_consent.cookiegroup\",\n    \"pk\": 3,\n    \"fields\": {\n        \"varname\": \"required\",\n        \"name\": \"Required\",\n        \"description\": \"\",\n        \"is_required\": true,\n        \"is_deletable\": false,\n        \"ordering\": 0,\n        \"created\": \"2023-06-11T13:40:58.798Z\"\n    }\n},\n{\n    \"model\": \"cookie_consent.cookie\",\n    \"pk\": 1,\n    \"fields\": {\n        \"cookiegroup\": 3,\n        \"name\": \"sessionid\",\n        \"description\": \"Session ID to stay logged in.\",\n        \"path\": \"/\",\n        \"domain\": \"\",\n        \"created\": \"2023-06-11T13:41:39.982Z\"\n    }\n},\n{\n    \"model\": \"cookie_consent.cookie\",\n    \"pk\": 2,\n    \"fields\": {\n        \"cookiegroup\": 1,\n        \"name\": \"dummy\",\n        \"description\": \"\",\n        \"path\": \"/\",\n        \"domain\": \".google.com\",\n        \"created\": \"2023-06-11T13:43:26.360Z\"\n    }\n},\n{\n    \"model\": \"cookie_consent.cookie\",\n    \"pk\": 3,\n    \"fields\": {\n        \"cookiegroup\": 2,\n        \"name\": \"sample\",\n        \"description\": \"\",\n        \"path\": \"/\",\n        \"domain\": \"\",\n        \"created\": \"2023-06-11T13:43:43.888Z\"\n    }\n}\n]\n"
  },
  {
    "path": "testapp/settings.py",
    "content": "import os\nfrom pathlib import Path\n\nBASE_DIR = Path(__file__).resolve().parent\n\nINSTALLED_APPS = [\n    \"django.contrib.admin\",\n    \"django.contrib.auth\",\n    \"django.contrib.contenttypes\",\n    \"django.contrib.sessions\",\n    \"django.contrib.sites\",\n    \"django.contrib.staticfiles\",\n    \"django.contrib.messages\",\n    \"cookie_consent\",\n    \"testapp\",\n]\nSITE_ID = 1\n\nROOT_URLCONF = \"testapp.urls\"\n\nDEBUG = True\n\nUSE_TZ = True\n\nSTATIC_URL = \"/static/\"\nSTATICFILES_DIRS = [\n    BASE_DIR / \"static\",\n]\n\nTEMPLATES = [\n    {\n        \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n        \"DIRS\": [],\n        \"APP_DIRS\": True,\n        \"OPTIONS\": {\n            \"context_processors\": [\n                \"django.contrib.auth.context_processors.auth\",\n                \"django.template.context_processors.debug\",\n                \"django.template.context_processors.i18n\",\n                \"django.template.context_processors.media\",\n                \"django.template.context_processors.static\",\n                \"django.template.context_processors.tz\",\n                \"django.template.context_processors.request\",\n                \"django.contrib.messages.context_processors.messages\",\n            ],\n        },\n    },\n]\n\nSECRET_KEY = \"2n6)=vnp8@bu0om9d05vwf7@=5vpn%)97-!d*t4zq1mku%0-@j\"\n\nDATABASES = {\n    \"default\": {\n        \"ENGINE\": \"django.db.backends.sqlite3\",\n        \"NAME\": os.path.join(os.path.dirname(__file__), \"database.db\"),\n    }\n}\nDEFAULT_AUTO_FIELD = \"django.db.models.AutoField\"\n\nMIDDLEWARE_CLASSES = MIDDLEWARE = [\n    \"django.middleware.security.SecurityMiddleware\",\n    \"django.contrib.sessions.middleware.SessionMiddleware\",\n    \"django.middleware.common.CommonMiddleware\",\n    \"django.middleware.csrf.CsrfViewMiddleware\",\n    \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n    \"django.contrib.messages.middleware.MessageMiddleware\",\n    \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n    \"cookie_consent.middleware.CleanCookiesMiddleware\",\n]\n\n# Use the default appconf settings. In tests, use @override_settings if you need\n# some specific setting values.\n# COOKIE_CONSENT_NAME = \"cookie_consent\"\n"
  },
  {
    "path": "testapp/static/styles.css",
    "content": "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: center;\n  height: 25px;\n  line-height: 25px;\n  background: #eee;\n}\n"
  },
  {
    "path": "testapp/templates/show-cookie-bar-script.html",
    "content": "{% load static cookie_consent_tags %}\n{% static \"cookie_consent/cookiebar.module.js\" as cookiebar_src %}\n<script type=\"module\">\n    import {showCookieBar} from '{{ cookiebar_src }}';\n\n    const showShareButton = () => {\n        const template = document.getElementById('show-share-button')\n        const showButtonScript = template.content.cloneNode(true);\n        document.body.appendChild(showButtonScript);\n    };\n\n    showCookieBar({\n      statusUrl: '{{ status_url|escapejs }}',\n      templateSelector: '#cookie-consent__cookie-bar',\n      cookieGroupsSelector: '#cookie-consent__cookie-groups',\n      onShow: () => document.querySelector('body').classList.add('with-cookie-bar'),\n      onAccept: (cookieGroups) => {\n        document.querySelector('body').classList.remove('with-cookie-bar');\n        const hasSocial = cookieGroups.find(g => g.varname == 'social') !== undefined;\n        hasSocial && showShareButton();\n      },\n      onDecline: () => document.querySelector('body').classList.remove('with-cookie-bar'),\n    });\n\n    document.getElementById('loading-marker').style.display = 'inline';\n</script>\n\n<template id=\"show-share-button\">\n    <script type=\"text/javascript\">\n      document.getElementById('share-button').style.display = 'block';\n    </script>\n</template>\n"
  },
  {
    "path": "testapp/templates/test_page.html",
    "content": "{% load static %}\n{% load cookie_consent_tags %}\n{% url \"cookie_consent_cookie_group_list\" as url_cookies %}\n<!DOCTYPE html>\n<html>\n    <head>\n        <link href=\"{% static 'styles.css' %}\" rel=\"stylesheet\" />\n    </head>\n\n  <body>\n    <h1>Test page</h1>\n\n    <span id=\"loading-marker\" style=\"display:none\">page-done-loading</span>\n\n    <h2>Social cookies</h2>\n    <p>\n        sharing button is displayed below only if \"Social\" cookies are accepted.\n        <button id=\"share-button\" type=\"button\" style=\"display:none\">SHARE</button>\n    </p>\n\n    {# NOTE - this section is not compatible with django's full page cache #}\n    <h2>Optional cookies</h2>\n    {% if request|cookie_group_accepted:\"optional\" %}\n        <p>\"optional\" cookies accepted</p>\n    {% elif request|cookie_group_declined:\"optional\" %}\n        <p>\"optional\" cookies declined</p>\n    {% else %}\n        <p>\"optional\" cookies not accepted or declined</p>\n    {% endif %}\n\n    {# not existing cookie group #}\n    {% if request|cookie_group_accepted:\"foo=*:.foo.com\" %}\n        <p>None existing cookies</p>\n    {% endif %}\n    {# END of section not compatible with page cache #}\n\n    <p>\n        <a href=\"{{ url_cookies }}\">Cookies policy</a>\n    </p>\n\n    {% if request|cookie_consent_enabled %}\n        {% not_accepted_or_declined_cookie_groups request as cookie_groups %}\n\n        {# Set up the data and template for dynamic JS cookie bar #}\n        {% all_cookie_groups 'cookie-consent__cookie-groups' %}\n        {% comment %}\n            NOTE: to make this work with page caches, you'd typically leave out the\n            dynamic parts (such as {{ cookie_groups }}) and handle that dynamically\n            in JS.\n\n            For example, by getting the information dynamically from a template, putting\n            that in the template fragment and eventually calling the code from\n            cookiebar.module.js.\n\n            FIXME: add this to the docs\n        {% endcomment %}\n        <template id=\"cookie-consent__cookie-bar\">\n            {% with cookie_groups=cookie_groups|join:\", \" %}\n            <div class=\"cookie-bar\">\n                This site uses {{ cookie_groups }} cookies for better performance and user experience.\n                Do you agree to use these cookies?\n                {# Button is the more accessible role, but an anchor tag would also work #}\n                <button type=\"button\" class=\"cookie-consent__accept\">Accept</button>\n                <button type=\"button\" class=\"cookie-consent__decline\">Decline</button>\n                <a href=\"{{ url_cookies }}\">Cookies info</a>\n            </div>\n            {% endwith %}\n        </template>\n        {% url 'cookie_consent_status' as status_url %}\n        {% include \"./show-cookie-bar-script.html\" with status_url=status_url %}\n\n    {% endif %}\n\n  </body>\n</html>\n"
  },
  {
    "path": "testapp/urls.py",
    "content": "from django.contrib import admin\nfrom django.contrib.staticfiles.urls import staticfiles_urlpatterns\nfrom django.urls import include, path\n\nfrom .views import TestPageView\n\nurlpatterns = [\n    path(\"admin/\", admin.site.urls),\n    path(\"cookies/\", include(\"cookie_consent.urls\")),\n    path(\"\", TestPageView.as_view(), name=\"test_page\"),\n]\n\nurlpatterns += staticfiles_urlpatterns()\n"
  },
  {
    "path": "testapp/views.py",
    "content": "from django.views.generic import TemplateView\n\nfrom cookie_consent.util import get_cookie_value_from_request\n\n\nclass TestPageView(TemplateView):\n    template_name = \"test_page.html\"\n\n    def _should_set_cookie(self) -> bool:\n        if \"force\" in self.request.GET:\n            return True\n\n        cookie_value = get_cookie_value_from_request(self.request, \"optional\")\n        return cookie_value is True\n\n    def get(self, request, *args, **kwargs):\n        response = super().get(request, *args, **kwargs)\n        if self._should_set_cookie():\n            val = \"optional cookie set from django\"\n            response.set_cookie(\"optional_test_cookie\", val)\n        return response\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/conftest.py",
    "content": "import os\nfrom io import StringIO\nfrom pathlib import Path\n\nfrom django.core.management import call_command\n\nimport pytest\n\nfrom cookie_consent.cache import delete_cache\nfrom cookie_consent.models import Cookie, CookieGroup\n\nTEST_APP_DIR = Path(__file__).parent.parent.resolve() / \"testapp\"\n\n# otherwise pytest-playwright and pytest-django don't play nice :(\n# See https://github.com/microsoft/playwright-pytest/issues/46\nos.environ[\"DJANGO_ALLOW_ASYNC_UNSAFE\"] = \"1\"\n\n\n@pytest.fixture\ndef required_cookiegroup(db):\n    group = CookieGroup.objects.create(\n        varname=\"required\",\n        name=\"Functional cookies\",\n        is_required=True,\n        is_deletable=False,\n    )\n    Cookie.objects.create(cookiegroup=group, name=\"sessionid\")\n    return group\n\n\n@pytest.fixture\ndef optional_cookiegroup(db):\n    group = CookieGroup.objects.create(\n        varname=\"optional\",\n        name=\"Optional cookies\",\n        is_required=False,\n        is_deletable=True,\n    )\n    Cookie.objects.create(cookiegroup=group, name=\"evil-tracking\")\n    return group\n\n\n@pytest.fixture\ndef load_testapp_fixture(transactional_db):\n    fixture = str(TEST_APP_DIR / \"fixture.json\")\n    call_command(\"loaddata\", fixture, stdout=StringIO())\n\n\n@pytest.fixture(scope=\"function\", autouse=True)\ndef before_each_after_each():\n    yield\n    delete_cache()\n"
  },
  {
    "path": "tests/test_admin.py",
    "content": "from django.test import Client\nfrom django.urls import reverse\n\nimport pytest\nfrom pytest_django.asserts import assertContains\n\nfrom cookie_consent.models import CookieGroup\n\npytestmark = pytest.mark.django_db\n\n\ndef test_warning_icon_for_missing_cookies(\n    admin_client: Client,\n    required_cookiegroup: CookieGroup,\n    optional_cookiegroup: CookieGroup,\n):\n    optional_cookiegroup.cookie_set.all().delete()\n\n    admin_list_response = admin_client.get(\n        reverse(\"admin:cookie_consent_cookiegroup_changelist\")\n    )\n\n    assert admin_list_response.status_code == 200\n    assertContains(admin_list_response, \"admin/img/icon-alert\", count=1)\n"
  },
  {
    "path": "tests/test_cache.py",
    "content": "from django.test import TestCase, override_settings\n\nfrom cookie_consent.cache import delete_cache, get_cookie, get_cookie_group\nfrom cookie_consent.models import Cookie, CookieGroup\n\n\nclass CacheTest(TestCase):\n    def setUp(self):\n        super().setUp()\n        self.addCleanup(delete_cache)\n\n        self.cookie_group = CookieGroup.objects.create(\n            varname=\"optional\",\n            name=\"Optional\",\n        )\n        self.cookie = Cookie.objects.create(\n            cookiegroup=self.cookie_group,\n            name=\"foo\",\n        )\n\n    def test_get_cookie_group(self):\n        self.assertEqual(get_cookie_group(\"optional\"), self.cookie_group)\n\n    def test_get_cookie(self):\n        cookie_group = get_cookie_group(\"optional\")\n        self.assertEqual(get_cookie(cookie_group, \"foo\", \"\"), self.cookie)\n\n    def test_caching(self):\n        CookieGroup.objects.create(\n            varname=\"foo\",\n            name=\"Foo\",\n        )\n        with self.assertNumQueries(2):\n            cookie_group = get_cookie_group(\"optional\")\n            get_cookie_group(\"foo\")\n            get_cookie(cookie_group, \"foo\", \"\")\n\n    def test_caching_expire(self):\n        with self.assertNumQueries(2):\n            cookie_group = get_cookie_group(\"optional\")\n\n        self.cookie_group.name = \"Bar\"\n        self.cookie_group.save()\n\n        with self.assertNumQueries(2):\n            cookie_group = get_cookie_group(\"optional\")\n        self.assertEqual(cookie_group.name, \"Bar\")\n\n    @override_settings(\n        CACHES={\"tests\": {\"BACKEND\": \"django.core.cache.backends.dummy.DummyCache\"}},\n        COOKIE_CONSENT_CACHE_BACKEND=\"tests\",\n    )\n    def test_can_override_cache_settings(self):\n        \"\"\"\n        Assert that the cache backend/settings can be swapped out in tests.\n\n        Regression test for #41\n        \"\"\"\n        CookieGroup.objects.create(\n            varname=\"foo\",\n            name=\"Foo\",\n        )\n        # expect multiple calls to not be cached because of the no-op cache\n        with self.assertNumQueries(2 + 2):\n            get_cookie_group(\"optional\")\n            get_cookie_group(\"foo\")\n"
  },
  {
    "path": "tests/test_cookie_group_model.py",
    "content": "import pytest\n\nfrom cookie_consent.models import CookieGroup\n\n\ndef test_natural_key():\n    group = CookieGroup(varname=\"social\")\n\n    assert group.natural_key() == (\"social\",)\n\n\n@pytest.mark.django_db\ndef test_load_by_natural_key():\n    social_group = CookieGroup.objects.create(varname=\"social\")\n    CookieGroup.objects.create(varname=\"other\")\n\n    loaded_group = CookieGroup.objects.get_by_natural_key(\"social\")\n\n    assert loaded_group == social_group\n"
  },
  {
    "path": "tests/test_cookie_model.py",
    "content": "import pytest\n\nfrom cookie_consent.models import Cookie, CookieGroup\n\n\ndef test_natural_key():\n    cookie = Cookie(\n        cookiegroup=CookieGroup(varname=\"analytics\"), name=\"trck\", domain=\"example.com\"\n    )\n\n    assert cookie.natural_key() == (\"trck\", \"example.com\", \"analytics\")\n\n\n@pytest.mark.django_db\ndef test_load_by_natural_key():\n    social_group = CookieGroup.objects.create(varname=\"social\")\n    cookie = Cookie.objects.create(\n        cookiegroup=social_group, name=\"trck\", domain=\"example.com\"\n    )\n    Cookie.objects.create(cookiegroup=social_group, name=\"other\", domain=\"example.com\")\n\n    loaded_cookie = Cookie.objects.get_by_natural_key(\"trck\", \"example.com\", \"social\")\n\n    assert loaded_cookie == cookie\n"
  },
  {
    "path": "tests/test_javascript_cookiebar.py",
    "content": "\"\"\"\nTest the behaviour of the dynamic (JS based) cookiebar module.\n\nSee docs: https://playwright.dev/python/docs/test-runners for CLI options.\n\"\"\"\n\nfrom django.urls import reverse\n\nimport pytest\nfrom playwright.sync_api import Page, expect\n\npytestmark = [pytest.mark.django_db, pytest.mark.e2e]\n\n\nCOOKIE_BAR_CONTENT = \"\"\"\nThis site uses Social, Optional cookies for better performance and user experience.\nDo you agree to use these cookies?\n\"\"\"\n\n\n@pytest.fixture(scope=\"function\", autouse=True)\ndef before_each_after_each(live_server, page: Page, load_testapp_fixture):\n    test_page_url = f\"{live_server.url}{reverse('test_page')}\"\n    page.goto(test_page_url)\n    marker = page.get_by_text(\"page-done-loading\")\n    expect(marker).to_be_visible()\n    yield\n\n\ndef test_cookiebar_shows_initially(page: Page):\n    cookiebar = page.get_by_text(COOKIE_BAR_CONTENT)\n    expect(cookiebar).to_be_visible()\n\n\ndef test_cookiebar_accept_all(page: Page):\n    accept_button = page.get_by_role(\"button\", name=\"Accept\")\n    expect(accept_button).to_be_visible()\n\n    accept_button.click()\n\n    expect(page.get_by_text(COOKIE_BAR_CONTENT)).not_to_be_visible()\n    share_button = page.get_by_role(\"button\", name=\"SHARE\")\n    expect(share_button).to_be_visible()\n\n\ndef test_cookiebar_decline_all(page: Page):\n    decline_button = page.get_by_role(\"button\", name=\"Decline\")\n    expect(decline_button).to_be_visible()\n\n    decline_button.click()\n\n    expect(page.get_by_text(COOKIE_BAR_CONTENT)).not_to_be_visible()\n    share_button = page.get_by_role(\"button\", name=\"SHARE\")\n    expect(share_button).not_to_be_visible()\n\n\n@pytest.mark.parametrize(\"btn_text\", [\"Accept\", \"Decline\"])\ndef test_cookiebar_not_shown_anymore_after_accept_or_decline(btn_text: str, page: Page):\n    expect(page.get_by_text(COOKIE_BAR_CONTENT)).to_be_visible()\n\n    button = page.get_by_role(\"button\", name=btn_text)\n    expect(button).to_be_visible()\n\n    button.click()\n    expect(page.get_by_text(COOKIE_BAR_CONTENT)).not_to_be_visible()\n\n    page.reload()\n    expect(page.get_by_text(COOKIE_BAR_CONTENT)).not_to_be_visible()\n\n\ndef test_on_accept_handler_runs_on_load(page: Page, live_server):\n    accept_button = page.get_by_role(\"button\", name=\"Accept\")\n    accept_button.click()\n    page.wait_for_load_state(\"networkidle\")\n    # wait for fetch calls to complete & avoid test race conditions...\n    share_button = page.get_by_role(\"button\", name=\"SHARE\")\n    expect(share_button).to_be_visible()\n\n    test_page_url = f\"{live_server.url}{reverse('test_page')}\"\n    page.goto(test_page_url)\n    marker = page.get_by_text(\"page-done-loading\")\n    expect(marker).to_be_visible()\n\n    share_button = page.get_by_role(\"button\", name=\"SHARE\")\n    expect(share_button).to_be_visible()\n"
  },
  {
    "path": "tests/test_middleware.py",
    "content": "from django.test import TestCase, override_settings\nfrom django.test.client import RequestFactory\nfrom django.urls import reverse\n\nfrom cookie_consent.cache import delete_cache\nfrom cookie_consent.models import Cookie, CookieGroup\n\nfactory = RequestFactory()\n\n\n@override_settings(COOKIE_CONSENT_OPT_OUT=False)\nclass CleanCookiesMiddlewareTests(TestCase):\n    @classmethod\n    def setUpTestData(cls):\n        super().setUpTestData()\n\n        cls.cookie_group = CookieGroup.objects.create(\n            varname=\"optional\",\n            name=\"Optional (test) cookies\",\n        )\n        cls.cookie = Cookie.objects.create(\n            cookiegroup=cls.cookie_group,\n            name=\"optional_test_cookie\",\n            domain=\"127.0.0.1\",\n            path=\"/\",\n        )\n\n    def setUp(self):\n        super().setUp()\n        self.addCleanup(delete_cache)\n\n    def _accept_and_set_cookie(self):\n        with self.subTest(\"initial setup\"):\n            # ensure we start with cookies first being accepted\n            endpoint = reverse(\"cookie_consent_accept\")\n\n            consent_response = self.client.post(\n                endpoint,\n                data={\"cookie_groups\": [\"optional\"]},\n                follow=True,\n                headers={\"x-requested-with\": \"XMLHttpRequest\"},\n            )\n            self.assertEqual(consent_response.status_code, 200)\n\n            # hit the test page to set the cookie\n            self.client.get(reverse(\"test_page\"))\n            self.assertIn(\"optional_test_cookie\", self.client.cookies)\n\n    def assertCookieDeleted(self, name: str):\n        if name not in self.client.cookies:\n            self.fail(\n                \"Cookie not present in client cookies, which is required to delete it\"\n            )\n\n        # deleting a cookie is done by setting the expiry date to the past/max-age 0\n        # so that the browser effectively is instructed to delete the cookie\n        cookie = self.client.cookies[name]\n        cookie_dict = dict(cookie)\n        self.assertEqual(cookie_dict[\"max-age\"], 0)\n        self.assertIn(\"1970\", cookie_dict[\"expires\"])\n        self.assertEqual(cookie.value, \"\")\n\n    def test_middleware_decline_previously_accepted_cookiegroup_cookies_are_deleted(\n        self,\n    ):\n        self._accept_and_set_cookie()\n\n        with self.subTest(\"decline prevously accepted group\"):\n            url = reverse(\"cookie_consent_decline\")\n\n            decline_response = self.client.post(\n                url, data={\"cookie_groups\": [\"optional\"]}, follow=True\n            )\n\n            self.assertEqual(decline_response.status_code, 200)\n\n        # fetch the test page and assert the middleware deleted the cookie\n        self.client.get(reverse(\"test_page\"))\n\n        self.assertCookieDeleted(\"optional_test_cookie\")\n\n    def test_middleware_no_cookie_consent_cookie_present_cookies_are_deleted(self):\n        self._accept_and_set_cookie()\n        # Delete cookie_consent cookie\n        del self.client.cookies[\"cookie_consent\"]\n\n        # fetch the test page and assert the middleware deleted the cookie\n        self.client.get(reverse(\"test_page\"))\n\n        # Check if cookie_consent cookie is deleted\n        self.assertCookieDeleted(\"optional_test_cookie\")\n\n    def test_cookie_consent_disabled(self):\n        self._accept_and_set_cookie()\n\n        with override_settings(COOKIE_CONSENT_ENABLED=False):\n            self.client.get(reverse(\"test_page\"))\n\n        cookie = self.client.cookies[\"optional_test_cookie\"]\n        self.assertEqual(cookie.value, \"optional cookie set from django\")\n\n    def test_cookie_group_not_deletable(self):\n        self.cookie_group.is_deletable = False\n        self.cookie_group.save()\n        self._accept_and_set_cookie()\n\n        self.client.get(reverse(\"test_page\"))\n\n        cookie = self.client.cookies[\"optional_test_cookie\"]\n        self.assertEqual(cookie.value, \"optional cookie set from django\")\n\n    @override_settings(COOKIE_CONSENT_OPT_OUT=True)\n    def test_with_opt_out_behaviour(self):\n        # set the cookie\n        self.client.get(reverse(\"test_page\"), {\"force\": \"1\"})\n\n        # call view to run middleware\n        self.client.get(reverse(\"test_page\"))\n\n        cookie = self.client.cookies[\"optional_test_cookie\"]\n        self.assertEqual(cookie.value, \"optional cookie set from django\")\n"
  },
  {
    "path": "tests/test_models.py",
    "content": "import string\nfrom copy import deepcopy\n\nfrom django.conf import settings\nfrom django.core.cache import caches\nfrom django.core.exceptions import ValidationError\nfrom django.test import TestCase, override_settings\n\nimport pytest\nfrom hypothesis import given, strategies as st\n\nfrom cookie_consent.cache import CACHE_KEY, delete_cache\nfrom cookie_consent.models import Cookie, CookieGroup, validate_cookie_name\n\npatch_caches = override_settings(\n    COOKIE_CONSENT_CACHE_BACKEND=\"tests\",\n    CACHES={\n        **deepcopy(settings.CACHES),\n        \"tests\": {\n            \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\",\n        },\n    },\n)\n\n\nclass CacheMixin:\n    def populateCache(self):\n        cache = caches[\"tests\"]\n        cache.set(CACHE_KEY, {}, timeout=3600)\n\n    def assertCacheNotPopulated(self):\n        cache = caches[\"tests\"]\n        has_key = cache.has_key(CACHE_KEY)\n        self.assertFalse(has_key)\n\n\n@patch_caches\nclass CookieGroupTest(CacheMixin, TestCase):\n    def setUp(self):\n        self.addCleanup(delete_cache)\n\n        self.cookie_group = CookieGroup.objects.create(\n            varname=\"optional\",\n            name=\"Optional\",\n        )\n        self.cookie = Cookie.objects.create(\n            cookiegroup=self.cookie_group,\n            name=\"foo\",\n        )\n\n    def test_get_version(self):\n        self.assertEqual(\n            self.cookie_group.get_version(), self.cookie.created.isoformat()\n        )\n\n    def test_bulk_delete(self):\n        self.populateCache()\n\n        deleted_objs_count, _ = CookieGroup.objects.filter(\n            id=self.cookie_group.id\n        ).delete()\n\n        # Deleting a CookieGroup also deletes the associated Cookies, that's why we\n        # expect a count of 2.\n        self.assertEqual(deleted_objs_count, 2)\n        self.assertCacheNotPopulated()\n\n    def test_bulk_update(self):\n        self.populateCache()\n\n        updated_objs_count = CookieGroup.objects.filter(id=self.cookie_group.id).update(\n            name=\"Optional2\"\n        )\n\n        self.assertEqual(updated_objs_count, 1)\n        self.assertCacheNotPopulated()\n\n\n@patch_caches\nclass CookieTest(CacheMixin, TestCase):\n    def setUp(self):\n        self.addCleanup(delete_cache)\n\n        self.cookie_group = CookieGroup.objects.create(\n            varname=\"optional\",\n            name=\"Optional\",\n        )\n        self.cookie = Cookie.objects.create(\n            cookiegroup=self.cookie_group,\n            name=\"foo\",\n            domain=\".example.com\",\n        )\n\n    def test_varname(self):\n        self.assertEqual(self.cookie.varname, \"optional=foo:.example.com\")\n\n    def test_bulk_delete(self):\n        self.populateCache()\n\n        deleted_objs_count, _ = Cookie.objects.filter(id=self.cookie.id).delete()\n\n        self.assertEqual(deleted_objs_count, 1)\n        self.assertCacheNotPopulated()\n\n    def test_bulk_update(self):\n        self.populateCache()\n\n        updated_objs_count = Cookie.objects.filter(id=self.cookie.id).update(\n            name=\"foo2\"\n        )\n\n        self.assertEqual(updated_objs_count, 1)\n        self.assertCacheNotPopulated()\n\n\n@given(\n    name=st.text(\n        alphabet=string.ascii_letters + string.digits + \"-_\",\n        min_size=1,\n    )\n)\ndef test_valid_cookie_name_does_not_raise(name):\n    try:\n        validate_cookie_name(name)\n    except ValidationError:\n        pytest.fail(reason=f\"Expected {name} to be valid\")\n\n\n@pytest.mark.parametrize(\n    \"name\",\n    (\n        \"space inside\",\n        \"a!b\",\n        \"$\",\n    ),\n)\ndef test_invalid_cookie_name_raises(name: str):\n    with pytest.raises(ValidationError):\n        validate_cookie_name(name)\n"
  },
  {
    "path": "tests/test_prune_cookie_consent_logs.py",
    "content": "from datetime import timedelta\nfrom io import StringIO\n\nfrom django.core.management import call_command\nfrom django.utils import timezone\n\nimport pytest\n\nfrom cookie_consent.models import ACTION_ACCEPTED, LogItem\n\n\n@pytest.fixture\ndef make_log_item(optional_cookiegroup):\n    def _make(days_old: int = 0, **kwargs) -> LogItem:\n        item = LogItem.objects.create(\n            action=ACTION_ACCEPTED,\n            cookiegroup=optional_cookiegroup,\n            version=\"1\",\n        )\n        created = timezone.now() - timedelta(days=days_old, **kwargs)\n        LogItem.objects.filter(pk=item.pk).update(created=created)\n        return item\n\n    return _make\n\n\ndef test_prunes_old_items_with_default_days(make_log_item):\n    old = make_log_item(days_old=91)\n    recent = make_log_item(days_old=10)\n\n    call_command(\"prune_cookie_consent_logs\", stdout=StringIO(), stderr=StringIO())\n\n    assert not LogItem.objects.filter(pk=old.pk).exists()\n    assert LogItem.objects.filter(pk=recent.pk).exists()\n\n\ndef test_prunes_items_older_than_custom_days(make_log_item):\n    old = make_log_item(days_old=31)\n    recent = make_log_item(days_old=5)\n\n    call_command(\n        \"prune_cookie_consent_logs\", days=30, stdout=StringIO(), stderr=StringIO()\n    )\n\n    assert not LogItem.objects.filter(pk=old.pk).exists()\n    assert LogItem.objects.filter(pk=recent.pk).exists()\n\n\ndef test_no_items_deleted_when_all_recent(make_log_item):\n    item = make_log_item(days_old=1)\n\n    call_command(\"prune_cookie_consent_logs\", stdout=StringIO(), stderr=StringIO())\n\n    assert LogItem.objects.filter(pk=item.pk).exists()\n\n\ndef test_output_reports_deleted_count(make_log_item):\n    make_log_item(days_old=91)\n    make_log_item(days_old=91)\n\n    out = StringIO()\n    call_command(\"prune_cookie_consent_logs\", stdout=out)\n\n    assert out.getvalue().strip() == \"Deleted 2 log item(s) older than 90 days.\"\n\n\ndef test_strict_days_cutoff(make_log_item):\n    \"\"\"\n    Test that the cutoff is strict and doesn't just look at the date part.\n    \"\"\"\n    # Item created exactly 90 days ago + 4 hour (outside the prune window, so deleted)\n    old = make_log_item(days_old=90, hours=4)\n    # Item created exactly 90 days ago - 1 hour (inside the prune window, so kept)\n    recent = make_log_item(days_old=89, hours=23)\n\n    call_command(\"prune_cookie_consent_logs\", stdout=StringIO(), stderr=StringIO())\n\n    assert not LogItem.objects.filter(pk=old.pk).exists()\n    assert LogItem.objects.filter(pk=recent.pk).exists()\n"
  },
  {
    "path": "tests/test_settings.py",
    "content": "from django.urls import reverse\n\nimport pytest\n\npytestmark = pytest.mark.django_db\n\n\n@pytest.mark.parametrize(\n    (\"setting\", \"value\", \"assertion\"),\n    [\n        (\"MAX_AGE\", 3600, {\"max-age\": 3600}),\n        (\"DOMAIN\", None, {\"domain\": \"\"}),\n        (\"DOMAIN\", \"example.com\", {\"domain\": \"example.com\"}),\n        (\"SECURE\", True, {\"secure\": True}),\n        (\"SECURE\", False, {\"secure\": \"\"}),\n        (\"SECURE\", None, {\"secure\": \"\"}),\n        (\"HTTPONLY\", True, {\"httponly\": True}),\n        (\"HTTPONLY\", None, {\"httponly\": \"\"}),\n        (\"HTTPONLY\", False, {\"httponly\": \"\"}),\n        (\"SAMESITE\", \"Lax\", {\"samesite\": \"Lax\"}),\n        (\"SAMESITE\", None, {\"samesite\": \"\"}),\n        (\"SAMESITE\", False, {\"samesite\": \"\"}),\n        (\"SAMESITE\", \"None\", {\"samesite\": \"None\"}),\n        (\"SAMESITE\", \"Strict\", {\"samesite\": \"Strict\"}),\n    ],\n)\n@pytest.mark.django_db\ndef test_cookie_consent_cookie_options(\n    settings, client, optional_cookiegroup, setting, value, assertion\n):\n    accept_url = reverse(\"cookie_consent_accept\")\n    setattr(settings, f\"COOKIE_CONSENT_{setting}\", value)\n\n    client.post(accept_url, data={\"all_groups\": \"true\"})\n\n    cookie = client.cookies[settings.COOKIE_CONSENT_NAME]\n    for key, expected in assertion.items():\n        assert cookie[key] == expected\n"
  },
  {
    "path": "tests/test_templatetags.py",
    "content": "from textwrap import dedent\nfrom typing import Any\n\nfrom django.template import Context, Template\n\nimport pytest\n\n\ndef render(tpl: str, context: dict[str, Any] | None = None) -> str:\n    template = Template(dedent(tpl).strip())\n    return template.render(Context(context))\n\n\nNOT_ACCEPT_OR_DECLINED_TEMPLATE = \"\"\"\n{% load cookie_consent_tags %}\n{% not_accepted_or_declined_cookie_groups request as cookie_groups %}\n{% if cookie_groups %}FOUND COOKIES{% else %}NO COOKIES{% endif %}\n\"\"\"\n\n\n@pytest.mark.django_db\ndef test_not_accepted_or_declined_cookie_groups_only_required_cookies(\n    required_cookiegroup, rf\n):\n    context = {\"request\": rf.get(\"/\")}\n\n    output = render(NOT_ACCEPT_OR_DECLINED_TEMPLATE, context).strip()\n\n    assert output == \"NO COOKIES\"\n\n\n@pytest.mark.django_db\ndef test_not_accepted_or_declined_cookie_groups_only_optional_cookies(\n    optional_cookiegroup, rf\n):\n    context = {\"request\": rf.get(\"/\")}\n\n    output = render(NOT_ACCEPT_OR_DECLINED_TEMPLATE, context).strip()\n\n    assert output == \"FOUND COOKIES\"\n\n\ndef test_not_accepted_or_declined_cookie_groups_required_and_optional_cookies(\n    required_cookiegroup, optional_cookiegroup, rf\n):\n    context = {\"request\": rf.get(\"/\")}\n\n    output = render(NOT_ACCEPT_OR_DECLINED_TEMPLATE, context).strip()\n\n    assert output == \"FOUND COOKIES\"\n"
  },
  {
    "path": "tests/test_util.py",
    "content": "from datetime import datetime\n\nfrom django.test import TestCase\nfrom django.test.client import RequestFactory\nfrom django.test.utils import override_settings\n\nfrom hypothesis import example, given, strategies as st\n\nfrom cookie_consent.conf import settings\nfrom cookie_consent.models import Cookie, CookieGroup\nfrom cookie_consent.util import (\n    dict_to_cookie_str,\n    get_cookie_groups,\n    get_cookie_value_from_request,\n    is_cookie_consent_enabled,\n    parse_cookie_str,\n)\n\n\nclass UtilTest(TestCase):\n    def setUp(self):\n        self.cookie_group = CookieGroup.objects.create(\n            varname=\"optional\",\n            name=\"Optional\",\n        )\n        self.cookie = Cookie.objects.create(\n            cookiegroup=self.cookie_group,\n            name=\"foo\",\n        )\n        self.factory = RequestFactory()\n        self.request = self.factory.get(\"\")\n\n    def test_parse_cookie_str(self):\n        cookie_str = \"foo=2013-06-04T01:08:58.262162|bar=2013-06-04T01:08:58\"\n        res = parse_cookie_str(cookie_str)\n        dic = {\n            \"foo\": \"2013-06-04T01:08:58.262162\",\n            \"bar\": \"2013-06-04T01:08:58\",\n        }\n        self.assertEqual(res, dic)\n\n    def test_dict_to_cookie_str(self):\n        cookie_str = \"|\"\n        dic = {\n            \"foo\": \"2013-06-04T01:08:58.262162\",\n            \"bar\": \"2013-06-04T01:08:58\",\n        }\n        cookie_str = dict_to_cookie_str(dic)\n        self.assertEqual(parse_cookie_str(cookie_str), dic)\n\n    def test_get_cookie_value_from_request(self):\n        cookie_str = dict_to_cookie_str({\"optional\": self.cookie_group.get_version()})\n        self.request.COOKIES[settings.COOKIE_CONSENT_NAME] = cookie_str\n        res = get_cookie_value_from_request(self.request, \"optional\")\n        self.assertTrue(res)\n\n    def test_get_cookie_value_from_request_declined(self):\n        cookie_str = dict_to_cookie_str({\"optional\": datetime(1999, 1, 1).isoformat()})\n        self.request.COOKIES[settings.COOKIE_CONSENT_NAME] = cookie_str\n        res = get_cookie_value_from_request(self.request, \"optional\")\n        self.assertFalse(res)\n\n    def test_get_cookie_value_from_request_empty(self):\n        res = get_cookie_value_from_request(self.request, \"optional\")\n        self.assertIsNone(res)\n\n    def test_get_cookie_value_from_request_added_cookies(self):\n        cookie_str = dict_to_cookie_str(\n            {\n                \"optional\": self.cookie_group.get_version(),\n            }\n        )\n        Cookie.objects.create(\n            cookiegroup=self.cookie_group,\n            name=\"bar\",\n            domain=\".example.com\",\n        )\n        self.request.COOKIES[settings.COOKIE_CONSENT_NAME] = cookie_str\n        res = get_cookie_value_from_request(self.request, \"optional\")\n        self.assertIsNone(res)\n\n    def test_get_cookie_value_from_request_specific_cookie(self):\n        cookie_str = dict_to_cookie_str({\"optional\": self.cookie_group.get_version()})\n        self.request.COOKIES[settings.COOKIE_CONSENT_NAME] = cookie_str\n        res = get_cookie_value_from_request(self.request, \"optional\", \"foo:\")\n        self.assertTrue(res)\n\n        Cookie.objects.create(\n            cookiegroup=self.cookie_group,\n            name=\"bar\",\n            domain=\".example.com\",\n        )\n        res = get_cookie_value_from_request(\n            self.request, \"optional\", \"bar:.example.com\"\n        )\n        self.assertFalse(res)\n\n        res = get_cookie_value_from_request(self.request, \"optional\", \"foo:\")\n        self.assertTrue(res)\n\n        cookie_str = dict_to_cookie_str({\"optional\": self.cookie_group.get_version()})\n        self.request.COOKIES[settings.COOKIE_CONSENT_NAME] = cookie_str\n        res = get_cookie_value_from_request(\n            self.request, \"optional\", \"bar:.example.com\"\n        )\n        self.assertTrue(res)\n\n    def test_is_cookie_consent_enabled(self):\n        self.assertTrue(is_cookie_consent_enabled(None))\n\n    @override_settings(COOKIE_CONSENT_ENABLED=lambda r: False)\n    def test_is_cookie_consent_enabled_callable(self):\n        self.assertFalse(is_cookie_consent_enabled(None))\n\n    def test_get_cookie_groups(self):\n        self.assertIn(self.cookie_group, get_cookie_groups(\"optional\"))\n\n        cookie_group2 = CookieGroup.objects.create(\n            varname=\"foo\",\n            name=\"foo\",\n        )\n        self.assertIn(self.cookie_group, get_cookie_groups(\"foo,optional\"))\n        self.assertIn(cookie_group2, get_cookie_groups(\"foo,optional\"))\n\n\n@example({\"\": \"|\"})\n@example({\"\": \"=\"})\n@given(\n    cookie_dict=st.dictionaries(\n        keys=st.text(min_size=0),\n        values=st.text(min_size=0),\n    )\n)\ndef test_serialize_and_parse_cookie_str(cookie_dict):\n    serialized = dict_to_cookie_str(cookie_dict)\n    parsed = parse_cookie_str(serialized)\n\n    assert len(parsed.keys()) <= len(cookie_dict.keys())\n\n\n@given(cookie_str=st.text(min_size=0))\ndef test_parse_cookie_str(cookie_str: str):\n    parsed = parse_cookie_str(cookie_str)\n\n    assert isinstance(parsed, dict)\n"
  },
  {
    "path": "tests/test_views.py",
    "content": "from collections.abc import Collection\n\nfrom django.test import Client\nfrom django.urls import reverse\n\nimport pytest\nfrom pytest_django.asserts import assertContains, assertRedirects, assertTemplateUsed\n\nfrom cookie_consent.models import (\n    ACTION_ACCEPTED,\n    ACTION_DECLINED,\n    Cookie,\n    CookieGroup,\n    LogItem,\n)\n\n\n@pytest.mark.django_db\ndef test_cookiegroup_list_view(client: Client, optional_cookiegroup: CookieGroup):\n    response = client.get(reverse(\"cookie_consent_cookie_group_list\"))\n\n    assert response.status_code == 200\n    assertTemplateUsed(\"cookie_consent/cookiegroup_list.html\")\n    assertContains(response, '<input type=\"submit\" value=\"Accept\">')\n    assertContains(response, '<input type=\"submit\" value=\"Decline\">')\n\n\ndef assertAcceptedCookieGroups(client: Client, varnames: Collection[str]):\n    cookie_status = client.get(reverse(\"cookie_consent_status\")).json()\n    assert set(cookie_status[\"acceptedCookieGroups\"]) == set(varnames)\n\n\ndef assertDeclinedCookieGroups(client: Client, varnames: Collection[str]):\n    cookie_status = client.get(reverse(\"cookie_consent_status\")).json()\n    assert set(cookie_status[\"declinedCookieGroups\"]) == set(varnames)\n\n\n@pytest.mark.parametrize(\n    \"next_param,expected_url\",\n    (\n        (reverse(\"test_page\"), reverse(\"test_page\")),\n        (\"https://evil.com\", \"/fallback/\"),\n    ),\n)\n@pytest.mark.django_db\ndef test_processing_get_success_url(\n    client: Client, settings, next_param: str, expected_url: str\n):\n    \"\"\"\n    Assert that the ``?next`` query param is used and sanitized.\n\n    Redirects to other hosts are unsafe and a fallback is expected instead.\n    \"\"\"\n    settings.COOKIE_CONSENT_SUCCESS_URL = \"/fallback/\"\n\n    response = client.post(\n        reverse(\"cookie_consent_accept\"),\n        data={\"all_groups\": \"true\", \"next\": next_param},\n    )\n\n    assertRedirects(response, expected_url, fetch_redirect_response=False)\n\n\n@pytest.mark.django_db\ndef test_alternative_redirect_fallback(client: Client, settings):\n    settings.COOKIE_CONSENT_SUCCESS_URL = \"/alternative\"\n\n    response = client.post(\n        reverse(\"cookie_consent_accept\"),\n        data={\"all_groups\": \"true\"},\n    )\n\n    assertRedirects(response, \"/alternative\", fetch_redirect_response=False)\n\n\n@pytest.mark.django_db\ndef test_accept_multiple_cookiegroups_submitted_via_post_body(client: Client):\n    cookie_group_1 = CookieGroup.objects.create(varname=\"group_1\", name=\"Optional 1\")\n    Cookie.objects.create(cookiegroup=cookie_group_1, name=\"foo\")\n    cookie_group_2 = CookieGroup.objects.create(varname=\"group_2\", name=\"Optional 2\")\n    Cookie.objects.create(cookiegroup=cookie_group_2, name=\"bar\")\n    cookie_group_3 = CookieGroup.objects.create(varname=\"group_3\", name=\"Optional 3\")\n    Cookie.objects.create(cookiegroup=cookie_group_3, name=\"baz\")\n    body = {\n        \"all_groups\": \"false\",\n        \"cookie_groups\": [\"group_1\", \"group_2\"],\n    }\n\n    response = client.post(reverse(\"cookie_consent_accept\"), data=body)\n\n    assertAcceptedCookieGroups(client, {\"group_1\", \"group_2\"})\n    assertRedirects(response, expected_url=\"/cookies/\")\n\n\n@pytest.mark.django_db\ndef test_accept_all_cookiegroups(client: Client, optional_cookiegroup: CookieGroup):\n    response = client.post(\n        reverse(\"cookie_consent_accept\"), data={\"all_groups\": \"true\"}\n    )\n\n    assertRedirects(\n        response,\n        reverse(\"cookie_consent_cookie_group_list\"),\n        fetch_redirect_response=False,\n    )\n    assertAcceptedCookieGroups(client, {\"optional\"})\n\n\n@pytest.mark.django_db\ndef test_accept_cookie_view_ajax(client: Client, optional_cookiegroup: CookieGroup):\n    response = client.post(\n        reverse(\"cookie_consent_accept\"),\n        data={\"cookie_groups\": \"optional\"},\n        headers={\"x-requested-with\": \"XMLHttpRequest\"},\n    )\n\n    assert response.status_code == 200\n    assert response.content == b\"\"\n\n\n@pytest.mark.django_db\ndef test_accept_cookie_invalid_varname(\n    client: Client, optional_cookiegroup: CookieGroup\n):\n    assert optional_cookiegroup.varname != \"missing\"\n\n    response = client.post(\n        reverse(\"cookie_consent_accept\"), data={\"cookie_groups\": \"missing\"}\n    )\n\n    assert response.status_code == 200\n    assertContains(response, \"Select a valid choice\")\n\n\n@pytest.mark.django_db\ndef test_ajax_like_accept_cookie_invalid_varname(\n    client: Client, optional_cookiegroup: CookieGroup\n):\n    assert optional_cookiegroup.varname != \"missing\"\n\n    response = client.post(\n        reverse(\"cookie_consent_accept\"),\n        data={\"cookie_groups\": \"missing\"},\n        headers={\"x-cookie-consent-fetch\": \"1\"},\n    )\n\n    assert response.status_code == 200\n    errors = response.json()\n    assert \"cookie_groups\" in errors\n    assert errors[\"cookie_groups\"][0][\"code\"] == \"invalid_choice\"\n\n\n@pytest.mark.django_db\ndef test_decline_multiple_cookiegroups_submitted_via_post_body(client: Client):\n    cookie_group_1 = CookieGroup.objects.create(varname=\"group_1\", name=\"Optional 1\")\n    Cookie.objects.create(cookiegroup=cookie_group_1, name=\"foo\")\n    cookie_group_2 = CookieGroup.objects.create(varname=\"group_2\", name=\"Optional 2\")\n    Cookie.objects.create(cookiegroup=cookie_group_2, name=\"bar\")\n    cookie_group_3 = CookieGroup.objects.create(varname=\"group_3\", name=\"Optional 3\")\n    Cookie.objects.create(cookiegroup=cookie_group_3, name=\"baz\")\n    body = {\n        \"all_groups\": \"false\",\n        \"cookie_groups\": [\"group_1\", \"group_2\"],\n    }\n\n    response = client.post(reverse(\"cookie_consent_decline\"), data=body)\n\n    assertDeclinedCookieGroups(client, {\"group_1\", \"group_2\"})\n    assertRedirects(response, expected_url=\"/cookies/\")\n\n\n@pytest.mark.django_db\ndef test_decline_all_cookiegroups(client: Client, optional_cookiegroup: CookieGroup):\n    response = client.post(\n        reverse(\"cookie_consent_decline\"), data={\"all_groups\": \"true\"}\n    )\n\n    assertRedirects(\n        response,\n        reverse(\"cookie_consent_cookie_group_list\"),\n        fetch_redirect_response=False,\n    )\n    assertDeclinedCookieGroups(client, {\"optional\"})\n\n\n@pytest.mark.django_db\ndef test_decline_cookie_view_ajax(client: Client, optional_cookiegroup: CookieGroup):\n    response = client.post(\n        reverse(\"cookie_consent_decline\"),\n        data={\"cookie_groups\": \"optional\"},\n        headers={\"x-requested-with\": \"XMLHttpRequest\"},\n    )\n\n    assert response.status_code == 200\n    assert response.content == b\"\"\n\n\n@pytest.mark.django_db\ndef test_decline_cookie_invalid_varname(\n    client: Client, optional_cookiegroup: CookieGroup\n):\n    assert optional_cookiegroup.varname != \"missing\"\n\n    response = client.post(\n        reverse(\"cookie_consent_decline\"), data={\"cookie_groups\": \"missing\"}\n    )\n\n    assert response.status_code == 200\n    assertContains(response, \"Select a valid choice\")\n\n\n@pytest.mark.django_db\ndef test_ajax_like_decline_cookie_invalid_varname(\n    client: Client, optional_cookiegroup: CookieGroup\n):\n    assert optional_cookiegroup.varname != \"missing\"\n\n    response = client.post(\n        reverse(\"cookie_consent_decline\"),\n        data={\"cookie_groups\": \"missing\"},\n        headers={\"x-cookie-consent-fetch\": \"1\"},\n    )\n\n    assert response.status_code == 200\n    errors = response.json()\n    assert \"cookie_groups\" in errors\n    assert errors[\"cookie_groups\"][0][\"code\"] == \"invalid_choice\"\n\n\n@pytest.mark.django_db\ndef test_logging_enabled(client: Client, optional_cookiegroup: CookieGroup):\n    # accept and decline should each produce a log item\n    client.post(reverse(\"cookie_consent_accept\"), data={\"cookie_groups\": \"optional\"})\n    client.post(reverse(\"cookie_consent_decline\"), data={\"cookie_groups\": \"optional\"})\n\n    log_items = LogItem.objects.all()\n\n    assert len(log_items) == 2\n\n    accept_log_item = next(item for item in log_items if item.action == ACTION_ACCEPTED)\n    assert accept_log_item.cookiegroup == optional_cookiegroup\n    assert accept_log_item.version == optional_cookiegroup.get_version()\n\n    decline_log_item = next(\n        item for item in log_items if item.action == ACTION_DECLINED\n    )\n    assert decline_log_item.cookiegroup == optional_cookiegroup\n    assert decline_log_item.version == optional_cookiegroup.get_version()\n\n\n@pytest.mark.django_db\ndef test_logging_disabled(client: Client, optional_cookiegroup: CookieGroup, settings):\n    settings.COOKIE_CONSENT_LOG_ENABLED = False\n    # accept and decline should each produce a log item\n    client.post(reverse(\"cookie_consent_accept\"), data={\"cookie_groups\": \"optional\"})\n    client.post(reverse(\"cookie_consent_decline\"), data={\"cookie_groups\": \"optional\"})\n\n    assert not LogItem.objects.exists()\n\n\n@pytest.mark.django_db\ndef test_integration_test_page_works(client: Client, optional_cookiegroup: CookieGroup):\n    CookieGroup.objects.create(varname=\"social\", name=\"Social\")\n    Cookie.objects.create(cookiegroup=optional_cookiegroup, name=\"optional_test_cookie\")\n    url = reverse(\"test_page\")\n\n    response = client.get(url)\n    assertContains(response, '\"optional\" cookies not accepted or declined')\n\n    # accept the optional group\n    client.post(reverse(\"cookie_consent_accept\"), data={\"cookie_groups\": \"optional\"})\n    after_accept_response = client.get(url)\n    assertContains(after_accept_response, '\"optional\" cookies accepted')\n    assert (\n        client.cookies[\"optional_test_cookie\"].value\n        == \"optional cookie set from django\"\n    )\n\n    # decline the optional group\n    client.post(reverse(\"cookie_consent_decline\"), data={\"cookie_groups\": \"optional\"})\n    after_decline_response = client.get(url)\n    assert client.cookies[\"optional_test_cookie\"].value == \"\"\n    assertContains(after_decline_response, '\"optional\" cookies declined')\n"
  },
  {
    "path": "tox.ini",
    "content": "[tox]\nenvlist =\n    py{310,311}-django{42,52}\n    py{312,313,314}-django{52,60}\n    ruff\n    docs\nskip_missing_interpreters = true\n\n[gh-actions]\npython =\n    3.10: py310\n    3.11: py311\n    3.12: py312\n    3.13: py313\n    3.14: py314\n\n[gh-actions:env]\nDJANGO =\n    4.2: django42\n    5.2: django52\n    6.0: django60\n\n[testenv]\nsetenv =\n    DJANGO_SETTINGS_MODULE=testapp.settings\n    PYTHONPATH={toxinidir}\nextras =\n    tests\n\ndeps =\n  django42: Django~=4.2.0\n  django52: Django~=5.2.0\n  django60: Django~=6.0.0\ncommands =\n  pytest tests \\\n   -m 'not e2e' \\\n   --cov --cov-report xml:reports/coverage-{envname}.xml \\\n   {posargs}\n\n[testenv:e2e]\nsetenv =\n    DJANGO_SETTINGS_MODULE=testapp.settings\n    PYTHONPATH={toxinidir}\nextras =\n    tests\n\ndeps =\n  Django~=5.2.0\ncommands =\n  pytest tests \\\n   --cov --cov-report xml:reports/coverage-{envname}.xml \\\n   {posargs}\n\n[testenv:ruff]\nextras = tests\nskipsdist = True\ncommands =\n    ruff check --output-format=github .\n    ruff format --check\n\n[testenv:docs]\nbasepython=python\nchangedir=docs\nskipsdist=true\nextras =\n    tests\n    docs\ncommands=\n    pytest check_sphinx.py -v \\\n    --tb=auto \\\n    {posargs}\n"
  }
]