diff --git a/actstream/__init__.py b/actstream/__init__.py index 192fd774..7bbe37fe 100644 --- a/actstream/__init__.py +++ b/actstream/__init__.py @@ -12,5 +12,5 @@ default_app_config = 'actstream.apps.ActstreamConfig' -__version__ = '2.0.0' +__version__ = '2.0.1' __author__ = 'Asif Saif Uddin, Justin Quick ' diff --git a/actstream/actions.py b/actstream/actions.py index 4b2be684..9e038e60 100644 --- a/actstream/actions.py +++ b/actstream/actions.py @@ -1,7 +1,7 @@ -from django.apps import apps from django.utils.translation import gettext_lazy as _ from django.utils.timezone import now from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import FieldDoesNotExist from actstream import settings from actstream.signals import action @@ -17,6 +17,7 @@ def follow(user, obj, send_action=True, actor_only=True, flag='', **kwargs): If ``send_action`` is ``True`` (the default) then a `` started following `` action signal is sent. + Kwargs that can be passed to the Follow model instance will be passed. Extra keyword arguments are passed to the action.send call. If ``actor_only`` is ``True`` (the default) then only actions where the @@ -32,11 +33,23 @@ def follow(user, obj, send_action=True, actor_only=True, flag='', **kwargs): follow(request.user, group, actor_only=False, flag='liking') """ check(obj) - instance, created = apps.get_model('actstream', 'follow').objects.get_or_create( + follow_model = settings.get_follow_model() + instance, created = follow_model.objects.get_or_create( user=user, object_id=obj.pk, flag=flag, content_type=ContentType.objects.get_for_model(obj), actor_only=actor_only ) + follow_updated = False + for attr in list(kwargs): + try: + follow_model._meta.get_field(attr) + except FieldDoesNotExist: + pass + else: + follow_updated = True + setattr(instance, attr, kwargs.pop(attr)) + if follow_updated: + instance.save() if send_action and created: if not flag: action.send(user, verb=_('started following'), target=obj, **kwargs) @@ -60,7 +73,7 @@ def unfollow(user, obj, send_action=False, flag=''): unfollow(request.user, other_user, flag='watching') """ check(obj) - qs = apps.get_model('actstream', 'follow').objects.filter( + qs = settings.get_follow_model().objects.filter( user=user, object_id=obj.pk, content_type=ContentType.objects.get_for_model(obj) ) @@ -91,7 +104,7 @@ def is_following(user, obj, flag=''): """ check(obj) - qs = apps.get_model('actstream', 'follow').objects.filter( + qs = settings.get_follow_model().objects.filter( user=user, object_id=obj.pk, content_type=ContentType.objects.get_for_model(obj) ) @@ -105,6 +118,7 @@ def is_following(user, obj, flag=''): def action_handler(verb, **kwargs): """ Handler function to create Action instance upon action signal call. + Extra kwargs will be passed to the Action instance """ kwargs.pop('signal', None) actor = kwargs.pop('sender') @@ -118,7 +132,7 @@ def action_handler(verb, **kwargs): elif hasattr(verb, '_args'): verb = verb._args[0] - newaction = apps.get_model('actstream', 'action')( + newaction = settings.get_action_model()( actor_content_type=ContentType.objects.get_for_model(actor), actor_object_id=actor.pk, verb=str(verb), @@ -134,6 +148,13 @@ def action_handler(verb, **kwargs): setattr(newaction, '%s_object_id' % opt, obj.pk) setattr(newaction, '%s_content_type' % opt, ContentType.objects.get_for_model(obj)) + for attr in list(kwargs): + try: + settings.get_action_model()._meta.get_field(attr) + except FieldDoesNotExist: + pass + else: + setattr(newaction, attr, kwargs.pop(attr)) if settings.USE_JSONFIELD and len(kwargs): newaction.data = kwargs newaction.save(force_insert=True) diff --git a/actstream/admin.py b/actstream/admin.py index 14015913..f79e99a7 100644 --- a/actstream/admin.py +++ b/actstream/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from actstream import models +from actstream.settings import get_action_model, get_follow_model # Use django-generic-admin widgets if available try: @@ -25,5 +25,5 @@ class FollowAdmin(ModelAdmin): raw_id_fields = ('user', 'content_type') -admin.site.register(models.Action, ActionAdmin) -admin.site.register(models.Follow, FollowAdmin) +admin.site.register(get_action_model(), ActionAdmin) +admin.site.register(get_follow_model(), FollowAdmin) diff --git a/actstream/apps.py b/actstream/apps.py index 48faa830..29db5de6 100644 --- a/actstream/apps.py +++ b/actstream/apps.py @@ -1,9 +1,4 @@ -from collections import OrderedDict - -import django -from django.apps import apps from django.apps import AppConfig -from django.conf import settings from django.db.models.signals import pre_delete from actstream import settings as actstream_settings @@ -18,7 +13,7 @@ class ActstreamConfig(AppConfig): def ready(self): from actstream.actions import action_handler action.connect(action_handler, dispatch_uid='actstream.models') - action_class = self.get_model('action') + action_class = actstream_settings.get_action_model() if actstream_settings.USE_JSONFIELD: if not hasattr(action_class, 'data'): diff --git a/actstream/drf/serializers.py b/actstream/drf/serializers.py index 991c559e..26e44a0b 100644 --- a/actstream/drf/serializers.py +++ b/actstream/drf/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from generic_relations.relations import GenericRelatedField -from actstream.models import Follow, Action +from actstream import settings as actstream_settings from actstream.registry import registry, label from actstream.settings import DRF_SETTINGS, import_obj @@ -72,7 +72,7 @@ class ActionSerializer(DEFAULT_SERIALIZER): action_object = get_grf() class Meta: - model = Action + model = actstream_settings.get_action_model() fields = 'id verb public description timestamp actor target action_object'.split() @@ -97,7 +97,7 @@ class FollowSerializer(DEFAULT_SERIALIZER): follow_object = get_grf() class Meta: - model = Follow + model = actstream_settings.get_follow_model() fields = 'id flag user follow_object started actor_only'.split() @@ -108,5 +108,5 @@ class FollowingSerializer(DEFAULT_SERIALIZER): follow_object = get_grf() class Meta: - model = Follow + model = actstream_settings.get_follow_model() fields = ['follow_object'] diff --git a/actstream/drf/views.py b/actstream/drf/views.py index bd6b3f8e..241bc52e 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -13,7 +13,7 @@ from actstream.drf import serializers from actstream import models from actstream.registry import label -from actstream.settings import DRF_SETTINGS, import_obj +from actstream.settings import DRF_SETTINGS, import_obj, get_action_model, get_follow_model from actstream.signals import action as action_signal from actstream.actions import follow as follow_action @@ -52,7 +52,7 @@ def get_permissions(self): class ActionViewSet(DefaultModelViewSet): - queryset = models.Action.objects.public().order_by('-timestamp', '-id').prefetch_related() + queryset = get_action_model().objects.public().order_by('-timestamp', '-id').prefetch_related() serializer_class = serializers.ActionSerializer @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST'], serializer_class=serializers.SendActionSerializer) @@ -159,7 +159,7 @@ def any_stream(self, request, content_type_id, object_id): class FollowViewSet(DefaultModelViewSet): - queryset = models.Follow.objects.order_by('-started', '-id').prefetch_related() + queryset = get_follow_model().objects.order_by('-started', '-id').prefetch_related() serializer_class = serializers.FollowSerializer permission_classes = [permissions.IsAuthenticated] @@ -185,7 +185,7 @@ def is_following(self, request, content_type_id, object_id): """ ctype = get_object_or_404(ContentType, id=content_type_id) instance = ctype.get_object_for_this_type(pk=object_id) - following = models.Follow.objects.is_following(request.user, instance) + following = get_follow_model().objects.is_following(request.user, instance) data = {'is_following': following} return Response(json.dumps(data)) @@ -195,7 +195,7 @@ def following(self, request): """ Returns a JSON response whether the current user is following the object from content_type_id/object_id pair """ - qs = models.Follow.objects.following_qs(request.user) + qs = get_follow_model().objects.following_qs(request.user) return Response(serializers.FollowingSerializer(qs, many=True).data) @action(detail=False, permission_classes=[permissions.IsAuthenticated], @@ -208,7 +208,7 @@ def followers(self, request): if user_model not in serializers.registered_serializers: raise ModelNotRegistered(f'Auth user "{user_model.__name__}" not registered with actstream') serializer = serializers.registered_serializers[user_model] - followers = models.Follow.objects.followers(request.user) + followers = get_follow_model().objects.followers(request.user) return Response(serializer(followers, many=True).data) diff --git a/actstream/feeds.py b/actstream/feeds.py index c29030c2..7edba6ae 100644 --- a/actstream/feeds.py +++ b/actstream/feeds.py @@ -11,7 +11,8 @@ from django.http import HttpResponse, Http404 from django.urls import reverse -from actstream.models import Action, model_stream, user_stream, any_stream +from actstream.models import model_stream, user_stream, any_stream +from actstream.settings import get_action_model class AbstractActivityStream: @@ -254,6 +255,7 @@ def items(self, request, *args, **kwargs): ) + class UserActivityMixin: def get_object(self, request): @@ -277,7 +279,7 @@ def get_object(self): return def get_stream(self): - return getattr(Action.objects, self.name) + return getattr(get_action_model().objects, self.name) def items(self, *args, **kwargs): return self.get_stream()(*args[1:], **kwargs) diff --git a/actstream/follows.py b/actstream/follows.py index acde3782..6707a4fd 100644 --- a/actstream/follows.py +++ b/actstream/follows.py @@ -1,6 +1,6 @@ from django.core.exceptions import ImproperlyConfigured -from actstream.models import Follow +from actstream.settings import get_follow_model def delete_orphaned_follows(sender, instance=None, **kwargs): @@ -11,6 +11,6 @@ def delete_orphaned_follows(sender, instance=None, **kwargs): return try: - Follow.objects.for_object(instance).delete() + get_follow_model().objects.for_object(instance).delete() except ImproperlyConfigured: # raised by actstream for irrelevant models pass diff --git a/actstream/managers.py b/actstream/managers.py index c44ca251..796b8cc3 100644 --- a/actstream/managers.py +++ b/actstream/managers.py @@ -8,6 +8,7 @@ from actstream.gfk import GFKManager from actstream.decorators import stream from actstream.registry import check +from actstream.settings import get_follow_model class ActionManager(GFKManager): @@ -99,7 +100,7 @@ def user(self, obj: Model, with_user_activity=False, follow_flag=None, **kwargs) actor_object_id=obj.pk ) - follows = apps.get_model('actstream', 'follow').objects.filter(user=obj) + follows = get_follow_model().objects.filter(user=obj) if follow_flag: follows = follows.filter(flag=follow_flag) diff --git a/actstream/models.py b/actstream/models.py index 13bb83b5..bc603019 100644 --- a/actstream/models.py +++ b/actstream/models.py @@ -13,7 +13,7 @@ from actstream.managers import FollowManager -class Follow(models.Model): +class AbstractFollow(models.Model): """ Lets a user follow the activities of any specific actor """ @@ -36,13 +36,14 @@ class Follow(models.Model): objects = FollowManager() class Meta: + abstract = True unique_together = ('user', 'content_type', 'object_id', 'flag') def __str__(self): return '{} -> {} : {}'.format(self.user, self.follow_object, self.flag) -class Action(models.Model): +class AbstractAction(models.Model): """ Action model describing the actor acting out a verb (on an optional target). @@ -72,7 +73,7 @@ class Action(models.Model): """ actor_content_type = models.ForeignKey( - ContentType, related_name='actor', + ContentType, related_name='%(app_label)s_actor', on_delete=models.CASCADE, db_index=True ) actor_object_id = models.CharField(max_length=255, db_index=True) @@ -83,7 +84,7 @@ class Action(models.Model): target_content_type = models.ForeignKey( ContentType, blank=True, null=True, - related_name='target', + related_name='%(app_label)s_target', on_delete=models.CASCADE, db_index=True ) target_object_id = models.CharField( @@ -96,7 +97,7 @@ class Action(models.Model): action_object_content_type = models.ForeignKey( ContentType, blank=True, null=True, - related_name='action_object', + related_name='%(app_label)s_action_object', on_delete=models.CASCADE, db_index=True ) action_object_object_id = models.CharField( @@ -114,6 +115,7 @@ class Action(models.Model): objects = actstream_settings.get_action_manager() class Meta: + abstract = True ordering = ('-timestamp',) def __str__(self): @@ -165,12 +167,22 @@ def get_absolute_url(self): 'actstream_detail', args=[self.pk]) +class Follow(AbstractFollow): + class Meta(AbstractFollow.Meta): + swappable = 'ACTSTREAM_FOLLOW_MODEL' + + +class Action(AbstractAction): + class Meta(AbstractAction.Meta): + swappable = 'ACTSTREAM_ACTION_MODEL' + + # convenient accessors -actor_stream = Action.objects.actor -action_object_stream = Action.objects.action_object -target_stream = Action.objects.target -user_stream = Action.objects.user -model_stream = Action.objects.model_actions -any_stream = Action.objects.any -followers = Follow.objects.followers -following = Follow.objects.following +actor_stream = actstream_settings.get_action_model().objects.actor +action_object_stream = actstream_settings.get_action_model().objects.action_object +target_stream = actstream_settings.get_action_model().objects.target +user_stream = actstream_settings.get_action_model().objects.user +model_stream = actstream_settings.get_action_model().objects.model_actions +any_stream = actstream_settings.get_action_model().objects.any +followers = actstream_settings.get_follow_model().objects.followers +following = actstream_settings.get_follow_model().objects.following diff --git a/actstream/registry.py b/actstream/registry.py index 1a910cc7..948d0ac3 100644 --- a/actstream/registry.py +++ b/actstream/registry.py @@ -5,6 +5,8 @@ from django.db.models.base import ModelBase from django.core.exceptions import ImproperlyConfigured +from actstream.settings import get_action_model + class RegistrationError(Exception): pass @@ -14,7 +16,7 @@ def setup_generic_relations(model_class): """ Set up GenericRelations for actionable models. """ - Action = apps.get_model('actstream', 'action') + Action = get_action_model() if Action is None: raise RegistrationError( diff --git a/actstream/settings.py b/actstream/settings.py index 9a37f5c9..8a87b91d 100644 --- a/actstream/settings.py +++ b/actstream/settings.py @@ -1,3 +1,4 @@ +from django.apps import apps from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -48,3 +49,27 @@ def get_action_manager(): DRF_SETTINGS[item] = { label.lower(): obj for label, obj in DRF_SETTINGS[item].items() } + + +def get_swappable_model(model): + model_lookup = getattr(settings, 'ACTSTREAM_%s_MODEL' % model.upper(), 'actstream.%s' % model) + try: + return apps.get_model(model_lookup, require_ready=False) + except ValueError: + raise ImproperlyConfigured( + "%s must be of the form 'app_label.model_name'" % model_lookup + ) + except LookupError: + raise ImproperlyConfigured( + "Model '%s' has not been installed" % model_lookup + ) + + +def get_follow_model(): + """Return the Follow model that is active.""" + return get_swappable_model('Follow') + + +def get_action_model(): + """Return the Action model that is active.""" + return get_swappable_model('Action') diff --git a/actstream/templatetags/activity_tags.py b/actstream/templatetags/activity_tags.py index 17e94728..f0a1fcba 100644 --- a/actstream/templatetags/activity_tags.py +++ b/actstream/templatetags/activity_tags.py @@ -3,7 +3,7 @@ from django.template.loader import render_to_string from django.urls import reverse -from actstream.models import Follow, Action +from actstream.settings import get_follow_model, get_action_model register = Library() @@ -27,7 +27,7 @@ def render(self, context): if self.flag: kwargs['flag'] = self.flag - if Follow.objects.is_following(context.get('user'), actor_instance, flag=self.flag): + if get_follow_model().objects.is_following(context.get('user'), actor_instance, flag=self.flag): return reverse('actstream_unfollow', kwargs=kwargs) if self.actor_only: return reverse('actstream_follow', kwargs=kwargs) @@ -121,7 +121,7 @@ def is_following(user, actor): You are already following {{ another_user }} {% endif %} """ - return Follow.objects.is_following(user, actor) + return get_follow_model().objects.is_following(user, actor) class IsFollowing(AsNode): @@ -132,7 +132,7 @@ def render_result(self, context): actor = self.args[1].resolve(context) flag = self.args[2].resolve(context) - return Follow.objects.is_following(user, actor, flag=flag) + return get_follow_model().objects.is_following(user, actor, flag=flag) def is_following_tag(parser, token): @@ -252,10 +252,10 @@ def activity_stream(context, stream_type, *args, **kwargs): """ if stream_type == 'model': stream_type = 'model_actions' - if not hasattr(Action.objects, stream_type): + if not hasattr(get_action_model().objects, stream_type): raise TemplateSyntaxError('Action manager has no attribute: %s' % stream_type) ctxvar = kwargs.pop('as', 'stream') - context[ctxvar] = getattr(Action.objects, stream_type)(*args, **kwargs) + context[ctxvar] = getattr(get_action_model().objects, stream_type)(*args, **kwargs) return '' diff --git a/actstream/tests/base.py b/actstream/tests/base.py index 4965d774..00eacee9 100644 --- a/actstream/tests/base.py +++ b/actstream/tests/base.py @@ -12,7 +12,7 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse -from actstream.models import Action, Follow +from actstream.settings import get_follow_model, get_action_model from actstream.registry import register, unregister from actstream.actions import follow from actstream.signals import action @@ -66,8 +66,8 @@ def tearDown(self): model = apps.get_model(*model.split('.')) unregister(model) model.objects.all().delete() - Action.objects.all().delete() - Follow.objects.all().delete() + get_action_model().objects.all().delete() + get_follow_model().objects.all().delete() self.User.objects.all().delete() def capture(self, viewname, *args, query_string=''): diff --git a/actstream/tests/test_activity.py b/actstream/tests/test_activity.py index 47bd5cc5..c9733046 100644 --- a/actstream/tests/test_activity.py +++ b/actstream/tests/test_activity.py @@ -5,11 +5,12 @@ from django.utils.translation import activate, get_language from django.urls import reverse -from actstream.models import (Action, Follow, model_stream, user_stream, +from actstream.models import (model_stream, user_stream, actor_stream, following, followers) from actstream.actions import follow, unfollow from actstream.signals import action from actstream.tests.base import DataTestCase, render +from actstream.settings import get_follow_model, get_action_model class ActivityTestCase(DataTestCase): @@ -119,20 +120,20 @@ def test_doesnt_generate_duplicate_follow_records(self): f1 = follow(s, g) self.assertTrue(f1 is not None, "Should have received a new follow " "record") - self.assertTrue(isinstance(f1, Follow), "Returns a Follow object") + self.assertTrue(isinstance(f1, get_follow_model()), "Returns a Follow object") - follows = Follow.objects.filter(user=s, object_id=g.pk, - content_type=self.group_ct) + follows = get_follow_model().objects.filter(user=s, object_id=g.pk, + content_type=self.group_ct) self.assertEqual(1, follows.count(), "Should only have 1 follow record here") f2 = follow(s, g) - follows = Follow.objects.filter(user=s, object_id=g.pk, - content_type=self.group_ct) + follows = get_follow_model().objects.filter(user=s, object_id=g.pk, + content_type=self.group_ct) self.assertEqual(1, follows.count(), "Should still only have 1 follow record here") self.assertTrue(f2 is not None, "Should have received a Follow object") - self.assertTrue(isinstance(f2, Follow), "Returns a Follow object") + self.assertTrue(isinstance(f2, get_follow_model()), "Returns a Follow object") self.assertEqual(f1, f2, "Should have received the same Follow " "object that I first submitted") @@ -142,12 +143,12 @@ def test_following_models_OR_query(self): following(self.user1, Group, self.User), domap=False) def test_y_no_orphaned_follows(self): - follows = Follow.objects.count() + follows = get_follow_model().objects.count() self.user2.delete() # 2 Follow objects are deleted: # * "User2 follows group" because of the on_delete=models.CASCADE # * "User1 follows User2" because of the pre_delete signal - self.assertEqual(follows - 2, Follow.objects.count()) + self.assertEqual(follows - 2, get_follow_model().objects.count()) def test_z_no_orphaned_actions(self): actions = self.user1.actor_actions.count() @@ -259,7 +260,7 @@ def test_is_following_tag_with_verb_variable(self): self.assertEqual(render(src, user=self.user1, group=self.another_group, verb='liking'), '') def test_none_returns_an_empty_queryset(self): - qs = Action.objects.none() + qs = get_action_model().objects.none() self.assertFalse(qs.exists()) self.assertEqual(qs.count(), 0) @@ -276,5 +277,5 @@ def test_store_untranslated_string(self): self.assertEqual(verb, 'Anglais') action.send(self.user1, verb=verb, action_object=self.comment, target=self.group, timestamp=self.testdate) - self.assertTrue(Action.objects.filter(verb='English').exists()) + self.assertTrue(get_action_model().objects.filter(verb='English').exists()) activate(lang) diff --git a/actstream/tests/test_apps.py b/actstream/tests/test_apps.py index e2b8e2b8..3e8a1508 100644 --- a/actstream/tests/test_apps.py +++ b/actstream/tests/test_apps.py @@ -2,6 +2,8 @@ from django.apps.registry import apps +from actstream.settings import get_action_model + class ActstreamConfigTestCase(TestCase): @@ -10,8 +12,7 @@ def test_data_field_is_added_to_action_class_only_once_even_if_app_is_loaded_aga actstream_config.ready() actstream_config.ready() - from actstream.models import Action - data_fields = [field for field in Action._meta.fields if field.name == 'data'] + data_fields = [field for field in get_action_model()._meta.fields if field.name == 'data'] self.assertEqual( len(data_fields), 1 diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index b89c7933..41b3d89e 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -5,8 +5,7 @@ from django.urls import reverse from actstream.tests.base import DataTestCase -from actstream.settings import USE_DRF, DRF_SETTINGS -from actstream.models import Action, Follow +from actstream.settings import USE_DRF, DRF_SETTINGS, get_action_model, get_follow_model from actstream import signals @@ -125,7 +124,7 @@ def test_action_send(self): } post = self.auth_client.post(reverse('action-send'), body) assert post.status_code == 201 - action = Action.objects.first() + action = get_action_model().objects.first() assert action.description == body['description'] assert action.verb == body['verb'] assert action.actor == self.user1 @@ -141,7 +140,7 @@ def test_follow(self): } post = self.auth_client.post(reverse('follow-follow'), body) assert post.status_code == 201 - follow = Follow.objects.order_by('-id').first() + follow = get_follow_model().objects.order_by('-id').first() assert follow.follow_object == self.comment assert follow.user == self.user1 assert follow.user == self.user1 diff --git a/actstream/tests/test_gfk.py b/actstream/tests/test_gfk.py index da8194fe..2d36933f 100644 --- a/actstream/tests/test_gfk.py +++ b/actstream/tests/test_gfk.py @@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import Group -from actstream.models import Action +from actstream.settings import get_action_model from actstream.tests.base import LTE @@ -18,28 +18,28 @@ def setUp(self): self.user2, _ = User.objects.get_or_create(username='Two') self.user3, _ = User.objects.get_or_create(username='Three') self.user4, _ = User.objects.get_or_create(username='Four') - Action.objects.get_or_create( + get_action_model().objects.get_or_create( actor_content_type=self.user_ct, actor_object_id=self.user1.id, verb='followed', target_content_type=self.user_ct, target_object_id=self.user2.id ) - Action.objects.get_or_create( + get_action_model().objects.get_or_create( actor_content_type=self.user_ct, actor_object_id=self.user1.id, verb='followed', target_content_type=self.user_ct, target_object_id=self.user3.id ) - Action.objects.get_or_create( + get_action_model().objects.get_or_create( actor_content_type=self.user_ct, actor_object_id=self.user1.id, verb='followed', target_content_type=self.user_ct, target_object_id=self.user4.id ) - Action.objects.get_or_create( + get_action_model().objects.get_or_create( actor_content_type=self.user_ct, actor_object_id=self.user1.id, verb='joined', @@ -49,8 +49,9 @@ def setUp(self): def test_fetch_generic_relations(self): # baseline without fetch_generic_relations - _actions = Action.objects.filter(actor_content_type=self.user_ct, - actor_object_id=self.user1.id) + _actions = get_action_model().objects.filter(actor_content_type=self.user_ct, + actor_object_id=self.user1.id) + def actions(): return _actions._clone() num_content_types = len(set(actions().values_list( @@ -87,8 +88,10 @@ def actions(): return _actions._clone() action_actor_targets_fetch_generic_all) # fetch only 1 generic relation, but access both gfks + def generic(): return actions().fetch_generic_relations('target') + self.assertNumQueries(LTE(n + num_content_types + 2), lambda: [ (a.actor, a.target) for a in generic()]) action_actor_targets_fetch_generic_target = [ diff --git a/actstream/tests/test_views.py b/actstream/tests/test_views.py index 5ab8ab7a..9c7e23ee 100644 --- a/actstream/tests/test_views.py +++ b/actstream/tests/test_views.py @@ -2,6 +2,7 @@ from django.urls import reverse +from actstream.settings import get_action_model, get_follow_model from actstream import models from actstream.tests.base import DataTestCase @@ -34,13 +35,13 @@ def test_follow_unfollow(self): action = {'actor_content_type': self.user_ct, 'actor_object_id': self.user1.pk, 'target_content_type': self.user_ct, 'target_object_id': self.user3.pk, 'verb': 'started following'} - models.Follow.objects.get(**follow) - models.Action.objects.get(**action) + get_follow_model().objects.get(**follow) + get_action_model().objects.get(**action) response = self.get('actstream_unfollow', self.user_ct.pk, self.user3.pk) self.assertEqual(response.status_code, 204) self.assertEqual(len(response.templates), 0) - self.assertRaises(models.Follow.DoesNotExist, models.Follow.objects.get, **follow) + self.assertRaises(get_follow_model().DoesNotExist, get_follow_model().objects.get, **follow) response = self.get('actstream_unfollow', self.user_ct.pk, self.user3.pk, next='/redirect/') self.assertEqual(response.status_code, 302) @@ -55,13 +56,13 @@ def test_follow_unfollow_with_flag(self): action = {'actor_content_type': self.user_ct, 'actor_object_id': self.user1.pk, 'target_content_type': self.user_ct, 'target_object_id': self.user3.pk, 'verb': 'started watching'} - models.Follow.objects.get(**follow) - models.Action.objects.get(**action) + get_follow_model().objects.get(**follow) + get_action_model().objects.get(**action) response = self.get('actstream_unfollow', self.user_ct.pk, self.user3.pk, 'watching') self.assertEqual(response.status_code, 204) self.assertEqual(len(response.templates), 0) - self.assertRaises(models.Follow.DoesNotExist, models.Follow.objects.get, **follow) + self.assertRaises(get_follow_model().DoesNotExist, get_follow_model().objects.get, **follow) response = self.get('actstream_unfollow', self.user_ct.pk, self.user3.pk, 'watching', next='/redirect/') self.assertEqual(response.status_code, 302) diff --git a/docs/changelog.rst b/docs/changelog.rst index a7e0c0a6..46f48ce1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,7 @@ Changelog 2.0.1 ----- + - Added support for custom Action and Follow models - Added support for Django 5.0 and 5.1 - Added support for Python 3.10, 3.11 and 3.12 - Updated GitHub actions diff --git a/docs/drf.rst b/docs/drf.rst index 6e977339..1715b14b 100644 --- a/docs/drf.rst +++ b/docs/drf.rst @@ -3,7 +3,7 @@ Django ReST Framework Integration ================================= -As of version 2.0.0, django-activity-stream now supports integration with `Django ReST Framework `_. +As of version 2.0.1, django-activity-stream now supports integration with `Django ReST Framework `_. DRF provides a standardized way of interacting with models stored in Django. It provides standard create/update/get operations using http standard methods. diff --git a/runtests/custom/__init__.py b/runtests/custom/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/runtests/custom/migrations/0001_initial.py b/runtests/custom/migrations/0001_initial.py new file mode 100644 index 00000000..72a6fd40 --- /dev/null +++ b/runtests/custom/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 2.0.5 on 2018-05-03 23:51 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CustomAction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('actor_object_id', models.CharField(db_index=True, max_length=255)), + ('verb', models.CharField(db_index=True, max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('target_object_id', models.CharField(blank=True, db_index=True, max_length=255, null=True)), + ('action_object_object_id', models.CharField(blank=True, db_index=True, max_length=255, null=True)), + ('timestamp', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('public', models.BooleanField(db_index=True, default=True)), + ('quest', models.CharField(max_length=200)), + ('action_object_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='custom_customaction_action_objects', related_query_name='custom_(class)s_action_objects', to='contenttypes.ContentType')), + ('actor_content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='custom_customaction_actors', related_query_name='custom_(class)s_actors', to='contenttypes.ContentType')), + ('target_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='custom_customaction_targets', related_query_name='custom_(class)s_targets', to='contenttypes.ContentType')), + ], + options={ + 'ordering': ('-timestamp',), + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CustomFollow', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.CharField(db_index=True, max_length=255)), + ('actor_only', models.BooleanField(default=True, verbose_name='Only follow actions where the object is the target.')), + ('flag', models.CharField(blank=True, db_index=True, default='', max_length=255)), + ('started', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('is_special', models.BooleanField(default=False)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='custom_customfollow_related', related_query_name='custom_(class)ss', to='contenttypes.ContentType')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customfollows', related_query_name='customfollows', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AlterUniqueTogether( + name='customfollow', + unique_together={('user', 'content_type', 'object_id', 'flag')}, + ), + ] diff --git a/runtests/custom/migrations/__init__.py b/runtests/custom/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/runtests/custom/models.py b/runtests/custom/models.py new file mode 100644 index 00000000..b468da10 --- /dev/null +++ b/runtests/custom/models.py @@ -0,0 +1,10 @@ +from django.db import models +from actstream.models import AbstractAction, AbstractFollow + + +class CustomAction(AbstractAction): + quest = models.CharField(max_length=200) + + +class CustomFollow(AbstractFollow): + is_special = models.BooleanField(default=False) diff --git a/runtests/custom/tests.py b/runtests/custom/tests.py new file mode 100644 index 00000000..e369af95 --- /dev/null +++ b/runtests/custom/tests.py @@ -0,0 +1,45 @@ +from django.test.utils import override_settings + +from actstream.settings import get_action_model, get_follow_model +from actstream.signals import action +from actstream.tests.base import ActivityBaseTestCase +from actstream.actions import follow, unfollow + +from .models import CustomAction, CustomFollow + + +@override_settings( + ACTSTREAM_ACTION_MODEL='custom.CustomAction', + ACTSTREAM_FOLLOW_MODEL='custom.CustomFollow' +) +class CustomModelTests(ActivityBaseTestCase): + def setUp(self): + super(CustomModelTests, self).setUp() + self.user1 = self.User.objects.create(username='test1') + self.user2 = self.User.objects.create(username='test2') + + def test_custom_action_model(self): + self.assertEqual(get_action_model(), CustomAction) + + def test_custom_follow_model(self): + self.assertEqual(get_follow_model(), CustomFollow) + + def test_custom_data(self): + """Adding custom data to a model field works as expected.""" + action.send(self.user1, verb='was created', quest='to be awesome') + self.assertEqual(CustomAction.objects.count(), 1) + self.assertEqual(CustomAction .objects.first().quest, 'to be awesome') + + def test_custom_follow(self): + follow(self.user1, self.user2, is_special=True, quest='holy grail') + custom_follow = get_follow_model().objects.first() + self.assertEqual(custom_follow.user, self.user1) + self.assertEqual(custom_follow.follow_object, self.user2) + self.assertEqual(custom_follow.is_special, True) + custom_action = get_action_model().objects.first() + self.assertEqual(custom_action.actor, self.user1) + self.assertEqual(custom_action.target, self.user2) + self.assertEqual(custom_action.quest, 'holy grail') + + unfollow(self.user1, self.user2) + self.assertFalse(get_follow_model().objects.exists()) diff --git a/runtests/settings.py b/runtests/settings.py index 209ab6e1..b75eb29c 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -132,6 +132,7 @@ 'testapp', 'testapp_nested', + 'custom' ] try: diff --git a/runtests/testapp/tests/test_django.py b/runtests/testapp/tests/test_django.py index 528384b1..f2340f43 100644 --- a/runtests/testapp/tests/test_django.py +++ b/runtests/testapp/tests/test_django.py @@ -5,9 +5,9 @@ from actstream.signals import action from actstream.registry import register, unregister -from actstream.models import Action, actor_stream, model_stream +from actstream.models import actor_stream, model_stream from actstream.tests.base import render, ActivityBaseTestCase -from actstream.settings import USE_JSONFIELD +from actstream.settings import USE_JSONFIELD, get_action_model from testapp.models import MyUser, Abstract, Unregistered @@ -19,9 +19,9 @@ def setUp(self): action.send(self.user, verb='was created') def test_accessor(self): - self.assertEqual(len(Action.objects.testfoo(self.user)), 1) + self.assertEqual(len(get_action_model().objects.testfoo(self.user)), 1) self.assertEqual( - len(Action.objects.testfoo(self.user, datetime(1970, 1, 1))), + len(get_action_model().objects.testfoo(self.user, datetime(1970, 1, 1))), 0 ) @@ -72,7 +72,7 @@ def test_jsonfield(self): tags=['sayings'], more_data={'pk': self.user.pk} ) - newaction = Action.objects.filter(verb='said')[0] + newaction = get_action_model().objects.filter(verb='said')[0] self.assertEqual(newaction.data['text'], 'foobar') self.assertEqual(newaction.data['tags'], ['sayings']) self.assertEqual(newaction.data['more_data'], {'pk': self.user.pk})