diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..725db95
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+
+django_voting.egg-info/*
+
+*.pyc
+
+dist/tango-voting-0.2.tar.gz
+
+tango_voting.egg-info/*
+
+*.gz
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 14cc54b..2a37a4f 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -1,3 +1,18 @@
=======================
Django Voting Changelog
-=======================
\ No newline at end of file
+=======================
+
+### 0.4.1
+Corrected is_authenticated check
+
+### 0.4.0
+Updated for Django 2.0 compatibility.
+
+### 0.3.2
+Update ForeignKeys to use on_delete for 2.0 compatibility.
+
+### 0.3.1
+Call settings.AUTH_USER_MODEL correctly
+
+### 0.3.0
+Updated for Django 1.8+
diff --git a/INSTALL.txt b/INSTALL.txt
deleted file mode 100644
index 56434af..0000000
--- a/INSTALL.txt
+++ /dev/null
@@ -1,14 +0,0 @@
-Thanks for downloading django-voting.
-
-To install it, run the following command inside this directory:
-
- python setup.py install
-
-Or if you'd prefer you can simply place the included ``voting``
-directory somewhere on your Python path, or symlink to it from
-somewhere on your Python path; this is useful if you're working from a
-Subversion checkout.
-
-Note that this application requires Python 2.3 or later, and Django
-0.97-pre or later. You can obtain Python from http://www.python.org/ and
-Django from http://www.djangoproject.com/.
\ No newline at end of file
diff --git a/MANIFEST.in b/MANIFEST.in
index df27391..59e4ff9 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,7 +1,7 @@
include CHANGELOG.txt
-include INSTALL.txt
include LICENSE.txt
include MANIFEST.in
include README.txt
recursive-include docs *
+recursive-include voting/templates/voting *
recursive-include voting/tests *
diff --git a/README.txt b/README.txt
index 9f55f61..29ace4b 100644
--- a/README.txt
+++ b/README.txt
@@ -1,10 +1,11 @@
=============
-Django Voting
+Tango Voting
=============
-This is a generic voting application for Django projects
+This is a generic voting application for Django projects,
+based on Jonathan Buchanan's old [django-voting](http://code.google.com/p/django-voting/) app,
+with additional improvements and enhancements.
-For installation instructions, see the file "INSTALL.txt" in this
-directory; for instructions on how to use this application, and on
+For instructions on how to use this application, and on
what it provides, see the file "overview.txt" in the "docs/"
-directory.
\ No newline at end of file
+directory.
diff --git a/build/lib/voting/__init__.py b/build/lib/voting/__init__.py
new file mode 100644
index 0000000..449f64a
--- /dev/null
+++ b/build/lib/voting/__init__.py
@@ -0,0 +1 @@
+VERSION = (0, 1, None)
\ No newline at end of file
diff --git a/build/lib/voting/admin.py b/build/lib/voting/admin.py
new file mode 100644
index 0000000..6179681
--- /dev/null
+++ b/build/lib/voting/admin.py
@@ -0,0 +1,8 @@
+from django.contrib import admin
+from voting.models import Vote
+
+
+class VoteAdmin(admin.ModelAdmin):
+ list_display = ('user', 'content_type', 'vote')
+
+admin.site.register(Vote, VoteAdmin)
diff --git a/build/lib/voting/managers.py b/build/lib/voting/managers.py
new file mode 100644
index 0000000..71d6d57
--- /dev/null
+++ b/build/lib/voting/managers.py
@@ -0,0 +1,203 @@
+from django.conf import settings
+from django.db import connection, models
+
+try:
+ from django.db.models.sql.aggregates import Aggregate
+except ImportError:
+ supports_aggregates = False
+else:
+ supports_aggregates = True
+
+from django.contrib.contenttypes.models import ContentType
+
+if supports_aggregates:
+ class CoalesceWrapper(Aggregate):
+ sql_template = 'COALESCE(%(function)s(%(field)s), %(default)s)'
+
+ def __init__(self, lookup, **extra):
+ self.lookup = lookup
+ self.extra = extra
+
+ def _default_alias(self):
+ return '%s__%s' % (self.lookup, self.__class__.__name__.lower())
+ default_alias = property(_default_alias)
+
+ def add_to_query(self, query, alias, col, source, is_summary):
+ super(CoalesceWrapper, self).__init__(col, source, is_summary, **self.extra)
+ query.aggregate_select[alias] = self
+
+
+ class CoalesceSum(CoalesceWrapper):
+ sql_function = 'SUM'
+
+
+ class CoalesceCount(CoalesceWrapper):
+ sql_function = 'COUNT'
+
+
+class VoteManager(models.Manager):
+ def get_score(self, obj):
+ """
+ Get a dictionary containing the total score for ``obj`` and
+ the number of votes it's received.
+ """
+ ctype = ContentType.objects.get_for_model(obj)
+ result = self.filter(object_id=obj._get_pk_val(),
+ content_type=ctype).extra(
+ select={
+ 'score': 'COALESCE(SUM(vote), 0)',
+ 'num_votes': 'COALESCE(COUNT(vote), 0)',
+ }).values_list('score', 'num_votes')[0]
+
+ return {
+ 'score': int(result[0]),
+ 'num_votes': int(result[1]),
+ }
+
+ def get_scores_in_bulk(self, objects):
+ """
+ Get a dictionary mapping object ids to total score and number
+ of votes for each object.
+ """
+ object_ids = [o._get_pk_val() for o in objects]
+ if not object_ids:
+ return {}
+
+ ctype = ContentType.objects.get_for_model(objects[0])
+
+ if supports_aggregates:
+ queryset = self.filter(
+ object_id__in = object_ids,
+ content_type = ctype,
+ ).values(
+ 'object_id',
+ ).annotate(
+ score = CoalesceSum('vote', default='0'),
+ num_votes = CoalesceCount('vote', default='0'),
+ )
+ else:
+ queryset = self.filter(
+ object_id__in = object_ids,
+ content_type = ctype,
+ ).extra(
+ select = {
+ 'score': 'COALESCE(SUM(vote), 0)',
+ 'num_votes': 'COALESCE(COUNT(vote), 0)',
+ }
+ ).values('object_id', 'score', 'num_votes')
+ queryset.query.group_by.append('object_id')
+
+ vote_dict = {}
+ for row in queryset:
+ vote_dict[row['object_id']] = {
+ 'score': int(row['score']),
+ 'num_votes': int(row['num_votes']),
+ }
+
+ return vote_dict
+
+ def record_vote(self, obj, user, vote):
+ """
+ Record a user's vote on a given object. Only allows a given user
+ to vote once, though that vote may be changed.
+
+ A zero vote indicates that any existing vote should be removed.
+ """
+ if vote not in (+1, 0, -1):
+ raise ValueError('Invalid vote (must be +1/0/-1)')
+ ctype = ContentType.objects.get_for_model(obj)
+ try:
+ v = self.get(user=user, content_type=ctype,
+ object_id=obj._get_pk_val())
+ if vote == 0:
+ v.delete()
+ else:
+ v.vote = vote
+ v.save()
+ except models.ObjectDoesNotExist:
+ if vote != 0:
+ self.create(user=user, content_type=ctype,
+ object_id=obj._get_pk_val(), vote=vote)
+
+ def get_top(self, Model, limit=10, reversed=False):
+ """
+ Get the top N scored objects for a given model.
+
+ Yields (object, score) tuples.
+ """
+ ctype = ContentType.objects.get_for_model(Model)
+ query = """
+ SELECT object_id, SUM(vote) as %s
+ FROM %s
+ WHERE content_type_id = %%s
+ GROUP BY object_id""" % (
+ connection.ops.quote_name('score'),
+ connection.ops.quote_name(self.model._meta.db_table),
+ )
+
+ # MySQL has issues with re-using the aggregate function in the
+ # HAVING clause, so we alias the score and use this alias for
+ # its benefit.
+ if settings.DATABASES['default']['ENGINE'] == 'mysql':
+ having_score = connection.ops.quote_name('score')
+ else:
+ having_score = 'SUM(vote)'
+ if reversed:
+ having_sql = ' HAVING %(having_score)s < 0 ORDER BY %(having_score)s ASC LIMIT %%s'
+ else:
+ having_sql = ' HAVING %(having_score)s > 0 ORDER BY %(having_score)s DESC LIMIT %%s'
+ query += having_sql % {
+ 'having_score': having_score,
+ }
+
+ cursor = connection.cursor()
+ cursor.execute(query, [ctype.id, limit])
+ results = cursor.fetchall()
+
+ # Use in_bulk() to avoid O(limit) db hits.
+ objects = Model.objects.in_bulk([id for id, score in results])
+
+ # Yield each object, score pair. Because of the lazy nature of generic
+ # relations, missing objects are silently ignored.
+ for id, score in results:
+ if id in objects:
+ yield objects[id], int(score)
+
+ def get_bottom(self, Model, limit=10):
+ """
+ Get the bottom (i.e. most negative) N scored objects for a given
+ model.
+
+ Yields (object, score) tuples.
+ """
+ return self.get_top(Model, limit, True)
+
+ def get_for_user(self, obj, user):
+ """
+ Get the vote made on the given object by the given user, or
+ ``None`` if no matching vote exists.
+ """
+ if not user.is_authenticated():
+ return None
+ ctype = ContentType.objects.get_for_model(obj)
+ try:
+ vote = self.get(content_type=ctype, object_id=obj._get_pk_val(),
+ user=user)
+ except models.ObjectDoesNotExist:
+ vote = None
+ return vote
+
+ def get_for_user_in_bulk(self, objects, user):
+ """
+ Get a dictionary mapping object ids to votes made by the given
+ user on the corresponding objects.
+ """
+ vote_dict = {}
+ if len(objects) > 0:
+ ctype = ContentType.objects.get_for_model(objects[0])
+ votes = list(self.filter(content_type__pk=ctype.id,
+ object_id__in=[obj._get_pk_val() \
+ for obj in objects],
+ user__pk=user.id))
+ vote_dict = dict([(vote.object_id, vote) for vote in votes])
+ return vote_dict
diff --git a/build/lib/voting/models.py b/build/lib/voting/models.py
new file mode 100644
index 0000000..7fbbad0
--- /dev/null
+++ b/build/lib/voting/models.py
@@ -0,0 +1,45 @@
+from django.conf import settings
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+
+from voting.managers import VoteManager
+
+SCORES = (
+ (u'+1', +1),
+ (u'-1', -1),
+)
+
+
+class Vote(models.Model):
+ """
+ A vote on an object by a User.
+ """
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.PROTECT
+ )
+ content_type = models.ForeignKey(
+ ContentType,
+ on_delete=models.PROTECT
+ )
+ object_id = models.PositiveIntegerField()
+ object = GenericForeignKey('content_type', 'object_id')
+ vote = models.SmallIntegerField(choices=SCORES)
+
+ objects = VoteManager()
+
+ class Meta:
+ app_label = 'voting'
+ db_table = 'votes'
+ # One vote per user per object
+ unique_together = (('user', 'content_type', 'object_id'),)
+
+ def __unicode__(self):
+ return u'%s: %s on %s' % (self.user, self.vote, self.object)
+
+ def is_upvote(self):
+ return self.vote == 1
+
+ def is_downvote(self):
+ return self.vote == -1
diff --git a/build/lib/voting/templates/voting/confirm_vote.html b/build/lib/voting/templates/voting/confirm_vote.html
new file mode 100644
index 0000000..826c025
--- /dev/null
+++ b/build/lib/voting/templates/voting/confirm_vote.html
@@ -0,0 +1,6 @@
+{% extends "base.html" %}
+
+{% block content %}
+
Thank you!
+ Your vote has been confirmed.
+{% endblock %}
\ No newline at end of file
diff --git a/build/lib/voting/templatetags/__init__.py b/build/lib/voting/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/build/lib/voting/templatetags/voting_tags.py b/build/lib/voting/templatetags/voting_tags.py
new file mode 100644
index 0000000..4e19e38
--- /dev/null
+++ b/build/lib/voting/templatetags/voting_tags.py
@@ -0,0 +1,270 @@
+from django import template
+from django.utils.html import escape
+
+from voting.models import Vote
+
+register = template.Library()
+
+
+class ScoreForObjectNode(template.Node):
+ def __init__(self, object, context_var):
+ self.object = object
+ self.context_var = context_var
+
+ def render(self, context):
+ try:
+ object = template.resolve_variable(self.object, context)
+ except template.VariableDoesNotExist:
+ return ''
+ context[self.context_var] = Vote.objects.get_score(object)
+ return ''
+
+
+class ScoresForObjectsNode(template.Node):
+ def __init__(self, objects, context_var):
+ self.objects = objects
+ self.context_var = context_var
+
+ def render(self, context):
+ try:
+ objects = template.resolve_variable(self.objects, context)
+ except template.VariableDoesNotExist:
+ return ''
+ context[self.context_var] = Vote.objects.get_scores_in_bulk(objects)
+ return ''
+
+
+class VoteByUserNode(template.Node):
+ def __init__(self, user, object, context_var):
+ self.user = user
+ self.object = object
+ self.context_var = context_var
+
+ def render(self, context):
+ try:
+ user = template.resolve_variable(self.user, context)
+ object = template.resolve_variable(self.object, context)
+ except template.VariableDoesNotExist:
+ return ''
+ context[self.context_var] = Vote.objects.get_for_user(object, user)
+ return ''
+
+
+class VotesByUserNode(template.Node):
+ def __init__(self, user, objects, context_var):
+ self.user = user
+ self.objects = objects
+ self.context_var = context_var
+
+ def render(self, context):
+ try:
+ user = template.resolve_variable(self.user, context)
+ objects = template.resolve_variable(self.objects, context)
+ except template.VariableDoesNotExist:
+ return ''
+ context[self.context_var] = Vote.objects.get_for_user_in_bulk(objects, user)
+ return ''
+
+
+class DictEntryForItemNode(template.Node):
+ def __init__(self, item, dictionary, context_var):
+ self.item = item
+ self.dictionary = dictionary
+ self.context_var = context_var
+
+ def render(self, context):
+ try:
+ dictionary = template.resolve_variable(self.dictionary, context)
+ item = template.resolve_variable(self.item, context)
+ except template.VariableDoesNotExist:
+ return ''
+ context[self.context_var] = dictionary.get(item.id, None)
+ return ''
+
+
+def do_score_for_object(parser, token):
+ """
+ Retrieves the total score for an object and the number of votes
+ it's received and stores them in a context variable which has
+ ``score`` and ``num_votes`` properties.
+
+ Example usage::
+
+ {% score_for_object widget as score %}
+
+ {{ score.score }}point{{ score.score|pluralize }}
+ after {{ score.num_votes }} vote{{ score.num_votes|pluralize }}
+ """
+ bits = token.contents.split()
+ if len(bits) != 4:
+ raise template.TemplateSyntaxError("'%s' tag takes exactly three arguments" % bits[0])
+ if bits[2] != 'as':
+ raise template.TemplateSyntaxError("second argument to '%s' tag must be 'as'" % bits[0])
+ return ScoreForObjectNode(bits[1], bits[3])
+
+
+def do_scores_for_objects(parser, token):
+ """
+ Retrieves the total scores for a list of objects and the number of
+ votes they have received and stores them in a context variable.
+
+ Example usage::
+
+ {% scores_for_objects widget_list as score_dict %}
+ """
+ bits = token.contents.split()
+ if len(bits) != 4:
+ raise template.TemplateSyntaxError("'%s' tag takes exactly three arguments" % bits[0])
+ if bits[2] != 'as':
+ raise template.TemplateSyntaxError("second argument to '%s' tag must be 'as'" % bits[0])
+ return ScoresForObjectsNode(bits[1], bits[3])
+
+
+def do_vote_by_user(parser, token):
+ """
+ Retrieves the ``Vote`` cast by a user on a particular object and
+ stores it in a context variable. If the user has not voted, the
+ context variable will be ``None``.
+
+ Example usage::
+
+ {% vote_by_user user on widget as vote %}
+ """
+ bits = token.contents.split()
+ if len(bits) != 6:
+ raise template.TemplateSyntaxError("'%s' tag takes exactly five arguments" % bits[0])
+ if bits[2] != 'on':
+ raise template.TemplateSyntaxError("second argument to '%s' tag must be 'on'" % bits[0])
+ if bits[4] != 'as':
+ raise template.TemplateSyntaxError("fourth argument to '%s' tag must be 'as'" % bits[0])
+ return VoteByUserNode(bits[1], bits[3], bits[5])
+
+
+def do_votes_by_user(parser, token):
+ """
+ Retrieves the votes cast by a user on a list of objects as a
+ dictionary keyed with object ids and stores it in a context
+ variable.
+
+ Example usage::
+
+ {% votes_by_user user on widget_list as vote_dict %}
+ """
+ bits = token.contents.split()
+ if len(bits) != 6:
+ raise template.TemplateSyntaxError("'%s' tag takes exactly four arguments" % bits[0])
+ if bits[2] != 'on':
+ raise template.TemplateSyntaxError("second argument to '%s' tag must be 'on'" % bits[0])
+ if bits[4] != 'as':
+ raise template.TemplateSyntaxError("fourth argument to '%s' tag must be 'as'" % bits[0])
+ return VotesByUserNode(bits[1], bits[3], bits[5])
+
+
+def do_dict_entry_for_item(parser, token):
+ """
+ Given an object and a dictionary keyed with object ids - as
+ returned by the ``votes_by_user`` and ``scores_for_objects``
+ template tags - retrieves the value for the given object and
+ stores it in a context variable, storing ``None`` if no value
+ exists for the given object.
+
+ Example usage::
+
+ {% dict_entry_for_item widget from vote_dict as vote %}
+ """
+ bits = token.contents.split()
+ if len(bits) != 6:
+ raise template.TemplateSyntaxError("'%s' tag takes exactly five arguments" % bits[0])
+ if bits[2] != 'from':
+ raise template.TemplateSyntaxError("second argument to '%s' tag must be 'from'" % bits[0])
+ if bits[4] != 'as':
+ raise template.TemplateSyntaxError("fourth argument to '%s' tag must be 'as'" % bits[0])
+ return DictEntryForItemNode(bits[1], bits[3], bits[5])
+
+register.tag('score_for_object', do_score_for_object)
+register.tag('scores_for_objects', do_scores_for_objects)
+register.tag('vote_by_user', do_vote_by_user)
+register.tag('votes_by_user', do_votes_by_user)
+register.tag('dict_entry_for_item', do_dict_entry_for_item)
+
+
+# Simple Tags
+
+
+def confirm_vote_message(object_description, vote_direction):
+ """
+ Creates an appropriate message asking the user to confirm the given vote
+ for the given object description.
+
+ Example usage::
+
+ {% confirm_vote_message widget.title direction %}
+ """
+ if vote_direction == 'clear':
+ message = 'Confirm clearing your vote for %s.'
+ else:
+ message = 'Confirm %s vote for %%s.' % vote_direction
+ return message % (escape(object_description),)
+
+register.simple_tag(confirm_vote_message)
+
+
+# Filters
+
+
+def vote_display(vote, arg=None):
+ """
+ Given a string mapping values for up and down votes, returns one
+ of the strings according to the given ``Vote``:
+
+ ========= ===================== =============
+ Vote type Argument Outputs
+ ========= ===================== =============
+ ``+1`` ``"Bodacious,Bogus"`` ``Bodacious``
+ ``-1`` ``"Bodacious,Bogus"`` ``Bogus``
+ ========= ===================== =============
+
+ If no string mapping is given, "Up" and "Down" will be used.
+
+ Example usage::
+
+ {{ vote|vote_display:"Bodacious,Bogus" }}
+ """
+ if arg is None:
+ arg = 'Up,Down'
+ bits = arg.split(',')
+ if len(bits) != 2:
+ return vote.vote # Invalid arg
+ up, down = bits
+ if vote.vote == 1:
+ return up
+ return down
+
+
+@register.filter
+def set_flag(score, arg=-3):
+ """
+ Gets vote total and returns a "toxic" flag if the object has
+ been downvoted into oblivion.
+ Threshold defaults to -3, but can be set when the tag is called.
+ Example usage::
+ {{ vote|set_flag }}
+ {{ vote|set_flag:"-5" }}
+ """
+ if score and score <= int(arg):
+ return 'toxic'
+ return ''
+
+
+@register.filter
+def clean_score(score, arg=None):
+ """
+ If the score is below 0, returns 0 so negative scores aren't public.
+ Example usage::
+ {{ score.score|clean_score" }}
+ """
+ if isinstance(score, dict):
+ score = score.get('score')
+ if not score or score < 0:
+ score = 0
+ return score
diff --git a/build/lib/voting/tests/__init__.py b/build/lib/voting/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/build/lib/voting/tests/models.py b/build/lib/voting/tests/models.py
new file mode 100644
index 0000000..eaf405b
--- /dev/null
+++ b/build/lib/voting/tests/models.py
@@ -0,0 +1,10 @@
+from django.db import models
+
+class Item(models.Model):
+ name = models.CharField(max_length=50)
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ ordering = ['name']
diff --git a/build/lib/voting/tests/runtests.py b/build/lib/voting/tests/runtests.py
new file mode 100644
index 0000000..bc1ba37
--- /dev/null
+++ b/build/lib/voting/tests/runtests.py
@@ -0,0 +1,8 @@
+import os, sys
+os.environ['DJANGO_SETTINGS_MODULE'] = 'voting.tests.settings'
+
+from django.test.simple import run_tests
+
+failures = run_tests(None, verbosity=9)
+if failures:
+ sys.exit(failures)
diff --git a/build/lib/voting/tests/settings.py b/build/lib/voting/tests/settings.py
new file mode 100644
index 0000000..8d2506e
--- /dev/null
+++ b/build/lib/voting/tests/settings.py
@@ -0,0 +1,27 @@
+import os
+
+DIRNAME = os.path.dirname(__file__)
+
+DATABASE_ENGINE = 'sqlite3'
+DATABASE_NAME = os.path.join(DIRNAME, 'database.db')
+
+#DATABASE_ENGINE = 'mysql'
+#DATABASE_NAME = 'tagging_test'
+#DATABASE_USER = 'root'
+#DATABASE_PASSWORD = ''
+#DATABASE_HOST = 'localhost'
+#DATABASE_PORT = '3306'
+
+#DATABASE_ENGINE = 'postgresql_psycopg2'
+#DATABASE_NAME = 'tagging_test'
+#DATABASE_USER = 'postgres'
+#DATABASE_PASSWORD = ''
+#DATABASE_HOST = 'localhost'
+#DATABASE_PORT = '5432'
+
+INSTALLED_APPS = (
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'voting',
+ 'voting.tests',
+)
diff --git a/build/lib/voting/tests/tests.py b/build/lib/voting/tests/tests.py
new file mode 100644
index 0000000..25bb5a8
--- /dev/null
+++ b/build/lib/voting/tests/tests.py
@@ -0,0 +1,86 @@
+r"""
+>>> from django.contrib.auth.models import User
+>>> from voting.models import Vote
+>>> from voting.tests.models import Item
+
+##########
+# Voting #
+##########
+
+# Basic voting ###############################################################
+
+>>> i1 = Item.objects.create(name='test1')
+>>> users = []
+>>> for username in ['u1', 'u2', 'u3', 'u4']:
+... users.append(User.objects.create_user(username, '%s@test.com' % username, 'test'))
+>>> Vote.objects.get_score(i1)
+{'score': 0, 'num_votes': 0}
+>>> Vote.objects.record_vote(i1, users[0], +1)
+>>> Vote.objects.get_score(i1)
+{'score': 1, 'num_votes': 1}
+>>> Vote.objects.record_vote(i1, users[0], -1)
+>>> Vote.objects.get_score(i1)
+{'score': -1, 'num_votes': 1}
+>>> Vote.objects.record_vote(i1, users[0], 0)
+>>> Vote.objects.get_score(i1)
+{'score': 0, 'num_votes': 0}
+>>> for user in users:
+... Vote.objects.record_vote(i1, user, +1)
+>>> Vote.objects.get_score(i1)
+{'score': 4, 'num_votes': 4}
+>>> for user in users[:2]:
+... Vote.objects.record_vote(i1, user, 0)
+>>> Vote.objects.get_score(i1)
+{'score': 2, 'num_votes': 2}
+>>> for user in users[:2]:
+... Vote.objects.record_vote(i1, user, -1)
+>>> Vote.objects.get_score(i1)
+{'score': 0, 'num_votes': 4}
+
+>>> Vote.objects.record_vote(i1, user, -2)
+Traceback (most recent call last):
+ ...
+ValueError: Invalid vote (must be +1/0/-1)
+
+# Retrieval of votes #########################################################
+
+>>> i2 = Item.objects.create(name='test2')
+>>> i3 = Item.objects.create(name='test3')
+>>> i4 = Item.objects.create(name='test4')
+>>> Vote.objects.record_vote(i2, users[0], +1)
+>>> Vote.objects.record_vote(i3, users[0], -1)
+>>> Vote.objects.record_vote(i4, users[0], 0)
+>>> vote = Vote.objects.get_for_user(i2, users[0])
+>>> (vote.vote, vote.is_upvote(), vote.is_downvote())
+(1, True, False)
+>>> vote = Vote.objects.get_for_user(i3, users[0])
+>>> (vote.vote, vote.is_upvote(), vote.is_downvote())
+(-1, False, True)
+>>> Vote.objects.get_for_user(i4, users[0]) is None
+True
+
+# In bulk
+>>> votes = Vote.objects.get_for_user_in_bulk([i1, i2, i3, i4], users[0])
+>>> [(id, vote.vote) for id, vote in votes.items()]
+[(1, -1), (2, 1), (3, -1)]
+>>> Vote.objects.get_for_user_in_bulk([], users[0])
+{}
+
+>>> for user in users[1:]:
+... Vote.objects.record_vote(i2, user, +1)
+... Vote.objects.record_vote(i3, user, +1)
+... Vote.objects.record_vote(i4, user, +1)
+>>> list(Vote.objects.get_top(Item))
+[(, 4), (, 3), (, 2)]
+>>> for user in users[1:]:
+... Vote.objects.record_vote(i2, user, -1)
+... Vote.objects.record_vote(i3, user, -1)
+... Vote.objects.record_vote(i4, user, -1)
+>>> list(Vote.objects.get_bottom(Item))
+[(, -4), (, -3), (, -2)]
+
+>>> Vote.objects.get_scores_in_bulk([i1, i2, i3, i4])
+{1: {'score': 0, 'num_votes': 4}, 2: {'score': -2, 'num_votes': 4}, 3: {'score': -4, 'num_votes': 4}, 4: {'score': -3, 'num_votes': 3}}
+>>> Vote.objects.get_scores_in_bulk([])
+{}
+"""
diff --git a/build/lib/voting/views.py b/build/lib/voting/views.py
new file mode 100644
index 0000000..30d92a9
--- /dev/null
+++ b/build/lib/voting/views.py
@@ -0,0 +1,98 @@
+import json
+
+from django.contrib.contenttypes.models import ContentType
+from django.http import HttpResponse, HttpResponseRedirect
+from django.contrib.auth.decorators import login_required
+from django.template import loader, RequestContext
+
+from .models import Vote
+
+VOTE_DIRECTIONS = (('up', 1), ('down', -1), ('clear', 0))
+
+
+@login_required
+def generic_vote_on_object(request, model_name, object_id, direction,
+ post_vote_redirect=None, template_name=None,
+ template_loader=loader, extra_context=None, context_processors=None,
+ template_object_name='object', allow_xmlhttprequest=False):
+ """
+ Really generic object vote function.
+ Gets object and model via content_type.
+ Expects URL format:
+ {% url 'generic_vote' 'content_type' object.id '' %}
+
+ The given template will be used to confirm the vote if this view is
+ fetched using GET; vote registration will only be performed if this
+ view is POSTed.
+
+ If ``allow_xmlhttprequest`` is ``True`` and an XMLHttpRequest is
+ detected by examining the ``HTTP_X_REQUESTED_WITH`` header, the
+ ``xmlhttp_vote_on_object`` view will be used to process the
+ request - this makes it trivial to implement voting via
+ XMLHttpRequest with a fallback for users who don't have JavaScript
+ enabled.
+
+ Templates:``/_confirm_vote.html``
+ Context:
+ object
+ The object being voted on.
+ direction
+ The type of vote which will be registered for the object.
+ """
+
+ try:
+ vote = dict(VOTE_DIRECTIONS)[direction]
+ except KeyError:
+ raise AttributeError('\'%s\' is not a valid vote type.' % direction)
+
+ # TO-DO: check by app_label, also
+ ctype = ContentType.objects.filter(model=model_name)[0]
+ obj = ctype.get_object_for_this_type(pk=object_id)
+
+ if request.is_ajax() and request.method == 'GET':
+ return json_error_response('XMLHttpRequest votes can only be made using POST.')
+
+ Vote.objects.record_vote(obj, request.user, vote)
+
+ if request.is_ajax():
+ return HttpResponse(json.dumps({'success': True,
+ 'score': Vote.objects.get_score(obj)}))
+
+ if extra_context is None:
+ extra_context = {}
+
+ # Look up the object to be voted on
+ if request.method == 'POST':
+ if post_vote_redirect is not None:
+ next = post_vote_redirect
+ elif 'next' in request.REQUEST:
+ next = request.REQUEST['next']
+ elif hasattr(obj, 'get_absolute_url'):
+ if callable(getattr(obj, 'get_absolute_url')):
+ next = obj.get_absolute_url()
+ else:
+ next = obj.get_absolute_url
+ else:
+ raise AttributeError('Generic vote view must be called with either post_vote_redirect, a "next" parameter in the request, or the object being voted on must define a get_absolute_url method or property.')
+ Vote.objects.record_vote(obj, request.user, vote)
+ return HttpResponseRedirect(next)
+ else:
+ if not template_name:
+ template_name = 'voting/confirm_vote.html'
+ t = template_loader.get_template(template_name)
+ c = RequestContext(request, {
+ template_object_name: obj,
+ 'direction': direction,
+ }, context_processors)
+ for key, value in extra_context.items():
+ if callable(value):
+ c[key] = value()
+ else:
+ c[key] = value
+ response = HttpResponse(t.render(c))
+ return response
+
+
+def json_error_response(error_message, *args, **kwargs):
+ return HttpResponse(json.dumps(dict(success=False,
+ error_message=error_message)))
diff --git a/dist/tango_voting-0.3.2-py3-none-any.whl b/dist/tango_voting-0.3.2-py3-none-any.whl
new file mode 100644
index 0000000..eaa95e4
Binary files /dev/null and b/dist/tango_voting-0.3.2-py3-none-any.whl differ
diff --git a/docs/overview.txt b/docs/overview.txt
index f7c97fd..a75c191 100644
--- a/docs/overview.txt
+++ b/docs/overview.txt
@@ -153,21 +153,17 @@ XMLHttpRequest ``POST``.
The following sample URLconf demonstrates using a generic view for
voting on a model, allowing for regular voting and XMLHttpRequest
-voting at the same URL::
+voting at the same URL. Only one URL is required for all models,
+by passing the model name when calling the URL::
from django.conf.urls.defaults import *
- from voting.views import vote_on_object
- from shop.apps.products.models import Widget
- widget_dict = {
- 'model': Widget,
- 'template_object_name': 'widget',
- 'allow_xmlhttprequest': True,
- }
+ url(
+ name="generic_vote",
+ regex=r'^vote/(?P[-\w]+)/(?P\d+)/(?Pup|down)vote/$',
+ view='voting.views.vote_on_object'
+ ),
- urlpatterns = patterns('',
- (r'^widgets/(?P\d+)/(?Pup|down|clear)vote/?$', vote_on_object, widget_dict),
- )
``voting.views.vote_on_object``
--------------------------------
@@ -395,4 +391,4 @@ If no string mapping is given, ``'Up'`` and ``'Down'`` will be used.
Example usage::
- {{ vote|vote_display:"Bodacious,Bogus" }}
\ No newline at end of file
+ {{ vote|vote_display:"Bodacious,Bogus" }}
diff --git a/setup.py b/setup.py
index b61a615..c2edf35 100644
--- a/setup.py
+++ b/setup.py
@@ -1,33 +1,24 @@
from distutils.core import setup
-from distutils.command.install import INSTALL_SCHEMES
-
-# Tell distutils to put the data_files in platform-specific installation
-# locations. See here for an explanation:
-# http://groups.google.com/group/comp.lang.python/browse_thread/thread/35ec7b2fed36eaec/2105ee4d9e8042cb
-for scheme in INSTALL_SCHEMES.values():
- scheme['data'] = scheme['purelib']
-
-# Dynamically calculate the version based on tagging.VERSION.
-version_tuple = __import__('voting').VERSION
-if version_tuple[2] is not None:
- version = "%d.%d_%s" % version_tuple
-else:
- version = "%d.%d" % version_tuple[:2]
+from setuptools import find_packages
setup(
- name = 'django-voting',
- version = version,
- description = 'Generic voting application for Django',
- author = 'Jonathan Buchanan',
- author_email = 'jonathan.buchanan@gmail.com',
- url = 'http://code.google.com/p/django-voting/',
- packages = ['voting', 'voting.templatetags', 'voting.tests'],
- classifiers = ['Development Status :: 4 - Beta',
- 'Environment :: Web Environment',
- 'Framework :: Django',
- 'Intended Audience :: Developers',
- 'License :: OSI Approved :: BSD License',
- 'Operating System :: OS Independent',
- 'Programming Language :: Python',
- 'Topic :: Utilities'],
-)
\ No newline at end of file
+ name='tango-voting',
+ version='0.4.1',
+ author='Jonathan Buchanan/Tim Baxter',
+ author_email='mail.baxter@gmail.com',
+ url='https://github.com/tBaxter/django-voting',
+ license='LICENSE',
+ description="""Generic voting application for Django,
+ based on django-voting by Jonathan Buchanan""",
+ packages=find_packages(),
+ include_package_data=True,
+ classifiers=[
+ 'Development Status :: 4 - Beta',
+ 'Environment :: Web Environment',
+ 'Framework :: Django',
+ 'Intended Audience :: Developers',
+ 'License :: OSI Approved :: BSD License',
+ 'Operating System :: OS Independent',
+ 'Programming Language :: Python',
+ 'Topic :: Utilities'],
+)
diff --git a/voting/admin.py b/voting/admin.py
index 65e836c..6179681 100644
--- a/voting/admin.py
+++ b/voting/admin.py
@@ -1,4 +1,8 @@
from django.contrib import admin
from voting.models import Vote
-admin.site.register(Vote)
+
+class VoteAdmin(admin.ModelAdmin):
+ list_display = ('user', 'content_type', 'vote')
+
+admin.site.register(Vote, VoteAdmin)
diff --git a/voting/managers.py b/voting/managers.py
index 3162c3b..936c401 100644
--- a/voting/managers.py
+++ b/voting/managers.py
@@ -138,7 +138,7 @@ def get_top(self, Model, limit=10, reversed=False):
# MySQL has issues with re-using the aggregate function in the
# HAVING clause, so we alias the score and use this alias for
# its benefit.
- if settings.DATABASE_ENGINE == 'mysql':
+ if settings.DATABASES['default']['ENGINE'] == 'mysql':
having_score = connection.ops.quote_name('score')
else:
having_score = 'SUM(vote)'
@@ -177,7 +177,7 @@ def get_for_user(self, obj, user):
Get the vote made on the given object by the given user, or
``None`` if no matching vote exists.
"""
- if not user.is_authenticated():
+ if not user.is_authenticated:
return None
ctype = ContentType.objects.get_for_model(obj)
try:
diff --git a/voting/models.py b/voting/models.py
index cebbd15..7fbbad0 100644
--- a/voting/models.py
+++ b/voting/models.py
@@ -1,6 +1,6 @@
-from django.contrib.contenttypes import generic
+from django.conf import settings
+from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
-from django.contrib.auth.models import User
from django.db import models
from voting.managers import VoteManager
@@ -10,19 +10,27 @@
(u'-1', -1),
)
+
class Vote(models.Model):
"""
A vote on an object by a User.
"""
- user = models.ForeignKey(User)
- content_type = models.ForeignKey(ContentType)
- object_id = models.PositiveIntegerField()
- object = generic.GenericForeignKey('content_type', 'object_id')
- vote = models.SmallIntegerField(choices=SCORES)
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.PROTECT
+ )
+ content_type = models.ForeignKey(
+ ContentType,
+ on_delete=models.PROTECT
+ )
+ object_id = models.PositiveIntegerField()
+ object = GenericForeignKey('content_type', 'object_id')
+ vote = models.SmallIntegerField(choices=SCORES)
objects = VoteManager()
class Meta:
+ app_label = 'voting'
db_table = 'votes'
# One vote per user per object
unique_together = (('user', 'content_type', 'object_id'),)
diff --git a/voting/templates/voting/confirm_vote.html b/voting/templates/voting/confirm_vote.html
new file mode 100644
index 0000000..826c025
--- /dev/null
+++ b/voting/templates/voting/confirm_vote.html
@@ -0,0 +1,6 @@
+{% extends "base.html" %}
+
+{% block content %}
+ Thank you!
+ Your vote has been confirmed.
+{% endblock %}
\ No newline at end of file
diff --git a/voting/templatetags/voting_tags.py b/voting/templatetags/voting_tags.py
index 7fc4dc8..4e19e38 100644
--- a/voting/templatetags/voting_tags.py
+++ b/voting/templatetags/voting_tags.py
@@ -5,7 +5,6 @@
register = template.Library()
-# Tags
class ScoreForObjectNode(template.Node):
def __init__(self, object, context_var):
@@ -20,6 +19,7 @@ def render(self, context):
context[self.context_var] = Vote.objects.get_score(object)
return ''
+
class ScoresForObjectsNode(template.Node):
def __init__(self, objects, context_var):
self.objects = objects
@@ -33,6 +33,7 @@ def render(self, context):
context[self.context_var] = Vote.objects.get_scores_in_bulk(objects)
return ''
+
class VoteByUserNode(template.Node):
def __init__(self, user, object, context_var):
self.user = user
@@ -48,6 +49,7 @@ def render(self, context):
context[self.context_var] = Vote.objects.get_for_user(object, user)
return ''
+
class VotesByUserNode(template.Node):
def __init__(self, user, objects, context_var):
self.user = user
@@ -63,6 +65,7 @@ def render(self, context):
context[self.context_var] = Vote.objects.get_for_user_in_bulk(objects, user)
return ''
+
class DictEntryForItemNode(template.Node):
def __init__(self, item, dictionary, context_var):
self.item = item
@@ -78,6 +81,7 @@ def render(self, context):
context[self.context_var] = dictionary.get(item.id, None)
return ''
+
def do_score_for_object(parser, token):
"""
Retrieves the total score for an object and the number of votes
@@ -98,6 +102,7 @@ def do_score_for_object(parser, token):
raise template.TemplateSyntaxError("second argument to '%s' tag must be 'as'" % bits[0])
return ScoreForObjectNode(bits[1], bits[3])
+
def do_scores_for_objects(parser, token):
"""
Retrieves the total scores for a list of objects and the number of
@@ -114,6 +119,7 @@ def do_scores_for_objects(parser, token):
raise template.TemplateSyntaxError("second argument to '%s' tag must be 'as'" % bits[0])
return ScoresForObjectsNode(bits[1], bits[3])
+
def do_vote_by_user(parser, token):
"""
Retrieves the ``Vote`` cast by a user on a particular object and
@@ -133,6 +139,7 @@ def do_vote_by_user(parser, token):
raise template.TemplateSyntaxError("fourth argument to '%s' tag must be 'as'" % bits[0])
return VoteByUserNode(bits[1], bits[3], bits[5])
+
def do_votes_by_user(parser, token):
"""
Retrieves the votes cast by a user on a list of objects as a
@@ -152,6 +159,7 @@ def do_votes_by_user(parser, token):
raise template.TemplateSyntaxError("fourth argument to '%s' tag must be 'as'" % bits[0])
return VotesByUserNode(bits[1], bits[3], bits[5])
+
def do_dict_entry_for_item(parser, token):
"""
Given an object and a dictionary keyed with object ids - as
@@ -179,8 +187,10 @@ def do_dict_entry_for_item(parser, token):
register.tag('votes_by_user', do_votes_by_user)
register.tag('dict_entry_for_item', do_dict_entry_for_item)
+
# Simple Tags
+
def confirm_vote_message(object_description, vote_direction):
"""
Creates an appropriate message asking the user to confirm the given vote
@@ -198,8 +208,10 @@ def confirm_vote_message(object_description, vote_direction):
register.simple_tag(confirm_vote_message)
+
# Filters
+
def vote_display(vote, arg=None):
"""
Given a string mapping values for up and down votes, returns one
@@ -222,10 +234,37 @@ def vote_display(vote, arg=None):
arg = 'Up,Down'
bits = arg.split(',')
if len(bits) != 2:
- return vote.vote # Invalid arg
+ return vote.vote # Invalid arg
up, down = bits
if vote.vote == 1:
return up
return down
-register.filter(vote_display)
\ No newline at end of file
+
+@register.filter
+def set_flag(score, arg=-3):
+ """
+ Gets vote total and returns a "toxic" flag if the object has
+ been downvoted into oblivion.
+ Threshold defaults to -3, but can be set when the tag is called.
+ Example usage::
+ {{ vote|set_flag }}
+ {{ vote|set_flag:"-5" }}
+ """
+ if score and score <= int(arg):
+ return 'toxic'
+ return ''
+
+
+@register.filter
+def clean_score(score, arg=None):
+ """
+ If the score is below 0, returns 0 so negative scores aren't public.
+ Example usage::
+ {{ score.score|clean_score" }}
+ """
+ if isinstance(score, dict):
+ score = score.get('score')
+ if not score or score < 0:
+ score = 0
+ return score
diff --git a/voting/views.py b/voting/views.py
index 352f6ad..30d92a9 100644
--- a/voting/views.py
+++ b/voting/views.py
@@ -1,20 +1,25 @@
+import json
+
from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist
-from django.http import Http404, HttpResponse, HttpResponseRedirect
-from django.contrib.auth.views import redirect_to_login
+from django.http import HttpResponse, HttpResponseRedirect
+from django.contrib.auth.decorators import login_required
from django.template import loader, RequestContext
-from django.utils import simplejson
-from voting.models import Vote
+from .models import Vote
VOTE_DIRECTIONS = (('up', 1), ('down', -1), ('clear', 0))
-def vote_on_object(request, model, direction, post_vote_redirect=None,
- object_id=None, slug=None, slug_field=None, template_name=None,
+
+@login_required
+def generic_vote_on_object(request, model_name, object_id, direction,
+ post_vote_redirect=None, template_name=None,
template_loader=loader, extra_context=None, context_processors=None,
template_object_name='object', allow_xmlhttprequest=False):
"""
- Generic object vote function.
+ Really generic object vote function.
+ Gets object and model via content_type.
+ Expects URL format:
+ {% url 'generic_vote' 'content_type' object.id '' %}
The given template will be used to confirm the vote if this view is
fetched using GET; vote registration will only be performed if this
@@ -34,38 +39,33 @@ def vote_on_object(request, model, direction, post_vote_redirect=None,
direction
The type of vote which will be registered for the object.
"""
- if allow_xmlhttprequest and request.is_ajax():
- return xmlhttprequest_vote_on_object(request, model, direction,
- object_id=object_id, slug=slug,
- slug_field=slug_field)
-
- if extra_context is None: extra_context = {}
- if not request.user.is_authenticated():
- return redirect_to_login(request.path)
try:
vote = dict(VOTE_DIRECTIONS)[direction]
except KeyError:
- raise AttributeError("'%s' is not a valid vote type." % vote_type)
+ raise AttributeError('\'%s\' is not a valid vote type.' % direction)
- # Look up the object to be voted on
- lookup_kwargs = {}
- if object_id:
- lookup_kwargs['%s__exact' % model._meta.pk.name] = object_id
- elif slug and slug_field:
- lookup_kwargs['%s__exact' % slug_field] = slug
- else:
- raise AttributeError('Generic vote view must be called with either '
- 'object_id or slug and slug_field.')
- try:
- obj = model._default_manager.get(**lookup_kwargs)
- except ObjectDoesNotExist:
- raise Http404, 'No %s found for %s.' % (model._meta.app_label, lookup_kwargs)
+ # TO-DO: check by app_label, also
+ ctype = ContentType.objects.filter(model=model_name)[0]
+ obj = ctype.get_object_for_this_type(pk=object_id)
+
+ if request.is_ajax() and request.method == 'GET':
+ return json_error_response('XMLHttpRequest votes can only be made using POST.')
+ Vote.objects.record_vote(obj, request.user, vote)
+
+ if request.is_ajax():
+ return HttpResponse(json.dumps({'success': True,
+ 'score': Vote.objects.get_score(obj)}))
+
+ if extra_context is None:
+ extra_context = {}
+
+ # Look up the object to be voted on
if request.method == 'POST':
if post_vote_redirect is not None:
next = post_vote_redirect
- elif request.REQUEST.has_key('next'):
+ elif 'next' in request.REQUEST:
next = request.REQUEST['next']
elif hasattr(obj, 'get_absolute_url'):
if callable(getattr(obj, 'get_absolute_url')):
@@ -73,17 +73,12 @@ def vote_on_object(request, model, direction, post_vote_redirect=None,
else:
next = obj.get_absolute_url
else:
- raise AttributeError('Generic vote view must be called with either '
- 'post_vote_redirect, a "next" parameter in '
- 'the request, or the object being voted on '
- 'must define a get_absolute_url method or '
- 'property.')
+ raise AttributeError('Generic vote view must be called with either post_vote_redirect, a "next" parameter in the request, or the object being voted on must define a get_absolute_url method or property.')
Vote.objects.record_vote(obj, request.user, vote)
return HttpResponseRedirect(next)
else:
if not template_name:
- template_name = '%s/%s_confirm_vote.html' % (
- model._meta.app_label, model._meta.object_name.lower())
+ template_name = 'voting/confirm_vote.html'
t = template_loader.get_template(template_name)
c = RequestContext(request, {
template_object_name: obj,
@@ -97,57 +92,7 @@ def vote_on_object(request, model, direction, post_vote_redirect=None,
response = HttpResponse(t.render(c))
return response
-def json_error_response(error_message):
- return HttpResponse(simplejson.dumps(dict(success=False,
- error_message=error_message)))
-
-def xmlhttprequest_vote_on_object(request, model, direction,
- object_id=None, slug=None, slug_field=None):
- """
- Generic object vote function for use via XMLHttpRequest.
-
- Properties of the resulting JSON object:
- success
- ``true`` if the vote was successfully processed, ``false``
- otherwise.
- score
- The object's updated score and number of votes if the vote
- was successfully processed.
- error_message
- Contains an error message if the vote was not successfully
- processed.
- """
- if request.method == 'GET':
- return json_error_response(
- 'XMLHttpRequest votes can only be made using POST.')
- if not request.user.is_authenticated():
- return json_error_response('Not authenticated.')
-
- try:
- vote = dict(VOTE_DIRECTIONS)[direction]
- except KeyError:
- return json_error_response(
- '\'%s\' is not a valid vote type.' % direction)
- # Look up the object to be voted on
- lookup_kwargs = {}
- if object_id:
- lookup_kwargs['%s__exact' % model._meta.pk.name] = object_id
- elif slug and slug_field:
- lookup_kwargs['%s__exact' % slug_field] = slug
- else:
- return json_error_response('Generic XMLHttpRequest vote view must be '
- 'called with either object_id or slug and '
- 'slug_field.')
- try:
- obj = model._default_manager.get(**lookup_kwargs)
- except ObjectDoesNotExist:
- return json_error_response(
- 'No %s found for %s.' % (model._meta.verbose_name, lookup_kwargs))
-
- # Vote and respond
- Vote.objects.record_vote(obj, request.user, vote)
- return HttpResponse(simplejson.dumps({
- 'success': True,
- 'score': Vote.objects.get_score(obj),
- }))
+def json_error_response(error_message, *args, **kwargs):
+ return HttpResponse(json.dumps(dict(success=False,
+ error_message=error_message)))