├── .coveragerc ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGES.rst ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.rst ├── LICENSE.rst ├── MANIFEST.in ├── README.rst ├── pyproject.toml ├── setup.py ├── src └── voting │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── managers.py │ ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_vote_object_id.py │ └── __init__.py │ ├── models.py │ ├── templatetags │ ├── __init__.py │ └── voting_tags.py │ ├── urls.py │ ├── utils │ ├── __init__.py │ └── user_model.py │ └── views.py ├── tests ├── test_app │ ├── __init__.py │ └── models.py ├── test_models.py └── test_settings.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = voting 4 | omit = 5 | */migrations/*.py 6 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-voting' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: 3.8 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -U pip 26 | python -m pip install -U setuptools twine wheel 27 | 28 | - name: Build package 29 | run: | 30 | python setup.py --version 31 | python setup.py sdist --format=gztar bdist_wheel 32 | twine check dist/* 33 | 34 | - name: Upload packages to Jazzband 35 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 36 | uses: pypa/gh-action-pypi-publish@master 37 | with: 38 | user: jazzband 39 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 40 | repository_url: https://jazzband.co/projects/django-voting/upload 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: build (Python ${{ matrix.python-version }}) 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | 23 | - name: Get pip cache dir 24 | id: pip-cache 25 | run: | 26 | echo "::set-output name=dir::$(pip cache dir)" 27 | 28 | - name: Cache 29 | uses: actions/cache@v2 30 | with: 31 | path: ${{ steps.pip-cache.outputs.dir }} 32 | key: 33 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} 34 | restore-keys: | 35 | ${{ matrix.python-version }}-v1- 36 | 37 | - name: Install dependencies 38 | run: | 39 | python -m pip install --upgrade pip 40 | python -m pip install --upgrade tox tox-gh-actions 41 | 42 | - name: Tox tests 43 | run: | 44 | tox -v 45 | 46 | - name: Upload coverage 47 | uses: codecov/codecov-action@v1 48 | with: 49 | name: Python ${{ matrix.python-version }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python bytecode 2 | *.pyc 3 | *.pyo 4 | __pycache__ 5 | 6 | # setuptools-generated 7 | *.egg-info/ 8 | .eggs/ 9 | build/ 10 | dist/ 11 | 12 | # Tox working directory 13 | .tox 14 | 15 | # pytest cache directory 16 | .cache/ 17 | 18 | # Coverage 19 | .coverage 20 | coverage.xml 21 | htmlcov 22 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/psf/black 4 | rev: 24.4.2 5 | hooks: 6 | - id: black 7 | language_version: python3.8 8 | args: 9 | - "--target-version" 10 | - "py38" 11 | - repo: https://github.com/PyCQA/flake8 12 | rev: "7.1.0" 13 | hooks: 14 | - id: flake8 15 | args: ["--max-line-length", "88"] 16 | - repo: https://github.com/PyCQA/isort 17 | rev: 5.13.2 18 | hooks: 19 | - id: isort 20 | - repo: https://github.com/asottile/pyupgrade 21 | rev: v3.16.0 22 | hooks: 23 | - id: pyupgrade 24 | args: [--py38-plus] 25 | - repo: https://github.com/adamchainz/django-upgrade 26 | rev: 1.20.0 27 | hooks: 28 | - id: django-upgrade 29 | args: [--target-version, "3.2"] 30 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 1.1.0 (2022-11-04) 5 | ------------------ 6 | 7 | * Drop Python 3.6 support. 8 | * Drop Django 3.0 support. 9 | * Add Django 3.2 support. 10 | * Add Python 3.10 support. 11 | * Change ``Vote.object_id`` type to ``TextField`` to support 12 | other primary key types like ``UUIDField``. 13 | * Drop Django 3.1 support. 14 | * Add Django 4.0 support. 15 | * Drop Django 2.2 support. 16 | * Add Django 4.1 support. 17 | * Add ``get_voted_users`` to get the users voted on the given object. 18 | * Add Python 3.11 support. 19 | 20 | 1.0 (2021-03-10) 21 | ---------------- 22 | 23 | * Replaced ``voting.VERSION`` with more canonical ``voting.__version__``. 24 | 25 | * Added Django migrations. 26 | 27 | * Drop South migrations. 28 | 29 | * Add Django 2.2, 3.0, 3.1 support, drop support for all versions before that. 30 | 31 | * Move CI to GitHub Actions: https://github.com/jazzband/django-voting/actions 32 | 33 | 0.2 (2012-07-26) 34 | ---------------- 35 | 36 | * Django 1.4 compatibility (timezone support) 37 | * Added a ``time_stamp`` field to ``Vote`` model 38 | * Added South migrations. 39 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | 2 | Contributing 3 | ------------ 4 | 5 | .. image:: https://jazzband.co/static/img/jazzband.svg 6 | :target: https://jazzband.co/ 7 | :alt: Jazzband 8 | 9 | 10 | This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. 11 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | django-voting 2 | ------------- 3 | 4 | Copyright (c) 2007, Jonathan Buchanan 5 | Copyright (c) 2012, Jannis Leidel 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | this software and associated documentation files (the "Software"), to deal in 9 | the Software without restriction, including without limitation the rights to 10 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 11 | the Software, and to permit persons to whom the Software is furnished to do so, 12 | subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 19 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 20 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 21 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 22 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | CheeseRater 25 | ----------- 26 | 27 | Copyright (c) 2007, Jacob Kaplan-Moss 28 | All rights reserved. 29 | 30 | Redistribution and use in source and binary forms, with or without modification, 31 | are permitted provided that the following conditions are met: 32 | 33 | 1. Redistributions of source code must retain the above copyright notice, 34 | this list of conditions and the following disclaimer. 35 | 36 | 2. Redistributions in binary form must reproduce the above copyright 37 | notice, this list of conditions and the following disclaimer in the 38 | documentation and/or other materials provided with the distribution. 39 | 40 | 3. Neither the name of Django nor the names of its contributors may be used 41 | to endorse or promote products derived from this software without 42 | specific prior written permission. 43 | 44 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 45 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 46 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 47 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 48 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 49 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 50 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 51 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 52 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 53 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.rst LICENSE.rst README.rst tox.ini 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Django Voting 3 | ============= 4 | 5 | .. image:: https://jazzband.co/static/img/badge.svg 6 | :target: https://jazzband.co/ 7 | :alt: Jazzband 8 | 9 | .. image:: https://github.com/jazzband/django-voting/workflows/Test/badge.svg 10 | :target: https://github.com/jazzband/django-voting/actions 11 | :alt: GitHub Actions 12 | 13 | .. image:: https://codecov.io/gh/jazzband/django-voting/branch/master/graph/badge.svg 14 | :target: https://codecov.io/gh/jazzband/django-voting 15 | :alt: Code coverage 16 | 17 | .. image:: https://img.shields.io/pypi/pyversions/django-voting.svg 18 | :target: https://pypi.python.org/pypi/django-voting 19 | :alt: Supported Python versions 20 | 21 | .. image:: https://img.shields.io/pypi/djversions/django-voting.svg 22 | :target: https://pypi.org/project/django-voting 23 | :alt: Supported Django versions 24 | 25 | A generic voting application for Django projects, which allows 26 | registering of votes for any ``Model`` instance. 27 | 28 | Installation 29 | ============ 30 | 31 | 1. Install the ``django-voting`` distribution 32 | 33 | 2. Add ``voting`` to your ``INSTALLED_APPS``:: 34 | 35 | INSTALLED_APPS = [ 36 | … 37 | 'voting', 38 | … 39 | ] 40 | 41 | 3. Run ``django-admin migrate`` 42 | 43 | Usage 44 | ===== 45 | 46 | Votes 47 | ----- 48 | 49 | Votes are represented by the ``Vote`` model, which lives in the 50 | ``voting.models`` module. 51 | 52 | Votes are recorded using the ``record_vote`` helper function:: 53 | 54 | >>> from django.contrib.auth.models import User 55 | >>> from shop.apps.products.models import Widget 56 | >>> from voting.models import Vote 57 | >>> user = User.objects.get(pk=1) 58 | >>> widget = Widget.objects.get(pk=1) 59 | >>> Vote.objects.record_vote(widget, user, +1) 60 | 61 | The score for an object can be retrieved using the ``get_score`` 62 | helper function:: 63 | 64 | >>> Vote.objects.get_score(widget) 65 | {'score': 1, 'num_votes': 1} 66 | 67 | If the same user makes another vote on the same object, their vote 68 | is either modified or deleted, as appropriate:: 69 | 70 | >>> Vote.objects.record_vote(widget, user, -1) 71 | >>> Vote.objects.get_score(widget) 72 | {'score': -1, 'num_votes': 1} 73 | >>> Vote.objects.record_vote(widget, user, 0) 74 | >>> Vote.objects.get_score(widget) 75 | {'score': 0, 'num_votes': 0} 76 | 77 | Fields 78 | ~~~~~~ 79 | 80 | ``Vote`` objects have the following fields: 81 | 82 | * ``user`` -- The user who made the vote. Users are represented by 83 | the ``django.contrib.auth.models.User`` model. 84 | * ``content_type`` -- The ContentType of the object voted on. 85 | * ``object_id`` -- The id of the object voted on. 86 | * ``object`` -- The object voted on. 87 | * ``vote`` -- The vote which was made: ``+1`` or ``-1``. 88 | 89 | Methods 90 | ~~~~~~~ 91 | 92 | ``Vote`` objects have the following custom methods: 93 | 94 | * ``is_upvote`` -- Returns ``True`` if ``vote`` is ``+1``. 95 | 96 | * ``is_downvote`` -- Returns ``True`` if ``vote`` is ``-1``. 97 | 98 | Manager functions 99 | ~~~~~~~~~~~~~~~~~ 100 | 101 | The ``Vote`` model has a custom manager that has the following helper 102 | functions: 103 | 104 | * ``record_vote(obj, user, vote)`` -- Record a user's vote on a 105 | given object. Only allows a given user to vote once on any given 106 | object, but the vote may be changed. 107 | 108 | ``vote`` must be one of ``1`` (up vote), ``-1`` (down vote) or 109 | ``0`` (remove vote). 110 | 111 | * ``get_score(obj)`` -- Gets the total score for ``obj`` and the 112 | total number of votes it's received. 113 | 114 | Returns a dictionary with ``score`` and ``num_votes`` keys. 115 | 116 | * ``get_scores_in_bulk(objects)`` -- Gets score and vote count 117 | details for all the given objects. Score details consist of a 118 | dictionary which has ``score`` and ``num_vote`` keys. 119 | 120 | Returns a dictionary mapping object ids to score details. 121 | 122 | * ``get_top(Model, limit=10, reversed=False)`` -- Gets the top 123 | ``limit`` scored objects for a given model. 124 | 125 | If ``reversed`` is ``True``, the bottom ``limit`` scored objects 126 | are retrieved instead. 127 | 128 | Yields ``(object, score)`` tuples. 129 | 130 | * ``get_bottom(Model, limit=10)`` -- A convenience method which 131 | calls ``get_top`` with ``reversed=True``. 132 | 133 | Gets the bottom (i.e. most negative) ``limit`` scored objects 134 | for a given model. 135 | 136 | Yields ``(object, score)`` tuples. 137 | 138 | * ``get_for_user(obj, user)`` -- Gets the vote made on the given 139 | object by the given user, or ``None`` if no matching vote 140 | exists. 141 | 142 | * ``get_for_user_in_bulk(objects, user)`` -- Gets the votes 143 | made on all the given objects by the given user. 144 | 145 | Returns a dictionary mapping object ids to votes. 146 | 147 | * ``get_voted_users(object)`` -- Gets all users 148 | voted on the given object. 149 | 150 | Returns a list of objects contains user ids. 151 | 152 | Generic Views 153 | ------------- 154 | 155 | The ``voting.views`` module contains views to handle a couple of 156 | common cases: displaying a page to confirm a vote when it is requested 157 | via ``GET`` and making the vote itself via ``POST``, or voting via 158 | XMLHttpRequest ``POST``. 159 | 160 | The following sample URLconf demonstrates using a generic view for 161 | voting on a model, allowing for regular voting and XMLHttpRequest 162 | voting at the same URL:: 163 | 164 | from django.urls import re_path 165 | from voting.views import vote_on_object 166 | from shop.apps.products.models import Widget 167 | 168 | widget_kwargs = { 169 | 'model': Widget, 170 | 'template_object_name': 'widget', 171 | 'allow_xmlhttprequest': True, 172 | } 173 | 174 | urlpatterns = [ 175 | re_path( 176 | r"^widgets/(?P\d+)/(?Pup|down|clear)vote/?$", 177 | vote_on_object, 178 | kwargs=widget_kwargs, 179 | ), 180 | ] 181 | 182 | ``voting.views.vote_on_object`` 183 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 184 | 185 | **Description:** 186 | 187 | A view that displays a confirmation page and votes on an object. The 188 | given object will only be voted on if the request method is ``POST``. 189 | If this view is fetched via ``GET``, it will display a confirmation 190 | page that should contain a form that ``POST``\s to the same URL. 191 | 192 | **Required arguments:** 193 | 194 | * ``model``: The Django model class of the object that will be 195 | voted on. 196 | 197 | * Either ``object_id`` or (``slug`` *and* ``slug_field``) is 198 | required. 199 | 200 | If you provide ``object_id``, it should be the value of the 201 | primary-key field for the object being voted on. 202 | 203 | Otherwise, ``slug`` should be the slug of the given object, and 204 | ``slug_field`` should be the name of the slug field in the 205 | ``QuerySet``'s model. 206 | 207 | * ``direction``: The kind of vote to be made, must be one of 208 | ``'up'``, ``'down'`` or ``'clear'``. 209 | 210 | * Either a ``post_vote_redirect`` argument defining a URL must 211 | be supplied, or a ``next`` parameter must supply a URL in the 212 | request when the vote is ``POST``\ed, or the object being voted 213 | on must define a ``get_absolute_url`` method or property. 214 | 215 | The view checks for these in the order given above. 216 | 217 | **Optional arguments:** 218 | 219 | * ``allow_xmlhttprequest``: A boolean that designates whether this 220 | view should also allow votes to be made via XMLHttpRequest. 221 | 222 | If this is ``True``, the request headers will be check for an 223 | ``HTTP_X_REQUESTED_WITH`` header which has a value of 224 | ``XMLHttpRequest``. If this header is found, processing of the 225 | current request is delegated to 226 | ``voting.views.xmlhttprequest_vote_on_object``. 227 | 228 | * ``template_name``: The full name of a template to use in 229 | rendering the page. This lets you override the default template 230 | name (see below). 231 | 232 | * ``template_loader``: The template loader to use when loading the 233 | template. By default, it's ``django.template.loader``. 234 | 235 | * ``extra_context``: A dictionary of values to add to the template 236 | context. By default, this is an empty dictionary. If a value in 237 | the dictionary is callable, the generic view will call it just 238 | before rendering the template. 239 | 240 | * ``context_processors``: A list of template-context processors to 241 | apply to the view's template. 242 | 243 | * ``template_object_name``: Designates the name of the template 244 | variable to use in the template context. By default, this is 245 | ``'object'``. 246 | 247 | **Template name:** 248 | 249 | If ``template_name`` isn't specified, this view will use the template 250 | ``/_confirm_vote.html`` by default. 251 | 252 | **Template context:** 253 | 254 | In addition to ``extra_context``, the template's context will be: 255 | 256 | * ``object``: The original object that's about to be voted on. 257 | This variable's name depends on the ``template_object_name`` 258 | parameter, which is ``'object'`` by default. If 259 | ``template_object_name`` is ``'foo'``, this variable's name will 260 | be ``foo``. 261 | 262 | * ``direction``: The argument which was given for the vote's 263 | ``direction`` (see above). 264 | 265 | ``voting.views.xmlhttprequest_vote_on_object`` 266 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 267 | 268 | **Description:** 269 | 270 | A view for use in voting on objects via XMLHttpRequest. The given 271 | object will only be voted on if the request method is ``POST``. This 272 | view will respond with JSON text instead of rendering a template or 273 | redirecting. 274 | 275 | **Required arguments:** 276 | 277 | * ``model``: The Django model class of the object that will be 278 | voted on. 279 | 280 | * Either ``object_id`` or (``slug`` *and* ``slug_field``) is 281 | required. 282 | 283 | If you provide ``object_id``, it should be the value of the 284 | primary-key field for the object being voted on. 285 | 286 | Otherwise, ``slug`` should be the slug of the given object, and 287 | ``slug_field`` should be the name of the slug field in the 288 | ``QuerySet``'s model. 289 | 290 | * ``direction``: The kind of vote to be made, must be one of 291 | ``'up'``, ``'down'`` or ``'clear'``. 292 | 293 | **JSON text context:** 294 | 295 | The context provided by the JSON text returned will be: 296 | 297 | * ``success``: ``true`` if the vote was successfully processed, 298 | ``false`` otherwise. 299 | 300 | * ``score``: an object containing a ``score`` property, which 301 | holds the object's updated score, and a ``num_votes`` property, 302 | which holds the total number of votes cast for the object. 303 | 304 | * ``error_message``: if the vote was not successfully processed, 305 | this property will contain an error message. 306 | 307 | 308 | Template tags 309 | ------------- 310 | 311 | The ``voting.templatetags.voting_tags`` module defines a number of 312 | template tags which may be used to retrieve and display voting 313 | details. 314 | 315 | Tag reference 316 | ~~~~~~~~~~~~~ 317 | 318 | score_for_object 319 | ```````````````` 320 | 321 | Retrieves the total score for an object and the number of votes 322 | it's received, storing them in a context variable which has ``score`` 323 | and ``num_votes`` properties. 324 | 325 | Example usage:: 326 | 327 | {% score_for_object widget as score %} 328 | 329 | {{ score.score }} point{{ score.score|pluralize }} 330 | after {{ score.num_votes }} vote{{ score.num_votes|pluralize }} 331 | 332 | scores_for_objects 333 | `````````````````` 334 | 335 | Retrieves the total scores and number of votes cast for a list of 336 | objects as a dictionary keyed with the objects' ids and stores it in a 337 | context variable. 338 | 339 | Example usage:: 340 | 341 | {% scores_for_objects widget_list as scores %} 342 | 343 | vote_by_user 344 | ```````````` 345 | 346 | Retrieves the ``Vote`` cast by a user on a particular object and 347 | stores it in a context variable. If the user has not voted, the 348 | context variable will be ``None``. 349 | 350 | Example usage:: 351 | 352 | {% vote_by_user user on widget as vote %} 353 | 354 | votes_by_user 355 | ````````````` 356 | 357 | Retrieves the votes cast by a user on a list of objects as a 358 | dictionary keyed with object ids and stores it in a context 359 | variable. 360 | 361 | Example usage:: 362 | 363 | {% votes_by_user user on widget_list as vote_dict %} 364 | 365 | dict_entry_for_item 366 | ``````````````````` 367 | 368 | Given an object and a dictionary keyed with object ids - as returned 369 | by the ``votes_by_user`` and ``scores_for_objects`` template tags - 370 | retrieves the value for the given object and stores it in a context 371 | variable, storing ``None`` if no value exists for the given object. 372 | 373 | Example usage:: 374 | 375 | {% dict_entry_for_item widget from vote_dict as vote %} 376 | 377 | confirm_vote_message 378 | ```````````````````` 379 | 380 | Intended for use in vote confirmatio templates, creates an appropriate 381 | message asking the user to confirm the given vote for the given object 382 | description. 383 | 384 | Example usage:: 385 | 386 | {% confirm_vote_message widget.title direction %} 387 | 388 | Filter reference 389 | ~~~~~~~~~~~~~~~~ 390 | 391 | vote_display 392 | ```````````` 393 | 394 | Given a string mapping values for up and down votes, returns one 395 | of the strings according to the given ``Vote``: 396 | 397 | ========= ===================== ============= 398 | Vote type Argument Outputs 399 | ========= ===================== ============= 400 | ``+1`` ``'Bodacious,Bogus'`` ``Bodacious`` 401 | ``-1`` ``'Bodacious,Bogus'`` ``Bogus`` 402 | ========= ===================== ============= 403 | 404 | If no string mapping is given, ``'Up'`` and ``'Down'`` will be used. 405 | 406 | Example usage:: 407 | 408 | {{ vote|vote_display:"Bodacious,Bogus" }} 409 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ["py38"] 3 | 4 | # black compatible isort 5 | [tool.isort] 6 | profile = "black" 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | setup( 4 | name="django-voting", 5 | use_scm_version={"version_scheme": "post-release"}, 6 | description="Generic voting application for Django", 7 | long_description=open("README.rst").read(), 8 | long_description_content_type="text/x-rst", 9 | author="Jonathan Buchanan", 10 | author_email="jonathan.buchanan@gmail.com", 11 | maintainer="Jannis Leidel", 12 | maintainer_email="jannis@leidel.info", 13 | url="https://github.com/pjdelport/django-voting", 14 | package_dir={"": "src"}, 15 | packages=find_packages("src"), 16 | setup_requires=[ 17 | "setuptools_scm", 18 | ], 19 | python_requires=">=3.8", 20 | install_requires=[ 21 | "Django >=3.2", 22 | ], 23 | classifiers=[ 24 | "Development Status :: 4 - Beta", 25 | "Environment :: Web Environment", 26 | "Framework :: Django", 27 | "Framework :: Django :: 3.2", 28 | "Framework :: Django :: 4.2", 29 | "Framework :: Django :: 5.0", 30 | "Intended Audience :: Developers", 31 | "License :: OSI Approved :: BSD License", 32 | "Operating System :: OS Independent", 33 | "Programming Language :: Python", 34 | "Programming Language :: Python :: 3", 35 | "Programming Language :: Python :: 3 :: Only", 36 | "Programming Language :: Python :: 3.8", 37 | "Programming Language :: Python :: 3.9", 38 | "Programming Language :: Python :: 3.10", 39 | "Programming Language :: Python :: 3.11", 40 | "Programming Language :: Python :: 3.12", 41 | "Topic :: Utilities", 42 | ], 43 | ) 44 | -------------------------------------------------------------------------------- /src/voting/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version 2 | 3 | try: 4 | __version__ = version("django-voting") 5 | except Exception: 6 | # package is not installed 7 | __version__ = None 8 | -------------------------------------------------------------------------------- /src/voting/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from voting.models import Vote 4 | 5 | admin.site.register(Vote) 6 | -------------------------------------------------------------------------------- /src/voting/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DefenderAppConfig(AppConfig): 5 | name = "voting" 6 | default_auto_field = "django.db.models.AutoField" 7 | -------------------------------------------------------------------------------- /src/voting/managers.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from django.conf import settings 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.db import models 6 | from django.db.models import Count, Sum 7 | 8 | ZERO_VOTES_ALLOWED = getattr(settings, "VOTING_ZERO_VOTES_ALLOWED", False) 9 | 10 | 11 | class VoteManager(models.Manager): 12 | def get_score(self, obj): 13 | """ 14 | Get a dictionary containing the total score for ``obj`` and 15 | the number of votes it's received. 16 | """ 17 | ctype = ContentType.objects.get_for_model(obj) 18 | result = self.filter(object_id=obj._get_pk_val(), content_type=ctype).aggregate( 19 | score=Sum("vote"), num_votes=Count("vote") 20 | ) 21 | 22 | if result["score"] is None: 23 | result["score"] = 0 24 | return result 25 | 26 | def get_scores_in_bulk(self, objects): 27 | """ 28 | Get a dictionary mapping object ids to total score and number 29 | of votes for each object. 30 | """ 31 | object_ids = [o._get_pk_val() for o in objects] 32 | if not object_ids: 33 | return {} 34 | 35 | ctype = ContentType.objects.get_for_model(objects[0]) 36 | 37 | queryset = ( 38 | self.filter( 39 | object_id__in=object_ids, 40 | content_type=ctype, 41 | ) 42 | .values( 43 | "object_id", 44 | ) 45 | .annotate(score=Sum("vote"), num_votes=Count("vote")) 46 | ) 47 | 48 | vote_dict = {} 49 | for row in queryset: 50 | vote_dict[row["object_id"]] = { 51 | "score": int(row["score"]), 52 | "num_votes": int(row["num_votes"]), 53 | } 54 | 55 | return vote_dict 56 | 57 | def record_vote(self, obj, user, vote): 58 | """ 59 | Record a user's vote on a given object. Only allows a given user 60 | to vote once, though that vote may be changed. 61 | 62 | A zero vote indicates that any existing vote should be removed. 63 | """ 64 | if vote not in (+1, 0, -1): 65 | raise ValueError("Invalid vote (must be +1/0/-1)") 66 | ctype = ContentType.objects.get_for_model(obj) 67 | try: 68 | v = self.get(user=user, content_type=ctype, object_id=obj._get_pk_val()) 69 | if vote == 0 and not ZERO_VOTES_ALLOWED: 70 | v.delete() 71 | else: 72 | v.vote = vote 73 | v.save() 74 | except models.ObjectDoesNotExist: 75 | if not ZERO_VOTES_ALLOWED and vote == 0: 76 | return 77 | self.create( 78 | user=user, content_type=ctype, object_id=obj._get_pk_val(), vote=vote 79 | ) 80 | 81 | def get_top(self, model, limit=10, reversed=False): 82 | """ 83 | Get the top N scored objects for a given model. 84 | 85 | Yields (object, score) tuples. 86 | """ 87 | ctype = ContentType.objects.get_for_model(model) 88 | results = ( 89 | self.filter(content_type=ctype) 90 | .values("object_id") 91 | .annotate(score=Sum("vote")) 92 | ) 93 | if reversed: 94 | results = results.order_by("score") 95 | else: 96 | results = results.order_by("-score") 97 | 98 | # Use in_bulk() to avoid O(limit) db hits. 99 | objects = model.objects.in_bulk([item["object_id"] for item in results[:limit]]) 100 | 101 | # Yield each object, score pair. Because of the lazy nature of generic 102 | # relations, missing objects are silently ignored. 103 | for item in results[:limit]: 104 | id, score = item["object_id"], item["score"] 105 | if not score: 106 | continue 107 | if isinstance(model._meta.pk, models.AutoField): 108 | id = int(id) 109 | elif isinstance(model._meta.pk, models.UUIDField): 110 | id = UUID(id) 111 | if id in objects: 112 | yield objects[id], int(score) 113 | 114 | def get_bottom(self, Model, limit=10): 115 | """ 116 | Get the bottom (i.e. most negative) N scored objects for a given 117 | model. 118 | 119 | Yields (object, score) tuples. 120 | """ 121 | return self.get_top(Model, limit, True) 122 | 123 | def get_for_user(self, obj, user): 124 | """ 125 | Get the vote made on the given object by the given user, or 126 | ``None`` if no matching vote exists. 127 | """ 128 | if not user.is_authenticated: 129 | return None 130 | ctype = ContentType.objects.get_for_model(obj) 131 | try: 132 | vote = self.get(content_type=ctype, object_id=obj._get_pk_val(), user=user) 133 | except models.ObjectDoesNotExist: 134 | vote = None 135 | return vote 136 | 137 | def get_for_user_in_bulk(self, objects, user): 138 | """ 139 | Get a dictionary mapping object ids to votes made by the given 140 | user on the corresponding objects. 141 | """ 142 | vote_dict = {} 143 | if len(objects) > 0: 144 | ctype = ContentType.objects.get_for_model(objects[0]) 145 | votes = list( 146 | self.filter( 147 | content_type__pk=ctype.id, 148 | object_id__in=[obj._get_pk_val() for obj in objects], 149 | user__pk=user.id, 150 | ) 151 | ) 152 | vote_dict = {vote.object_id: vote for vote in votes} 153 | return vote_dict 154 | 155 | def get_voted_users(self, obj): 156 | """ 157 | Gets all users voted on the given object. 158 | """ 159 | ctype = ContentType.objects.get_for_model(obj) 160 | return ( 161 | self.filter(content_type=ctype, object_id=obj._get_pk_val()) 162 | .order_by("user") 163 | .values("user") 164 | ) 165 | -------------------------------------------------------------------------------- /src/voting/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | import django.utils.timezone 2 | from django.conf import settings 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("contenttypes", "0001_initial"), 9 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Vote", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | primary_key=True, 20 | auto_created=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("object_id", models.PositiveIntegerField()), 26 | ("vote", models.SmallIntegerField(choices=[(1, "+1"), (-1, "-1")])), 27 | ( 28 | "time_stamp", 29 | models.DateTimeField( 30 | editable=False, default=django.utils.timezone.now 31 | ), 32 | ), 33 | ( 34 | "content_type", 35 | models.ForeignKey( 36 | to="contenttypes.ContentType", on_delete=models.CASCADE 37 | ), 38 | ), 39 | ( 40 | "user", 41 | models.ForeignKey( 42 | to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE 43 | ), 44 | ), 45 | ], 46 | options={ 47 | "db_table": "votes", 48 | }, 49 | bases=(models.Model,), 50 | ), 51 | migrations.AlterUniqueTogether( 52 | name="vote", 53 | unique_together={("user", "content_type", "object_id")}, 54 | ), 55 | ] 56 | -------------------------------------------------------------------------------- /src/voting/migrations/0002_alter_vote_object_id.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("voting", "0001_initial"), 7 | ] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="vote", 12 | name="object_id", 13 | field=models.TextField(), 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /src/voting/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-voting/5721fb664760d22ae49affb39255f6e3e186160b/src/voting/migrations/__init__.py -------------------------------------------------------------------------------- /src/voting/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.fields import GenericForeignKey 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db import models 4 | from django.utils.timezone import now 5 | 6 | from voting.managers import VoteManager 7 | from voting.utils.user_model import get_user_model_name 8 | 9 | User = get_user_model_name() 10 | 11 | SCORES = ( 12 | (+1, "+1"), 13 | (-1, "-1"), 14 | ) 15 | 16 | 17 | class Vote(models.Model): 18 | """ 19 | A vote on an object by a User. 20 | """ 21 | 22 | user = models.ForeignKey(User, on_delete=models.CASCADE) 23 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 24 | object_id = models.TextField() 25 | object = GenericForeignKey("content_type", "object_id") 26 | vote = models.SmallIntegerField(choices=SCORES) 27 | time_stamp = models.DateTimeField(editable=False, default=now) 28 | 29 | objects = VoteManager() 30 | 31 | class Meta: 32 | db_table = "votes" 33 | # One vote per user per object 34 | unique_together = (("user", "content_type", "object_id"),) 35 | 36 | def __str__(self): 37 | return f"{self.user}: {self.vote} on {self.object}" 38 | 39 | def is_upvote(self): 40 | return self.vote == 1 41 | 42 | def is_downvote(self): 43 | return self.vote == -1 44 | -------------------------------------------------------------------------------- /src/voting/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-voting/5721fb664760d22ae49affb39255f6e3e186160b/src/voting/templatetags/__init__.py -------------------------------------------------------------------------------- /src/voting/templatetags/voting_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.html import escape 3 | 4 | from voting.models import Vote 5 | 6 | register = template.Library() 7 | 8 | 9 | class ScoreForObjectNode(template.Node): 10 | def __init__(self, object, context_var): 11 | self.object = object 12 | self.context_var = context_var 13 | 14 | def render(self, context): 15 | try: 16 | object = template.Variable(self.object).resolve(context) 17 | except template.VariableDoesNotExist: 18 | return "" 19 | context[self.context_var] = Vote.objects.get_score(object) 20 | return "" 21 | 22 | 23 | class ScoresForObjectsNode(template.Node): 24 | def __init__(self, objects, context_var): 25 | self.objects = objects 26 | self.context_var = context_var 27 | 28 | def render(self, context): 29 | try: 30 | objects = template.Variable(self.object).resolve(context) 31 | except template.VariableDoesNotExist: 32 | return "" 33 | context[self.context_var] = Vote.objects.get_scores_in_bulk(objects) 34 | return "" 35 | 36 | 37 | class VoteByUserNode(template.Node): 38 | def __init__(self, user, object, context_var): 39 | self.user = user 40 | self.object = object 41 | self.context_var = context_var 42 | 43 | def render(self, context): 44 | try: 45 | user = template.Variable(self.user).resolve(context) 46 | object = template.Variable(self.object).resolve(context) 47 | except template.VariableDoesNotExist: 48 | return "" 49 | context[self.context_var] = Vote.objects.get_for_user(object, user) 50 | return "" 51 | 52 | 53 | class VotesByUserNode(template.Node): 54 | def __init__(self, user, objects, context_var): 55 | self.user = user 56 | self.objects = objects 57 | self.context_var = context_var 58 | 59 | def render(self, context): 60 | try: 61 | user = template.Variable(self.user).resolve(context) 62 | objects = template.Variable(self.objects).resolve(context) 63 | except template.VariableDoesNotExist: 64 | return "" 65 | context[self.context_var] = Vote.objects.get_for_user_in_bulk(objects, user) 66 | return "" 67 | 68 | 69 | class DictEntryForItemNode(template.Node): 70 | def __init__(self, item, dictionary, context_var): 71 | self.item = item 72 | self.dictionary = dictionary 73 | self.context_var = context_var 74 | 75 | def render(self, context): 76 | try: 77 | dictionary = template.Variable(self.dictionary).resolve(context) 78 | item = template.Variable(self.item).resolve(context) 79 | except template.VariableDoesNotExist: 80 | return "" 81 | context[self.context_var] = dictionary.get(item.id, None) 82 | return "" 83 | 84 | 85 | def do_score_for_object(parser, token): 86 | """ 87 | Retrieves the total score for an object and the number of votes 88 | it's received and stores them in a context variable which has 89 | ``score`` and ``num_votes`` properties. 90 | 91 | Example usage:: 92 | 93 | {% score_for_object widget as score %} 94 | 95 | {{ score.score }}point{{ score.score|pluralize }} 96 | after {{ score.num_votes }} vote{{ score.num_votes|pluralize }} 97 | """ 98 | bits = token.contents.split() 99 | if len(bits) != 4: 100 | raise template.TemplateSyntaxError( 101 | "'%s' tag takes exactly three arguments" % bits[0] 102 | ) 103 | if bits[2] != "as": 104 | raise template.TemplateSyntaxError( 105 | "second argument to '%s' tag must be 'as'" % bits[0] 106 | ) 107 | return ScoreForObjectNode(bits[1], bits[3]) 108 | 109 | 110 | def do_scores_for_objects(parser, token): 111 | """ 112 | Retrieves the total scores for a list of objects and the number of 113 | votes they have received and stores them in a context variable. 114 | 115 | Example usage:: 116 | 117 | {% scores_for_objects widget_list as score_dict %} 118 | """ 119 | bits = token.contents.split() 120 | if len(bits) != 4: 121 | raise template.TemplateSyntaxError( 122 | "'%s' tag takes exactly three arguments" % bits[0] 123 | ) 124 | if bits[2] != "as": 125 | raise template.TemplateSyntaxError( 126 | "second argument to '%s' tag must be 'as'" % bits[0] 127 | ) 128 | return ScoresForObjectsNode(bits[1], bits[3]) 129 | 130 | 131 | def do_vote_by_user(parser, token): 132 | """ 133 | Retrieves the ``Vote`` cast by a user on a particular object and 134 | stores it in a context variable. If the user has not voted, the 135 | context variable will be ``None``. 136 | 137 | Example usage:: 138 | 139 | {% vote_by_user user on widget as vote %} 140 | """ 141 | bits = token.contents.split() 142 | if len(bits) != 6: 143 | raise template.TemplateSyntaxError( 144 | "'%s' tag takes exactly five arguments" % bits[0] 145 | ) 146 | if bits[2] != "on": 147 | raise template.TemplateSyntaxError( 148 | "second argument to '%s' tag must be 'on'" % bits[0] 149 | ) 150 | if bits[4] != "as": 151 | raise template.TemplateSyntaxError( 152 | "fourth argument to '%s' tag must be 'as'" % bits[0] 153 | ) 154 | return VoteByUserNode(bits[1], bits[3], bits[5]) 155 | 156 | 157 | def do_votes_by_user(parser, token): 158 | """ 159 | Retrieves the votes cast by a user on a list of objects as a 160 | dictionary keyed with object ids and stores it in a context 161 | variable. 162 | 163 | Example usage:: 164 | 165 | {% votes_by_user user on widget_list as vote_dict %} 166 | """ 167 | bits = token.contents.split() 168 | if len(bits) != 6: 169 | raise template.TemplateSyntaxError( 170 | "'%s' tag takes exactly four arguments" % bits[0] 171 | ) 172 | if bits[2] != "on": 173 | raise template.TemplateSyntaxError( 174 | "second argument to '%s' tag must be 'on'" % bits[0] 175 | ) 176 | if bits[4] != "as": 177 | raise template.TemplateSyntaxError( 178 | "fourth argument to '%s' tag must be 'as'" % bits[0] 179 | ) 180 | return VotesByUserNode(bits[1], bits[3], bits[5]) 181 | 182 | 183 | def do_dict_entry_for_item(parser, token): 184 | """ 185 | Given an object and a dictionary keyed with object ids - as 186 | returned by the ``votes_by_user`` and ``scores_for_objects`` 187 | template tags - retrieves the value for the given object and 188 | stores it in a context variable, storing ``None`` if no value 189 | exists for the given object. 190 | 191 | Example usage:: 192 | 193 | {% dict_entry_for_item widget from vote_dict as vote %} 194 | """ 195 | bits = token.contents.split() 196 | if len(bits) != 6: 197 | raise template.TemplateSyntaxError( 198 | "'%s' tag takes exactly five arguments" % bits[0] 199 | ) 200 | if bits[2] != "from": 201 | raise template.TemplateSyntaxError( 202 | "second argument to '%s' tag must be 'from'" % bits[0] 203 | ) 204 | if bits[4] != "as": 205 | raise template.TemplateSyntaxError( 206 | "fourth argument to '%s' tag must be 'as'" % bits[0] 207 | ) 208 | return DictEntryForItemNode(bits[1], bits[3], bits[5]) 209 | 210 | 211 | register.tag("score_for_object", do_score_for_object) 212 | register.tag("scores_for_objects", do_scores_for_objects) 213 | register.tag("vote_by_user", do_vote_by_user) 214 | register.tag("votes_by_user", do_votes_by_user) 215 | register.tag("dict_entry_for_item", do_dict_entry_for_item) 216 | 217 | 218 | # Simple Tags 219 | 220 | 221 | def confirm_vote_message(object_description, vote_direction): 222 | """ 223 | Creates an appropriate message asking the user to confirm the given vote 224 | for the given object description. 225 | 226 | Example usage:: 227 | 228 | {% confirm_vote_message widget.title direction %} 229 | """ 230 | if vote_direction == "clear": 231 | message = "Confirm clearing your vote for %s." 232 | else: 233 | message = ( 234 | "Confirm %s vote for %%s." 235 | % vote_direction 236 | ) 237 | return message % (escape(object_description),) 238 | 239 | 240 | register.simple_tag(confirm_vote_message) 241 | 242 | # Filters 243 | 244 | 245 | def vote_display(vote, arg=None): 246 | """ 247 | Given a string mapping values for up and down votes, returns one 248 | of the strings according to the given ``Vote``: 249 | 250 | ========= ===================== ============= 251 | Vote type Argument Outputs 252 | ========= ===================== ============= 253 | ``+1`` ``"Bodacious,Bogus"`` ``Bodacious`` 254 | ``-1`` ``"Bodacious,Bogus"`` ``Bogus`` 255 | ========= ===================== ============= 256 | 257 | If no string mapping is given, "Up" and "Down" will be used. 258 | 259 | Example usage:: 260 | 261 | {{ vote|vote_display:"Bodacious,Bogus" }} 262 | """ 263 | if arg is None: 264 | arg = "Up,Down" 265 | bits = arg.split(",") 266 | if len(bits) != 2: 267 | return vote.vote # Invalid arg 268 | up, down = bits 269 | if vote.vote == 1: 270 | return up 271 | return down 272 | 273 | 274 | register.filter(vote_display) 275 | -------------------------------------------------------------------------------- /src/voting/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from .views import vote_on_object_with_lazy_model 4 | 5 | urlpatterns = [ 6 | re_path( 7 | r"^vote/(?P[\w\.-]+)/(?P\w+)/" 8 | r"(?P\d+)/(?Pup|down|clear)/$", 9 | vote_on_object_with_lazy_model, 10 | { 11 | "allow_xmlhttprequest": True, 12 | }, 13 | name="voting_vote", 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /src/voting/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-voting/5721fb664760d22ae49affb39255f6e3e186160b/src/voting/utils/__init__.py -------------------------------------------------------------------------------- /src/voting/utils/user_model.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def get_user_model_name(): 5 | """ 6 | Returns the app_label.object_name string for the user model. 7 | """ 8 | return getattr(settings, "AUTH_USER_MODEL", "auth.User") 9 | -------------------------------------------------------------------------------- /src/voting/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.apps import apps 4 | from django.contrib.auth.views import redirect_to_login 5 | from django.core.exceptions import ObjectDoesNotExist 6 | from django.http import ( 7 | Http404, 8 | HttpResponse, 9 | HttpResponseBadRequest, 10 | HttpResponseRedirect, 11 | ) 12 | from django.template import RequestContext, loader 13 | 14 | from voting.models import Vote 15 | 16 | VOTE_DIRECTIONS = (("up", 1), ("down", -1), ("clear", 0)) 17 | 18 | 19 | def vote_on_object( 20 | request, 21 | model, 22 | direction, 23 | post_vote_redirect=None, 24 | object_id=None, 25 | slug=None, 26 | slug_field=None, 27 | template_name=None, 28 | template_loader=loader, 29 | extra_context=None, 30 | context_processors=None, 31 | template_object_name="object", 32 | allow_xmlhttprequest=False, 33 | ): 34 | """ 35 | Generic object vote function. 36 | 37 | The given template will be used to confirm the vote if this view is 38 | fetched using GET; vote registration will only be performed if this 39 | view is POSTed. 40 | 41 | If ``allow_xmlhttprequest`` is ``True`` and an XMLHttpRequest is 42 | detected by examining the ``HTTP_X_REQUESTED_WITH`` header, the 43 | ``xmlhttp_vote_on_object`` view will be used to process the 44 | request - this makes it trivial to implement voting via 45 | XMLHttpRequest with a fallback for users who don't have JavaScript 46 | enabled. 47 | 48 | Templates:``/_confirm_vote.html`` 49 | Context: 50 | object 51 | The object being voted on. 52 | direction 53 | The type of vote which will be registered for the object. 54 | """ 55 | if allow_xmlhttprequest and request.is_ajax(): 56 | return xmlhttprequest_vote_on_object( 57 | request, 58 | model, 59 | direction, 60 | object_id=object_id, 61 | slug=slug, 62 | slug_field=slug_field, 63 | ) 64 | 65 | if extra_context is None: 66 | extra_context = {} 67 | if not request.user.is_authenticated: 68 | return redirect_to_login(request.path) 69 | 70 | try: 71 | vote = dict(VOTE_DIRECTIONS)[direction] 72 | except KeyError: 73 | raise AttributeError("'%s' is not a valid vote type." % direction) 74 | 75 | # Look up the object to be voted on 76 | lookup_kwargs = {} 77 | if object_id: 78 | lookup_kwargs["%s__exact" % model._meta.pk.name] = object_id 79 | elif slug and slug_field: 80 | lookup_kwargs["%s__exact" % slug_field] = slug 81 | else: 82 | raise AttributeError( 83 | "Generic vote view must be called with either " 84 | "object_id or slug and slug_field." 85 | ) 86 | try: 87 | obj = model._default_manager.get(**lookup_kwargs) 88 | except ObjectDoesNotExist: 89 | raise Http404(f"No {model._meta.app_label} found for {lookup_kwargs}.") 90 | 91 | if request.method == "POST": 92 | if post_vote_redirect is not None: 93 | next = post_vote_redirect 94 | elif "next" in request.POST: 95 | next = request.POST.get("next", "") 96 | elif hasattr(obj, "get_absolute_url"): 97 | if callable(getattr(obj, "get_absolute_url")): 98 | next = obj.get_absolute_url() 99 | else: 100 | next = obj.get_absolute_url 101 | else: 102 | raise AttributeError( 103 | "Generic vote view must be called with either " 104 | 'post_vote_redirect, a "next" parameter in ' 105 | "the request, or the object being voted on " 106 | "must define a get_absolute_url method or " 107 | "property." 108 | ) 109 | Vote.objects.record_vote(obj, request.user, vote) 110 | return HttpResponseRedirect(next) 111 | else: 112 | if not template_name: 113 | template_name = "{}/{}_confirm_vote.html".format( 114 | model._meta.app_label, 115 | model._meta.object_name.lower(), 116 | ) 117 | t = template_loader.get_template(template_name) 118 | c = RequestContext( 119 | request, 120 | { 121 | template_object_name: obj, 122 | "direction": direction, 123 | }, 124 | context_processors, 125 | ) 126 | for key, value in extra_context.items(): 127 | if callable(value): 128 | c[key] = value() 129 | else: 130 | c[key] = value 131 | response = HttpResponse(t.render(c)) 132 | return response 133 | 134 | 135 | def vote_on_object_with_lazy_model(request, app_label, model_name, *args, **kwargs): 136 | """ 137 | Generic object vote view that takes app_label and model_name instead 138 | of a model class and calls ``vote_on_object`` view. 139 | Returns HTTP 400 (Bad Request) if there is no model matching the app_label 140 | and model_name. 141 | """ 142 | model = apps.get_model(app_label, model_name) 143 | if not model: 144 | return HttpResponseBadRequest(f"Model {app_label}.{model_name} does not exist") 145 | return vote_on_object(request, model=model, *args, **kwargs) 146 | 147 | 148 | def json_error_response(error_message): 149 | return HttpResponse(json.dumps(dict(success=False, error_message=error_message))) 150 | 151 | 152 | def xmlhttprequest_vote_on_object( 153 | request, model, direction, object_id=None, slug=None, slug_field=None 154 | ): 155 | """ 156 | Generic object vote function for use via XMLHttpRequest. 157 | 158 | Properties of the resulting JSON object: 159 | success 160 | ``true`` if the vote was successfully processed, ``false`` 161 | otherwise. 162 | score 163 | The object's updated score and number of votes if the vote 164 | was successfully processed. 165 | error_message 166 | Contains an error message if the vote was not successfully 167 | processed. 168 | """ 169 | if request.method == "GET": 170 | return json_error_response("XMLHttpRequest votes can only be made using POST.") 171 | if not request.user.is_authenticated: 172 | return json_error_response("Not authenticated.") 173 | 174 | try: 175 | vote = dict(VOTE_DIRECTIONS)[direction] 176 | except KeyError: 177 | return json_error_response("'%s' is not a valid vote type." % direction) 178 | 179 | # Look up the object to be voted on 180 | lookup_kwargs = {} 181 | if object_id: 182 | lookup_kwargs["%s__exact" % model._meta.pk.name] = object_id 183 | elif slug and slug_field: 184 | lookup_kwargs["%s__exact" % slug_field] = slug 185 | else: 186 | return json_error_response( 187 | "Generic XMLHttpRequest vote view must be " 188 | "called with either object_id or slug and " 189 | "slug_field." 190 | ) 191 | try: 192 | obj = model._default_manager.get(**lookup_kwargs) 193 | except ObjectDoesNotExist: 194 | return json_error_response( 195 | f"No {model._meta.verbose_name} found for {lookup_kwargs}." 196 | ) 197 | 198 | # Vote and respond 199 | Vote.objects.record_vote(obj, request.user, vote) 200 | return HttpResponse( 201 | json.dumps( 202 | { 203 | "success": True, 204 | "score": Vote.objects.get_score(obj), 205 | } 206 | ) 207 | ) 208 | -------------------------------------------------------------------------------- /tests/test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-voting/5721fb664760d22ae49affb39255f6e3e186160b/tests/test_app/__init__.py -------------------------------------------------------------------------------- /tests/test_app/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | 5 | 6 | class Item(models.Model): 7 | name = models.CharField(max_length=50) 8 | 9 | def __str__(self): 10 | return self.name 11 | 12 | class Meta: 13 | ordering = ["name"] 14 | 15 | 16 | class ItemWithUUID(models.Model): 17 | id = models.UUIDField(primary_key=True, default=uuid.uuid4) 18 | name = models.CharField(max_length=50) 19 | 20 | def __str__(self): 21 | return f"{self.name}({self.id})" 22 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test import TestCase 3 | from test_app.models import Item, ItemWithUUID 4 | 5 | from voting.models import Vote 6 | 7 | 8 | class BasicVotingTests(TestCase): 9 | """ 10 | Basic voting 11 | """ 12 | 13 | def setUp(self): 14 | self.item = Item.objects.create(name="test1") 15 | self.item_with_uuid = ItemWithUUID.objects.create(name="test1") 16 | self.users = [] 17 | for username in ["u1", "u2", "u3", "u4"]: 18 | self.users.append( 19 | User.objects.create_user(username, "%s@test.com" % username, "test") 20 | ) 21 | 22 | def test_print_model_with_uuid_primary_key(self): 23 | Vote.objects.record_vote(self.item_with_uuid, self.users[0], +1) 24 | expected = f"u1: 1 on {self.item_with_uuid.name}({self.item_with_uuid.id})" 25 | result = Vote.objects.all()[0] 26 | self.assertEqual(str(result), expected) 27 | 28 | def test_print_model(self): 29 | Vote.objects.record_vote(self.item, self.users[0], +1) 30 | expected = "u1: 1 on test1" 31 | result = Vote.objects.all()[0] 32 | self.assertEqual(str(result), expected) 33 | 34 | def test_novotes(self): 35 | result = Vote.objects.get_score(self.item) 36 | self.assertEqual(result, {"score": 0, "num_votes": 0}) 37 | 38 | def test_onevoteplus(self): 39 | Vote.objects.record_vote(self.item, self.users[0], +1) 40 | result = Vote.objects.get_score(self.item) 41 | self.assertEqual(result, {"score": 1, "num_votes": 1}) 42 | 43 | def test_onevoteminus(self): 44 | Vote.objects.record_vote(self.item, self.users[0], -1) 45 | result = Vote.objects.get_score(self.item) 46 | self.assertEqual(result, {"score": -1, "num_votes": 1}) 47 | 48 | def test_onevotezero(self): 49 | Vote.objects.record_vote(self.item, self.users[0], 0) 50 | result = Vote.objects.get_score(self.item) 51 | self.assertEqual(result, {"score": 0, "num_votes": 0}) 52 | 53 | def test_allvoteplus(self): 54 | for user in self.users: 55 | Vote.objects.record_vote(self.item, user, +1) 56 | result = Vote.objects.get_score(self.item) 57 | self.assertEqual(result, {"score": 4, "num_votes": 4}) 58 | for user in self.users[:2]: 59 | Vote.objects.record_vote(self.item, user, 0) 60 | result = Vote.objects.get_score(self.item) 61 | self.assertEqual(result, {"score": 2, "num_votes": 2}) 62 | for user in self.users[:2]: 63 | Vote.objects.record_vote(self.item, user, -1) 64 | result = Vote.objects.get_score(self.item) 65 | self.assertEqual(result, {"score": 0, "num_votes": 4}) 66 | 67 | def test_wrongvote(self): 68 | try: 69 | Vote.objects.record_vote(self.item, self.users[0], -2) 70 | except ValueError as e: 71 | self.assertEqual(e.args[0], "Invalid vote (must be +1/0/-1)") 72 | else: 73 | self.fail("Did nor raise 'ValueError: Invalid vote (must be +1/0/-1)'") 74 | 75 | 76 | class VoteRetrievalTests(TestCase): 77 | """ 78 | Retrieval of votes 79 | """ 80 | 81 | def setUp(self): 82 | self.items = [] 83 | self.items_with_uuid = [] 84 | for name in ["test1", "test2", "test3", "test4"]: 85 | self.items.append(Item.objects.create(name=name)) 86 | self.items_with_uuid.append(ItemWithUUID.objects.create(name=name)) 87 | self.users = [] 88 | for username in ["u1", "u2", "u3", "u4"]: 89 | self.users.append( 90 | User.objects.create_user(username, "%s@test.com" % username, "test") 91 | ) 92 | for user in self.users: 93 | Vote.objects.record_vote(self.items[0], user, +1) 94 | Vote.objects.record_vote(self.items_with_uuid[0], user, +1) 95 | for user in self.users[:2]: 96 | Vote.objects.record_vote(self.items[0], user, 0) 97 | Vote.objects.record_vote(self.items_with_uuid[0], user, 0) 98 | for user in self.users[:2]: 99 | Vote.objects.record_vote(self.items[0], user, -1) 100 | Vote.objects.record_vote(self.items_with_uuid[0], user, -1) 101 | Vote.objects.record_vote(self.items[1], self.users[0], +1) 102 | Vote.objects.record_vote(self.items[2], self.users[0], -1) 103 | Vote.objects.record_vote(self.items[3], self.users[0], 0) 104 | Vote.objects.record_vote(self.items_with_uuid[1], self.users[0], +1) 105 | Vote.objects.record_vote(self.items_with_uuid[2], self.users[0], -1) 106 | Vote.objects.record_vote(self.items_with_uuid[3], self.users[0], 0) 107 | 108 | def test_get_pos_vote(self): 109 | vote = Vote.objects.get_for_user(self.items[1], self.users[0]) 110 | result = (vote.vote, vote.is_upvote(), vote.is_downvote()) 111 | expected = (1, True, False) 112 | self.assertEqual(result, expected) 113 | 114 | def test_get_neg_vote(self): 115 | vote = Vote.objects.get_for_user(self.items[2], self.users[0]) 116 | result = (vote.vote, vote.is_upvote(), vote.is_downvote()) 117 | expected = (-1, False, True) 118 | self.assertEqual(result, expected) 119 | 120 | def test_get_zero_vote(self): 121 | self.assertTrue(Vote.objects.get_for_user(self.items[3], self.users[0]) is None) 122 | 123 | def test_in_bulk1(self): 124 | votes = Vote.objects.get_for_user_in_bulk(self.items, self.users[0]) 125 | self.assertEqual( 126 | [(id, vote.vote) for id, vote in votes.items()], 127 | [("1", -1), ("2", 1), ("3", -1)], 128 | ) 129 | 130 | def test_empty_items(self): 131 | result = Vote.objects.get_for_user_in_bulk([], self.users[0]) 132 | self.assertEqual(result, {}) 133 | 134 | def test_get_top(self): 135 | for user in self.users[1:]: 136 | Vote.objects.record_vote(self.items[1], user, +1) 137 | Vote.objects.record_vote(self.items[2], user, +1) 138 | Vote.objects.record_vote(self.items[3], user, +1) 139 | result = list(Vote.objects.get_top(Item)) 140 | expected = [(self.items[1], 4), (self.items[3], 3), (self.items[2], 2)] 141 | self.assertEqual(result, expected) 142 | 143 | def test_get_top_for_model_with_uuid_primary_key(self): 144 | for user in self.users[1:]: 145 | Vote.objects.record_vote(self.items_with_uuid[1], user, +1) 146 | Vote.objects.record_vote(self.items_with_uuid[2], user, +1) 147 | Vote.objects.record_vote(self.items_with_uuid[3], user, +1) 148 | result = list(Vote.objects.get_top(ItemWithUUID)) 149 | expected = [ 150 | (self.items_with_uuid[1], 4), 151 | (self.items_with_uuid[3], 3), 152 | (self.items_with_uuid[2], 2), 153 | ] 154 | self.assertEqual(result, expected) 155 | 156 | def test_get_bottom(self): 157 | for user in self.users[1:]: 158 | Vote.objects.record_vote(self.items[1], user, +1) 159 | Vote.objects.record_vote(self.items[2], user, +1) 160 | Vote.objects.record_vote(self.items[3], user, +1) 161 | for user in self.users[1:]: 162 | Vote.objects.record_vote(self.items[1], user, -1) 163 | Vote.objects.record_vote(self.items[2], user, -1) 164 | Vote.objects.record_vote(self.items[3], user, -1) 165 | result = list(Vote.objects.get_bottom(Item)) 166 | expected = [(self.items[2], -4), (self.items[3], -3), (self.items[1], -2)] 167 | self.assertEqual(result, expected) 168 | 169 | def test_get_bottom_for_model_with_uuid_primary_key(self): 170 | for user in self.users[1:]: 171 | Vote.objects.record_vote(self.items_with_uuid[1], user, +1) 172 | Vote.objects.record_vote(self.items_with_uuid[2], user, +1) 173 | Vote.objects.record_vote(self.items_with_uuid[3], user, +1) 174 | for user in self.users[1:]: 175 | Vote.objects.record_vote(self.items_with_uuid[1], user, -1) 176 | Vote.objects.record_vote(self.items_with_uuid[2], user, -1) 177 | Vote.objects.record_vote(self.items_with_uuid[3], user, -1) 178 | result = list(Vote.objects.get_bottom(ItemWithUUID)) 179 | expected = [ 180 | (self.items_with_uuid[2], -4), 181 | (self.items_with_uuid[3], -3), 182 | (self.items_with_uuid[1], -2), 183 | ] 184 | self.assertEqual(result, expected) 185 | 186 | def test_get_scores_in_bulk(self): 187 | for user in self.users[1:]: 188 | Vote.objects.record_vote(self.items[1], user, +1) 189 | Vote.objects.record_vote(self.items[2], user, +1) 190 | Vote.objects.record_vote(self.items[3], user, +1) 191 | for user in self.users[1:]: 192 | Vote.objects.record_vote(self.items[1], user, -1) 193 | Vote.objects.record_vote(self.items[2], user, -1) 194 | Vote.objects.record_vote(self.items[3], user, -1) 195 | result = Vote.objects.get_scores_in_bulk(self.items) 196 | expected = { 197 | "1": {"score": 0, "num_votes": 4}, 198 | "2": {"score": -2, "num_votes": 4}, 199 | "3": {"score": -4, "num_votes": 4}, 200 | "4": {"score": -3, "num_votes": 3}, 201 | } 202 | self.assertEqual(result, expected) 203 | 204 | def test_get_scores_in_bulk_with_uuid_primary_key(self): 205 | for user in self.users[1:]: 206 | Vote.objects.record_vote(self.items_with_uuid[1], user, +1) 207 | Vote.objects.record_vote(self.items_with_uuid[2], user, +1) 208 | Vote.objects.record_vote(self.items_with_uuid[3], user, +1) 209 | for user in self.users[1:]: 210 | Vote.objects.record_vote(self.items_with_uuid[1], user, -1) 211 | Vote.objects.record_vote(self.items_with_uuid[2], user, -1) 212 | Vote.objects.record_vote(self.items_with_uuid[3], user, -1) 213 | result = Vote.objects.get_scores_in_bulk(self.items_with_uuid) 214 | expected = { 215 | str(self.items_with_uuid[0].id): {"score": 0, "num_votes": 4}, 216 | str(self.items_with_uuid[1].id): {"score": -2, "num_votes": 4}, 217 | str(self.items_with_uuid[2].id): {"score": -4, "num_votes": 4}, 218 | str(self.items_with_uuid[3].id): {"score": -3, "num_votes": 3}, 219 | } 220 | self.assertEqual(result, expected) 221 | 222 | def test_get_scores_in_bulk_no_items(self): 223 | result = Vote.objects.get_scores_in_bulk([]) 224 | self.assertEqual(result, {}) 225 | 226 | def test_get_voted_users(self): 227 | self.assertQuerysetEqual( 228 | Vote.objects.get_voted_users(self.items[0]), 229 | [{"user": 1}, {"user": 2}, {"user": 3}, {"user": 4}], 230 | ) 231 | self.assertQuerysetEqual( 232 | Vote.objects.get_voted_users(self.items[1]), [{"user": 1}] 233 | ) 234 | self.assertQuerysetEqual(Vote.objects.get_voted_users(self.items[3]), []) 235 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DIRNAME = os.path.dirname(__file__) 4 | 5 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 6 | 7 | SECRET_KEY = "foo" 8 | 9 | INSTALLED_APPS = ( 10 | "django.contrib.sessions", 11 | "django.contrib.auth", 12 | "django.contrib.contenttypes", 13 | "voting", 14 | "test_app", 15 | ) 16 | 17 | MIDDLEWARE_CLASSES = [ 18 | "django.middleware.common.CommonMiddleware", 19 | "django.middleware.csrf.CsrfViewMiddleware", 20 | ] 21 | 22 | ROOT_URLCONF = "voting.urls" 23 | 24 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 25 | 26 | USE_TZ = True 27 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{38,39,310}-dj32 4 | py{38,39,310,311}-dj42 5 | py{310,311,312}-dj{50,main} 6 | py38-lint 7 | 8 | [testenv] 9 | usedevelop = True 10 | deps = 11 | pytest 12 | pytest-cov 13 | pytest-django 14 | pytest-flake8 15 | dj32: Django>=3.2,<4.0 16 | dj42: Django>=4.2,<4.3 17 | dj50: Django>=5.0,<5.1 18 | djmain: https://github.com/django/django/archive/main.tar.gz 19 | setenv = 20 | DJANGO_SETTINGS_MODULE = test_settings 21 | PYTHONPATH = {toxinidir}/tests 22 | commands = 23 | {envbindir}/django-admin check 24 | pytest --cov voting --cov-append --cov-branch --cov-report term-missing --cov-report=xml 25 | ignore_outcome = 26 | djmain: True 27 | 28 | [gh-actions] 29 | python = 30 | 3.8: py38 31 | 3.9: py39 32 | 3.10: py310 33 | 3.11: py311 34 | 3.12: py312 35 | 36 | [gh-actions:env] 37 | DJANGO = 38 | 3.2: dj32 39 | 4.2: dj42 40 | 5.0: dj50 41 | main: djmain 42 | 43 | [testenv:py38-lint] 44 | deps = pre-commit 45 | commands = 46 | pre-commit run --all-files 47 | --------------------------------------------------------------------------------