├── .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 |
--------------------------------------------------------------------------------