├── .github └── workflows │ ├── codeql-analysis.yml │ ├── publish.yaml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── pyproject.toml ├── pytest.ini ├── querystring_tag ├── __init__.py ├── apps.py ├── constants.py ├── expressions.py ├── parse_utils.py ├── templatetags │ ├── __init__.py │ ├── querystring_tag.py │ └── tests.py ├── testapp │ ├── settings.py │ └── urls.py └── utils.py ├── setup.cfg └── setup.py /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: push 4 | 5 | jobs: 6 | analyze: 7 | name: Analyze 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v2 12 | with: 13 | # We must fetch at least the immediate parents so that if this is 14 | # a pull request then we can checkout the head. 15 | fetch-depth: 2 16 | 17 | # Initializes the CodeQL tools for scanning. 18 | - name: ⏳ Initialize CodeQL 19 | uses: github/codeql-action/init@v2 20 | with: 21 | languages: "python" 22 | 23 | - name: 🔬 Analyze code 24 | uses: github/codeql-action/analyze@v2 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: "Publish" 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | jobs: 9 | publish_to_pypi: 10 | name: 📦 Publish to PyPi 11 | runs-on: ubuntu-latest 12 | env: 13 | DJANGO_SETTINGS_MODULE: querystring_tag.testapp.settings 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | - name: 🐍 Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: 3.9 21 | - name: ⬇️ Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install -e .[test] 25 | - name: 🧪 Run tests 26 | run: pytest 27 | - name: ⬇️ Install build dependencies 28 | run: python -m pip install build --user 29 | - name: 🏗️ Package for PyPi 30 | run: python -m build --sdist --wheel --outdir dist/ . 31 | - name: 🚀 Publish 32 | uses: pypa/gh-action-pypi-publish@release/v1 33 | with: 34 | user: __token__ 35 | password: ${{ secrets.PYPI_API_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "Test" 2 | 3 | on: push 4 | 5 | # Our test suite should cover: 6 | # - Compatibility with the most recent versions of Python and Django 7 | # - At least one test run for older supported version of Python and Django 8 | 9 | # Current configuration: 10 | # - python 3.11, django 4.1 11 | # - python 3.10, django 4.0 12 | # - python 3.9, django 3.2 13 | 14 | jobs: 15 | lint: 16 | name: 🧹 Lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: actions/setup-python@v4 21 | with: 22 | python-version: 3.11 23 | cache: "pip" 24 | cache-dependency-path: "**/setup.cfg" 25 | - run: pip install -e .[lint] 26 | - name: Run flake8 27 | run: | 28 | # stop the build if there are Python syntax errors or undefined names 29 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 30 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 31 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 32 | - name: Run isort 33 | run: isort . --check-only --diff 34 | - name: Run black 35 | run: black . --check --fast 36 | 37 | test: 38 | name: 🧪 Test 39 | needs: lint 40 | runs-on: ubuntu-latest 41 | continue-on-error: ${{ matrix.experimental }} 42 | env: 43 | DJANGO_SETTINGS_MODULE: querystring_tag.testapp.settings 44 | strategy: 45 | matrix: 46 | include: 47 | - name: dj41-py310 48 | django: 'Django>=4.1,<4.2' 49 | python: '3.10' 50 | latest: true 51 | experimental: false 52 | - name: dj32-py39 53 | django: 'Django>=3.2,<4.0' 54 | python: '3.9' 55 | experimental: false 56 | - name: dj31-py38 57 | django: 'Django>=3.1,<3.2' 58 | python: '3.8' 59 | experimental: false 60 | - name: djmain-py311 61 | django: 'git+https://github.com/django/django.git@main#egg=Django' 62 | python: '3.11' 63 | experimental: true 64 | steps: 65 | - uses: actions/checkout@v3 66 | - uses: actions/setup-python@v4 67 | with: 68 | python-version: ${{ matrix.python }} 69 | - uses: actions/cache@v3 70 | with: 71 | path: ~/.cache/pip 72 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.cfg') }}-${{ matrix.name }} 73 | restore-keys: ${{ runner.os }}-pip- 74 | - run: | 75 | pip install -e .[test] 76 | pip install "${{ matrix.django }}" 77 | - if: ${{ !matrix.latest }} 78 | run: pytest 79 | - if: ${{ matrix.latest }} 80 | run: pytest --junitxml=junit/test-results.xml --cov=querystring_tag 81 | - if: ${{ matrix.latest }} 82 | uses: codecov/codecov-action@v3 83 | with: 84 | name: Python 3.10 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # django-querystring-tag Changelog 2 | 3 | ## In development 4 | 5 | - TBC 6 | 7 | ## 1.0.2 (2022-05-01) 8 | 9 | - Tidying of internal APIs and docstring improvements 10 | - README improvements 11 | - Improved test coverage 12 | 13 | ## 1.0.1 (2022-04-29) 14 | 15 | - Fixed issue with `dict` values not being preserved when provided as the `source_data` value. 16 | - Fixed TypeError when providing a `QueryDict` as the `source_data` value. 17 | - Fixed `model_value_field` option values not being respected. 18 | 19 | ## 1.0 (2022-04-29) 20 | 21 | - Initial release 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Torchbox Ltd and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Torchbox nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE *.txt *.md 2 | graft querystring_tag 3 | global-exclude testapp/* 4 | global-exclude __pycache__ 5 | global-exclude *.py[co] 6 | global-exclude .DS_Store 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-querystring-tag 2 | 3 |

4 | PyPi version 5 | Test workflow status 6 | Coverage status 7 | License: BSD 3-Clause 8 | Code style: Black 9 |

