diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5c67b5..b65f55e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -q Django==${{ matrix.django-version }} flake8 coverage djangorestframework + pip install -q Django==${{ matrix.django-version }} flake8 coverage djangorestframework swapper tox - name: Lint with flake8 run: | flake8 --exclude vote/migrations/* vote diff --git a/.gitignore b/.gitignore index 3be0ca2..9f5943c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__/ htmlcov .coverage .eggs/ +.tox/ diff --git a/README.md b/README.md index a440036..5f70e85 100644 --- a/README.md +++ b/README.md @@ -87,3 +87,75 @@ POST /api/comments/{id}/vote/ POST /api/comments/{id}/vote/ {"action":"down"} DELETE /api/comments/{id}/vote/ ``` + +### Swapping Vote Model + +To swap Vote model with your own model: + +1. Declare your own Vote model: + + ``` + # myvote/models.py + + from vote.base_models import AbstractVote + from vote.models import VoteModel + + class MyVote(AbstractVote): + '''To test model swapping''' + modified = models.DateTimeField(auto_now=True) + + class Meta: + abstract = False + unique_together = ('user_id', 'content_type', 'object_id', 'action') + index_together = ('content_type', 'object_id') + ``` + +2. In your votable model: + + ``` + from vote.models import VotableManager + from myvote.models import MyVote + + 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(MyVote) + ``` + +3. Update settings, swapping the Vote model with yours: + + ``` + VOTE_VOTE_MODEL = 'myvote.MyVote + ``` + +Alternatively, declare your own VotableManager, which overrides the constructor +specifying `MyVote` as the `through` argument. + +``` +class MyVotableManager(VotableMangaer): + def __init__(self, **kwargs): + super().__init__(MyVote, **kwargs) + + +class MyVoteModel(VoteModel): + votes = MyVotableManager() + + class Meta: + abstract = True +``` + +Then you can use `MyVoteModel` instead of the default `VoteModel` in your +votable models: + +``` +class Comment(MyVoteModel): + 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) +``` diff --git a/runtests.py b/runtests.py index c84883d..c2fe59b 100755 --- a/runtests.py +++ b/runtests.py @@ -3,8 +3,12 @@ from django.conf import settings from django.core.management import execute_from_command_line +from django.utils.functional import empty -if not settings.configured: + +def configure(**options): + if settings._wrapped is not empty: + settings._wrapped = empty settings.configure( SECRET_KEY="secret key", DATABASES={ @@ -40,8 +44,12 @@ }, }, ], + **options ) +if not settings.configured: + configure() + def runtests(): argv = sys.argv[:1] + ["test"] + sys.argv[1:] diff --git a/runtests_swapped.py b/runtests_swapped.py new file mode 100755 index 0000000..d1b357c --- /dev/null +++ b/runtests_swapped.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +import sys + +from django.conf import settings +from django.core.management import execute_from_command_line +from runtests import configure + +if not settings.configured: + configure() + + +def runtests_swapped(): + argv = sys.argv[:1] + ["test", "test.tests.VoteTest.test_vote_up"] + sys.argv[1:] + execute_from_command_line(argv) + configure(VOTE_VOTE_MODEL='test.MyVote') + from test.models import Comment, MyVote + from vote.models import VotableManager + from vote.utils import _reset_vote_model + _reset_vote_model() + # The VoteModel's votes manager has to be updated for the new Vote model + Comment.votes = VotableManager(MyVote) + argv = sys.argv[:1] + ["test"] + sys.argv[1:] + execute_from_command_line(argv) + + +if __name__ == "__main__": + runtests_swapped() diff --git a/test/models.py b/test/models.py index 31cd0d4..fcac4d9 100644 --- a/test/models.py +++ b/test/models.py @@ -1,8 +1,19 @@ from django.db import models +from vote.base_models import AbstractVote from vote.models import VoteModel # Create your models here. +class MyVote(AbstractVote): + '''To test model swapping''' + modified = models.DateTimeField(auto_now=True) + + class Meta: + abstract = False + unique_together = ('user_id', 'content_type', 'object_id', 'action') + index_together = ('content_type', 'object_id') + + class Comment(VoteModel): user_id = models.BigIntegerField() content = models.TextField() diff --git a/test/tests.py b/test/tests.py index 05d9a6c..e3c53d7 100644 --- a/test/tests.py +++ b/test/tests.py @@ -1,8 +1,10 @@ from __future__ import absolute_import from django.test import TestCase from django.contrib.auth.models import User -from vote.models import Vote, UP, DOWN -from test.models import Comment +from vote.base_models import UP, DOWN +from vote.models import Vote +from test.models import Comment, MyVote +from vote.utils import _get_vote_model class VoteTest(TestCase): @@ -23,7 +25,8 @@ def setUp(self): def tearDown(self): self.model.objects.all().delete() - self.through.objects.all().delete() + vote_model = _get_vote_model() + vote_model.objects.all().delete() User.objects.all().delete() def call_api(self, api_name, model=None, *args, **kwargs): @@ -81,7 +84,8 @@ def test_vote_get(self): content="I'm a comment") self.assertIsNone(self.call_api('get', comment, self.user2.pk)) self.call_api('up', comment, self.user2.pk) - vote = Vote.objects.first() + vote_model = _get_vote_model() + vote = vote_model.objects.first() self.assertEqual(vote, self.call_api('get', comment, self.user2.pk)) def test_vote_all(self): diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..f530a66 --- /dev/null +++ b/tox.ini @@ -0,0 +1,19 @@ +[tox] +envlist = py39-django{22,32,40},py39-django{22,32,40}-swapper + +[testenv-django{22,32,40}] +deps = + django22: Django==2.2 + django32: Django==3.2 + django40: Django==4.0 + djangorestframework +commands = ./runtests.py + +[testenv:py39-django{22,32,40}-swapper] +deps = + django22: Django==2.2 + django32: Django==3.2 + django40: Django==4.0 + djangorestframework + swapper: swapper==1.3.0 +commands = ./runtests_swapped.py diff --git a/vote/base_models.py b/vote/base_models.py new file mode 100644 index 0000000..8ecfe33 --- /dev/null +++ b/vote/base_models.py @@ -0,0 +1,56 @@ +from django.db import models +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey + +UP = 0 +DOWN = 1 + + +class VoteManager(models.Manager): + + def filter(self, *args, **kwargs): + if 'content_object' in kwargs: + content_object = kwargs.pop('content_object') + content_type = ContentType.objects.get_for_model(content_object) + kwargs.update({ + 'content_type': content_type, + 'object_id': content_object.pk + }) + + return super(VoteManager, self).filter(*args, **kwargs) + + +class AbstractVote(models.Model): + ACTION_FIELD = { + UP: 'num_vote_up', + DOWN: 'num_vote_down' + } + + user_id = models.BigIntegerField() + content_type = models.ForeignKey( + ContentType, + related_name='+', + on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey() + action = models.PositiveSmallIntegerField(default=UP) + create_at = models.DateTimeField(auto_now_add=True) + + objects = VoteManager() + + class Meta: + abstract = True + index_together = ('content_type', 'object_id') + + @classmethod + def votes_for(cls, model, instance=None, action=UP): + ct = ContentType.objects.get_for_model(model) + kwargs = { + "content_type": ct, + "action": action + } + if instance is not None: + kwargs["object_id"] = instance.pk + + return cls.objects.filter(**kwargs) diff --git a/vote/managers.py b/vote/managers.py index 853c3ce..c21ea50 100644 --- a/vote/managers.py +++ b/vote/managers.py @@ -53,7 +53,9 @@ def __init__(self, through, model, instance, field_name='votes'): self.field_name = field_name def vote(self, user_id, action): + '''Returns the Vote object on success. None on failure.''' try: + vote = None with transaction.atomic(): self.instance = self.model.objects.select_for_update().get( pk=self.instance.pk) @@ -75,10 +77,12 @@ def vote(self, user_id, 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) + vote = self.through.objects.create( + user_id=user_id, + content_type=content_type, + object_id=self.instance.pk, + action=action + ) statistics_field = self.through.ACTION_FIELD.get(action) setattr(self.instance, statistics_field, @@ -86,9 +90,9 @@ def vote(self, user_id, action): self.instance.save() - return True + return vote except (OperationalError, IntegrityError): - return False + return None @instance_required def up(self, user_id): diff --git a/vote/models.py b/vote/models.py index 5c5baa9..23a79a9 100644 --- a/vote/models.py +++ b/vote/models.py @@ -1,56 +1,21 @@ from django.db import models -from django.contrib.contenttypes.models import ContentType -from django.contrib.contenttypes.fields import GenericForeignKey +from vote.base_models import AbstractVote from vote.managers import VotableManager +try: + from swapper import swappable_setting +except ImportError: + swappable_setting = None -UP = 0 -DOWN = 1 - -class VoteManager(models.Manager): - - def filter(self, *args, **kwargs): - if 'content_object' in kwargs: - content_object = kwargs.pop('content_object') - content_type = ContentType.objects.get_for_model(content_object) - kwargs.update({ - 'content_type': content_type, - 'object_id': content_object.pk - }) - - return super(VoteManager, self).filter(*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, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField() - content_object = GenericForeignKey() - action = models.PositiveSmallIntegerField(default=UP) - create_at = models.DateTimeField(auto_now_add=True) - - objects = VoteManager() +class Vote(AbstractVote): class Meta: + abstract = False unique_together = ('user_id', 'content_type', 'object_id', 'action') index_together = ('content_type', 'object_id') - - @classmethod - def votes_for(cls, model, instance=None, action=UP): - ct = ContentType.objects.get_for_model(model) - kwargs = { - "content_type": ct, - "action": action - } - if instance is not None: - kwargs["object_id"] = instance.pk - - return cls.objects.filter(**kwargs) + swappable = None + if swappable_setting: + swappable = swappable_setting('vote', 'Vote') class VoteModel(models.Model): diff --git a/vote/templatetags/vote.py b/vote/templatetags/vote.py index 858a6ae..94dc307 100644 --- a/vote/templatetags/vote.py +++ b/vote/templatetags/vote.py @@ -3,7 +3,7 @@ from django import template from django.contrib.auth.models import AnonymousUser -from vote.models import UP +from vote.base_models import UP register = template.Library() diff --git a/vote/utils.py b/vote/utils.py index c5625e9..b030fa5 100644 --- a/vote/utils.py +++ b/vote/utils.py @@ -1,6 +1,28 @@ from functools import wraps from django.contrib.contenttypes.models import ContentType +_vote_model = None + + +def _get_vote_model(): + '''Optimized load_model, which caches the vote_model during the first call + and returns it for every subsequent call.''' + global _vote_model + if not _vote_model: + try: + from swapper import load_model + _vote_model = load_model('vote', 'Vote') + except ImportError: + from .models import Vote + _vote_model = Vote + return _vote_model + + +def _reset_vote_model(): + '''Function for test instrumentation only.''' + global _vote_model + _vote_model = None + def instance_required(func): @wraps(func) @@ -15,11 +37,12 @@ def inner(self, *args, **kwargs): def add_field_to_objects(model, objects, user_id): - from vote.models import Vote, UP, DOWN + from vote.base_models import UP, DOWN + # from vote.models import Vote content_type = ContentType.objects.get_for_model(model) object_ids = [r.id for r in objects] - voted_object_ids = Vote.objects.filter( + voted_object_ids = _get_vote_model().objects.filter( user_id=user_id, content_type=content_type, object_id__in=object_ids