├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── models.py └── test_model.py ├── tox.ini └── updown ├── __init__.py ├── exceptions.py ├── fields.py ├── forms.py ├── migrations ├── 0001_initial.py └── __init__.py ├── models.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | dist/ 3 | build/ 4 | django_updown.egg-info/ 5 | env/ 6 | .cache/ 7 | .tox/ 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | 4 | python: 5 | - "2.7" 6 | - "3.4" 7 | - "3.5" 8 | 9 | sudo: false 10 | 11 | env: 12 | - DJANGO=1.10 13 | - DJANGO=1.11 14 | - DJANGO=2.0 15 | 16 | matrix: 17 | fast_finish: true 18 | 19 | include: 20 | - { python: "3.6", env: DJANGO=1.11 } 21 | - { python: "3.6", env: DJANGO=2.0 } 22 | 23 | exclude: 24 | - { python: "2.7", env: DJANGO=2.0 } 25 | 26 | install: 27 | - pip install tox tox-travis 28 | 29 | script: 30 | - tox 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.1.0 4 | 5 | - Add support for Django 2.0 (thanks to [agusmakmun](https://github.com/agusmakmun)) 6 | 7 | ## 1.0.2 8 | 9 | - Replaced IPAddressField in migration (thanks to [baster33](https://github.com/baster33)) 10 | 11 | ## 1.0.1 12 | 13 | - Fixed IPAddressField error (thanks to [agusmakmun](https://github.com/agusmakmun)) 14 | 15 | ## 1.0.0 16 | 17 | - Dropped support for Python 2.6 18 | - Added Python 3 support 19 | - Dropped support for Django < 1.8 20 | - Django 1.8 & Django 1.9 are now supported 21 | - Tests are run using tox (for all Python and Django versions) 22 | - Switched to more semantic versioning 23 | - Removed old south migrations, added Django migrations 24 | - Refactoring and cleanup 25 | 26 | ## 0.5: 27 | 28 | - Fixed DateTimeField RuntimeWarning (thanks to [yurtaev](https://github.com/yurtaev)) 29 | - Tests are using Django 1.4.10 now 30 | 31 | ## 0.4: 32 | 33 | - Usage of `AUTH_USER_MODEL` instead of `auth.models.User` (thanks to [timbutler](https://github.com/timbutler)) 34 | 35 | ## 0.3: 36 | 37 | - Removed south as dependency 38 | - Small cleanups (thanks to [gwrtheyrn](https://github.com/gwrtheyrn>)) 39 | 40 | ## 0.2: 41 | 42 | - Updated `related_name` to avoid namespace clashes 43 | - Added south as dependency 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | Copyright (c) 2010-2016, Daniel Banck 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | Redistributions in binary form must reproduce the above copyright notice, this 12 | list of conditions and the following disclaimer in the documentation and/or 13 | other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | include LICENSE.md 3 | include README.md 4 | recursive-exclude * __pycache__ 5 | recursive-exclude * *.py[co] 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-updown 2 | 3 | > Simple Django application for adding Youtube like up and down voting. 4 | 5 | [![Build Status](https://secure.travis-ci.org/weluse/django-updown.png?branch=master)](http://travis-ci.org/weluse/django-updown) 6 | 7 | ## Install 8 | 9 | ``` 10 | pip install django-updown 11 | ``` 12 | 13 | ## Usage 14 | 15 | Add `"updown"` to your `INSTALLED_APPS`. Then just add a `RatingField` to 16 | your existing model: 17 | 18 | from django.db import models 19 | from updown.fields import RatingField 20 | 21 | class Video(models.Model): 22 | # ...other fields... 23 | rating = RatingField() 24 | 25 | You can also allow the user to change his vote: 26 | 27 | class Video(models.Model): 28 | # ...other fields... 29 | rating = RatingField(can_change_vote=True) 30 | 31 | Now you can write your own view to submit ratings or use the predefined: 32 | 33 | from updown.views import AddRatingFromModel 34 | 35 | urlpatterns = patterns("", 36 | url(r"^(?P\d+)/rate/(?P[\d\-]+)$", AddRatingFromModel(), { 37 | 'app_label': 'video', 38 | 'model': 'Video', 39 | 'field_name': 'rating', 40 | }, name="video_rating"), 41 | ) 42 | 43 | To submit a vote just go to ``video//rate/(1|-1)``. If you allowed users to 44 | change they're vote, they can do it with the same url. 45 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import re 5 | from setuptools import setup, find_packages 6 | 7 | 8 | def get_version(package): 9 | """ 10 | Return package version as listed in `__version__` in `init.py`. 11 | """ 12 | init_py = open(os.path.join(package, '__init__.py')).read() 13 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 14 | 15 | 16 | version = get_version('updown') 17 | 18 | 19 | setup( 20 | name='django-updown', 21 | version=version, 22 | description='Reusable Django application for youtube \ 23 | like up and down voting.', 24 | author='Daniel Banck', 25 | author_email='dbanck@weluse.de', 26 | url='http://github.com/weluse/django-updown/tree/master', 27 | packages=find_packages(), 28 | zip_safe=False, 29 | classifiers=[ 30 | 'Development Status :: 5 - Production/Stable', 31 | 'Environment :: Web Environment', 32 | 'Framework :: Django', 33 | 'Framework :: Django :: 1.10', 34 | 'Framework :: Django :: 1.11', 35 | 'Framework :: Django :: 2.0', 36 | 'Intended Audience :: Developers', 37 | 'License :: OSI Approved :: BSD License', 38 | 'Operating System :: OS Independent', 39 | 'Programming Language :: Python', 40 | 'Programming Language :: Python :: 2', 41 | 'Programming Language :: Python :: 2.7', 42 | 'Programming Language :: Python :: 3', 43 | 'Programming Language :: Python :: 3.4', 44 | 'Programming Language :: Python :: 3.5', 45 | 'Programming Language :: Python :: 3.6', 46 | 'Topic :: Internet :: WWW/HTTP', 47 | ] 48 | ) 49 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weluse/django-updown/983d6d59708a58c226ad8b0fb4a79e1b507567cd/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_configure(): 2 | from django.conf import settings 3 | 4 | settings.configure( 5 | DEBUG_PROPAGATE_EXCEPTIONS=True, 6 | DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', 7 | 'NAME': ':memory:'}}, 8 | SITE_ID=1, 9 | SECRET_KEY='not very secret in tests', 10 | MIDDLEWARE_CLASSES=( 11 | 'django.middleware.common.CommonMiddleware', 12 | 'django.contrib.sessions.middleware.SessionMiddleware', 13 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 14 | ), 15 | INSTALLED_APPS=( 16 | 'django.contrib.auth', 17 | 'django.contrib.contenttypes', 18 | 19 | 'updown', 20 | 'tests', 21 | ), 22 | PASSWORD_HASHERS=( 23 | 'django.contrib.auth.hashers.MD5PasswordHasher', 24 | ), 25 | ) 26 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | tests.models 4 | ~~~~~~~~~~~~ 5 | 6 | Defines models required for testing 7 | 8 | :copyright: 2016, weluse (https://weluse.de) 9 | :author: 2016, Daniel Banck 10 | :license: BSD, see LICENSE for more details. 11 | """ 12 | from __future__ import unicode_literals 13 | 14 | from django.db import models 15 | from updown.fields import RatingField 16 | 17 | 18 | class RatingTestModel(models.Model): 19 | rating = RatingField(can_change_vote=True) 20 | rating2 = RatingField(can_change_vote=False) 21 | 22 | def __unicode__(self): 23 | return unicode(self.pk) 24 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | tests.test_model 4 | ~~~~~~~~~~~~~~~~ 5 | 6 | Tests the models provided by the updown rating app 7 | 8 | :copyright: 2016, weluse (https://weluse.de) 9 | :author: 2016, Daniel Banck 10 | :license: BSD, see LICENSE for more details. 11 | """ 12 | from __future__ import unicode_literals 13 | import random 14 | 15 | from django.test import TestCase 16 | from django.contrib.auth.models import User 17 | 18 | from updown.models import SCORE_TYPES 19 | from updown.exceptions import CannotChangeVote 20 | 21 | from tests.models import RatingTestModel 22 | 23 | 24 | class TestRatingModel(TestCase): 25 | """Test case for the generic rating app""" 26 | 27 | def setUp(self): 28 | self.instance = RatingTestModel.objects.create() 29 | 30 | self.user = User.objects.create( 31 | username=str(random.randint(0, 100000000)) 32 | ) 33 | self.user2 = User.objects.create( 34 | username=str(random.randint(0, 100000000)) 35 | ) 36 | 37 | def test_basic_vote(self): 38 | """Test a simple vote""" 39 | self.instance.rating.add(SCORE_TYPES['LIKE'], self.user, 40 | '192.168.0.1') 41 | 42 | self.assertEquals(self.instance.rating_likes, 1) 43 | 44 | def test_change_vote(self): 45 | self.instance.rating.add(SCORE_TYPES['LIKE'], self.user, 46 | '192.168.0.1') 47 | self.instance.rating.add(SCORE_TYPES['DISLIKE'], self.user, 48 | '192.168.0.1') 49 | 50 | self.assertEquals(self.instance.rating_likes, 0) 51 | self.assertEquals(self.instance.rating_dislikes, 1) 52 | 53 | def test_change_vote_disallowed(self): 54 | self.instance.rating2.add(SCORE_TYPES['LIKE'], self.user, 55 | '192.168.0.1') 56 | self.assertRaises(CannotChangeVote, self.instance.rating2.add, 57 | SCORE_TYPES['DISLIKE'], self.user, '192.168.0.1') 58 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts=--tb=short 3 | 4 | [tox] 5 | envlist = 6 | {py27,py34,py35}-django110, 7 | {py27,py34,py35,py36}-django111, 8 | {py34,py35,py36}-django20, 9 | 10 | [travis:env] 11 | DJANGO = 12 | 1.10: django110 13 | 1.11: django111 14 | 2.0: django20 15 | 16 | [testenv] 17 | commands = py.test tests/ 18 | envdir = {toxworkdir}/venvs/{envname} 19 | setenv = 20 | PYTHONDONTWRITEBYTECODE=1 21 | PYTHONWARNINGS=once 22 | deps = 23 | django110: Django>=1.10,<1.11 24 | django111: Django>=1.11,<2.0 25 | django20: Django>=2.0,<2.1 26 | pytest 27 | pytest-django 28 | -------------------------------------------------------------------------------- /updown/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.1.0' 2 | 3 | # Version synonym 4 | VERSION = __version__ 5 | -------------------------------------------------------------------------------- /updown/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some custom exceptions 3 | """ 4 | from __future__ import unicode_literals 5 | 6 | 7 | class InvalidRating(ValueError): 8 | pass 9 | 10 | 11 | class AuthRequired(TypeError): 12 | pass 13 | 14 | 15 | class CannotChangeVote(Exception): 16 | pass 17 | -------------------------------------------------------------------------------- /updown/fields.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fields needed for the updown ratings 3 | """ 4 | from __future__ import unicode_literals 5 | 6 | from django.db.models import IntegerField, PositiveIntegerField 7 | from django.conf import settings 8 | 9 | from updown.models import Vote, SCORE_TYPES 10 | from updown.exceptions import InvalidRating, AuthRequired, CannotChangeVote 11 | from updown import forms 12 | 13 | 14 | if 'django.contrib.contenttypes' not in settings.INSTALLED_APPS: 15 | raise ImportError("django-updown requires django.contrib.contenttypes " 16 | "in your INSTALLED_APPS") 17 | 18 | from django.contrib.contenttypes.models import ContentType 19 | 20 | __all__ = ('Rating', 'RatingField', 'AnonymousRatingField') 21 | 22 | try: 23 | from hashlib import md5 24 | except ImportError: 25 | from md5 import new as md5 26 | 27 | 28 | def md5_hexdigest(value): 29 | return md5(value.encode()).hexdigest() 30 | 31 | 32 | class Rating(object): 33 | def __init__(self, likes, dislikes): 34 | self.likes = likes 35 | self.dislikes = dislikes 36 | 37 | 38 | class RatingManager(object): 39 | def __init__(self, instance, field): 40 | self.content_type = None 41 | self.instance = instance 42 | self.field = field 43 | 44 | self.like_field_name = "{}_likes".format(self.field.name,) 45 | self.dislike_field_name = "{}_dislikes".format(self.field.name,) 46 | 47 | def is_authenticated(self, user): 48 | """ different method between django 1.* and 2.* """ 49 | if callable(user.is_authenticated): 50 | return user.is_authenticated() 51 | return user.is_authenticated 52 | 53 | def get_rating_for_user(self, user, ip_address=None): 54 | kwargs = { 55 | 'content_type': self.get_content_type(), 56 | 'object_id': self.instance.pk, 57 | 'key': self.field.key 58 | } 59 | 60 | if not (user and self.is_authenticated(user)): 61 | if not ip_address: 62 | raise ValueError("``user`` or ``ip_address`` must be " 63 | "present.") 64 | kwargs['user__isnull'] = True 65 | kwargs['ip_address'] = ip_address 66 | else: 67 | kwargs['user'] = user 68 | 69 | try: 70 | rating = Vote.objects.get(**kwargs) 71 | return rating.score 72 | except Vote.DoesNotExist: 73 | pass 74 | return 75 | 76 | def get_content_type(self): 77 | if self.content_type is None: 78 | self.content_type = ContentType.objects.get_for_model( 79 | self.instance) 80 | return self.content_type 81 | 82 | def add(self, score, user, ip_address, commit=True): 83 | try: 84 | score = int(score) 85 | except (ValueError, TypeError): 86 | raise InvalidRating("{} is not a valid score for {}".format( 87 | score, self.field.name)) 88 | 89 | if score not in SCORE_TYPES.values(): 90 | raise InvalidRating("{} is not a valid score".format(score)) 91 | 92 | is_anonymous = (user is None or not self.is_authenticated(user)) 93 | if is_anonymous and not self.field.allow_anonymous: 94 | raise AuthRequired("User must be a user, not '{}'".format(user)) 95 | 96 | if is_anonymous: 97 | user = None 98 | 99 | defaults = { 100 | 'score': score, 101 | 'ip_address': ip_address 102 | } 103 | 104 | kwargs = { 105 | 'content_type': self.get_content_type(), 106 | 'object_id': self.instance.pk, 107 | 'key': self.field.key, 108 | 'user': user 109 | } 110 | if not user: 111 | kwargs['ip_address'] = ip_address 112 | 113 | try: 114 | rating, created = Vote.objects.get(**kwargs), False 115 | except Vote.DoesNotExist: 116 | kwargs.update(defaults) 117 | rating, created = Vote.objects.create(**kwargs), True 118 | 119 | has_changed = False 120 | if not created: 121 | if self.field.can_change_vote: 122 | has_changed = True 123 | if (rating.score == SCORE_TYPES['LIKE']): 124 | self.likes -= 1 125 | else: 126 | self.dislikes -= 1 127 | if (score == SCORE_TYPES['LIKE']): 128 | self.likes += 1 129 | else: 130 | self.dislikes += 1 131 | rating.score = score 132 | rating.save() 133 | else: 134 | raise CannotChangeVote() 135 | else: 136 | has_changed = True 137 | if (rating.score == SCORE_TYPES['LIKE']): 138 | self.likes += 1 139 | else: 140 | self.dislikes += 1 141 | 142 | if has_changed: 143 | if commit: 144 | self.instance.save() 145 | 146 | def _get_likes(self, default=None): 147 | return getattr(self.instance, self.like_field_name, default) 148 | 149 | def _set_likes(self, value): 150 | return setattr(self.instance, self.like_field_name, value) 151 | 152 | likes = property(_get_likes, _set_likes) 153 | 154 | def _get_dislikes(self, default=None): 155 | return getattr(self.instance, self.dislike_field_name, default) 156 | 157 | def _set_dislikes(self, value): 158 | return setattr(self.instance, self.dislike_field_name, value) 159 | 160 | dislikes = property(_get_dislikes, _set_dislikes) 161 | 162 | def get_difference(self): 163 | return self.likes - self.dislikes 164 | 165 | def get_quotient(self): 166 | return float(self.likes) / max(self.dislikes, 1) 167 | 168 | 169 | class RatingCreator(object): 170 | def __init__(self, field): 171 | self.field = field 172 | self.like_field_name = "{}_likes".format(self.field.name) 173 | self.dislike_field_name = "{}_dislikes".format(self.field.name) 174 | 175 | def __get__(self, instance, type=None): 176 | if instance is None: 177 | return self.field 178 | return RatingManager(instance, self.field) 179 | 180 | def __set__(self, instance, value): 181 | if isinstance(value, Rating): 182 | setattr(instance, self.like_field_name, value.likes) 183 | setattr(instance, self.dislike_field_name, value.dislikes) 184 | else: 185 | raise TypeError("{} value must be a Rating instance, not '{}'". 186 | format(self.field.name, value)) 187 | 188 | 189 | class RatingField(IntegerField): 190 | def __init__(self, delimiter="|", *args, **kwargs): 191 | self.can_change_vote = kwargs.pop('can_change_vote', False) 192 | self.allow_anonymous = kwargs.pop('allow_anonymous', False) 193 | self.delimiter = delimiter 194 | kwargs['editable'] = False 195 | kwargs['default'] = 0 196 | kwargs['blank'] = True 197 | super(RatingField, self).__init__(*args, **kwargs) 198 | 199 | def contribute_to_class(self, cls, name): 200 | self.name = name 201 | self.like_field = PositiveIntegerField(editable=False, default=0, 202 | blank=True) 203 | cls.add_to_class("{}_likes".format(self.name), self.like_field) 204 | self.dislike_field = PositiveIntegerField(editable=False, default=0, 205 | blank=True) 206 | cls.add_to_class("{}_dislikes".format(self.name), self.dislike_field) 207 | self.key = md5_hexdigest(self.name) 208 | 209 | field = RatingCreator(self) 210 | if not hasattr(cls, '_ratings'): 211 | cls._ratings = [] 212 | cls._ratings.append(self) 213 | 214 | setattr(cls, name, field) 215 | 216 | def to_python(self, value): 217 | # If it's already a list, leave it 218 | if isinstance(value, list): 219 | return value 220 | 221 | # Otherwise, split by delimiter 222 | return value.split(self.delimiter) 223 | 224 | def get_prep_value(self, value): 225 | return self.delimiter.join(value) 226 | 227 | def get_db_prep_save(self, value, connection): 228 | pass 229 | 230 | def get_db_prep_lookup(self, lookup_type, value, connection, 231 | prepared=False): 232 | raise NotImplementedError(self.get_db_prep_lookup) 233 | 234 | def formfield(self, **kwargs): 235 | defaults = {'form_class': forms.RatingField} 236 | defaults.update(kwargs) 237 | return super(RatingField, self).formfield(**defaults) 238 | 239 | 240 | class AnonymousRatingField(RatingField): 241 | def __init__(self, *args, **kwargs): 242 | kwargs['allow_anonymous'] = True 243 | super(AnonymousRatingField, self).__init__(*args, **kwargs) 244 | 245 | 246 | try: 247 | from south.modelsinspector import add_introspection_rules 248 | except ImportError: 249 | pass 250 | else: 251 | add_introspection_rules([ 252 | ( 253 | [RatingField], # Class(es) these apply to 254 | [], # Positional arguments (not used) 255 | { # Keyword argument 256 | "delimiter": ["delimiter", {"default": "|"}], 257 | }, 258 | ), 259 | ], ["^updown\.fields\.RatingField"]) 260 | -------------------------------------------------------------------------------- /updown/forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | Very basic form fields 3 | """ 4 | from __future__ import unicode_literals 5 | 6 | from django import forms 7 | 8 | __all__ = ('RatingField',) 9 | 10 | 11 | class RatingField(forms.ChoiceField): 12 | pass 13 | -------------------------------------------------------------------------------- /updown/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import django.utils.timezone 6 | from django.conf import settings 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ('contenttypes', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Vote', 19 | fields=[ 20 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 21 | ('object_id', models.PositiveIntegerField()), 22 | ('key', models.CharField(max_length=32)), 23 | ('score', models.SmallIntegerField(choices=[(-1, 'DISLIKE'), (1, 'LIKE')])), 24 | ('ip_address', models.GenericIPAddressField()), 25 | ('date_added', models.DateTimeField(default=django.utils.timezone.now, editable=False)), 26 | ('date_changed', models.DateTimeField(default=django.utils.timezone.now, editable=False)), 27 | ('content_type', models.ForeignKey(related_name='updown_votes', to='contenttypes.ContentType', on_delete=models.CASCADE)), 28 | ('user', models.ForeignKey(related_name='updown_votes', blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), 29 | ], 30 | options={ 31 | }, 32 | bases=(models.Model,), 33 | ), 34 | migrations.AlterUniqueTogether( 35 | name='vote', 36 | unique_together=set([('content_type', 'object_id', 'key', 'user', 'ip_address')]), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /updown/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weluse/django-updown/983d6d59708a58c226ad8b0fb4a79e1b507567cd/updown/migrations/__init__.py -------------------------------------------------------------------------------- /updown/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | The vote model for storing ratings 3 | """ 4 | from __future__ import unicode_literals 5 | 6 | from django.db import models 7 | from django.contrib.contenttypes.models import ContentType 8 | from django.contrib.contenttypes.fields import GenericForeignKey 9 | from django.utils.encoding import python_2_unicode_compatible 10 | from django.conf import settings 11 | from django.utils import timezone 12 | 13 | _SCORE_TYPE_CHOICES = ( 14 | (-1, 'DISLIKE'), 15 | (1, 'LIKE'), 16 | ) 17 | 18 | SCORE_TYPES = dict((value, key) for key, value in _SCORE_TYPE_CHOICES) 19 | 20 | 21 | @python_2_unicode_compatible 22 | class Vote(models.Model): 23 | content_type = models.ForeignKey(ContentType, related_name="updown_votes", 24 | on_delete=models.CASCADE) 25 | object_id = models.PositiveIntegerField() 26 | key = models.CharField(max_length=32) 27 | score = models.SmallIntegerField(choices=_SCORE_TYPE_CHOICES) 28 | user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, 29 | related_name="updown_votes", on_delete=models.CASCADE) 30 | ip_address = models.GenericIPAddressField() 31 | date_added = models.DateTimeField(default=timezone.now, editable=False) 32 | date_changed = models.DateTimeField(default=timezone.now, editable=False) 33 | 34 | content_object = GenericForeignKey() 35 | 36 | class Meta: 37 | unique_together = (('content_type', 'object_id', 'key', 'user', 38 | 'ip_address')) 39 | 40 | def __str__(self): 41 | return u"%s voted %s on %s" % (self.user, self.score, 42 | self.content_object) 43 | 44 | def save(self, *args, **kwargs): 45 | self.date_changed = timezone.now() 46 | super(Vote, self).save(*args, **kwargs) 47 | 48 | def partial_ip_address(self): 49 | ip = self.ip_address.split('.') 50 | ip[-1] = 'xxx' 51 | return '.'.join(ip) 52 | 53 | partial_ip_address = property(partial_ip_address) 54 | -------------------------------------------------------------------------------- /updown/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic views for voting 3 | """ 4 | from __future__ import unicode_literals 5 | 6 | from django.contrib.contenttypes.models import ContentType 7 | from django.core.exceptions import ObjectDoesNotExist 8 | from django.http import HttpResponse, Http404 9 | 10 | from updown.exceptions import InvalidRating, AuthRequired, CannotChangeVote 11 | 12 | 13 | class AddRatingView(object): 14 | def __call__(self, request, content_type_id, object_id, field_name, score): 15 | """__call__(request, content_type_id, object_id, field_name, score) 16 | 17 | Adds a vote to the specified model field.""" 18 | 19 | try: 20 | instance = self.get_instance(content_type_id, object_id) 21 | except ObjectDoesNotExist: 22 | raise Http404('Object does not exist') 23 | 24 | context = self.get_context(request) 25 | context['instance'] = instance 26 | 27 | try: 28 | field = getattr(instance, field_name) 29 | except AttributeError: 30 | return self.invalid_field_response(request, context) 31 | 32 | context.update({ 33 | 'field': field, 34 | 'score': score, 35 | }) 36 | 37 | try: 38 | had_voted = bool(field.get_rating_for_user( 39 | request.user, request.META['REMOTE_ADDR'])) 40 | 41 | context['had_voted'] = had_voted 42 | field.add(score, request.user, request.META['REMOTE_ADDR']) 43 | except AuthRequired: 44 | return self.authentication_required_response(request, context) 45 | except InvalidRating: 46 | return self.invalid_rating_response(request, context) 47 | except CannotChangeVote: 48 | return self.cannot_change_vote_response(request, context) 49 | if had_voted: 50 | return self.rating_changed_response(request, context) 51 | return self.rating_added_response(request, context) 52 | 53 | def get_context(self, request, context={}): 54 | return context 55 | 56 | def render_to_response(self, template, context, request): 57 | raise NotImplementedError 58 | 59 | def rating_changed_response(self, request, context): 60 | response = HttpResponse('Vote changed.') 61 | return response 62 | 63 | def rating_added_response(self, request, context): 64 | response = HttpResponse('Vote recorded.') 65 | return response 66 | 67 | def authentication_required_response(self, request, context): 68 | response = HttpResponse('You must be logged in to vote.') 69 | response.status_code = 403 70 | return response 71 | 72 | def cannot_change_vote_response(self, request, context): 73 | response = HttpResponse('You have already voted.') 74 | response.status_code = 403 75 | return response 76 | 77 | def invalid_field_response(self, request, context): 78 | response = HttpResponse('Invalid field name.') 79 | response.status_code = 403 80 | return response 81 | 82 | def invalid_rating_response(self, request, context): 83 | response = HttpResponse('Invalid rating value.') 84 | response.status_code = 403 85 | return response 86 | 87 | def get_instance(self, content_type_id, object_id): 88 | return ContentType.objects.get(pk=content_type_id)\ 89 | .get_object_for_this_type(pk=object_id) 90 | 91 | 92 | class AddRatingFromModel(AddRatingView): 93 | def __call__(self, request, model, app_label, object_id, field_name, 94 | score, **kwargs): 95 | """__call__(request, model, app_label, object_id, field_name, score) 96 | 97 | Adds a vote to the specified model field.""" 98 | try: 99 | content_type = ContentType.objects.get(model=model.lower(), 100 | app_label=app_label) 101 | except ContentType.DoesNotExist: 102 | raise Http404('Invalid `model` or `app_label`.') 103 | 104 | return super(AddRatingFromModel, self).__call__( 105 | request, content_type.id, object_id, field_name, score) 106 | --------------------------------------------------------------------------------