diff --git a/CHANGELOG.md b/CHANGELOG.md index f4647cf..79a08a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +2.1.4(2017.02.09) +------------------ +https://github.com/Beeblio/django-vote/tree/2.1.3 + +* Support vote down + + 2.1.2(2016.11.02) ------------------ https://github.com/Beeblio/django-vote/tree/2.1.2 diff --git a/README.md b/README.md index 97ec3df..1b2ff2b 100644 --- a/README.md +++ b/README.md @@ -25,44 +25,50 @@ INSTALLED_APPS = ( ) ``` +#### Add `VoteModel` to the model you want to vote + +```python +from vote.models import VoteModel + +class ArticleReview(VoteModel, models.Model): + ... +``` + #### Run migrate ```shell +manage.py makemigrations manage.py migrate ``` -#### Declare vote field to the model you want to vote - -```python -from vote.managers import VotableManager - -class ArticleReview(models.Model): - ... - votes = VotableManager() -``` #### Use vote API ```python review = ArticleReview.objects.get(pk=1) -# Adds a new vote to the object +# Up vote to the object review.votes.up(user_id) -# Removes vote to the object +# Down vote to the object review.votes.down(user_id) +# Removes a vote from the object +review.votes.delete(user_id) + # Check if the user already voted the object review.votes.exists(user_id) -# Returns all instances voted by user -Review.votes.all(user_id) - # Returns the number of votes for the object review.votes.count() # Returns a list of users who voted and their voting date -review.votes.users() +review.votes.user_ids() + + +# Returns all instances voted by user +Review.votes.all(user_id) + ``` ``django-vote`` now requires Django 1.7 or greater. (for Django < 1.7, please install previous release `django-vote==1.1.3`) diff --git a/docs/api.rst b/docs/api.rst index 826da77..f08897f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,7 +1,7 @@ The API ======= -After you've got your ``VotableManager`` added to your model you can start +After you've got your ``VoteModel`` added to your model you can start playing around with the API. .. class:: VotableManager([through=None, verbose_name="Votes", field_name='votes', extra_field=None]) @@ -11,30 +11,39 @@ playing around with the API. :param field_name: The field name added to the query :param extra_field: The field on your model. It will be updated when up or down - .. method:: up(user) + .. method:: up(user_id) This adds a vote to an object by the ``user``. ``IntegrityError`` will be raised if the user has voted before:: >>> comments.votes.up(user) - .. method:: down(user) + .. method:: down(user_id) Removes the vote from an object. No exception is raised if the user doesn't have voted the object. + + .. method:: delete(user_id) - .. method:: exists(user) + Removes the vote from an object. No exception is raised if the user + doesn't have voted the object. + + .. method:: exists(user_id, action=UP) Check if user has voted the instance before. - .. method:: all(user) + .. method:: all(user_id, action=UP) Get all instances voted by the specify user. - .. method:: count() + .. method:: user_ids(action=UP) + + Get all user_ids voted the instance + + .. method:: count(action=UP) The count of all votes for an object. - .. method:: annotate(queryset=None, user=None) + .. method:: annotate(queryset=None, user_id=None, reverse=True, sort=True) Add annotation data to the ``queyset`` diff --git a/docs/changelog.rst b/docs/changelog.rst index c49697b..f6022d0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,12 @@ Changelog ========= +2.1.4(2017.02.09) +------------------ + +* Support vote down + + 2.1.2(2016.11.02) ------------------ diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 03bfcf6..1cfdeab 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -9,32 +9,18 @@ To get started using ``django-vote`` simply install it with Add ``"vote"`` to your project's ``INSTALLED_APPS`` setting. -Run:: - - ./manage.py migrate And then to any model you want vote on do the following:: from django.db import models - from vote.managers import VotableManager + from vote.models import VoteModel - class Comment(models.Model): + class Comment(VoteModel,models.Model): # ... fields here + +Run:: - votes = VotableManager() - - - -If you want to save number of votes directly on original model:: - - from django.db import models - - from vote.managers import VotableManager - - - class Comment(models.Model): - # ... fields here - num_votes = models.PositiveIntegerField(default=0) - votes = VotableManager(extra_field='num_votes') + ./manage.py makemigrations + ./manage.py migrate diff --git a/test/models.py b/test/models.py index 5225093..31cd0d4 100644 --- a/test/models.py +++ b/test/models.py @@ -1,23 +1,11 @@ from django.db import models -from vote.managers import VotableManager +from vote.models import VoteModel # Create your models here. -class Comment(models.Model): +class Comment(VoteModel): user_id = models.BigIntegerField() content = models.TextField() num_vote = models.IntegerField(default=0) create_at = models.DateTimeField(auto_now_add=True) update_at = models.DateTimeField(auto_now=True) - - votes = VotableManager() - - -class CustomVoteComment(models.Model): - user_id = models.BigIntegerField() - content = models.TextField() - num_vote = models.PositiveIntegerField(default=0) - create_at = models.DateTimeField(auto_now_add=True) - update_at = models.DateTimeField(auto_now=True) - - custom_votes = VotableManager(extra_field='num_vote') diff --git a/test/tests.py b/test/tests.py index 918ab54..4635aed 100644 --- a/test/tests.py +++ b/test/tests.py @@ -1,8 +1,8 @@ from __future__ import absolute_import from django.test import TestCase from django.contrib.auth.models import User -from vote.models import Vote -from test.models import Comment, CustomVoteComment +from vote.models import Vote, UP, DOWN +from test.models import Comment class VoteTest(TestCase): @@ -34,32 +34,44 @@ def test_vote_up(self): content="I'm a comment") self.call_api('up', comment, self.user2.pk) - self.assertEqual(self.call_api('count', comment), 1) + self.assertEqual(self.call_api('count', comment, UP), 1) # call up again, will not increase count res = self.call_api('up', comment, self.user2.pk) self.assertEqual(res, False) - self.assertEqual(self.call_api('count', comment), 1) + self.assertEqual(self.call_api('count', comment, UP), 1) def test_vote_down(self): comment = self.model.objects.create(user_id=self.user1.pk, content="I'm a comment") # no votes yet, no exception raised - self.assertEqual(self.call_api('count'), 0) + self.assertEqual(self.call_api('count', comment, DOWN), 0) self.call_api('down', comment, self.user2.pk) + self.assertEqual(self.call_api('count', comment, DOWN), 1) self.call_api('up', comment, self.user2.pk) - self.assertEqual(self.call_api('count', comment), 1) + self.assertEqual(self.call_api('count', comment, UP), 1) self.call_api('down', comment, self.user2.pk) - self.assertEqual(self.call_api('count', comment), 0) + self.assertEqual(self.call_api('count', comment, DOWN), 1) + + def test_vote_delete(self): + comment = self.model.objects.create(user_id=self.user1.pk, + content="I'm a comment") + + self.call_api('up', comment, self.user2.pk) + self.assertEqual(self.call_api('count', comment, UP), 1) + self.call_api('delete', comment, self.user2.pk) + self.assertEqual(self.call_api('count', comment, UP), 0) def test_vote_exists(self): comment = self.model.objects.create(user_id=self.user1.pk, content="I'm a comment") - self.assertFalse(self.call_api('exists', comment, self.user2.pk)) + self.assertFalse(self.call_api('exists', comment, self.user2.pk, + UP)) self.call_api('up', comment, self.user2.pk) - self.assertTrue(self.call_api('exists', comment, self.user2.pk)) + self.assertTrue(self.call_api('exists', comment, + self.user2.pk, UP)) def test_vote_all(self): comment = self.model.objects.create(user_id=self.user1.pk, @@ -81,7 +93,7 @@ def test_vote_count(self): def test_user_ids(self): comment = self.model.objects.create(user_id=self.user1.pk, - content="I'm a comment") + content="I'm a comment") self.call_api('up', comment, self.user1.pk) self.assertEqual(len(list(self.call_api('user_ids', comment))), 1) @@ -116,7 +128,7 @@ def test_vote_annotate(self): self.assertEqual(comments[-2].pk, comment1.pk) for comment in comments: - self.assertTrue(hasattr(comment, 'num_votes')) + self.assertTrue(hasattr(comment, 'vote_score')) # call annotate with queryset and user_id comments = self.model.objects.filter(user_id=self.user1.pk) @@ -126,15 +138,15 @@ def test_vote_annotate(self): self.assertEqual(comments[1].pk, comment1.pk) for comment in comments: - self.assertTrue(hasattr(comment, 'num_votes')) - self.assertTrue(hasattr(comment, 'is_voted')) + self.assertTrue(hasattr(comment, 'vote_score')) + self.assertTrue(hasattr(comment, 'is_voted_up')) + self.assertTrue(hasattr(comment, 'is_voted_down')) if comment.pk in (comment1.pk, comment2.pk): - self.assertTrue(comment.is_voted) + self.assertTrue(comment.is_voted_up) else: - self.assertFalse(comment.is_voted) + self.assertFalse(comment.is_voted_up) def test_objects_with_status(self): - test_field = 'is_test_voted' comments = [ self.model( user_id=self.user1.pk, @@ -154,27 +166,28 @@ def test_objects_with_status(self): comment_ids = [comment2.id, comment1.id] votes = getattr(self.model, self.field_name) - comments = votes.vote_by(self.user2.pk, ids=comment_ids, - field=test_field) + comments = votes.vote_by(self.user2.pk, ids=comment_ids) self.assertEqual(comments[0].id, comment2.id) for comment in comments: - self.assertTrue(hasattr(comment, test_field)) + self.assertTrue(hasattr(comment, 'is_voted_up')) + self.assertTrue(hasattr(comment, 'is_voted_down')) comment_ids = [comment1.id, comment2.id] votes = getattr(self.model, self.field_name) - comments = votes.vote_by(self.user2.pk, ids=comment_ids, - field=test_field) + comments = votes.vote_by(self.user2.pk, ids=comment_ids) self.assertEqual(comments[0].id, comment1.id) for comment in comments: - self.assertTrue(hasattr(comment, test_field)) + self.assertTrue(hasattr(comment, 'is_voted_up')) + self.assertTrue(hasattr(comment, 'is_voted_down')) comments = votes.vote_by(self.user2.pk, queryset=Comment.objects.all()) for comment in comments: - self.assertTrue(hasattr(comment, 'is_voted')) + self.assertTrue(hasattr(comment, 'is_voted_up')) + self.assertTrue(hasattr(comment, 'is_voted_down')) self.assertRaises(ValueError, lambda: votes.vote_by(self.user2.pk)) @@ -193,8 +206,3 @@ def test_vote_templatetag(self): res = self.client.get('/comments/') self.client.login(username=self.user1.username, password='111111') self.client.get('/comments/') - -class CustomVoteTest(VoteTest): - through = Vote - model = CustomVoteComment - field_name = 'custom_votes' diff --git a/vote/__init__.py b/vote/__init__.py index 0ea6bce..5dc2c95 100644 --- a/vote/__init__.py +++ b/vote/__init__.py @@ -1,3 +1,3 @@ -VERSION = (2, 1, 3) +VERSION = (2, 1, 4) default_app_config = 'vote.apps.VoteAppConfig' diff --git a/vote/managers.py b/vote/managers.py index a2cf067..bdea804 100644 --- a/vote/managers.py +++ b/vote/managers.py @@ -1,26 +1,27 @@ from django.db import models, transaction, IntegrityError -from django.db.models import Count, F from django.db.models.query import QuerySet from django.db.utils import OperationalError from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.models import ContentType from django.utils.translation import ugettext_lazy as _ -from vote.models import Vote from vote.utils import instance_required, add_field_to_objects +UP = 0 +DOWN = 1 + class VotedQuerySet(QuerySet): + """ if call votes.annotate with an `user` argument then add `is_voted` to each instance. """ def __init__(self, model=None, query=None, using=None, user_id=None, - hints=None, field='is_voted'): + hints=None): self.user_id = user_id - self.vote_field = field super(VotedQuerySet, self).__init__(model, query, using, hints) def __iter__(self): @@ -32,8 +33,7 @@ def __iter__(self): objects = self._result_cache user_id = self.user_id - objects = add_field_to_objects(self.model, objects, user_id, - field=self.vote_field) + objects = add_field_to_objects(self.model, objects, user_id) self._result_cache = objects return iter(objects) @@ -45,38 +45,67 @@ def _clone(self): class _VotableManager(models.Manager): - def __init__(self, through, model, instance, field_name='votes', - extra_field=None): + + def __init__(self, through, model, instance, field_name='votes'): self.through = through self.model = model self.instance = instance self.field_name = field_name - self.extra_field = extra_field - @instance_required - def up(self, user_id): + def vote(self, user_id, action): try: with transaction.atomic(): - self.through(user_id=user_id, - content_object=self.instance).save() + self.instance = self.model.objects.select_for_update().get( + pk=self.instance.pk) - if self.extra_field: - setattr(self.instance, self.extra_field, - F(self.extra_field)+1) + content_type = ContentType.objects.get_for_model(self.model) + try: + vote = self.through.objects.get(user_id=user_id, + content_type=content_type, + object_id=self.instance.pk) + if vote.action == action: + return False + vote.action = action + vote.save() + + # will delete your up if you vote down some instance that + # you have vote up + voted_field = self.through.ACTION_FIELD.get( + int(not action)) + setattr(self.instance, voted_field, + getattr(self.instance, voted_field) - 1) + except self.through.DoesNotExist: + self.through.objects.create(user_id=user_id, + content_type=content_type, + object_id=self.instance.pk, + action=action) - self.instance.save() + statistics_field = self.through.ACTION_FIELD.get(action) + setattr(self.instance, statistics_field, + getattr(self.instance, statistics_field) + 1) + + self.instance.save() return True except (OperationalError, IntegrityError): return False + @instance_required + def up(self, user_id): + return self.vote(user_id, action=UP) + @instance_required def down(self, user_id): + return self.vote(user_id, action=DOWN) + + @instance_required + def delete(self, user_id): try: with transaction.atomic(): content_type = ContentType.objects.get_for_model(self.instance) try: + # select_for_update will add a write lock here vote = self.through.objects.select_for_update().get( user_id=user_id, content_type_id=content_type.id, @@ -85,11 +114,13 @@ def down(self, user_id): except self.through.DoesNotExist: return False - if self.extra_field: - setattr(self.instance, self.extra_field, - F(self.extra_field)-1) + self.instance = self.model.objects.select_for_update().get( + pk=self.instance.pk) + statistics_field = self.through.ACTION_FIELD.get(vote.action) + setattr(self.instance, statistics_field, + getattr(self.instance, statistics_field) - 1) - self.instance.save() + self.instance.save() vote.delete() @@ -99,49 +130,48 @@ def down(self, user_id): return False @instance_required - def exists(self, user_id): + def exists(self, user_id, action=UP): return self.through.objects.filter( user_id=user_id, - content_object=self.instance + content_object=self.instance, + action=action ).exists() - def all(self, user_id): + def all(self, user_id, action=UP): content_type = ContentType.objects.get_for_model(self.model) object_ids = self.through.objects.filter( user_id=user_id, - content_type=content_type).values_list('object_id', flat=True) + content_type=content_type, + action=action).values_list('object_id', flat=True) return self.model.objects.filter(pk__in=list(object_ids)) - def count(self): - return self.through.votes_for(self.model, self.instance).count() + def count(self, action=UP): + return self.through.votes_for(self.model, + self.instance, action).count() - def user_ids(self): + def user_ids(self, action=UP): return self.through.votes_for( - self.model, self.instance + self.model, self.instance, action ).order_by('-create_at').values_list('user_id', 'create_at') - def annotate(self, queryset=None, user_id=None, annotation='num_votes', + def annotate(self, queryset=None, user_id=None, reverse=True, sort=True): - kwargs = {annotation: Count('%s__user_id' % self.field_name)} - if queryset is not None: queryset = queryset else: queryset = self.model.objects.all() - queryset = queryset.annotate(**kwargs) - if sort: - order = reverse and '-%s' % annotation or annotation + order = reverse and '-%s' % 'vote_score' or 'vote_score' queryset = queryset.order_by(order, '-id') return VotedQuerySet(model=queryset.model, query=queryset.query, user_id=user_id) - def vote_by(self, user_id, queryset=None, ids=None, field='is_voted'): + def vote_by(self, user_id, queryset=None, ids=None): if queryset is None and ids is None: raise ValueError("queryset or ids can not be None") @@ -149,19 +179,19 @@ def vote_by(self, user_id, queryset=None, ids=None, field='is_voted'): objects = self.model.objects.filter(id__in=ids) objects = sorted(objects, key=lambda x: ids.index(x.id)) - return add_field_to_objects(self.model, objects, user_id, - field=field) + return add_field_to_objects(self.model, objects, user_id) else: return VotedQuerySet(model=queryset.model, query=queryset.query, - user_id=user_id, field=field) + user_id=user_id) class VotableManager(GenericRelation): - def __init__(self, through=Vote, manager=_VotableManager, **kwargs): - self.through = through + + def __init__(self, through=None, manager=_VotableManager, **kwargs): + from vote.models import Vote + self.through = Vote if not through else through self.manager = manager kwargs['verbose_name'] = kwargs.get('verbose_name', _('Votes')) - self.extra_field = kwargs.pop('extra_field', None) super(VotableManager, self).__init__(self.through, **kwargs) def __get__(self, instance, model): @@ -175,7 +205,6 @@ def __get__(self, instance, model): model=model, instance=instance, field_name=self.name, - extra_field=self.extra_field, ) return manager diff --git a/vote/migrations/0001_initial.py b/vote/migrations/0001_initial.py index b721e0c..2a6aa4f 100644 --- a/vote/migrations/0001_initial.py +++ b/vote/migrations/0001_initial.py @@ -10,20 +10,20 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ('auth', '__first__'), - ('contenttypes', '__first__') - ] - + dependencies = [('auth', '__first__'), ('contenttypes', '__first__')] + operations = [ migrations.CreateModel( name='Vote', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), ('user_id', models.BigIntegerField()), ('object_id', models.PositiveIntegerField()), ('create_at', models.DateTimeField(auto_now_add=True)), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('content_type', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='contenttypes.ContentType')), ], ), migrations.AlterUniqueTogether( diff --git a/vote/migrations/0003_vote_action.py b/vote/migrations/0003_vote_action.py new file mode 100644 index 0000000..287b78f --- /dev/null +++ b/vote/migrations/0003_vote_action.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.5 on 2017-01-06 07:48 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vote', '0002_auto_20161229_1022'), + ] + + operations = [ + migrations.AddField( + model_name='vote', + name='action', + field=models.PositiveSmallIntegerField(default=0), + ), + ] diff --git a/vote/migrations/0004_auto_20170110_1150.py b/vote/migrations/0004_auto_20170110_1150.py new file mode 100644 index 0000000..9032720 --- /dev/null +++ b/vote/migrations/0004_auto_20170110_1150.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.5 on 2017-01-10 03:50 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vote', '0003_vote_action'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='vote', + unique_together=set([('user_id', 'content_type', + 'object_id', 'action')]), + ), + ] diff --git a/vote/models.py b/vote/models.py index 09f5b75..c237804 100644 --- a/vote/models.py +++ b/vote/models.py @@ -1,9 +1,14 @@ from django.db import models from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey +from vote.managers import VotableManager + +UP = 0 +DOWN = 1 class VoteManger(models.Manager): + def filter(self, *args, **kwargs): if 'content_object' in kwargs: content_object = kwargs.pop('content_object') @@ -17,25 +22,72 @@ def filter(self, *args, **kwargs): class Vote(models.Model): + ACTION_FIELD = { + UP: 'num_vote_up', + DOWN: 'num_vote_down' + } + user_id = models.BigIntegerField() content_type = models.ForeignKey(ContentType) object_id = models.PositiveIntegerField() content_object = GenericForeignKey() + action = models.PositiveSmallIntegerField(default=UP) create_at = models.DateTimeField(auto_now_add=True) objects = VoteManger() class Meta: - unique_together = ('user_id', 'content_type', 'object_id') + unique_together = ('user_id', 'content_type', 'object_id', 'action') index_together = ('content_type', 'object_id') @classmethod - def votes_for(cls, model, instance=None): + def votes_for(cls, model, instance=None, action=UP): ct = ContentType.objects.get_for_model(model) kwargs = { - "content_type": ct + "content_type": ct, + "action": action } if instance is not None: kwargs["object_id"] = instance.pk return cls.objects.filter(**kwargs) + + +class VoteModel(models.Model): + vote_score = models.IntegerField(default=0, db_index=True) + num_vote_up = models.PositiveIntegerField(default=0, db_index=True) + num_vote_down = models.PositiveIntegerField(default=0, db_index=True) + votes = VotableManager() + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + self.vote_score = self.calculate_vote_score + super(VoteModel, self).save(*args, **kwargs) + + @property + def calculate_vote_score(self): + return self.num_vote_up - self.num_vote_down + + @property + def is_voted_up(self): + try: + return self._is_voted_up + except AttributeError: + return False + + @is_voted_up.setter + def is_voted_up(self, value): + self._is_voted_up = value + + @property + def is_voted_down(self): + try: + return self._is_voted_down + except AttributeError: + return False + + @is_voted_down.setter + def is_voted_down(self, value): + self._is_voted_down = value diff --git a/vote/templatetags/vote.py b/vote/templatetags/vote.py index 124c4dc..bcd84b3 100644 --- a/vote/templatetags/vote.py +++ b/vote/templatetags/vote.py @@ -1,11 +1,15 @@ +from __future__ import absolute_import + from django import template from django.contrib.auth.models import AnonymousUser +from vote.models import UP + register = template.Library() @register.simple_tag -def vote_exists(model, user=AnonymousUser()): +def vote_exists(model, user=AnonymousUser(), action=UP): if user.is_anonymous(): return False - return model.votes.exists(user.pk) + return model.votes.exists(user.pk, action=action) diff --git a/vote/utils.py b/vote/utils.py index f349835..c5625e9 100644 --- a/vote/utils.py +++ b/vote/utils.py @@ -1,6 +1,5 @@ from functools import wraps from django.contrib.contenttypes.models import ContentType -from vote.models import Vote def instance_required(func): @@ -15,7 +14,8 @@ def inner(self, *args, **kwargs): return inner -def add_field_to_objects(model, objects, user_id, field='is_voted'): +def add_field_to_objects(model, objects, user_id): + from vote.models import Vote, UP, DOWN content_type = ContentType.objects.get_for_model(model) object_ids = [r.id for r in objects] @@ -23,9 +23,10 @@ def add_field_to_objects(model, objects, user_id, field='is_voted'): user_id=user_id, content_type=content_type, object_id__in=object_ids - ).values_list("object_id", flat=True) + ).values_list("object_id", "action") for r in objects: - setattr(r, field, r.pk in voted_object_ids) + r.is_voted_up = (r.pk, UP) in voted_object_ids + r.is_voted_down = (r.pk, DOWN) in voted_object_ids return objects