10 | 11 | This tiny package adds the `{% querystring %}` tag: A powerful, well tested template tag for modifying and rendering safe, suitably-encoded querystring values. 12 | 13 | It's the clean and simple way to create pagination links, filters and other state-preserving links, without cluttering up your view code! 14 | 15 | ## Installation 16 | 17 | 1. Install the package from pypi: 18 | 19 | **With Poetry** 20 | 21 | ```console 22 | $ poetry add django-querystring-tag 23 | ``` 24 | 25 | **With pip** 26 | 27 | ```console 28 | $ pip install django-querystring-tag 29 | ``` 30 | 31 | 2. Add `"querystring_tag"` to the `INSTALLED_APPS` list in your Django project settings. 32 | 33 | ### Add querystring_tag to builtins (optional) 34 | 35 | To use the `{% querystring %}` tag freely, without having to add `{% load querystring_tag %}` to all of your templates, you can add `"querystring_tag.templatetags.querystring_tag"` to the `['OPTIONS']['builtins']` list for your chosen template backend. [Here's an example](https://github.com/ababic/django-querystring-tag/blob/master/querystring_tag/testapp/settings.py#L36). 36 | 37 | ## How to use 38 | 39 | First, load the tag into the template you want to use it in: 40 | 41 | ``` 42 | {% load querystring_tag %} 43 | ``` 44 | 45 | You can then use the tag like this: 46 | 47 | ``` 48 | {% querystring param_one='value' param_two+='add-to-existing' param_three-="remove-from-existing" %} 49 | ``` 50 | 51 | ### The basics 52 | 53 | 1. The tag uses `request.GET` as the data source by default. Check out the [`source_data`](#source_data) option if you have other ideas. 54 | 2. The examples below are deliberately simple: You can make as many modifications in the same tag as you need. GO CRAZY! 55 | 3. You may be wondering "I want to use this in an include template, where the parameter name is dynamic. Will that work?". **Yes it will!** I know it's unusual, but you can [use tempalate variables for parameter names](#using-template-variables-for-parameter-names) too. 56 | 4. You don't want to preserve Google tracking parameters in links, do you? I thought not. Any parameters starting with `utm_` are removed by default. Add `remove_utm=False` if you would rather keep them. 57 | 5. You're probably not interested in preserving blank parameters in links either, are you? See? I read your mind! Blank values are removed by default too. Add `remove_blank=False` if you would rather keep them. 58 | 6. Want to variabalize the return value instead of rendering it? Go ahead and try the `as` option - It works just as you would expect. 59 | 60 | ### Use `=` to set or replace a parameter 61 | 62 | The most common requirement is to completely replace the value for a specific parameter. This is done using a regular keyword argument, with an `=` operator between the parameter name and value. For example, if your querystring looked like this: 63 | 64 | ``` 65 | ?q=test&baz=1 66 | ``` 67 | 68 | And you wanted to add a `foo` variable with the value `bar`, your querystring tag might look like this: 69 | 70 | ``` 71 | {% querystring foo="bar" %} 72 | ``` 73 | 74 | Which would result in the following output: 75 | 76 | ``` 77 | ?q=test&baz=1&foo=bar 78 | ``` 79 | 80 | ### Use `-=` to remove values from a multi-value parameter 81 | 82 | When working with multi-value parameters, you may find yourself having to **remove** a specific value, without affecting any of the others. 83 | 84 | In these situations, you can use the `-=` operator instead of the usual `=`. For example, if the current querystring looked something like this: 85 | 86 | ``` 87 | ?q=test&bar=1&bar=2&bar=3 88 | ``` 89 | 90 | And you wanted to remove `&bar=2`, your querystring tag might look like this: 91 | 92 | ``` 93 | {% querystring bar-=2 %} 94 | ``` 95 | 96 | Which would result in the following output: 97 | 98 | ``` 99 | ?q=test&bar=1&bar=3 100 | ``` 101 | 102 | If the specified value isn't present, the instruction will simply be ignored. 103 | 104 | ### Use `+=` to add values to a multi-value parameter 105 | 106 | When working with multi-value parameters, you may find yourself having to **add** a specific value for a parameter, without affecting any of the others. 107 | 108 | In these situations, you can use the `+=` operator instead of the usual `=`. For example, if the current querystring looked something like this: 109 | 110 | ``` 111 | ?q=test&bar=1&bar=2&bar=3 112 | ``` 113 | 114 | And you wanted to add `&bar=4`, your querystring tag might look like this: 115 | 116 | ``` 117 | {% querystring bar+=4 %} 118 | ``` 119 | 120 | Which would result in the following output: 121 | 122 | ``` 123 | ?q=test&bar=1&bar=2&bar=3&bar=4 124 | ``` 125 | 126 | If the specified value is already present, the instruction will simply be ignored. 127 | 128 | ### Use `only` to specify parameters you want to keep 129 | 130 | Use `only` at the start of your `{% querystring %}` tag when you want the querystring to include values for specific parameters only. 131 | 132 | For example, say the current querystring looked like this: 133 | 134 | ``` 135 | ?q=keywords&group=articles&category=2&published_after=2022-01-01 136 | ``` 137 | 138 | And you only wanted to include the `q` and `group` params in a link. You could do: 139 | 140 | ``` 141 | {% querystring only 'q' 'group' %} 142 | ``` 143 | 144 | Which would result in the following output: 145 | 146 | ``` 147 | ?q=keywords&group=articles 148 | ``` 149 | 150 | You can combine `only` with any number of modifications too. Just be sure to keep the `only` keyword and related parameter names as the left-most parameters, like so: 151 | 152 | ``` 153 | {% querystring only 'q' group="group_value" clear_session="true" %} 154 | ``` 155 | 156 | ### Use `discard` to specify parameters you want to lose 157 | 158 | Use `discard` at the start of your `{% querystring %}` tag when you want to exclude specific parameters from the querystring. 159 | 160 | For example, say the current querystring looked like this: 161 | 162 | ``` 163 | ?q=keywords&group=articles&category=2&published_after=2022-01-01 164 | ``` 165 | 166 | And you wanted to preserve everything except for `group` `published_after`. You could do: 167 | 168 | ``` 169 | {% querystring discard 'group' 'published_after' %} 170 | ``` 171 | 172 | Which would result in the following output: 173 | 174 | ``` 175 | ?q=keywords&group=articles 176 | ``` 177 | 178 | You can combine `discard` with any number of modifications too. Just be sure to keep the `discard` keyword and related parameter names as the left-most parameters, like so: 179 | 180 | ``` 181 | {% querystring discard 'published_after' group="group_value" clear_session="true" %} 182 | ``` 183 | 184 | ### Using template variables for parameter names 185 | 186 | Unlike a lot of custom template tags, `{% querystring %}` supports the use of template variables in keys as well as values. For example, if the tag was being used to generate pagination links, and `page_param_name` and `page_num` were variables available in the template, you could use them both like so: 187 | 188 | ``` 189 | {% querystring page_param_name=page_num %} 190 | ``` 191 | 192 | ### Supported value types 193 | 194 | Values can be strings, booleans, integers, dates, datetimes, Django model instances, or iterables of either of these values. 195 | 196 | When encountering a Django model instance, `{% querystring %}` will automatically take the `pk` value from it, and use that to modify the querystring. To use a different field value, you can use the tag's `model_value_field` option (see further down for details). Alternatively, you can add a `querystring_value_field` attribute to your model class. For example: 197 | 198 | ```python 199 | class Category(models.Model): 200 | name = models.CharField(max_length=50) 201 | slug = models.SlugField(max_length=50, unique=True) 202 | 203 | # use 'slug' values in querystrings 204 | querystring_value_field = "slug" 205 | ``` 206 | 207 | ### Specifying multiple values 208 | 209 | As mentioned above, you can provide an iterable as a value to specify multiple values for a parameter at once. That could be a native Python type, such as a `list`, `tuple` or `set`, but could also be anything that implements the `__iter__` method to support iteration, for example, a `QuerySet`. 210 | 211 | For example, if the context contained a variable `tag_list`, which was list of strings (`['tag1', 'tag2', 'tag3']`), you can include all 212 | of those values by referencing the list value. For example: 213 | 214 | ``` 215 | {% querystring tags=tag_list %} 216 | ``` 217 | 218 | The output of the above would be: 219 | 220 | ``` 221 | "?tags=tag1&tags=tag2&tags=tag3" 222 | ``` 223 | 224 | ## Options reference 225 | 226 | ### `source_data` 227 | 228 | **Supported value types**: `QueryDict`, `dict`, `str` 229 | 230 | **Default value**: `request.GET` 231 | 232 | The tag defaults to using `request.GET` as the data source for the querystring, but the `source_data` keyword argument can be used to specify use an alternative `QueryDict`, `dict` or string value. 233 | 234 | For example, say you were using a Django form to validate query data, and only want valid data to be included. You could use the Form's `cleaned_data` to generate a querystring instead: 235 | 236 | ``` 237 | {% querystring source_data=form.cleaned_data page=2 %} 238 | ``` 239 | 240 | ### `remove_blank` 241 | 242 | **Supported value types**: `bool` 243 | 244 | **Default value**: `True` 245 | 246 | Any parameter values with a value of `None` or `""` (an empty string) are removed from the querystring default. 247 | 248 | To retain blank values, include `remove_blank=False` in your `{% querystring %}` tag. 249 | 250 | ### `remove_utm` 251 | 252 | **Supported value types**: `bool` 253 | 254 | **Default value**: `True` 255 | 256 | Parameter names starting with `"utm_"` (the format used for Google Analytics tracking parameters) are exluded from the generated querystrings by default, as it's unlikley that you'll want these to be repeated in links to other pages. 257 | 258 | To retain these parameters instead, include `remove_utm=False` in your `{% querystring %}` tag. 259 | 260 | ### `model_value_field` 261 | 262 | **Supported value types**: `str` 263 | 264 | **Default value**: `"pk"` 265 | 266 | By default, when encountering a Django model instance as a value, `{% querystring %}` will take the `pk` value from the instance to use in the querystring. If you'd like to use a different field value, you can use the `model_value_field` option to specify an alternative field. 267 | 268 | For example, if the model had a `slug` field that you were using as the public-facing identifier, you could specify that `slug` values be used in the querystring, like so: 269 | 270 | ``` 271 | {% querystring tags=tag_queryset model_field_value='slug' %} 272 | ``` 273 | 274 | ## Testing the code locally 275 | 276 | If you have a recent version of Python 3 installed, you can use a simple virtualenv to run tests locally. After cloning the repository, navigate to the project's root directory on your machine, then run the following: 277 | 278 | ### Set up the virtualenv 279 | 280 | ```console 281 | $ virtualenv venv 282 | $ source venv/bin/activate 283 | $ pip install -e '.[test]' -U 284 | ``` 285 | 286 | ### Run tests 287 | 288 | ```console 289 | $ pytest 290 | ``` 291 | 292 | ### When you're done 293 | 294 | ```console 295 | $ deactivate 296 | ``` 297 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line_length = 88 3 | target-version = ['py38'] 4 | exclude = ''' 5 | ( 6 | /( 7 | \.eggs # exclude a few common directories in the 8 | | \.git # root of the project 9 | | \.mypy_cache 10 | | \.tox 11 | | \.venv 12 | | __pycache__ 13 | | _build 14 | | build 15 | | dist 16 | | docs 17 | | venv 18 | | node_modules 19 | )/ 20 | ) 21 | ''' 22 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = querystring_tag.testapp.settings 3 | python_files = tests.py test_*.py *_tests.py 4 | testpaths = 5 | querystring_tag 6 | addopts = --tb=short 7 | -------------------------------------------------------------------------------- /querystring_tag/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "Andy Babic" 2 | __author_email__ = "andyjbabic@gmail.com" 3 | __copyright__ = "Copyright 2021 Torchbox Ltd" 4 | __version__ = "1.0.3" 5 | 6 | 7 | def get_version(): 8 | return __version__ 9 | -------------------------------------------------------------------------------- /querystring_tag/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class QuerystringTagsAppConfig(AppConfig): 5 | name = "querystring_tag" 6 | -------------------------------------------------------------------------------- /querystring_tag/constants.py: -------------------------------------------------------------------------------- 1 | from django.db.models import TextChoices 2 | 3 | 4 | class QueryParamOperator(TextChoices): 5 | ADD = "+=", "add" 6 | REMOVE = "-=", "remove" 7 | SET = "=", "set" 8 | -------------------------------------------------------------------------------- /querystring_tag/expressions.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from django.http.request import QueryDict 4 | from django.template.base import FilterExpression, VariableDoesNotExist 5 | 6 | from .constants import QueryParamOperator 7 | from .utils import normalize_value 8 | 9 | 10 | class ParamModifierExpression: 11 | """ 12 | A special 'expression' designed to modify a `QueryDict` value, based 13 | on the 'param_name' and 'value' tokens supplied. 14 | 15 | NOTE: All of the values received by `__init__()` are `FilterExpression` 16 | objects, which need to be 'resolved' to get the actual values. This 17 | happens in the `resolve()` method. 18 | """ 19 | 20 | operator = None 21 | 22 | __slots__ = ( 23 | "param_name_expression", 24 | "value_expression", 25 | "model_value_field_expression", 26 | "param_name", 27 | "value", 28 | "model_value_field", 29 | ) 30 | 31 | def __init__( 32 | self, 33 | param_name: FilterExpression, 34 | value: FilterExpression, 35 | model_value_field: Union[FilterExpression, None], 36 | ): 37 | self.param_name_expression = param_name 38 | self.value_expression = value 39 | self.model_value_field_expression = model_value_field 40 | 41 | # The following will be updated when resolve() 42 | # is called for the above FilterExpression objects 43 | self.param_name = None 44 | self.value = None 45 | self.model_value_field = None 46 | 47 | def resolve(self, context, ignore_failures: bool = False) -> None: 48 | self.resolve_model_value_field(context, ignore_failures) 49 | self.resolve_param_name(context, ignore_failures) 50 | self.resolve_value(context, ignore_failures) 51 | 52 | def resolve_param_name(self, context, ignore_failures: bool = False) -> None: 53 | """ 54 | Sets the 'self.param_name' attribute value from 55 | `self.param_name_expression`. 56 | """ 57 | expr = self.param_name_expression 58 | self.param_name = expr.resolve(context, ignore_failures) or expr.token 59 | 60 | def resolve_value(self, context, ignore_failures: bool = False) -> None: 61 | """ 62 | Sets the `self.value` attribute value from `self.value_expression`. 63 | If not `None`, the value is normalized to a list of strings to match 64 | the value format of `QueryDict`. 65 | """ 66 | expr = self.value_expression 67 | resolved = expr.resolve(context, ignore_failures) 68 | if resolved is None: 69 | return 70 | 71 | # Normalize non-null values to a lists of strings 72 | if hasattr(resolved, "__iter__") and not isinstance(resolved, (str, bytes)): 73 | self.value = [normalize_value(v, self.model_value_field) for v in resolved] 74 | else: 75 | self.value = [normalize_value(resolved, self.model_value_field)] 76 | 77 | def resolve_model_value_field(self, context, ignore_failures: bool = False) -> None: 78 | """ 79 | Sets the `self.model_value_field` attribute value from 80 | `self.model_value_field_expression`. 81 | """ 82 | expr = self.model_value_field_expression 83 | if not expr: 84 | return 85 | self.model_value_field = expr.resolve(context, ignore_failures) 86 | 87 | def apply(self, querydict: QueryDict) -> None: 88 | """ 89 | Uses the resolved `self.param_name` and `self.value` values 90 | to modify the supplied QueryDict. 91 | """ 92 | raise NotImplementedError 93 | 94 | 95 | class SetValueExpression(ParamModifierExpression): 96 | operator = QueryParamOperator.SET 97 | 98 | def apply(self, querydict: QueryDict) -> None: 99 | if self.value is None: 100 | try: 101 | del querydict[self.param_name] 102 | except KeyError: 103 | pass 104 | else: 105 | querydict.setlist(self.param_name, self.value) 106 | 107 | 108 | class AddValueExpression(ParamModifierExpression): 109 | operator = QueryParamOperator.ADD 110 | 111 | def apply(self, querydict: QueryDict) -> None: 112 | current_values = set(querydict.getlist(self.param_name, ())) 113 | for val in self.value: 114 | if val not in current_values: 115 | querydict.appendlist(self.param_name, val) 116 | 117 | 118 | class RemoveValueExpression(ParamModifierExpression): 119 | operator = QueryParamOperator.REMOVE 120 | 121 | def apply(self, querydict: QueryDict) -> None: 122 | current_values = set(querydict.getlist(self.param_name, ())) 123 | querydict.setlist( 124 | self.param_name, [v for v in current_values if v not in self.value] 125 | ) 126 | 127 | 128 | PARAM_MODIFIER_EXPRESSIONS = { 129 | klass.operator: klass for klass in ParamModifierExpression.__subclasses__() 130 | } 131 | -------------------------------------------------------------------------------- /querystring_tag/parse_utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Iterable, List, Tuple 3 | 4 | from django.template.base import FilterExpression 5 | 6 | from .constants import QueryParamOperator 7 | 8 | # Regex pattern for recognising keyword arguments with '-=' and '+=' 9 | # operators in addition to the usual '=' 10 | KWARG_PATTERN = re.compile( 11 | r"(?P[^-+=\s]+)\s*(?P\-=|\+=|=)\s*(?P\S+)" 12 | ) 13 | 14 | 15 | def normalize_bits(bits: List[str]) -> List[str]: 16 | """ 17 | Further splits the list of strings returned by `token.split_contents()` 18 | into separate key, operator and value components without any surrounding 19 | white-space. This allows querystring_tag to better support varied spacing 20 | between option names and values. For example, these variations are all 21 | eqivalent to ["param", "+=", ""]: 22 | 23 | * param+='' 24 | * param += '' 25 | * param+= '' 26 | * param +='' 27 | """ 28 | return_value = [] 29 | 30 | for bit in bits: 31 | if bit in QueryParamOperator.values: 32 | return_value.append(bit) 33 | continue 34 | 35 | match = KWARG_PATTERN.match(bit) 36 | if match: 37 | return_value.extend( 38 | [match.group("key"), match.group("operator"), match.group("value")] 39 | ) 40 | continue 41 | 42 | separated_from_operator = False 43 | for operator in QueryParamOperator.values: 44 | operator_length = len(operator) 45 | if bit.startswith(operator): 46 | return_value.extend((operator, bit[operator_length:])) 47 | separated_from_operator = True 48 | break 49 | elif bit.endswith(operator): 50 | return_value.extend((bit[:-operator_length], operator)) 51 | separated_from_operator = True 52 | break 53 | 54 | if not separated_from_operator: 55 | return_value.append(bit) 56 | 57 | return return_value 58 | 59 | 60 | def extract_param_names(parser, bits: List[str]) -> Iterable[FilterExpression]: 61 | """ 62 | Return ``FilterExpression`` objects that represent the 'parameter names' 63 | that following the opening 'only' or 'remove' keywords. 64 | """ 65 | for i, bit in enumerate(bits): 66 | try: 67 | next_bit = bits[i + 1] 68 | if next_bit in QueryParamOperator.values: 69 | # param names are exhausted 70 | break 71 | except IndexError: 72 | pass 73 | 74 | yield parser.compile_filter(bit) 75 | 76 | 77 | def extract_kwarg_groups( 78 | parser, bits: List[str] 79 | ) -> Iterable[Tuple[FilterExpression, str, FilterExpression]]: 80 | """ 81 | Returns tuples representing each of the key/operator/value 82 | triples used by developers in a {% querystring %} tag. 83 | """ 84 | current_group = [] 85 | for i, bit in enumerate(bits): 86 | try: 87 | next_bit = bits[i + 1] 88 | if next_bit in QueryParamOperator.values: 89 | # this bit should be a new 'param name', so return 90 | # the current group and start a new one 91 | if current_group: 92 | yield tuple(current_group) 93 | current_group.clear() 94 | except IndexError: 95 | pass 96 | 97 | if bit in QueryParamOperator.values: 98 | current_group.append(bit) 99 | else: 100 | current_group.append(parser.compile_filter(bit)) 101 | 102 | if current_group: 103 | yield tuple(current_group) 104 | -------------------------------------------------------------------------------- /querystring_tag/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ababic/django-querystring-tag/bab1a840069fcb7b1c71adbf45d4fd6e48a91b0f/querystring_tag/templatetags/__init__.py -------------------------------------------------------------------------------- /querystring_tag/templatetags/querystring_tag.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, Union 2 | 3 | from django import template 4 | from django.http.request import QueryDict 5 | from django.template import TemplateSyntaxError 6 | from django.template.base import FilterExpression, Node 7 | from django.utils.safestring import mark_safe 8 | 9 | from ..expressions import PARAM_MODIFIER_EXPRESSIONS, ParamModifierExpression 10 | from ..parse_utils import extract_kwarg_groups, extract_param_names, normalize_bits 11 | from ..utils import normalize_value 12 | 13 | register = template.Library() 14 | 15 | 16 | @register.tag() 17 | def querystring(parser, token): 18 | """ 19 | The {% querystring %} template tag. The responsibility of this function is 20 | really just to parse the options values, and pass things on to 21 | `QuerystringTagNode.from_bits()` , which does all of the heavy lifting. 22 | """ 23 | 24 | # break token into individual key, operator and value strings 25 | bits = normalize_bits(token.split_contents()) 26 | 27 | return QuerystringTagNode.from_bits(bits, parser) 28 | 29 | 30 | class QuerystringTagNode(Node): 31 | @classmethod 32 | def from_bits(cls, bits: List[str], parser) -> "QuerystringTagNode": 33 | """ 34 | Returns a ``QuerystringTagNode`` instance, initialised from 35 | the `bits` extracted from a specific usage of {% querystring %} 36 | in a template. 37 | """ 38 | kwargs = cls.init_kwargs_from_bits(bits, parser) 39 | return cls(**kwargs) 40 | 41 | @classmethod 42 | def init_kwargs_from_bits(cls, bits: List[str], parser) -> dict: 43 | """ 44 | Converts the `bits` extracted from a specific usage of {% querystring %} 45 | into a dict of keyword arguments that can be used to to create a 46 | ``QuerystringTagNode`` instance. 47 | """ 48 | kwargs = {} 49 | 50 | # drop the initial "querystring" bit 51 | if bits and bits[0] == "querystring": 52 | bits.pop(0) 53 | 54 | if not bits: 55 | return kwargs 56 | 57 | # if the "as" keyword has been used to parameterize the result, 58 | # capture the target variable name and remove the items from `bits`. 59 | if "as" in bits: 60 | if len(bits) < 2 or bits[-2] != "as": 61 | raise TemplateSyntaxError( 62 | "When using the 'as' option, it must be used at the end of tag, with " 63 | "a single 'variable name' value included after the 'as' keyword." 64 | ) 65 | kwargs["target_variable_name"] = bits[-1].strip("'").strip('"') 66 | bits = bits[:-2] 67 | 68 | # if the 'only' or 'discard' options are used, identify all of the 69 | # 'parameter name' arguments that follow it, and remove them from `bits` 70 | if bits and bits[0] in ("only", "discard"): 71 | params = tuple(extract_param_names(parser, bits[1:])) 72 | if bits[0] == "only": 73 | kwargs["only"] = params 74 | else: 75 | kwargs["discard"] = params 76 | start_index = len(params) + 1 77 | bits = bits[start_index:] 78 | 79 | # the remaining bits should be keyword arguments, so we group them 80 | # into (key, operator, value) tuples 81 | model_value_field = None 82 | param_modifier_groups = [] 83 | for group in extract_kwarg_groups(parser, bits): 84 | # variabalize known option values 85 | if group[0].token == "remove_blank": 86 | kwargs["remove_blank"] = group[2] 87 | elif group[0].token == "remove_utm": 88 | kwargs["remove_utm"] = group[2] 89 | elif group[0].token == "source_data": 90 | kwargs["source_data"] = group[2] 91 | elif group[0].token == "model_value_field": 92 | model_value_field = group[2] 93 | kwargs["model_value_field"] = group[2] 94 | elif group[1] in PARAM_MODIFIER_EXPRESSIONS: 95 | # these will be dealt with below, once we have all option values 96 | param_modifier_groups.append(group) 97 | 98 | # convert special (key, operator, value) tuples to ParamModifierExpression 99 | # objects, which are capabile of modify the source QueryDict 100 | param_modifiers = [] 101 | for group in param_modifier_groups: 102 | expression_class = PARAM_MODIFIER_EXPRESSIONS.get(group[1]) 103 | param_modifiers.append( 104 | expression_class( 105 | param_name=group[0], 106 | value=group[2], 107 | model_value_field=model_value_field, 108 | ) 109 | ) 110 | kwargs["param_modifiers"] = param_modifiers 111 | 112 | return kwargs 113 | 114 | def __init__( 115 | self, 116 | *, 117 | source_data: Optional[Union[str, Dict[str, Any], QueryDict]] = None, 118 | only: Optional[List[FilterExpression]] = None, 119 | discard: Optional[List[FilterExpression]] = None, 120 | param_modifiers: Optional[List[ParamModifierExpression]] = None, 121 | model_value_field: Optional[FilterExpression] = None, 122 | remove_blank: Union[bool, FilterExpression] = True, 123 | remove_utm: Union[bool, FilterExpression] = True, 124 | target_variable_name: Optional[str] = None, 125 | ): 126 | self.source_data = source_data 127 | # parameters for the 'only' or 'discard' options 128 | self.only = only or () 129 | self.discard = discard or () 130 | # modifiers 131 | self.param_modifiers = param_modifiers or () 132 | # other options 133 | self.remove_blank = remove_blank 134 | self.remove_utm = remove_utm 135 | self.model_value_field = model_value_field 136 | # Set when 'as' is used to variabalize the value 137 | self.target_variable_name = target_variable_name 138 | 139 | def get_resolved_arguments(self, context): 140 | only = [var.resolve(context) for var in self.only] 141 | discard = [var.resolve(context) for var in self.discard] 142 | for item in self.param_modifiers: 143 | item.resolve(context) 144 | return only, discard, self.param_modifiers 145 | 146 | def get_base_querydict(self, context): 147 | if self.source_data is None: 148 | if "request" in context: 149 | return context["request"].GET.copy() 150 | return QueryDict("", mutable=True) 151 | try: 152 | source_data = self.source_data.resolve(context) 153 | except AttributeError: 154 | source_data = self.source_data 155 | if isinstance(source_data, QueryDict): 156 | return source_data.copy() 157 | if isinstance(source_data, dict): 158 | source = QueryDict("", mutable=True) 159 | if hasattr(self.model_value_field, "resolve"): 160 | model_value_field = self.model_value_field.resolve(context) 161 | else: 162 | model_value_field = None 163 | for key, value in source_data.items(): 164 | if hasattr(value, "__iter__") and not isinstance(value, (str, bytes)): 165 | source.setlist( 166 | key, 167 | (normalize_value(v, model_value_field) for v in value), 168 | ) 169 | else: 170 | source.setlist(key, [normalize_value(value)]) 171 | return source 172 | if isinstance(source_data, str): 173 | return QueryDict(source_data, mutable=True) 174 | # TODO: Fail more loudly when source_data value not supported 175 | return QueryDict("", mutable=True) 176 | 177 | @staticmethod 178 | def clean_querydict( 179 | querydict: QueryDict, remove_blank: bool = True, remove_utm: bool = True 180 | ) -> None: 181 | values_to_remove = {None} 182 | if remove_blank: 183 | values_to_remove.add("") 184 | 185 | for key, values in tuple(querydict.lists()): 186 | if remove_utm and key.lower().startswith("utm_"): 187 | del querydict[key] 188 | continue 189 | 190 | cleaned_values = [v for v in values if v not in values_to_remove] 191 | if cleaned_values: 192 | querydict.setlist(key, sorted(cleaned_values)) 193 | else: 194 | del querydict[key] 195 | 196 | def get_querydict(self, context) -> QueryDict: 197 | querydict = self.get_base_querydict(context) 198 | only, discard, param_modifiers = self.get_resolved_arguments(context) 199 | 200 | if only: 201 | remove_keys = (k for k in tuple(querydict.keys()) if k not in only) 202 | elif discard: 203 | remove_keys = discard 204 | else: 205 | remove_keys = () 206 | 207 | for key in remove_keys: 208 | try: 209 | del querydict[key] 210 | except KeyError: 211 | pass 212 | 213 | # Modify according to supplied kwargs 214 | for item in param_modifiers: 215 | item.apply(querydict) 216 | 217 | # Remove null/blank values and utm params 218 | remove_blank = self.remove_blank 219 | if hasattr(remove_blank, "resolve"): 220 | remove_blank = remove_blank.resolve(context) 221 | remove_utm = self.remove_utm 222 | if hasattr(remove_utm, "resolve"): 223 | remove_utm = remove_utm.resolve(context) 224 | self.clean_querydict(querydict, remove_blank, remove_utm) 225 | 226 | return querydict 227 | 228 | def get_querystring(self, context) -> str: 229 | querydict = self.get_querydict(context) 230 | return mark_safe("?" + querydict.urlencode()) 231 | 232 | def render(self, context): 233 | output = self.get_querystring(context) 234 | if self.target_variable_name is not None: 235 | context[self.target_variable_name] = output 236 | return "" 237 | return output 238 | -------------------------------------------------------------------------------- /querystring_tag/templatetags/tests.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import Any, Dict, Optional, Union 3 | 4 | from django.contrib.auth.models import User 5 | from django.http.request import QueryDict 6 | from django.template import Context, Template, TemplateSyntaxError 7 | from django.test import RequestFactory, SimpleTestCase 8 | 9 | # This is applied as GET data for requests created by render_tag() 10 | REQUEST_GET = {"foo": ["a", "b", "c"], "bar": [1, 2, 3], "baz": "single-value"} 11 | 12 | # When unmodified, the above GET data should produce this querystring 13 | UNMODIFIED_QUERYSTRING = "?foo=a&foo=b&foo=c&bar=1&bar=2&bar=3&baz=single-value" 14 | 15 | 16 | class TestQuerystringTag(SimpleTestCase): 17 | maxDiff = None 18 | 19 | @classmethod 20 | def setUpClass(cls): 21 | super().setUpClass() 22 | cls.request_factory = RequestFactory() 23 | 24 | @classmethod 25 | def render_tag( 26 | cls, 27 | *options: str, 28 | source: Union[str, Dict[str, Any], QueryDict] = None, 29 | add_to_template: Optional[str] = None, 30 | include_request_in_context: Optional[bool] = True, 31 | ) -> str: 32 | context_data = { 33 | "foo_param_name": "foo", 34 | "bar_param_name": "bar", 35 | "baz_param_name": "baz", 36 | "new_param_name": "newparam", 37 | "one": 1, 38 | "two": 2, 39 | "three": 3, 40 | "four": 4, 41 | "numbers": [1, 2, 3, 4], 42 | "start_of_year": date(2022, 1, 1), 43 | "letter_a": "a", 44 | "letter_b": "b", 45 | "letter_c": "c", 46 | "letter_d": "d", 47 | "letters": ["a", "b", "c", "d"], 48 | "user": User(pk=1, username="user-one"), 49 | "querydict": QueryDict("foo=1&foo=2&bar=baz", mutable=True), 50 | "dictionary": {"foo": ["1", "2"], "bar": "baz"}, 51 | } 52 | if include_request_in_context: 53 | request = cls.request_factory.get("/", data=REQUEST_GET) 54 | context_data["request"] = request 55 | 56 | tag_options = " ".join(options) 57 | if source is not None: 58 | context_data["source"] = source 59 | tag_options += " source_data=source" 60 | 61 | template_string = "{% querystring " + tag_options + " %}" 62 | if add_to_template: 63 | template_string += add_to_template 64 | template = Template(template_string) 65 | 66 | return template.render(Context(context_data)) 67 | 68 | def test_uses_request_get_as_data_source_by_default(self): 69 | result = self.render_tag() 70 | self.assertEqual(result, UNMODIFIED_QUERYSTRING) 71 | 72 | def test_creates_blank_data_source_if_request_is_unavailable(self): 73 | result = self.render_tag(include_request_in_context=False) 74 | self.assertEqual(result, "?") 75 | 76 | def test_as(self): 77 | """ 78 | NOTE: This is a simple test with no modifications. Compatibility with 79 | 'discard', 'only' and general modifications is covered by: 80 | - test_discard_with_modifications() 81 | - test_only_with_modifications() 82 | """ 83 | options = "as some_var" 84 | 85 | # When 'as' is specified, the template tag alone should not render anything 86 | result = self.render_tag(options) 87 | self.assertEqual(result, "") 88 | 89 | # If we render the 'as' target variable to the template, we should see 90 | # the output 91 | result = self.render_tag(options, add_to_template="{{ some_var }}") 92 | self.assertEqual(result, UNMODIFIED_QUERYSTRING) 93 | 94 | def test_as_requires_target_variable_name(self): 95 | with self.assertRaises(TemplateSyntaxError): 96 | self.render_tag("as") 97 | 98 | def test_add_new_param_with_string(self): 99 | result = self.render_tag("newparam='new'") 100 | self.assertEqual(result, UNMODIFIED_QUERYSTRING + "&newparam=new") 101 | 102 | def test_add_new_param_with_key_variable_substitution(self): 103 | result = self.render_tag("new_param_name='new'") 104 | self.assertEqual(result, UNMODIFIED_QUERYSTRING + "&newparam=new") 105 | 106 | def test_add_new_param_with_value_variable_substitution(self): 107 | result = self.render_tag("newparam=two") 108 | self.assertEqual(result, UNMODIFIED_QUERYSTRING + "&newparam=2") 109 | 110 | def test_add_new_param_with_value_list(self): 111 | source = "" 112 | result = self.render_tag("foo=letters", source=source) 113 | self.assertEqual(result, "?foo=a&foo=b&foo=c&foo=d") 114 | 115 | def test_add_new_param_with_model_object(self): 116 | source = "" 117 | result = self.render_tag("foo=user", source=source) 118 | self.assertEqual(result, "?foo=1") 119 | 120 | def test_add_new_param_with_model_value_field(self): 121 | source = "" 122 | options = [ 123 | "foo=user", 124 | "model_value_field='username'", 125 | ] 126 | result = self.render_tag(*options, source=source) 127 | self.assertEqual(result, "?foo=user-one") 128 | 129 | def test_add_new_param_with_non_existent_model_value_field_falls_back_to_pk(self): 130 | source = "" 131 | options = [ 132 | "foo=user", 133 | "model_value_field='secret_key'", 134 | ] 135 | result = self.render_tag(*options, source=source) 136 | self.assertEqual(result, "?foo=1") 137 | 138 | def test_replace_with_string(self): 139 | result = self.render_tag("foo='foo'") 140 | self.assertEqual(result, "?foo=foo&bar=1&bar=2&bar=3&baz=single-value") 141 | 142 | def test_replace_with_key_variable_substitution(self): 143 | result = self.render_tag("foo_param_name='foo'") 144 | self.assertEqual(result, "?foo=foo&bar=1&bar=2&bar=3&baz=single-value") 145 | 146 | def test_replace_with_value_variable_substitution(self): 147 | result = self.render_tag("foo=one") 148 | self.assertEqual(result, "?foo=1&bar=1&bar=2&bar=3&baz=single-value") 149 | 150 | def test_replace_with_key_and_value_variable_substitution(self): 151 | result = self.render_tag("foo_param_name=one") 152 | self.assertEqual(result, "?foo=1&bar=1&bar=2&bar=3&baz=single-value") 153 | 154 | def test_replace_with_value_list(self): 155 | source = "foo=bar" 156 | result = self.render_tag("foo=letters", source=source) 157 | self.assertEqual(result, "?foo=a&foo=b&foo=c&foo=d") 158 | 159 | def test_replace_with_none_removes_parameter(self): 160 | result = self.render_tag("foo=None bar=None") 161 | self.assertEqual(result, "?baz=single-value") 162 | 163 | def test_add_with_string(self): 164 | result = self.render_tag("foo+='d'") 165 | self.assertEqual( 166 | result, "?foo=a&foo=b&foo=c&foo=d&bar=1&bar=2&bar=3&baz=single-value" 167 | ) 168 | 169 | def test_add_with_key_variable_substitution(self): 170 | result = self.render_tag("foo_param_name+='d'") 171 | self.assertEqual( 172 | result, "?foo=a&foo=b&foo=c&foo=d&bar=1&bar=2&bar=3&baz=single-value" 173 | ) 174 | 175 | def test_add_with_value_variable_substitution(self): 176 | result = self.render_tag("foo+=letter_d") 177 | self.assertEqual( 178 | result, "?foo=a&foo=b&foo=c&foo=d&bar=1&bar=2&bar=3&baz=single-value" 179 | ) 180 | 181 | def test_add_with_date(self): 182 | source = "foo=bar" 183 | result = self.render_tag("foo+=start_of_year", source=source) 184 | self.assertEqual(result, "?foo=2022-01-01&foo=bar") 185 | 186 | def test_add_with_value_list(self): 187 | source = dict(foo=["x", "y", "z"]) 188 | result = self.render_tag("foo+=letters", source=source) 189 | self.assertEqual(result, "?foo=a&foo=b&foo=c&foo=d&foo=x&foo=y&foo=z") 190 | 191 | def test_add_with_model_object(self): 192 | source = "bar=2" 193 | result = self.render_tag("bar+=user", source=source) 194 | self.assertEqual(result, "?bar=1&bar=2") 195 | 196 | def test_add_with_model_value_field(self): 197 | source = "foo=user-two" 198 | options = [ 199 | "foo+=user", 200 | "model_value_field='username'", 201 | ] 202 | result = self.render_tag(*options, source=source) 203 | self.assertEqual(result, "?foo=user-one&foo=user-two") 204 | 205 | def test_add_with_non_existent_model_value_field_falls_back_to_pk(self): 206 | source = "foo=2" 207 | options = [ 208 | "foo+=user", 209 | "model_value_field='secret_key'", 210 | ] 211 | result = self.render_tag(*options, source=source) 212 | self.assertEqual(result, "?foo=1&foo=2") 213 | 214 | def test_add_with_key_and_value_variable_substitution(self): 215 | result = self.render_tag("foo_param_name+=letter_d") 216 | self.assertEqual( 217 | result, "?foo=a&foo=b&foo=c&foo=d&bar=1&bar=2&bar=3&baz=single-value" 218 | ) 219 | 220 | def test_add_with_mixed_option_spacing(self): 221 | source = "" 222 | options = [ 223 | # add '1' to 'bar' (consistant whitespace) 224 | "bar += 1", 225 | # add '2' to 'bar' (no whitespace) 226 | "bar+='2'", 227 | # add '3' to 'bar' (whitespace on right side of operator only) 228 | "bar+= 3", 229 | # add '4' to 'bar' (whitespace on left side of operator only) 230 | "bar +='4'", 231 | ] 232 | result = self.render_tag(*options, source=source) 233 | self.assertEqual(result, "?bar=1&bar=2&bar=3&bar=4") 234 | 235 | def test_add_with_mixed_option_spacing_and_variable_substitution(self): 236 | source = "bar=5" 237 | options = [ 238 | # add '1' to 'bar' (consistant whitespace) 239 | "bar_param_name += one", 240 | # add '2' to 'bar' (no whitespace) 241 | "bar_param_name+=two", 242 | # add '3' to 'bar' (whitespace on right side of operator only) 243 | "bar_param_name+= three", 244 | # add '4' to 'bar' (whitespace on left side of operator only) 245 | "bar_param_name +=four", 246 | ] 247 | result = self.render_tag(*options, source=source) 248 | self.assertEqual(result, "?bar=1&bar=2&bar=3&bar=4&bar=5") 249 | 250 | def test_remove_with_string(self): 251 | result = self.render_tag("bar-='1'") 252 | self.assertEqual(result, "?foo=a&foo=b&foo=c&bar=2&bar=3&baz=single-value") 253 | 254 | def test_remove_with_key_variable_substitution(self): 255 | result = self.render_tag("bar_param_name-='1'") 256 | self.assertEqual(result, "?foo=a&foo=b&foo=c&bar=2&bar=3&baz=single-value") 257 | 258 | def test_remove_with_value_variable_substitution(self): 259 | result = self.render_tag("bar-=one") 260 | self.assertEqual(result, "?foo=a&foo=b&foo=c&bar=2&bar=3&baz=single-value") 261 | 262 | def test_remove_with_value_list(self): 263 | source = dict(bar=[1, 2, 3, 8, 9, 10]) 264 | result = self.render_tag("bar-=numbers", source=source) 265 | self.assertEqual(result, "?bar=10&bar=8&bar=9") 266 | 267 | def test_remove_with_date(self): 268 | source = dict(foo=["bar", "2022-01-01"]) 269 | result = self.render_tag("foo-=start_of_year", source=source) 270 | self.assertEqual(result, "?foo=bar") 271 | 272 | def test_remove_with_model_object(self): 273 | source = dict(foo=[1, 2]) 274 | result = self.render_tag("foo-=user", source=source) 275 | self.assertEqual(result, "?foo=2") 276 | 277 | def test_remove_with_model_value_field(self): 278 | source = {"foo": ["user-one", "user-two"]} 279 | options = [ 280 | "foo-=user", 281 | "model_value_field='username'", 282 | ] 283 | result = self.render_tag(*options, source=source) 284 | self.assertEqual(result, "?foo=user-two") 285 | 286 | def test_remove_with_non_existent_model_value_field_falls_back_to_pk(self): 287 | source = {"foo": [1, 2]} 288 | options = [ 289 | "foo-=user", 290 | "model_value_field='secret_key'", 291 | ] 292 | result = self.render_tag(*options, source=source) 293 | self.assertEqual(result, "?foo=2") 294 | 295 | def test_remove_with_key_and_value_variable_substitution(self): 296 | result = self.render_tag("bar_param_name-=three") 297 | self.assertEqual(result, "?foo=a&foo=b&foo=c&bar=1&bar=2&baz=single-value") 298 | 299 | def test_remove_with_mixed_spacing(self): 300 | source = {"foo": ["a", "b", "c", "d", "x"]} 301 | options = [ 302 | # remove 'a' from 'foo' (consistant whitespace) 303 | "foo -= 'a'", 304 | # remove 'b' from 'foo' (no whitespace) 305 | "foo-='b'", 306 | # remove 'c' from 'foo' (whitespace on right side of operator only) 307 | "foo-= 'c'", 308 | # remove 'd' from 'foo' (whitespace on left side of operator only) 309 | "foo -='d'", 310 | ] 311 | result = self.render_tag(*options, source=source) 312 | self.assertEqual(result, "?foo=x") 313 | 314 | def test_remove_with_mixed_spacing_and_variable_substitution(self): 315 | source = {"foo": ["a", "b", "c", "d", "x"]} 316 | options = [ 317 | # remove 'a' from 'foo' (consistant whitespace) 318 | "foo_param_name -= letter_a", 319 | # remove 'b' from 'foo' (no whitespace) 320 | "foo_param_name-=letter_b", 321 | # remove 'c' from 'foo' (whitespace on right side of operator only) 322 | "foo_param_name-= letter_c", 323 | # remove 'd' from 'foo' (whitespace on left side of operator only) 324 | "foo_param_name -=letter_d", 325 | ] 326 | result = self.render_tag(*options, source=source) 327 | self.assertEqual(result, "?foo=x") 328 | 329 | def test_discard_with_string_param_names(self): 330 | result = self.render_tag("discard 'foo' 'bar'") 331 | self.assertEqual(result, "?baz=single-value") 332 | 333 | def test_discard_with_variable_param_names(self): 334 | result = self.render_tag("discard foo_param_name bar_param_name") 335 | self.assertEqual(result, "?baz=single-value") 336 | 337 | def test_discard_with_missing_params(self): 338 | source = "foo=bar" 339 | result = self.render_tag("discard 'x' 'y'", source=source) 340 | self.assertEqual(result, "?foo=bar") 341 | 342 | def test_discard_with_modifications(self): 343 | options_without_as = "discard 'foo' bar_param_name baz=letter_a newparam='new'" 344 | options_with_as = options_without_as + " as qs" 345 | expected_querystring = "?baz=a&newparam=new" 346 | 347 | # Without 'as', the template tag should render the expected querystring 348 | self.assertEqual(self.render_tag(options_without_as), expected_querystring) 349 | 350 | # With 'as', the template tag alone should not render anything 351 | self.assertEqual(self.render_tag(options_with_as), "") 352 | 353 | # With the 'as' target variable rendered in the template, 354 | # we should see the output 355 | self.assertEqual( 356 | self.render_tag(options_with_as, add_to_template="{{ qs }}"), 357 | expected_querystring, 358 | ) 359 | 360 | def test_only_with_string_param_names(self): 361 | result = self.render_tag("only 'foo' 'bar'") 362 | self.assertEqual(result, "?foo=a&foo=b&foo=c&bar=1&bar=2&bar=3") 363 | 364 | def test_only_with_variable_param_names(self): 365 | result = self.render_tag("only foo_param_name bar_param_name") 366 | self.assertEqual(result, "?foo=a&foo=b&foo=c&bar=1&bar=2&bar=3") 367 | 368 | result = self.render_tag("only 'foo' 'bar' baz=letter_a newparam='new'") 369 | self.assertEqual( 370 | result, "?foo=a&foo=b&foo=c&bar=1&bar=2&bar=3&baz=a&newparam=new" 371 | ) 372 | 373 | def test_only_with_missing_params(self): 374 | source = "foo=bar" 375 | result = self.render_tag("only 'x' 'y'", source=source) 376 | self.assertEqual(result, "?") 377 | 378 | def test_only_with_modifications(self): 379 | options_without_as = "only 'foo' 'bar' baz=letter_a newparam='new'" 380 | options_with_as = options_without_as + " as qs" 381 | expected_result = "?foo=a&foo=b&foo=c&bar=1&bar=2&bar=3&baz=a&newparam=new" 382 | 383 | # Without 'as', the template tag should render the expected querystring 384 | self.assertEqual(self.render_tag(options_without_as), expected_result) 385 | 386 | # With 'as', the template tag alone should not render anything 387 | self.assertEqual(self.render_tag(options_with_as), "") 388 | 389 | # With the 'as' target variable rendered in the template, 390 | # we should see the output 391 | self.assertEqual( 392 | self.render_tag(options_with_as, add_to_template="{{ qs }}"), 393 | expected_result, 394 | ) 395 | 396 | def test_with_querydict_source(self): 397 | source = QueryDict("foo=1&foo=2&bar=baz", mutable=True) 398 | result = self.render_tag("foo+=3 bar=None", source=source) 399 | self.assertEqual(result, "?foo=1&foo=2&foo=3") 400 | 401 | def test_with_unsuitable_source(self): 402 | source = ["lists", "are", "not", "supported"] 403 | result = self.render_tag("foo=1", source=source) 404 | self.assertEqual(result, "?foo=1") 405 | 406 | def test_remove_blank_default(self): 407 | source = {"foo": "", "bar": "", "baz": "not-empty"} 408 | result = self.render_tag(source=source) 409 | self.assertEqual(result, "?baz=not-empty") 410 | 411 | def test_remove_blank_true(self): 412 | source = {"foo": "", "bar": "", "baz": "not-empty"} 413 | result = self.render_tag("remove_blank=True", source=source) 414 | self.assertEqual(result, "?baz=not-empty") 415 | 416 | def test_remove_blank_false(self): 417 | source = {"foo": "", "bar": "", "baz": "not-empty"} 418 | result = self.render_tag("remove_blank=False", source=source) 419 | self.assertEqual(result, "?foo=&bar=&baz=not-empty") 420 | 421 | def test_remove_utm_default(self): 422 | source = dict( 423 | foo="bar", utm_source="email", utm_content="cta", utm_campaign="Test" 424 | ) 425 | result = self.render_tag(source=source) 426 | self.assertEqual(result, "?foo=bar") 427 | 428 | def test_remove_utm_true(self): 429 | source = dict( 430 | foo="bar", utm_source="email", utm_content="cta", utm_campaign="Test" 431 | ) 432 | result = self.render_tag("remove_utm=True", source=source) 433 | self.assertEqual(result, "?foo=bar") 434 | 435 | def test_remove_utm_false(self): 436 | source = dict( 437 | foo="bar", utm_source="email", utm_content="cta", utm_campaign="Test" 438 | ) 439 | result = self.render_tag("remove_utm=False", source=source) 440 | self.assertEqual( 441 | result, "?foo=bar&utm_source=email&utm_content=cta&utm_campaign=Test" 442 | ) 443 | -------------------------------------------------------------------------------- /querystring_tag/testapp/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 4 | 5 | ALLOWED_HOSTS = ["*"] 6 | 7 | INSTALLED_APPS = ( 8 | "django", 9 | "querystring_tag", 10 | "querystring_tag.testapp", 11 | ) 12 | 13 | ROOT_URLCONF = "querystring_tag.testapp.urls" 14 | SECRET_KEY = "fake-key" 15 | 16 | # Django i18n 17 | TIME_ZONE = "Europe/London" 18 | USE_TZ = True 19 | 20 | # Don't redirect to HTTPS in tests 21 | SECURE_SSL_REDIRECT = False 22 | 23 | # By default, Django uses a computationally difficult algorithm for passwords hashing. 24 | # We don't need such a strong algorithm in tests, so use MD5 25 | PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] 26 | 27 | TEMPLATES = [ 28 | { 29 | "BACKEND": "django.template.backends.django.DjangoTemplates", 30 | "APP_DIRS": True, 31 | "OPTIONS": { 32 | "context_processors": [ 33 | "django.template.context_processors.request", 34 | ], 35 | # Adding tag to built-ins so that it doesn't need importing in test templates 36 | "builtins": ["querystring_tag.templatetags.querystring_tag"], 37 | }, 38 | } 39 | ] 40 | -------------------------------------------------------------------------------- /querystring_tag/testapp/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] 2 | -------------------------------------------------------------------------------- /querystring_tag/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union 2 | 3 | from django.db.models import Model 4 | 5 | 6 | def normalize_value( 7 | value: Any, model_value_field: Union[None, str] = None 8 | ) -> Union[str, None]: 9 | if value is None: 10 | return None 11 | if isinstance(value, str): 12 | return value 13 | if hasattr(value, "isoformat"): 14 | return value.isoformat() 15 | if isinstance(value, Model): 16 | if model_value_field: 17 | try: 18 | return str(getattr(value, model_value_field)) 19 | except AttributeError: 20 | pass 21 | field_name = getattr(value, "querystring_value_field", "pk") 22 | return str(getattr(value, field_name)) 23 | return str(value) 24 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | name = django-querystring-tag 6 | version = attr: querystring_tag.get_version 7 | author = Andy Babic 8 | author_email = andyjbabic@gmail.com 9 | long_description = file: README.md 10 | long_description_content_type = text/markdown 11 | license = BSD-3-Clause 12 | license_file = LICENSE 13 | keywords= django querystring GET parameters pagination filter state-preserving link template tag util 14 | classifiers= 15 | Environment :: Web Environment 16 | Development Status :: 5 - Production/Stable 17 | Intended Audience :: Developers 18 | Operating System :: OS Independent 19 | License :: OSI Approved :: BSD License 20 | Natural Language :: English 21 | Programming Language :: Python 22 | Programming Language :: Python :: 3.7 23 | Programming Language :: Python :: 3.8 24 | Programming Language :: Python :: 3.9 25 | Framework :: Django 26 | Framework :: Django :: 3.1 27 | Framework :: Django :: 3.2 28 | Framework :: Django :: 4.0 29 | 30 | [options] 31 | packages = find: 32 | include_package_data = true 33 | install_requires = 34 | Django >=3.1 35 | python_requires = >=3.7 36 | 37 | [options.extras_require] 38 | lint = 39 | black ==22.3.0 40 | isort ==5.9.3 41 | flake8 ==3.9.2 42 | test = 43 | pytest-cov ==2.12.1 44 | pytest-mock ==3.6.1 45 | pytest-django ==4.4.0 46 | pytest ==6.2.4 47 | 48 | [flake8] 49 | ignore = C901,W503 50 | max-line-length = 120 51 | 52 | [isort] 53 | known_first_party=querystring_tag 54 | profile=black 55 | skip=migrations,node_modules,venv 56 | sections=STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 57 | default_section=THIRDPARTY 58 | multi_line_output=3 59 | include_trailing_comma=True 60 | force_grid_wrap=0 61 | use_parentheses=True 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | --------------------------------------------------------------------------------