diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e63254 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.egg-info +*.pyc +build +htmlcov +MANIFEST +.tox +.coverage diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..42bd900 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,16 @@ +language: python +python: 2.7 +install: + - pip install tox +env: + - TOXENV=py26-django1.4 + - TOXENV=py26-django1.5 + - TOXENV=py26-django1.6 + - TOXENV=py27-django1.4 + - TOXENV=py27-django1.5 + - TOXENV=py27-django1.6 + - TOXENV=py33-django1.5 + - TOXENV=py33-django1.6 + - TOXENV=coverage +script: + - tox -e $TOXENV diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 14cc54b..7e9f75d 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,10 @@ -======================= Django Voting Changelog -======================= \ No newline at end of file +======================= + +0.2 +--- + +* Django 1.4 compatibility (timezone support) +* Added a ``time_stamp`` field to ``Vote`` model +* Added South migrations. +* \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt index 169aee3..d55a81a 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -2,6 +2,7 @@ django-voting ------------- Copyright (c) 2007, Jonathan Buchanan +Copyright (c) 2012, Jannis Leidel Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/docs/overview.txt b/docs/overview.rst similarity index 99% rename from docs/overview.txt rename to docs/overview.rst index f7c97fd..ba90d0d 100644 --- a/docs/overview.txt +++ b/docs/overview.rst @@ -395,4 +395,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..ef7894b 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,4 @@ 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 @@ -15,19 +8,28 @@ version = "%d.%d" % version_tuple[:2] 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='django-voting', + version=version, + description='Generic voting application for Django', + author='Jonathan Buchanan', + author_email='jonathan.buchanan@gmail.com', + maintainer='Jannis Leidel', + maintainer_email='jannis@leidel.info', + url='https://github.com/pjdelport/django-voting', + packages=[ + 'voting', + 'voting.migrations', + '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', + ], +) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..9095e09 --- /dev/null +++ b/tox.ini @@ -0,0 +1,66 @@ +[tox] +envlist = + py26-django1.4, + py26-django1.5, + py26-django1.6, + py27-django1.4, + py27-django1.5, + py27-django1.6, + py33-django1.5, + py33-django1.6, + coverage + +[testenv] +setenv = + DJANGO_SETTINGS_MODULE = voting.tests.settings + PYTHONPATH = {toxinidir} +commands = + django-admin.py test voting + +[testenv:coverage] +basepython = python2.7 +commands = + coverage run --branch --omit=.tox/*,*/tests/*.py,*/migrations/*.py {envbindir}/django-admin.py test voting +deps = + coverage>=3.7, + Django>=1.6,<1.7 + +[testenv:py26-django1.4] +basepython = python2.6 +deps = + Django>=1.4.2,<1.5 + +[testenv:py26-django1.5] +basepython = python2.6 +deps = + Django>=1.5,<1.6 + +[testenv:py26-django1.6] +basepython = python2.6 +deps = + Django>=1.6,<1.7 + +[testenv:py27-django1.4] +basepython = python2.7 +deps = + Django>=1.4.2,<1.5 + +[testenv:py27-django1.5] +basepython = python2.7 +deps = + Django>=1.5,<1.6 + +[testenv:py27-django1.6] +basepython = python2.7 +deps = + Django>=1.6,<1.7 + +[testenv:py33-django1.5] +basepython = python3.3 +deps = + Django>=1.5,<1.6 + +[testenv:py33-django1.6] +basepython = python3.3 +deps = + Django>=1.6,<1.7 diff --git a/voting/__init__.py b/voting/__init__.py index 449f64a..6b515fe 100644 --- a/voting/__init__.py +++ b/voting/__init__.py @@ -1 +1 @@ -VERSION = (0, 1, None) \ No newline at end of file +VERSION = (0, 2, None) diff --git a/voting/managers.py b/voting/managers.py index 3162c3b..23e6d22 100644 --- a/voting/managers.py +++ b/voting/managers.py @@ -1,39 +1,13 @@ -from django.conf import settings -from django.db import connection, models +# coding: utf-8 -try: - from django.db.models.sql.aggregates import Aggregate -except ImportError: - supports_aggregates = False -else: - supports_aggregates = True +from __future__ import unicode_literals +from django.conf import settings +from django.db import models +from django.db.models import Sum, Count 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' - +ZERO_VOTES_ALLOWED = getattr(settings, 'VOTING_ZERO_VOTES_ALLOWED', False) class VoteManager(models.Manager): def get_score(self, obj): @@ -42,17 +16,17 @@ def get_score(self, obj): 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]), - } + result = self.filter( + object_id=obj._get_pk_val(), + content_type=ctype + ).aggregate( + score=Sum('vote'), + num_votes=Count('vote') + ) + + if result['score'] is None: + result['score'] = 0 + return result def get_scores_in_bulk(self, objects): """ @@ -62,38 +36,26 @@ def get_scores_in_bulk(self, objects): 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') - + + queryset = self.filter( + object_id__in=object_ids, + content_type=ctype, + ).values( + 'object_id', + ).annotate( + score=Sum('vote'), + num_votes=Count('vote') + ) + 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): @@ -109,57 +71,39 @@ def record_vote(self, obj, user, vote): try: v = self.get(user=user, content_type=ctype, object_id=obj._get_pk_val()) - if vote == 0: + if vote == 0 and not ZERO_VOTES_ALLOWED: 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) + if not ZERO_VOTES_ALLOWED and vote == 0: + return + self.create(user=user, content_type=ctype, + object_id=obj._get_pk_val(), vote=vote) - def get_top(self, Model, limit=10, reversed=False): + 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.DATABASE_ENGINE == 'mysql': - having_score = connection.ops.quote_name('score') - else: - having_score = 'SUM(vote)' + ctype = ContentType.objects.get_for_model(model) + results = self.filter(content_type=ctype).values('object_id').annotate(score=Sum('vote')) if reversed: - having_sql = ' HAVING %(having_score)s < 0 ORDER BY %(having_score)s ASC LIMIT %%s' + results = results.order_by('score') 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() + results = results.order_by('-score') # Use in_bulk() to avoid O(limit) db hits. - objects = Model.objects.in_bulk([id for id, score in results]) + objects = model.objects.in_bulk([item['object_id'] for item in results[:limit]]) # Yield each object, score pair. Because of the lazy nature of generic # relations, missing objects are silently ignored. - for id, score in results: + for item in results[:limit]: + id, score = item['object_id'], item['score'] + if not score: + continue if id in objects: yield objects[id], int(score) diff --git a/voting/migrations/0001_initial.py b/voting/migrations/0001_initial.py new file mode 100644 index 0000000..0529598 --- /dev/null +++ b/voting/migrations/0001_initial.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'Vote' + db.create_table('votes', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('content_type', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['contenttypes.ContentType'])), + ('object_id', self.gf('django.db.models.fields.PositiveIntegerField')()), + ('vote', self.gf('django.db.models.fields.SmallIntegerField')()), + )) + db.send_create_signal('voting', ['Vote']) + + # Adding unique constraint on 'Vote', fields ['user', 'content_type', 'object_id'] + db.create_unique('votes', ['user_id', 'content_type_id', 'object_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'Vote', fields ['user', 'content_type', 'object_id'] + db.delete_unique('votes', ['user_id', 'content_type_id', 'object_id']) + + # Deleting model 'Vote' + db.delete_table('votes') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'voting.vote': { + 'Meta': {'unique_together': "(('user', 'content_type', 'object_id'),)", 'object_name': 'Vote', 'db_table': "'votes'"}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'vote': ('django.db.models.fields.SmallIntegerField', [], {}) + } + } + + complete_apps = ['voting'] \ No newline at end of file diff --git a/voting/migrations/0002_auto__add_field_vote_time_stamp.py b/voting/migrations/0002_auto__add_field_vote_time_stamp.py new file mode 100644 index 0000000..9e52305 --- /dev/null +++ b/voting/migrations/0002_auto__add_field_vote_time_stamp.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'Vote.time_stamp' + db.add_column('votes', 'time_stamp', + self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'Vote.time_stamp' + db.delete_column('votes', 'time_stamp') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'voting.vote': { + 'Meta': {'unique_together': "(('user', 'content_type', 'object_id'),)", 'object_name': 'Vote', 'db_table': "'votes'"}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'time_stamp': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'vote': ('django.db.models.fields.SmallIntegerField', [], {}) + } + } + + complete_apps = ['voting'] \ No newline at end of file diff --git a/voting/migrations/__init__.py b/voting/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/voting/models.py b/voting/models.py index cebbd15..6b76cf2 100644 --- a/voting/models.py +++ b/voting/models.py @@ -1,24 +1,39 @@ +# coding: utf-8 + +from __future__ import unicode_literals + +from datetime import datetime + +from django.utils.encoding import python_2_unicode_compatible from django.contrib.contenttypes import generic from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import User from django.db import models +try: + from django.utils.timezone import now +except ImportError: + now = datetime.now + from voting.managers import VoteManager + SCORES = ( - (u'+1', +1), - (u'-1', -1), + (+1, '+1'), + (-1, '-1'), ) +@python_2_unicode_compatible class Vote(models.Model): """ A vote on an object by a User. """ - user = models.ForeignKey(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) + object_id = models.PositiveIntegerField() + object = generic.GenericForeignKey('content_type', 'object_id') + vote = models.SmallIntegerField(choices=SCORES) + time_stamp = models.DateTimeField(editable=False, default=now) objects = VoteManager() @@ -27,8 +42,8 @@ class Meta: # 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 __str__(self): + return '%s: %s on %s' % (self.user, self.vote, self.object) def is_upvote(self): return self.vote == 1 diff --git a/voting/templatetags/voting_tags.py b/voting/templatetags/voting_tags.py index 7fc4dc8..b1c1aad 100644 --- a/voting/templatetags/voting_tags.py +++ b/voting/templatetags/voting_tags.py @@ -1,3 +1,7 @@ +# coding: utf-8 + +from __future__ import unicode_literals + from django import template from django.utils.html import escape @@ -7,6 +11,7 @@ # Tags + class ScoreForObjectNode(template.Node): def __init__(self, object, context_var): self.object = object @@ -20,6 +25,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 +39,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 +55,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 +71,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 +87,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 +108,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 +125,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 +145,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 +165,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,6 +193,7 @@ 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): @@ -200,6 +215,7 @@ def confirm_vote_message(object_description, vote_direction): # Filters + def vote_display(vote, arg=None): """ Given a string mapping values for up and down votes, returns one @@ -222,10 +238,10 @@ 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(vote_display) diff --git a/voting/tests/__init__.py b/voting/tests/__init__.py index e69de29..25d4e24 100644 --- a/voting/tests/__init__.py +++ b/voting/tests/__init__.py @@ -0,0 +1,3 @@ +import django +if django.VERSION[0] == 1 and django.VERSION[1] < 6: + from .tests import * diff --git a/voting/tests/runtests.py b/voting/tests/runtests.py deleted file mode 100644 index bc1ba37..0000000 --- a/voting/tests/runtests.py +++ /dev/null @@ -1,8 +0,0 @@ -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/voting/tests/settings.py b/voting/tests/settings.py index 8d2506e..bc5c899 100644 --- a/voting/tests/settings.py +++ b/voting/tests/settings.py @@ -1,25 +1,22 @@ +# coding: utf-8 + +from __future__ import unicode_literals + 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' +DATABASES = { + 'default': { + 'ENGINE':'django.db.backends.sqlite3', + 'NAME': ':memory:' + } +} -#DATABASE_ENGINE = 'postgresql_psycopg2' -#DATABASE_NAME = 'tagging_test' -#DATABASE_USER = 'postgres' -#DATABASE_PASSWORD = '' -#DATABASE_HOST = 'localhost' -#DATABASE_PORT = '5432' +SECRET_KEY = 'foo' INSTALLED_APPS = ( + 'django.contrib.sessions', 'django.contrib.auth', 'django.contrib.contenttypes', 'voting', diff --git a/voting/tests/tests.py b/voting/tests/tests.py index 25bb5a8..01d049a 100644 --- a/voting/tests/tests.py +++ b/voting/tests/tests.py @@ -1,86 +1,156 @@ -r""" ->>> from django.contrib.auth.models import User ->>> from voting.models import Vote ->>> from voting.tests.models import Item +# coding: utf-8 -########## -# Voting # -########## +from __future__ import unicode_literals + +from django.contrib.auth.models import User +from django.test import TestCase + +from voting.models import Vote +from voting.tests.models import Item # 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) +class BasicVotingTests(TestCase): + + def setUp(self): + self.item = Item.objects.create(name='test1') + self.users = [] + for username in ['u1', 'u2', 'u3', 'u4']: + self.users.append(User.objects.create_user(username, '%s@test.com' % username, 'test')) + + def test_print_model(self): + Vote.objects.record_vote(self.item, self.users[0], +1) + expected = 'u1: 1 on test1' + result = Vote.objects.all()[0] + self.assertEqual(str(result), expected) + + def test_novotes(self): + result = Vote.objects.get_score(self.item) + self.assertEqual(result, {'score': 0, 'num_votes': 0}) + + def test_onevoteplus(self): + Vote.objects.record_vote(self.item, self.users[0], +1) + result = Vote.objects.get_score(self.item) + self.assertEqual(result, {'score': 1, 'num_votes': 1}) + + def test_onevoteminus(self): + Vote.objects.record_vote(self.item, self.users[0], -1) + result = Vote.objects.get_score(self.item) + self.assertEqual(result, {'score': -1, 'num_votes': 1}) + + def test_onevotezero(self): + Vote.objects.record_vote(self.item, self.users[0], 0) + result = Vote.objects.get_score(self.item) + self.assertEqual(result, {'score': 0, 'num_votes': 0}) + + def test_allvoteplus(self): + for user in self.users: + Vote.objects.record_vote(self.item, user, +1) + result = Vote.objects.get_score(self.item) + self.assertEqual(result, {'score': 4, 'num_votes': 4}) + for user in self.users[:2]: + Vote.objects.record_vote(self.item, user, 0) + result = Vote.objects.get_score(self.item) + self.assertEqual(result, {'score': 2, 'num_votes': 2}) + for user in self.users[:2]: + Vote.objects.record_vote(self.item, user, -1) + result = Vote.objects.get_score(self.item) + self.assertEqual(result, {'score': 0, 'num_votes': 4}) + + def test_wrongvote(self): + try: + Vote.objects.record_vote(self.item, self.users[0], -2) + except ValueError as e: + self.assertEqual(e.args[0], "Invalid vote (must be +1/0/-1)") + else: + self.fail("Did nor raise '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([]) -{} -""" +class VoteRetrievalTests(TestCase): + def setUp(self): + self.items = [] + for name in ['test1', 'test2', 'test3', 'test4']: + self.items.append(Item.objects.create(name=name)) + self.users = [] + for username in ['u1', 'u2', 'u3', 'u4']: + self.users.append(User.objects.create_user(username, '%s@test.com' % username, 'test')) + for user in self.users: + Vote.objects.record_vote(self.items[0], user, +1) + for user in self.users[:2]: + Vote.objects.record_vote(self.items[0], user, 0) + for user in self.users[:2]: + Vote.objects.record_vote(self.items[0], user, -1) + Vote.objects.record_vote(self.items[1], self.users[0], +1) + Vote.objects.record_vote(self.items[2], self.users[0], -1) + Vote.objects.record_vote(self.items[3], self.users[0], 0) + + def test_get_pos_vote(self): + vote = Vote.objects.get_for_user(self.items[1], self.users[0]) + result = (vote.vote, vote.is_upvote(), vote.is_downvote()) + expected = (1, True, False) + self.assertEqual(result, expected) + + def test_get_neg_vote(self): + vote = Vote.objects.get_for_user(self.items[2], self.users[0]) + result = (vote.vote, vote.is_upvote(), vote.is_downvote()) + expected = (-1, False, True) + self.assertEqual(result, expected) + + def test_get_zero_vote(self): + self.assertTrue(Vote.objects.get_for_user(self.items[3], self.users[0]) is None) + + def test_in_bulk1(self): + votes = Vote.objects.get_for_user_in_bulk(self.items, + self.users[0]) + self.assertEqual( + [(id, vote.vote) for id, vote in votes.items()], + [(1, -1), (2, 1), (3, -1)]) + + def test_empty_items(self): + result = Vote.objects.get_for_user_in_bulk([], self.users[0]) + self.assertEqual(result, {}) + + def test_get_top(self): + for user in self.users[1:]: + Vote.objects.record_vote(self.items[1], user, +1) + Vote.objects.record_vote(self.items[2], user, +1) + Vote.objects.record_vote(self.items[3], user, +1) + result = list(Vote.objects.get_top(Item)) + expected = [(self.items[1], 4), (self.items[3], 3), (self.items[2], 2)] + self.assertEqual(result, expected) + + def test_get_bottom(self): + for user in self.users[1:]: + Vote.objects.record_vote(self.items[1], user, +1) + Vote.objects.record_vote(self.items[2], user, +1) + Vote.objects.record_vote(self.items[3], user, +1) + for user in self.users[1:]: + Vote.objects.record_vote(self.items[1], user, -1) + Vote.objects.record_vote(self.items[2], user, -1) + Vote.objects.record_vote(self.items[3], user, -1) + result = list(Vote.objects.get_bottom(Item)) + expected = [(self.items[2], -4), (self.items[3], -3), (self.items[1], -2)] + self.assertEqual(result, expected) + + def test_get_scores_in_bulk(self): + for user in self.users[1:]: + Vote.objects.record_vote(self.items[1], user, +1) + Vote.objects.record_vote(self.items[2], user, +1) + Vote.objects.record_vote(self.items[3], user, +1) + for user in self.users[1:]: + Vote.objects.record_vote(self.items[1], user, -1) + Vote.objects.record_vote(self.items[2], user, -1) + Vote.objects.record_vote(self.items[3], user, -1) + result = Vote.objects.get_scores_in_bulk(self.items) + expected = { + 1: {'score': 0, 'num_votes': 4}, + 2: {'score': -2, 'num_votes': 4}, + 3: {'score': -4, 'num_votes': 4}, + 4: {'score': -3, 'num_votes': 3}, + } + self.assertEqual(result, expected) + + def test_get_scores_in_bulk_no_items(self): + result = Vote.objects.get_scores_in_bulk([]) + self.assertEqual(result, {}) diff --git a/voting/urls.py b/voting/urls.py new file mode 100644 index 0000000..0d1584f --- /dev/null +++ b/voting/urls.py @@ -0,0 +1,16 @@ +# coding: utf-8 + +from __future__ import unicode_literals + +from django.conf.urls.defaults import * + + +urlpatterns = patterns('', + url(r"^vote/(?P[\w\.-]+)/(?P\w+)/"\ + "(?P\d+)/(?Pup|down|clear)/$", + "voting.views.vote_on_object_with_lazy_model", { + "allow_xmlhttprequest": True, + }, + name="voting_vote" + ), +) diff --git a/voting/views.py b/voting/views.py index 352f6ad..7d5993b 100644 --- a/voting/views.py +++ b/voting/views.py @@ -1,14 +1,20 @@ -from django.contrib.contenttypes.models import ContentType +# coding: utf-8 + +from __future__ import unicode_literals + from django.core.exceptions import ObjectDoesNotExist -from django.http import Http404, HttpResponse, HttpResponseRedirect +from django.db.models import get_model +from django.http import Http404, HttpResponse, HttpResponseBadRequest, \ + HttpResponseRedirect from django.contrib.auth.views import redirect_to_login from django.template import loader, RequestContext -from django.utils import simplejson +import json from voting.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, template_loader=loader, extra_context=None, context_processors=None, @@ -39,14 +45,15 @@ def vote_on_object(request, model, direction, post_vote_redirect=None, object_id=object_id, slug=slug, slug_field=slug_field) - if extra_context is None: extra_context = {} + 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 = {} @@ -60,12 +67,13 @@ def vote_on_object(request, model, direction, post_vote_redirect=None, try: obj = model._default_manager.get(**lookup_kwargs) except ObjectDoesNotExist: - raise Http404, 'No %s found for %s.' % (model._meta.app_label, lookup_kwargs) + raise Http404('No %s found for %s.' % + (model._meta.app_label, lookup_kwargs)) 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')): @@ -97,12 +105,29 @@ def vote_on_object(request, model, direction, post_vote_redirect=None, response = HttpResponse(t.render(c)) return response + +def vote_on_object_with_lazy_model(request, app_label, model_name, *args, + **kwargs): + """ + Generic object vote view that takes app_label and model_name instead + of a model class and calls ``vote_on_object`` view. + Returns HTTP 400 (Bad Request) if there is no model matching the app_label + and model_name. + """ + model = get_model(app_label, model_name) + if not model: + return HttpResponseBadRequest('Model %s.%s does not exist' % ( + app_label, model_name)) + return vote_on_object(request, model=model, *args, **kwargs) + + def json_error_response(error_message): - return HttpResponse(simplejson.dumps(dict(success=False, + return HttpResponse(json.dumps(dict(success=False, error_message=error_message))) + def xmlhttprequest_vote_on_object(request, model, direction, - object_id=None, slug=None, slug_field=None): + object_id=None, slug=None, slug_field=None): """ Generic object vote function for use via XMLHttpRequest. @@ -147,7 +172,7 @@ def xmlhttprequest_vote_on_object(request, model, direction, # Vote and respond Vote.objects.record_vote(obj, request.user, vote) - return HttpResponse(simplejson.dumps({ + return HttpResponse(json.dumps({ 'success': True, 'score': Vote.objects.get_score(obj), }))