├── .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 |
5 |
6 |
7 |
8 |
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 |
--------------------------------------------------------------------------------