From 3b2db4163a8c7707773fb1276ec5ab05ca02d83c Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sat, 21 Nov 2020 00:07:41 -0500 Subject: [PATCH 1/6] basics of @slurms idea for Action/Follow models based on settings --- actstream/feeds.py | 20 ++++++++++------ actstream/models.py | 16 ++++++------- actstream/settings.py | 32 +++++++++++++++++++++++++ actstream/templatetags/activity_tags.py | 12 +++++----- actstream/tests/base.py | 8 +++---- actstream/tests/test_activity.py | 23 +++++++++--------- actstream/tests/test_apps.py | 5 ++-- actstream/tests/test_gfk.py | 19 ++++++++------- runtests/testapp/tests.py | 10 ++++---- 9 files changed, 93 insertions(+), 52 deletions(-) diff --git a/actstream/feeds.py b/actstream/feeds.py index 690366ea..6d92c0df 100644 --- a/actstream/feeds.py +++ b/actstream/feeds.py @@ -12,7 +12,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: @@ -20,11 +21,12 @@ class AbstractActivityStream: Abstract base class for all stream rendering. Supports hooks for fetching streams and formatting actions. """ + def get_stream(self, *args, **kwargs): """ Returns a stream method to use. """ - raise NotImplementedError + raise NotImplementedError def get_object(self, *args, **kwargs): """ @@ -46,7 +48,7 @@ def get_uri(self, action, obj=None, date=None): date = action.timestamp date = datetime_safe.new_datetime(date).strftime('%Y-%m-%d') return 'tag:{},{}:{}'.format(Site.objects.get_current().domain, date, - self.get_url(action, obj, False)) + self.get_url(action, obj, False)) def get_url(self, action, obj=None, domain=True): """ @@ -119,6 +121,7 @@ class ActivityStreamsAtomFeed(Atom1Feed): """ Feed rendering class for the v1.0 Atom Activity Stream Spec """ + def root_attributes(self): attrs = super(ActivityStreamsAtomFeed, self).root_attributes() attrs['xmlns:activity'] = 'http://activitystrea.ms/spec/1.0/' @@ -208,6 +211,7 @@ class JSONActivityFeed(AbstractActivityStream, View): """ Feed that generates feeds compatible with the v1.0 JSON Activity Stream spec """ + def dispatch(self, request, *args, **kwargs): return HttpResponse(self.serialize(request, *args, **kwargs), content_type='application/json') @@ -242,11 +246,12 @@ def get_object(self, request, content_type_id, object_id): def get_stream(self): return any_stream + class StreamKwargsMixin: - + def items(self, request, *args, **kwargs): - return self.get_stream()(self.get_object(request, *args, **kwargs),**self.get_stream_kwargs(request)) - + return self.get_stream()(self.get_object(request, *args, **kwargs), **self.get_stream_kwargs(request)) + class UserActivityMixin: @@ -263,6 +268,7 @@ def get_stream_kwargs(self, request): stream_kwargs['with_user_activity'] = request.GET['with_user_activity'].lower() == 'true' return stream_kwargs + class CustomStreamMixin: name = None @@ -270,7 +276,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/models.py b/actstream/models.py index 8c122de6..5bbe7f24 100644 --- a/actstream/models.py +++ b/actstream/models.py @@ -166,11 +166,11 @@ def get_absolute_url(self): # 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/settings.py b/actstream/settings.py index a86c1884..3edb6e2f 100644 --- a/actstream/settings.py +++ b/actstream/settings.py @@ -1,4 +1,8 @@ +from functools import lru_cache + +from django.apps import apps from django.conf import settings +from django.core.exceptions import ImproperlyConfigured SETTINGS = getattr(settings, 'ACTSTREAM_SETTINGS', {}) @@ -23,3 +27,31 @@ def get_action_manager(): FETCH_RELATIONS = SETTINGS.get('FETCH_RELATIONS', True) USE_JSONFIELD = SETTINGS.get('USE_JSONFIELD', False) + +FOLLOW_MODEL = SETTINGS.get('ACTSTREAM_FOLLOW_MODEL', 'actstream.Follow') +ACTION_MODEL = SETTINGS.get('ACTSTREAM_ACTION_MODEL', 'actstream.Action') + + +def get_swappable_model(model_lookup): + 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 + ) + + +@lru_cache(maxsize=None) +def get_follow_model(): + """Return the Follow model that is active.""" + return get_swappable_model(FOLLOW_MODEL) + + +@lru_cache(maxsize=None) +def get_action_model(): + """Return the Action model that is active.""" + return get_swappable_model(ACTION_MODEL) 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 cb323345..f1732731 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 @@ -63,12 +63,12 @@ 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=''): - response = self.client.get('{}?{}'.format(reverse(viewname, args=args),query_string)) + response = self.client.get('{}?{}'.format(reverse(viewname, args=args), query_string)) content = response.content.decode() if response['Content-Type'] == 'application/json': return loads(content) diff --git a/actstream/tests/test_activity.py b/actstream/tests/test_activity.py index b9247563..cf09be50 100644 --- a/actstream/tests/test_activity.py +++ b/actstream/tests/test_activity.py @@ -6,11 +6,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): @@ -120,20 +121,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") @@ -143,9 +144,9 @@ 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() - self.assertEqual(follows - 1, Follow.objects.count()) + self.assertEqual(follows - 1, get_follow_model().objects.count()) def test_z_no_orphaned_actions(self): actions = self.user1.actor_actions.count() @@ -257,7 +258,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) @@ -274,5 +275,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_gfk.py b/actstream/tests/test_gfk.py index 69fddaaf..7fb5a98e 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,9 +49,10 @@ 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 = lambda: _actions._clone() + _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( 'target_content_type_id', flat=True))) n = actions().count() @@ -86,7 +87,7 @@ def test_fetch_generic_relations(self): action_actor_targets_fetch_generic_all) # fetch only 1 generic relation, but access both gfks - generic = lambda: actions().fetch_generic_relations('target') + 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/runtests/testapp/tests.py b/runtests/testapp/tests.py index 611e9770..35428565 100644 --- a/runtests/testapp/tests.py +++ b/runtests/testapp/tests.py @@ -4,9 +4,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 Abstract, Unregistered @@ -18,9 +18,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 ) @@ -73,7 +73,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}) From a1267aa6676d89c7425709419bba314410cd1a9e Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Tue, 23 Mar 2021 21:25:23 -0400 Subject: [PATCH 2/6] wip: swappable model refactor --- actstream/actions.py | 17 ++++++++++++----- actstream/apps.py | 2 +- actstream/managers.py | 6 +++--- actstream/models.py | 22 +++++++++++++++++----- actstream/registry.py | 5 +++-- actstream/settings.py | 14 ++++---------- runtests/settings.py | 1 + tox.ini | 2 +- 8 files changed, 42 insertions(+), 27 deletions(-) diff --git a/actstream/actions.py b/actstream/actions.py index 7a18e708..7919d56d 100644 --- a/actstream/actions.py +++ b/actstream/actions.py @@ -1,7 +1,7 @@ -from django.apps import apps from django.utils.translation import ugettext_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 @@ -32,7 +32,7 @@ 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( + instance, created = settings.get_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 @@ -60,7 +60,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 +91,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) ) @@ -114,7 +114,7 @@ def action_handler(verb, **kwargs): if hasattr(verb, '_proxy____args'): verb = verb._proxy____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), @@ -130,6 +130,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.keys()): + 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/apps.py b/actstream/apps.py index de3120aa..868a9663 100644 --- a/actstream/apps.py +++ b/actstream/apps.py @@ -10,7 +10,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 = settings.get_action_model() if settings.USE_JSONFIELD and not hasattr(action_class, 'data'): from actstream.jsonfield import DataField, register_app diff --git a/actstream/managers.py b/actstream/managers.py index 7291e441..72ae8011 100644 --- a/actstream/managers.py +++ b/actstream/managers.py @@ -1,4 +1,3 @@ -from django.apps import apps from django.contrib.contenttypes.models import ContentType from django.db.models import Q @@ -6,6 +5,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): @@ -97,10 +97,10 @@ def user(self, obj, 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) - + content_types = ContentType.objects.filter( pk__in=follows.values('content_type_id') ) diff --git a/actstream/models.py b/actstream/models.py index 5bbe7f24..973bbebb 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,6 +167,16 @@ def get_absolute_url(self): 'actstream.views.detail', [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 = actstream_settings.get_action_model().objects.actor action_object_stream = actstream_settings.get_action_model().objects.action_object diff --git a/actstream/registry.py b/actstream/registry.py index 82515d9a..b14fd509 100644 --- a/actstream/registry.py +++ b/actstream/registry.py @@ -1,11 +1,12 @@ from inspect import isclass -import django from django.apps import apps from django.contrib.contenttypes.fields import GenericRelation from django.db.models.base import ModelBase from django.core.exceptions import ImproperlyConfigured +from actstream.settings import get_action_model + class RegistrationError(Exception): pass @@ -15,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 3edb6e2f..abff808e 100644 --- a/actstream/settings.py +++ b/actstream/settings.py @@ -1,5 +1,3 @@ -from functools import lru_cache - from django.apps import apps from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -28,11 +26,9 @@ def get_action_manager(): USE_JSONFIELD = SETTINGS.get('USE_JSONFIELD', False) -FOLLOW_MODEL = SETTINGS.get('ACTSTREAM_FOLLOW_MODEL', 'actstream.Follow') -ACTION_MODEL = SETTINGS.get('ACTSTREAM_ACTION_MODEL', 'actstream.Action') - -def get_swappable_model(model_lookup): +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: @@ -45,13 +41,11 @@ def get_swappable_model(model_lookup): ) -@lru_cache(maxsize=None) def get_follow_model(): """Return the Follow model that is active.""" - return get_swappable_model(FOLLOW_MODEL) + return get_swappable_model('Follow') -@lru_cache(maxsize=None) def get_action_model(): """Return the Action model that is active.""" - return get_swappable_model(ACTION_MODEL) + return get_swappable_model('Action') diff --git a/runtests/settings.py b/runtests/settings.py index b54e4fa1..405d058a 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -99,6 +99,7 @@ 'testapp', 'testapp_nested', + 'custom', ) ACTSTREAM_SETTINGS = { diff --git a/tox.ini b/tox.ini index 3b6524bc..33a963c0 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ deps = coverage>=4.5.1 django22: Django>=2.2,<3.0 django30: Django>=3.0,<3.1 - django30: Django>=3.1,<3.2 + django31: Django>=3.1,<3.2 mysql: mysqlclient>=1.4.2,<1.5 mysql: django-mysql>=2.4.1 postgres,sqlite: django-jsonfield>=1.0.1 From 0e6fe879b2184518ed9bee5796845c37c6495bda Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Tue, 23 Mar 2021 21:26:52 -0400 Subject: [PATCH 3/6] wip: custom test app --- runtests/custom/__init__.py | 0 runtests/custom/migrations/0001_initial.py | 60 ++++++++++++++++++++++ runtests/custom/migrations/__init__.py | 0 runtests/custom/models.py | 10 ++++ runtests/custom/tests.py | 29 +++++++++++ 5 files changed, 99 insertions(+) create mode 100644 runtests/custom/__init__.py create mode 100644 runtests/custom/migrations/0001_initial.py create mode 100644 runtests/custom/migrations/__init__.py create mode 100644 runtests/custom/models.py create mode 100644 runtests/custom/tests.py 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..71dd0a35 --- /dev/null +++ b/runtests/custom/tests.py @@ -0,0 +1,29 @@ +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 .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.user = self.User.objects.create(username='test') + + 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.user, verb='was created', quest='to be awesome') + self.assertEqual(CustomAction.objects.count(), 1) + self.assertEqual(CustomAction.objects.first().quest, 'to be awesome') From fa0aeb832e4235c6d1f7c195214c23a5c447423d Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 31 May 2021 18:05:54 -0400 Subject: [PATCH 4/6] use actstream_settings instead of django settings --- actstream/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actstream/apps.py b/actstream/apps.py index eed51fce..4162ae2c 100644 --- a/actstream/apps.py +++ b/actstream/apps.py @@ -15,7 +15,7 @@ class ActstreamConfig(AppConfig): def ready(self): from actstream.actions import action_handler action.connect(action_handler, dispatch_uid='actstream.models') - action_class = settings.get_action_model() + action_class = actstream_settings.get_action_model() if actstream_settings.USE_JSONFIELD: if not hasattr(action_class, 'data'): From 80114e74cf3eb921365cd881017128d956ce4db6 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 31 May 2021 19:02:41 -0400 Subject: [PATCH 5/6] custom following that can send attrs to Follow/Action insances --- actstream/actions.py | 18 ++++++++++++++++-- runtests/custom/tests.py | 22 +++++++++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/actstream/actions.py b/actstream/actions.py index 7919d56d..7fa11893 100644 --- a/actstream/actions.py +++ b/actstream/actions.py @@ -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 = settings.get_follow_model().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) @@ -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') @@ -130,7 +144,7 @@ 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.keys()): + for attr in list(kwargs): try: settings.get_action_model()._meta.get_field(attr) except FieldDoesNotExist: diff --git a/runtests/custom/tests.py b/runtests/custom/tests.py index 71dd0a35..e369af95 100644 --- a/runtests/custom/tests.py +++ b/runtests/custom/tests.py @@ -3,6 +3,7 @@ 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 @@ -14,7 +15,8 @@ class CustomModelTests(ActivityBaseTestCase): def setUp(self): super(CustomModelTests, self).setUp() - self.user = self.User.objects.create(username='test') + 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) @@ -24,6 +26,20 @@ def test_custom_follow_model(self): def test_custom_data(self): """Adding custom data to a model field works as expected.""" - action.send(self.user, verb='was created', quest='to be awesome') + 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') + 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()) From 30ee631850203039e568759660626a849e6bcb6b Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Thu, 5 Sep 2024 16:08:06 +0600 Subject: [PATCH 6/6] Updated code on existing branch (#543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * use python base image for simplicity * updating dev env w/ helpers * wip: actstream/registered models as factories for DRF * DRF testing and updates * drf extras for package * default permissions, custom viewsets * allow a 'my actions' view as viewset action * settings refactor * testing fix, user serializer to hide password * dont require drf for testing app * install drf for actions * install drf generics for actions * action posting and model/object drf feeds * weird ordering issues in tests * viewset ordering, test debugging for gh actions * weird db auto id bug? hope this works * drf following * add drf urlconf to actstream.urls * drf-spectacular in runtests * weird recursion error on urls * use expand fields as default behavior * use pytest finally * set sane ordering in qs, not on drf ordering fields * runtest project changes, more granular custom permissions/viewsets * dont load test data 2x * fugly settings remvoed * label model url names * testing restricted permissions * MODEL_FIELDS setting/testing * test fix * +pytest.ini * renamed to streams * renamed to streams * old decorators for compat. * readonly + public * manager arg typing * fixing test warnings * adding more streams from managers to views * views for following/follows/is_following * testfix for missing field * check post vs get for action sending * spectacular in runtests * dont require drf-spectacular for runtests * only test on push to main * test fix * local db setting overrides * move testapp tests to testapp and out of actstream * wip: using drf-spectacular to autogen api docs * use drf-spectacular * Django>=3.2 * Remove mysql details from docstring (#511) * Remove mysql details from docstring The django-mysql JSONField option (introduced before Django had its own builtin JSONField) has been removed, so we should not mention it in the docstring anymore. * Fix typos * docs: Fix a few typos There are small typos in: - actstream/actions.py - docs/concepts.rst Fixes: - Should read `untranslated` rather than `unstranslated`. - Should read `terminology` rather than `terminiology`. Signed-off-by: Tim Gates * +drf module to setup * new api docs * s/master/main/g trying coverage * v2 codeql, no coverage * new github workflow badge query * github actions + coveralls? * coveralls? * parallel coverage? * no custom coverage file name * coveralls * Fixes #515 - Make it work with Django 4.1 * Fixes #515 - Change comment to the change * django 4.1 in tox * separate django from drf tests * fix typo in installation docs. * strip out drf spectacular for now * remove json field compatibility, force django 3.2, refs #480 * start of drf docs * 1.4.2 prep * updating authors and changelog ln * updating drf docs * Create SECURITY.md * fix for #524 datetime_safe removal * feat(follows): delete object-orphaned Follows... ... based on Django pre_delete signal * Update codeql-analysis.yml * Create dependabot.yml * Bump actions/checkout from 2 to 3 Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v2...v3) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Bump actions/upload-artifact from 2 to 3 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 2 to 3. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v2...v3) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Bump actions/checkout from 3 to 4 Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * changelog entry for v2.0.0 release * Bump version to v2.0.0 * remove unused imports * add swappable models for drf part * add swappable models for drf part * release 2.0.1 --------- Signed-off-by: Tim Gates Signed-off-by: dependabot[bot] Co-authored-by: Justin Quick Co-authored-by: Justin Quick Co-authored-by: Christoph Bülter Co-authored-by: Tim Gates Co-authored-by: Marcus Aram Co-authored-by: Justin Quick Co-authored-by: khial mustapha Co-authored-by: Jens Nistler Co-authored-by: David Guillot Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Artush Ghazaryan --- .coveragerc | 1 + .github/dependabot.yml | 15 ++ .github/workflows/codeql-analysis.yml | 14 +- .github/workflows/workflow.yaml | 36 ++- .gitignore | 1 + AUTHORS.txt | 109 +++++---- CHANGELOG.rst | 2 +- Dockerfile | 42 ++-- README.rst | 4 +- SECURITY.md | 15 ++ actstream/__init__.py | 2 +- actstream/actions.py | 4 +- actstream/admin.py | 6 +- actstream/apps.py | 18 +- actstream/decorators.py | 31 +-- actstream/drf/__init__.py | 0 actstream/drf/serializers.py | 112 +++++++++ actstream/drf/urls.py | 18 ++ actstream/drf/views.py | 231 ++++++++++++++++++ actstream/feeds.py | 3 +- actstream/follows.py | 16 ++ actstream/jsonfield.py | 37 +-- actstream/managers.py | 22 +- actstream/registry.py | 11 +- actstream/settings.py | 38 ++- actstream/streams.py | 30 +++ actstream/tests/base.py | 9 +- actstream/tests/test_activity.py | 5 +- actstream/tests/test_drf.py | 168 +++++++++++++ actstream/tests/test_views.py | 13 +- actstream/urls.py | 13 +- docker-compose.yml | 3 +- docs/Makefile | 2 +- docs/api.rst | 24 +- docs/changelog.rst | 18 ++ docs/concepts.rst | 2 +- docs/conf.py | 5 +- docs/configuration.rst | 8 + docs/data.rst | 13 - docs/drf.rst | 56 +++++ docs/index.rst | 5 +- docs/installation.rst | 19 +- pytest.ini | 6 + runtests/requirements.txt | 9 +- runtests/settings.py | 97 ++++++-- runtests/testapp/__init__.py | 6 +- runtests/testapp/apps.py | 4 +- runtests/testapp/drf.py | 28 +++ runtests/testapp/management/__init__.py | 0 .../testapp/management/commands/__init__.py | 0 .../testapp/management/commands/initdb.py | 22 ++ runtests/testapp/tests/__ini__.py | 0 .../{tests.py => tests/test_django.py} | 29 ++- runtests/testapp/tests/test_drf.py | 25 ++ runtests/testapp/urls.py | 4 +- runtests/testapp_nested/__init__.py | 6 +- runtests/urls.py | 15 +- setup.py | 4 +- tox.ini | 32 ++- 59 files changed, 1164 insertions(+), 304 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 SECURITY.md create mode 100644 actstream/drf/__init__.py create mode 100644 actstream/drf/serializers.py create mode 100644 actstream/drf/urls.py create mode 100644 actstream/drf/views.py create mode 100644 actstream/follows.py create mode 100644 actstream/streams.py create mode 100644 actstream/tests/test_drf.py create mode 100644 docs/drf.rst create mode 100644 pytest.ini create mode 100644 runtests/testapp/drf.py create mode 100644 runtests/testapp/management/__init__.py create mode 100644 runtests/testapp/management/commands/__init__.py create mode 100644 runtests/testapp/management/commands/initdb.py create mode 100644 runtests/testapp/tests/__ini__.py rename runtests/testapp/{tests.py => tests/test_django.py} (76%) create mode 100644 runtests/testapp/tests/test_drf.py diff --git a/.coveragerc b/.coveragerc index ce257d8a..a6261170 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,6 @@ [run] branch = True +relative_files = True source = actstream omit = *migrations* diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..f44c7ac9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 461ae101..90ebd476 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,12 +13,10 @@ name: "CodeQL" on: push: - branches: [ master ] + branches: [ main ] pull_request: # The branches below must be a subset of the branches above - branches: [ master ] - schedule: - - cron: '23 23 * * 4' + branches: [ main ] jobs: analyze: @@ -38,11 +36,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,7 +51,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v2 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -67,4 +65,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml index 492113a1..bcc7cf26 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -1,18 +1,17 @@ name: Tests -# define when to run the action - -# define when to run the action on: push: - branches: [ 'master'] + branches: + - main paths: - '**.py' - '**.txt' - '**.yaml' - '**.toml' pull_request: - branches: [ 'master'] + branches: + - main paths: - '**.py' - '**.txt' @@ -81,7 +80,7 @@ jobs: steps: # check out revision to test - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # install python - name: Set up Python ${{ matrix.python-version }} @@ -110,6 +109,9 @@ jobs: if: matrix.database == 'postgres' run: pip install psycopg2-binary>=2.8.6 + - name: Install Django ReST framework libraries + run: pip install -U django-rest-framework rest-framework-generic-relations drf-spectacular + # install our package - name: Install package @@ -120,3 +122,25 @@ jobs: run: coverage run runtests/manage.py test -v3 --noinput actstream testapp testapp_nested env: DATABASE_ENGINE: ${{ matrix.database }} + # COVERAGE_FILE: ".coverage.${{ matrix.python_version }}" + + - name: Store coverage file + uses: actions/upload-artifact@v3 + with: + name: coverage + path: .coverage #.${{ matrix.python_version }} + + - name: Coveralls + uses: AndreMiras/coveralls-python-action@develop + with: + parallel: true + flag-name: Unit Test + + coveralls_finish: + needs: test + runs-on: ubuntu-latest + steps: + - name: Coveralls Finished + uses: AndreMiras/coveralls-python-action@develop + with: + parallel-finished: true diff --git a/.gitignore b/.gitignore index cb756834..3a4a7276 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ db.sqlite3 runtests/static/ .envrc .ropeproject/ +htmlcov/ diff --git a/AUTHORS.txt b/AUTHORS.txt index e8f00ef7..5fa7c9b9 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -1,105 +1,108 @@ -Justin Quick -Asif Saifuddin Auvi -Chris Beaven -ehsabd -yangxg -Ben Slavin -Deepak Prakash -Frank Wickström -Nick Sandford -Manuel Aristarán -Alexey Boriskin -Jason Culverhouse -Michael Jones -Piet Delport -Wade Williams -jordan -Bruno Amaral -Christoph Heer -Jens Nistler -Josh Ourisman -Nolan Brubaker -Trever Shick -Artem Khurshudov -Benjamin Kampmann -Chris -Christoph Bülter -David Gouldin -Dmitriy Narkevich -Herman Schaaf -Jocelyn Delalande -Matt Katz -Natan Yellin -Patrick Altman -Paul Collins -Ryan Allen -Steve Ivy -Tiago Henriques -Walter Scheper -artscoop -cmwaura -hugokernel -jbsag -moritz -neelesh Aaron Williamson Alejandro Seguí +Alexey Boriskin +anka-sirota <176934+anka-sirota@users.noreply.github.com> Anwesha Das +Artem Khurshudov +artscoop +Asif Saifuddin Auvi Aziz M. Bookwala +Benjamin Kampmann Ben Lopatin +Ben Slavin Bob Cribbs Bojan Mihelac Brian Slater <36425025+slater-brian-john@users.noreply.github.com> +Bruno Amaral Can Burak Cilingir +Chris Beaven +Chris Christoph Buelter +Christoph Bülter +Christoph Heer +cmwaura David Burke +David Gouldin +Deepak Prakash Denis Denis Surkov Dex Bleeker +Dmitriy Narkevich Donald Stufft +ehsabd Elf M. Sternberg Filip Wasilewski +fossabot Frank Wickström +Frank Wickström Gilberto Magalhães Hameed Gifford Hanu Prateek Kunduru +Herman Schaaf +hugokernel James <12661555+jmsmkn@users.noreply.github.com> Jannon Frank +Jason Culverhouse +jbsag Jeff Gordon +Jens Nistler +jess Jj +joaoxsouls +Jocelyn Delalande JocelynDelalande +jordan +Josh Ourisman +jpic +Justin Quick Keith Bussell +khial mustapha Kris Ciccarello +laginha Luis +Lutaaya Idris +Manuel Aristarán Marc Fargas +Marcus Aram +Matt Katz Michael Bertolacci +Michael Jones Missuor4ever +moritz Muhammed Kaplan +Natan Yellin +neelesh Nick Parsons +Nick Sandford +Nolan Brubaker +odeson24 +Patrick Altman Patrick Sier +Paul Collins Paul Nicolet Pedro Alcocer Pedro Burón Peter Walker +Piet Delport +riazanovslv <30866558+riazanovslv@users.noreply.github.com> Rob Terhaar Rodrigo Suárez +Ryan Allen Sandip Agrawal Santiago Piccinini +Steve Ivy Tamas Leposa The Gitter Badger +Tiago Henriques Tim Gates Tom Clancy Tony Narlock +Trever Shick +uy-rrodriguez <5296200+uy-rrodriguez@users.noreply.github.com> Victor Munene Vineet +Wade Williams +Walter Scheper Xavier L +yangxg Zbigniew Siciarz -anka-sirota <176934+anka-sirota@users.noreply.github.com> -fossabot -jess -joaoxsouls -jpic -laginha -odeson24 -riazanovslv <30866558+riazanovslv@users.noreply.github.com> -uy-rrodriguez <5296200+uy-rrodriguez@users.noreply.github.com> diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8d574fd0..8f20b76b 120000 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1 +1 @@ -./docs/source/changelog.rst \ No newline at end of file +./docs/changelog.rst \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index cc275fdf..8f7f8f45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,37 +1,27 @@ -FROM ubuntu:focal +FROM python:3.9 ENV PYTHONUNBUFFERED 1 ENV LC_ALL=C.UTF-8 -ARG DEBIAN_FRONTEND=noninteractive - -# the base image is also built using this Dockerfile, so we have to reset this -USER root - -RUN apt-get -y update && apt-get -y --no-install-recommends install \ - build-essential \ - gcc \ - gettext \ - python3-dev \ - python3-venv \ - && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /usr/share/doc/* /usr/share/locale/* /usr/share/man/* && \ - mkdir -p /app && \ - (useradd -m app || true) - -COPY --from=library/docker:latest /usr/local/bin/docker /usr/bin/docker -COPY --from=docker/compose:1.23.2 /usr/local/bin/docker-compose /usr/bin/docker-compose -WORKDIR /app +# +# RUN apt-get -y update && apt-get -y --no-install-recommends install \ +# build-essential \ +# gcc \ +# gettext \ +# python3-dev \ +# python3-venv \ +# && \ +# apt-get clean && \ +# rm -rf /var/lib/apt/lists/* /usr/share/doc/* /usr/share/locale/* /usr/share/man/* && \ +# mkdir -p /app && \ +# (useradd -m app || true) -ADD runtests/requirements.txt /app/ -USER app +WORKDIR /app -ENV PATH /home/app/venv/bin:${PATH} +ADD runtests/requirements.txt /app/ -RUN python3 -m venv ~/venv && \ - pip install -r /app/requirements.txt +RUN pip install -r requirements.txt ENV DJANGO_SETTINGS_MODULE settings diff --git a/README.rst b/README.rst index 12f22a98..95a31438 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,8 @@ Django Activity Stream ====================== -.. image:: https://github.com/justquick/django-activity-stream/workflows/Test%20and%20deploy/badge.svg - :target: https://github.com/justquick/django-activity-stream/actions?query=workflow%3A%22Test+and+deploy%22 +.. image:: https://github.com/justquick/django-activity-stream/actions/workflows/workflow.yaml/badge.svg + :target: https://github.com/justquick/django-activity-stream/actions/workflows/workflow.yaml .. image:: https://badges.gitter.im/django-activity-stream/Lobby.svg :alt: Join the chat at https://gitter.im/django-activity-stream/Lobby diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..81512780 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 1.4.x | :white_check_mark: | +| <1.4.0 | :x: | + +## Reporting a Vulnerability + +auvipy@gmail.com diff --git a/actstream/__init__.py b/actstream/__init__.py index 93f38a2c..7bbe37fe 100644 --- a/actstream/__init__.py +++ b/actstream/__init__.py @@ -12,5 +12,5 @@ default_app_config = 'actstream.apps.ActstreamConfig' -__version__ = '1.4.1' +__version__ = '2.0.1' __author__ = 'Asif Saif Uddin, Justin Quick ' diff --git a/actstream/actions.py b/actstream/actions.py index 3ae13dfb..95ae8b13 100644 --- a/actstream/actions.py +++ b/actstream/actions.py @@ -1,5 +1,3 @@ - -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 @@ -125,7 +123,7 @@ def action_handler(verb, **kwargs): kwargs.pop('signal', None) actor = kwargs.pop('sender') - # We must store the unstranslated string + # We must store the untranslated string # If verb is an ugettext_lazyed string, fetch the original string if hasattr(verb, '_proxy____args'): verb = verb._proxy____args[0] 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 4297f84a..29db5de6 100644 --- a/actstream/apps.py +++ b/actstream/apps.py @@ -1,9 +1,5 @@ -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 from actstream.signals import action @@ -26,13 +22,5 @@ def ready(self): action_class, 'data' ) - # dynamically load django_jsonfield_backport to INSTALLED_APPS - if django.VERSION < (3, 1) and 'django_jsonfield_backport' not in settings.INSTALLED_APPS: - settings.INSTALLED_APPS += ('django_jsonfield_backport', ) - # reset loaded apps - apps.app_configs = OrderedDict() - # reset initialization status - apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False - apps.clear_cache() - # re-initialize all apps - apps.populate(settings.INSTALLED_APPS) + from actstream.follows import delete_orphaned_follows + pre_delete.connect(delete_orphaned_follows) diff --git a/actstream/decorators.py b/actstream/decorators.py index 50aa8f7f..e461bd5f 100644 --- a/actstream/decorators.py +++ b/actstream/decorators.py @@ -1,30 +1 @@ -from functools import wraps - - -def stream(func): - """ - Stream decorator to be applied to methods of an ``ActionManager`` subclass - - Syntax:: - - from actstream.decorators import stream - from actstream.managers import ActionManager - - class MyManager(ActionManager): - @stream - def foobar(self, ...): - ... - - """ - @wraps(func) - def wrapped(manager, *args, **kwargs): - offset, limit = kwargs.pop('_offset', None), kwargs.pop('_limit', None) - qs = func(manager, *args, **kwargs) - if isinstance(qs, dict): - qs = manager.public(**qs) - elif isinstance(qs, (list, tuple)): - qs = manager.public(*qs) - if offset or limit: - qs = qs[offset:limit] - return qs.fetch_generic_relations() - return wrapped +from actstream.streams import stream diff --git a/actstream/drf/__init__.py b/actstream/drf/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/actstream/drf/serializers.py b/actstream/drf/serializers.py new file mode 100644 index 00000000..26e44a0b --- /dev/null +++ b/actstream/drf/serializers.py @@ -0,0 +1,112 @@ +from rest_framework import serializers +from generic_relations.relations import GenericRelatedField + +from actstream import settings as actstream_settings +from actstream.registry import registry, label +from actstream.settings import DRF_SETTINGS, import_obj + + +class ExpandRelatedField(serializers.RelatedField): + """ + Expands related fields to use other Serializer. Similar to the AS1 JSON spec + """ + def to_representation(self, value): + return registered_serializers[value.__class__](value).data + + +DEFAULT_SERIALIZER = serializers.ModelSerializer + + +def serializer_factory(model_class): + """ + Returns a subclass of `ModelSerializer` for each model_class in the registry + """ + model_label = label(model_class).lower() + if model_label in DRF_SETTINGS['SERIALIZERS']: + return import_obj(DRF_SETTINGS['SERIALIZERS'][model_label]) + model_fields = DRF_SETTINGS['MODEL_FIELDS'].get(model_label, '__all__') + meta_class = type('Meta', (), {'model': model_class, 'fields': model_fields}) + return type(f'{model_class.__name__}Serializer', (DEFAULT_SERIALIZER,), {'Meta': meta_class}) + + +def related_field_factory(model_class, queryset=None): + """ + Returns a subclass of `RelatedField` for each model_class in the registry + """ + if queryset is None: + queryset = model_class.objects.all() + related_field_class = serializers.PrimaryKeyRelatedField + kwargs = {'queryset': queryset} + if DRF_SETTINGS['HYPERLINK_FIELDS']: + related_field_class = serializers.HyperlinkedRelatedField + kwargs['view_name'] = f'{label(model_class)}-detail' + elif DRF_SETTINGS['EXPAND_FIELDS']: + related_field_class = ExpandRelatedField + field = type(f'{model_class.__name__}RelatedField', (related_field_class,), {}) + return field(**kwargs) + + +def registry_factory(factory): + """ + Returns a mapping of the registry's model_class applied with the factory function + """ + return {model_class: factory(model_class) for model_class in registry} + + +def get_grf(): + """ + Get a new `GenericRelatedField` instance for each use of the related field + """ + return GenericRelatedField(registry_factory(related_field_factory), read_only=True) + + +registered_serializers = registry_factory(serializer_factory) + + +class ActionSerializer(DEFAULT_SERIALIZER): + """ + Serializer for actstream.Action models in the activity feeds + """ + actor = get_grf() + target = get_grf() + action_object = get_grf() + + class Meta: + model = actstream_settings.get_action_model() + fields = 'id verb public description timestamp actor target action_object'.split() + + +class SendActionSerializer(serializers.Serializer): + """ + Serializer used when POSTing a new action to DRF + """ + verb = serializers.CharField(required=True, help_text='Action verb') + target_content_type_id = serializers.CharField() + target_object_id = serializers.CharField() + action_object_content_type_id = serializers.CharField() + action_object_object_id = serializers.CharField() + description = serializers.CharField() + public = serializers.BooleanField() + + +class FollowSerializer(DEFAULT_SERIALIZER): + """ + Serializer for actstream.Follow models in the activity feeds + """ + user = get_grf() + follow_object = get_grf() + + class Meta: + model = actstream_settings.get_follow_model() + fields = 'id flag user follow_object started actor_only'.split() + + +class FollowingSerializer(DEFAULT_SERIALIZER): + """ + Serializer for actstream.Follow models in the "following" activity feeds + """ + follow_object = get_grf() + + class Meta: + model = actstream_settings.get_follow_model() + fields = ['follow_object'] diff --git a/actstream/drf/urls.py b/actstream/drf/urls.py new file mode 100644 index 00000000..f0a04162 --- /dev/null +++ b/actstream/drf/urls.py @@ -0,0 +1,18 @@ +from django.utils.text import slugify + +from rest_framework import routers + +from actstream.drf.views import FollowViewSet, ActionViewSet, registered_viewsets + + +# Default names for actstream models +router = routers.DefaultRouter() +router.register(r'actions', ActionViewSet) +router.register(r'follows', FollowViewSet) + +# register a router for each model_class in the registry +for model_class, viewset in registered_viewsets.items(): + name = str(slugify(model_class._meta.verbose_name_plural)) + router.register(name, viewset) + +urlpatterns = router.urls diff --git a/actstream/drf/views.py b/actstream/drf/views.py new file mode 100644 index 00000000..241bc52e --- /dev/null +++ b/actstream/drf/views.py @@ -0,0 +1,231 @@ +import json + +from django.contrib.contenttypes.models import ContentType +from django.shortcuts import get_object_or_404 +from django.contrib.auth import get_user_model + +from rest_framework import viewsets +from rest_framework import permissions +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.exceptions import APIException, NotFound + +from actstream.drf import serializers +from actstream import models +from actstream.registry import label +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 + + +def get_or_not_found(klass, detail=None, **kwargs): + try: + return klass.objects.get(**kwargs) + except klass.DoesNotExist: + raise NotFound(detail, 404) + + +class ModelNotRegistered(APIException): + status_code = 400 + default_detail = 'Model requested was not registered. Use actstream.registry.register to add it' + default_code = 'model_not_registered' + + +class DefaultModelViewSet(viewsets.ReadOnlyModelViewSet): + + def get_permissions(self): + if isinstance(DRF_SETTINGS['PERMISSIONS'], (tuple, list)): + return [import_obj(permission)() for permission in DRF_SETTINGS['PERMISSIONS']] + if isinstance(DRF_SETTINGS['PERMISSIONS'], dict): + lookup = {key.lower(): value for key, value in DRF_SETTINGS['PERMISSIONS'].items()} + serializer = self.get_serializer() + if hasattr(serializer, 'Meta') and hasattr(serializer.Meta, 'model'): + model_label = label(serializer.Meta.model).lower() + if model_label in lookup: + permissions = lookup[model_label] + if isinstance(permissions, str): + permissions = [import_obj(permissions)()] + else: + permissions = [import_obj(permission)() for permission in permissions] + return permissions + return [] + + +class ActionViewSet(DefaultModelViewSet): + 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) + def send(self, request): + """ + Sends the action signal on POST + Must have a verb and optional target/action_object with content_type_id/object_id pairs + Actor is set as current logged in user + """ + data = request.data + if hasattr(data, 'dict'): + data = data.dict() + if 'verb' not in data: + return Response(status=400) + + for name in ('target', 'action_object'): + if f'{name}_content_type_id' in data and f'{name}_object_id' in data: + ctype = get_or_not_found( + ContentType, f'ContentType for {name} query does not exist', pk=data.pop(f'{name}_content_type_id')) + data[name] = get_or_not_found(ctype.model_class(), f'Object for {name} query does not exist', pk=data.pop(f'{name}_object_id')) + + # dont let users define timestamp + data.pop('timestamp', None) + + action_signal.send(sender=request.user, **data) + return Response(status=201) + + def get_stream(self, stream): + """ + Helper for paginating streams and serializing responses + """ + page = self.paginate_queryset(stream) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(stream, many=True) + return Response(serializer.data) + + def get_detail_stream(self, stream, content_type_id, object_id): + """ + Helper for returning a stream that takes a content type/object id to lookup an instance + """ + content_type = get_object_or_404(ContentType, id=content_type_id) + obj = content_type.get_object_for_this_type(pk=object_id) + return self.get_stream(stream(obj)) + + @action(detail=False, url_path='streams/my-actions', permission_classes=[permissions.IsAuthenticated], name='My Actions') + def my_actions(self, request): + """ + Returns all actions where the current user is the actor + See models.actor_stream + """ + return self.get_stream(models.actor_stream(request.user)) + + @action(detail=False, url_path='streams/following', permission_classes=[permissions.IsAuthenticated], name='Actions by followed users') + def following(self, request): + """ + Returns all actions for users that the current user follows + See models.user_stream + """ + kwargs = request.query_params.dict() + return self.get_stream(models.user_stream(request.user, **kwargs)) + + @action(detail=False, url_path='streams/model/(?P[^/.]+)', name='Model activity stream') + def model_stream(self, request, content_type_id): + """ + Returns all actions for a given content type. + See models.model_stream + """ + content_type = get_object_or_404(ContentType, id=content_type_id) + return self.get_stream(models.model_stream(content_type.model_class())) + + @action(detail=False, url_path='streams/actor/(?P[^/.]+)/(?P[^/.]+)', name='Actor activity stream') + def actor_stream(self, request, content_type_id, object_id): + """ + Returns all actions for a given object where the object is the actor + See models.actor_stream + """ + return self.get_detail_stream(models.actor_stream, content_type_id, object_id) + + @action(detail=False, url_path='streams/target/(?P[^/.]+)/(?P[^/.]+)', name='Target activity stream') + def target_stream(self, request, content_type_id, object_id): + """ + Returns all actions for a given object where the object is the target + See models.target_stream + """ + return self.get_detail_stream(models.target_stream, content_type_id, object_id) + + @action(detail=False, url_path='streams/action_object/(?P[^/.]+)/(?P[^/.]+)', name='Action object activity stream') + def action_object_stream(self, request, content_type_id, object_id): + """ + Returns all actions for a given object where the object is the action object + See models.action_object_stream + """ + return self.get_detail_stream(models.action_object_stream, content_type_id, object_id) + + @action(detail=False, url_path='streams/any/(?P[^/.]+)/(?P[^/.]+)', name='Any activity stream') + def any_stream(self, request, content_type_id, object_id): + """ + Returns all actions for a given object where the object is any actor/target/action_object + See models.any_stream + """ + return self.get_detail_stream(models.any_stream, content_type_id, object_id) + + +class FollowViewSet(DefaultModelViewSet): + queryset = get_follow_model().objects.order_by('-started', '-id').prefetch_related() + serializer_class = serializers.FollowSerializer + permission_classes = [permissions.IsAuthenticated] + + @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST']) + def follow(self, request): + """ + Creates the follow relationship. + The current user is set as user and the target is passed with content_type_id/object_id pair + """ + data = request.data.dict() + if 'content_type_id' not in data: + return Response(status=400) + ctype = get_object_or_404(ContentType, id=data.pop('content_type_id')) + obj = ctype.get_object_for_this_type(pk=data.pop('object_id')) + follow_action(request.user, obj, **data) + return Response(status=201) + + @action(detail=False, permission_classes=[permissions.IsAuthenticated], + url_path='is_following/(?P[^/.]+)/(?P[^/.]+)', name='True if user is following object') + def is_following(self, request, content_type_id, object_id): + """ + Returns a JSON response whether the current user is following the object from content_type_id/object_id pair + """ + ctype = get_object_or_404(ContentType, id=content_type_id) + instance = ctype.get_object_for_this_type(pk=object_id) + following = get_follow_model().objects.is_following(request.user, instance) + data = {'is_following': following} + return Response(json.dumps(data)) + + @action(detail=False, permission_classes=[permissions.IsAuthenticated], + url_path='following', name='List of instances I follow') + def following(self, request): + """ + Returns a JSON response whether the current user is following the object from content_type_id/object_id pair + """ + qs = get_follow_model().objects.following_qs(request.user) + return Response(serializers.FollowingSerializer(qs, many=True).data) + + @action(detail=False, permission_classes=[permissions.IsAuthenticated], + url_path='followers', name='List of followers for current user') + def followers(self, request): + """ + Returns a JSON response whether the current user is following the object from content_type_id/object_id pair + """ + user_model = get_user_model() + 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 = get_follow_model().objects.followers(request.user) + return Response(serializer(followers, many=True).data) + + +def viewset_factory(model_class, queryset=None): + """ + Returns a subclass of `ModelViewSet` for each model class in the registry + """ + if queryset is None: + queryset = model_class.objects.prefetch_related() + serializer_class = serializers.registered_serializers[model_class] + model_label = label(model_class) + if model_label in DRF_SETTINGS['VIEWSETS']: + return import_obj(DRF_SETTINGS['VIEWSETS'][model_label]) + return type(f'{model_class.__name__}ViewSet', (DefaultModelViewSet,), { + 'queryset': queryset, + 'serializer_class': serializer_class, + }) + + +registered_viewsets = serializers.registry_factory(viewset_factory) diff --git a/actstream/feeds.py b/actstream/feeds.py index a077e8e2..7edba6ae 100644 --- a/actstream/feeds.py +++ b/actstream/feeds.py @@ -7,7 +7,6 @@ from django.contrib.syndication.views import Feed, add_domain from django.contrib.sites.models import Site from django.utils.encoding import force_str -from django.utils import datetime_safe from django.views.generic import View from django.http import HttpResponse, Http404 from django.urls import reverse @@ -46,7 +45,7 @@ def get_uri(self, action, obj=None, date=None): """ if date is None: date = action.timestamp - date = datetime_safe.new_datetime(date).strftime('%Y-%m-%d') + date = date.strftime('%Y-%m-%d') return 'tag:{},{}:{}'.format(Site.objects.get_current().domain, date, self.get_url(action, obj, False)) diff --git a/actstream/follows.py b/actstream/follows.py new file mode 100644 index 00000000..6707a4fd --- /dev/null +++ b/actstream/follows.py @@ -0,0 +1,16 @@ +from django.core.exceptions import ImproperlyConfigured + +from actstream.settings import get_follow_model + + +def delete_orphaned_follows(sender, instance=None, **kwargs): + """ + Clean up Follow objects that refer to a Django object that's being deleted + """ + if str(sender._meta) == 'migrations.migration': + return + + try: + get_follow_model().objects.for_object(instance).delete() + except ImproperlyConfigured: # raised by actstream for irrelevant models + pass diff --git a/actstream/jsonfield.py b/actstream/jsonfield.py index 9a309097..231ba60d 100644 --- a/actstream/jsonfield.py +++ b/actstream/jsonfield.py @@ -1,22 +1,12 @@ ''' -Decide on a JSONField implementation based on available packages. - -There are two possible options, preferred in the following order: - - JSONField from django-jsonfield with django-jsonfield-compat - - JSONField from django-mysql (needs MySQL 5.7+) - -Raises an ImportError if USE_JSONFIELD is True but none of these are -installed. - -Falls back to a simple Django TextField if USE_JSONFIELD is False, -however that field will be removed by migration 0002 directly -afterwards. +django-activity-stream offered support for an optional JSON data field from 0.4.4 up until 1.4.0. +This was accomplished by overloading DataField with different model field types. +As of Django 3.2, the JSONField is supported by default. +However we need to keep this mapping to not break migrations. ''' -import django -from django.db import models -from django.core.exceptions import ImproperlyConfigured +from django.db.models import JSONField from actstream.settings import USE_JSONFIELD @@ -24,19 +14,4 @@ __all__ = ('DataField', ) -DataField = models.TextField - -if USE_JSONFIELD: - if django.VERSION >= (3, 1): - from django.db.models import JSONField - DataField = JSONField - else: - try: - from django_jsonfield_backport.models import JSONField - DataField = JSONField - except ImportError: - raise ImproperlyConfigured( - 'You must install django-jsonfield-backport, ' - 'if you wish to use a JSONField on your actions ' - 'and run Django < 3.1' - ) +DataField = JSONField diff --git a/actstream/managers.py b/actstream/managers.py index 2f77a618..796b8cc3 100644 --- a/actstream/managers.py +++ b/actstream/managers.py @@ -1,6 +1,9 @@ -from django.contrib.contenttypes.models import ContentType -from django.db.models import Q +from typing import Type +from django.apps import apps +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q, Model +from django.contrib.auth import get_user_model from actstream.gfk import GFKManager from actstream.decorators import stream @@ -21,7 +24,7 @@ def public(self, *args, **kwargs): return self.filter(*args, **kwargs) @stream - def actor(self, obj, **kwargs): + def actor(self, obj: Model, **kwargs): """ Stream of most recent actions where obj is the actor. Keyword arguments will be passed to Action.objects.filter @@ -30,7 +33,7 @@ def actor(self, obj, **kwargs): return obj.actor_actions.public(**kwargs) @stream - def target(self, obj, **kwargs): + def target(self, obj: Model, **kwargs): """ Stream of most recent actions where obj is the target. Keyword arguments will be passed to Action.objects.filter @@ -39,7 +42,7 @@ def target(self, obj, **kwargs): return obj.target_actions.public(**kwargs) @stream - def action_object(self, obj, **kwargs): + def action_object(self, obj: Model, **kwargs): """ Stream of most recent actions where obj is the action_object. Keyword arguments will be passed to Action.objects.filter @@ -48,7 +51,7 @@ def action_object(self, obj, **kwargs): return obj.action_object_actions.public(**kwargs) @stream - def model_actions(self, model, **kwargs): + def model_actions(self, model: Type[Model], **kwargs): """ Stream of most recent actions by any particular model """ @@ -62,7 +65,7 @@ def model_actions(self, model, **kwargs): ) @stream - def any(self, obj, **kwargs): + def any(self, obj: Model, **kwargs): """ Stream of most recent actions where obj is the actor OR target OR action_object. """ @@ -81,7 +84,7 @@ def any(self, obj, **kwargs): ), **kwargs) @stream - def user(self, obj, with_user_activity=False, follow_flag=None, **kwargs): + def user(self, obj: Model, with_user_activity=False, follow_flag=None, **kwargs): """Create a stream of the most recent actions by objects that the user is following.""" q = Q() qs = self.public() @@ -172,7 +175,8 @@ def followers(self, actor, flag=''): """ Returns a list of User objects who are following the given actor (eg my followers). """ - return [follow.user for follow in self.followers_qs(actor, flag=flag)] + user_ids = self.followers_qs(actor, flag=flag).values_list('user', flat=True) + return get_user_model().objects.filter(id__in=user_ids) def following_qs(self, user, *models, **kwargs): """ diff --git a/actstream/registry.py b/actstream/registry.py index b14fd509..948d0ac3 100644 --- a/actstream/registry.py +++ b/actstream/registry.py @@ -26,7 +26,7 @@ def setup_generic_relations(model_class): ) related_attr_name = 'related_query_name' - related_attr_value = 'actions_with_%s' % label(model_class) + related_attr_value = 'actions_with_%s' % label(model_class, '_') relations = {} for field in ('actor', 'target', 'action_object'): @@ -46,12 +46,15 @@ def setup_generic_relations(model_class): return relations -def label(model_class): +def label(model_class, sep='.'): + """ + Returns a string label for the model class. eg auth.User + """ if hasattr(model_class._meta, 'model_name'): model_name = model_class._meta.model_name else: model_name = model_class._meta.module_name - return '{}_{}'.format(model_class._meta.app_label, model_name) + return '{}{}{}'.format(model_class._meta.app_label, sep, model_name) def is_installed(model_class): @@ -59,7 +62,7 @@ def is_installed(model_class): Returns True if a model_class is installed. model_class._meta.installed is only reliable in Django 1.7+ """ - return model_class._meta.installed + return model_class._meta.app_config is not None def validate(model_class, exception_class=ImproperlyConfigured): diff --git a/actstream/settings.py b/actstream/settings.py index abff808e..8a87b91d 100644 --- a/actstream/settings.py +++ b/actstream/settings.py @@ -6,26 +6,50 @@ SETTINGS = getattr(settings, 'ACTSTREAM_SETTINGS', {}) +def import_obj(mod): + mod_path = mod.split('.') + try: + obj = __import__('.'.join(mod_path[:-1]), {}, {}, [mod_path[-1]]) + return getattr(obj, mod_path[-1]) + except: + raise ImportError(f'Cannot import: {mod}') + + def get_action_manager(): """ Returns the class of the action manager to use from ACTSTREAM_SETTINGS['MANAGER'] """ mod = SETTINGS.get('MANAGER', 'actstream.managers.ActionManager') - mod_path = mod.split('.') try: - return getattr(__import__('.'.join(mod_path[:-1]), {}, {}, - [mod_path[-1]]), mod_path[-1])() + return import_obj(mod)() except ImportError: - raise ImportError( - 'Cannot import %s try fixing ACTSTREAM_SETTINGS[MANAGER]' - 'setting.' % mod - ) + raise ImproperlyConfigured(f'Cannot import {mod} try fixing ACTSTREAM_SETTINGS[MANAGER] setting.') FETCH_RELATIONS = SETTINGS.get('FETCH_RELATIONS', True) USE_JSONFIELD = SETTINGS.get('USE_JSONFIELD', False) +USE_DRF = 'DRF' in SETTINGS + +DRF_SETTINGS = { + 'ENABLE': False, + 'EXPAND_FIELDS': True, + 'HYPERLINK_FIELDS': False, + 'SERIALIZERS': {}, + 'MODEL_FIELDS': {}, + 'VIEWSETS': {}, + 'PERMISSIONS': ['rest_framework.permissions.IsAuthenticated'] +} + +if USE_DRF: + DRF_SETTINGS.update(SETTINGS.get('DRF', {})) + + for item in ('SERIALIZERS', 'VIEWSETS', 'MODEL_FIELDS'): + 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) diff --git a/actstream/streams.py b/actstream/streams.py new file mode 100644 index 00000000..50aa8f7f --- /dev/null +++ b/actstream/streams.py @@ -0,0 +1,30 @@ +from functools import wraps + + +def stream(func): + """ + Stream decorator to be applied to methods of an ``ActionManager`` subclass + + Syntax:: + + from actstream.decorators import stream + from actstream.managers import ActionManager + + class MyManager(ActionManager): + @stream + def foobar(self, ...): + ... + + """ + @wraps(func) + def wrapped(manager, *args, **kwargs): + offset, limit = kwargs.pop('_offset', None), kwargs.pop('_limit', None) + qs = func(manager, *args, **kwargs) + if isinstance(qs, dict): + qs = manager.public(**qs) + elif isinstance(qs, (list, tuple)): + qs = manager.public(*qs) + if offset or limit: + qs = qs[offset:limit] + return qs.fetch_generic_relations() + return wrapped diff --git a/actstream/tests/base.py b/actstream/tests/base.py index f1732731..00eacee9 100644 --- a/actstream/tests/base.py +++ b/actstream/tests/base.py @@ -1,6 +1,6 @@ from json import loads from datetime import datetime -from inspect import getargspec +from inspect import signature from django.apps import apps from django.test import TestCase @@ -46,6 +46,9 @@ def setUp(self): for model in self.actstream_models: register(model) + def assertStartsWith(self, value, start): + self.assert_(value.startswith(start)) + def assertSetEqual(self, l1, l2, msg=None, domap=True): if domap: l1 = map(str, l1) @@ -83,10 +86,12 @@ def setUp(self): self.timesince = timesince(self.testdate).encode('utf8').replace( b'\xc2\xa0', b' ').decode() self.group_ct = ContentType.objects.get_for_model(Group) + self.site_ct = ContentType.objects.get_for_model(Site) super(DataTestCase, self).setUp() self.group = Group.objects.create(name='CoolGroup') self.another_group = Group.objects.create(name='NiceGroup') - if 'email' in getargspec(self.User.objects.create_superuser).args: + superuser_sig = signature(self.User.objects.create_superuser) + if 'email' in superuser_sig.parameters: self.user1 = self.User.objects.create_superuser('admin', 'admin@example.com', 'admin') self.user2 = self.User.objects.create_user('Two', 'two@example.com') self.user3 = self.User.objects.create_user('Three', 'three@example.com') diff --git a/actstream/tests/test_activity.py b/actstream/tests/test_activity.py index d21876f5..c9733046 100644 --- a/actstream/tests/test_activity.py +++ b/actstream/tests/test_activity.py @@ -145,7 +145,10 @@ def test_following_models_OR_query(self): def test_y_no_orphaned_follows(self): follows = get_follow_model().objects.count() self.user2.delete() - self.assertEqual(follows - 1, get_follow_model().objects.count()) + # 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, get_follow_model().objects.count()) def test_z_no_orphaned_actions(self): actions = self.user1.actor_actions.count() diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py new file mode 100644 index 00000000..41b3d89e --- /dev/null +++ b/actstream/tests/test_drf.py @@ -0,0 +1,168 @@ +from unittest import skipUnless +from json import loads + + +from django.urls import reverse + +from actstream.tests.base import DataTestCase +from actstream.settings import USE_DRF, DRF_SETTINGS, get_action_model, get_follow_model +from actstream import signals + + +class BaseDRFTestCase(DataTestCase): + def setUp(self): + from rest_framework.test import APIClient + + super().setUp() + self.auth_user = self.user1 + self.client = APIClient() + self.auth_client = APIClient() + self.auth_client.login(username=self.user1.username, password='admin') + + def get(self, *args, **kwargs): + auth = kwargs.pop('auth', False) + client = self.auth_client if auth else self.client + return client.get(*args, **kwargs).data + + def _check_urls(self, *urls): + from actstream.drf.urls import router + + registerd = [url[0] for url in router.registry] + root = reverse('api-root') + for url in urls: + assert url in registerd + objs = self.get(f'{root}{url}/', auth=True) + assert isinstance(objs, list) + if len(objs): + obj = self.get(f'{root}{url}/{objs[0]["id"]}/', auth=True) + assert objs[0] == obj + + +@skipUnless(USE_DRF, 'Django rest framework disabled') +class DRFActionTestCase(BaseDRFTestCase): + + def test_actstream(self): + actions = self.get(reverse('action-list')) + assert len(actions) == 11 + follows = self.get(reverse('follow-list')) + assert len(follows) == 6 + + @skipUnless(DRF_SETTINGS['HYPERLINK_FIELDS'], 'Related hyperlinks disabled') + def test_hyperlink_fields(self): + actions = self.get(reverse('action-list')) + action = self.get(reverse('action-detail', args=[actions[0]["id"]])) + assert action['timestamp'].startswith('2000-01-01T00:00:00') + assert action['actor'].startswith('http') + + @skipUnless(DRF_SETTINGS['EXPAND_FIELDS'], 'Related expanded fields disabled') + def test_expand_fields(self): + actions = self.get(reverse('action-list')) + action = self.get(reverse('action-detail', args=[actions[0]["id"]])) + assert action['timestamp'].startswith('2000-01-01T00:00:00') + self.assertIsInstance(action['target'], dict) + assert action['target']['username'] == 'Three' + + def test_urls(self): + self._check_urls('actions', 'follows') + + def test_permissions(self): + users = self.get(reverse('myuser-list')) + assert str(users['detail']) == 'Authentication credentials were not provided.' + users = self.get(reverse('myuser-list'), auth=True) + assert len(users) == 4 + + def test_model_fields(self): + sites = self.get(reverse('site-list')) + self.assertSetEqual(sites[0].keys(), ['id', 'domain']) + + def test_viewset(self): + resp = self.client.head(reverse('group-foo')) + assert resp.status_code == 420 + assert resp.data == ['chill'] + + def test_my_actions(self): + actions = self.get(reverse('action-my-actions'), auth=True) + assert len(actions) == 3 + assert actions[0]['verb'] == 'joined' + + def test_model(self): + actions = self.get(reverse('action-model-stream', args=[self.group_ct.id]), auth=True) + assert len(actions) == 7 + assert actions[0]['verb'] == 'joined' + + def test_target(self): + actions = self.get(reverse('action-target-stream', args=[self.group_ct.id, self.another_group.id]), auth=True) + assert len(actions) == 2 + assert actions[0]['target']['name'] == actions[1]['target']['name'] == 'NiceGroup' + + def test_action_object(self): + signals.action.send(self.user1, verb='created comment', + action_object=self.comment, target=self.group, + timestamp=self.testdate)[0][1] + url = reverse('action-action-object-stream', args=[self.site_ct.id, self.comment.id]) + actions = self.get(url, auth=True) + assert len(actions) == 1 + assert actions[0]['verb'] == 'created comment' + + def test_any(self): + url = reverse('action-any-stream', args=[self.user_ct.id, self.auth_user.id]) + actions = self.get(url, auth=True) + assert len(actions) == 4 + assert actions[0]['verb'] == 'joined' + + def test_following(self): + actions = self.get(reverse('action-following'), auth=True) + assert len(actions) == 2 + assert actions[0]['actor']['username'] == actions[1]['actor']['username'] == 'Two' + + def test_action_send(self): + body = { + 'verb': 'mentioned', + 'description': 'talked about a group', + 'target_content_type_id': self.group_ct.id, + 'target_object_id': self.group.id + } + post = self.auth_client.post(reverse('action-send'), body) + assert post.status_code == 201 + action = get_action_model().objects.first() + assert action.description == body['description'] + assert action.verb == body['verb'] + assert action.actor == self.user1 + assert action.target == self.group + + +@skipUnless(USE_DRF, 'Django rest framework disabled') +class DRFFollowTestCase(BaseDRFTestCase): + def test_follow(self): + body = { + 'content_type_id': self.site_ct.id, + 'object_id': self.comment.id + } + post = self.auth_client.post(reverse('follow-follow'), body) + assert post.status_code == 201 + 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 + assert follow.user == self.user1 + + def test_is_following(self): + url = reverse('follow-is-following', args=[self.site_ct.id, self.comment.id]) + resp = self.auth_client.get(url) + data = loads(resp.data) + assert not data['is_following'] + + url = reverse('follow-is-following', args=[self.user_ct.id, self.user2.id]) + resp = self.auth_client.get(url) + data = loads(resp.data) + assert data['is_following'] + + def test_followers(self): + followers = self.auth_client.get(reverse('follow-followers')).data + assert len(followers) == 1 + assert followers[0]['username'] == 'Four' + + def test_following(self): + following = self.auth_client.get(reverse('follow-following')).data + assert len(following) == 1 + assert following[0]['follow_object']['username'] == 'Two' 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/actstream/urls.py b/actstream/urls.py index ce88d388..d6cb6270 100644 --- a/actstream/urls.py +++ b/actstream/urls.py @@ -1,9 +1,18 @@ -from django.urls import re_path +from django.urls import re_path, include, path from actstream import feeds, views +from actstream.settings import USE_DRF +urlpatterns = [] -urlpatterns = [ +if USE_DRF: + from actstream.drf.urls import router + + urlpatterns += [ + path('api/', include(router.urls)), + ] + +urlpatterns += [ # User feeds re_path(r'^feed/$', feeds.UserActivityFeed(), name='actstream_feed'), re_path(r'^feed/atom/$', feeds.AtomUserActivityFeed(), diff --git a/docker-compose.yml b/docker-compose.yml index 28d6e737..fe1826f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,8 +3,7 @@ version: "3.5" services: django: build: . - user: app - command: sh -c "./manage.py migrate && ./manage.py collectstatic --noinput && ./manage.py runserver 0.0.0.0:8000" + command: sh -c "./manage.py migrate && ./manage.py collectstatic --noinput && ./manage.py runserver_plus 0.0.0.0:8000" volumes: - ./runtests:/app:rw - ./actstream:/app/actstream:cached diff --git a/docs/Makefile b/docs/Makefile index a438bbda..6ad86953 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,7 +3,7 @@ # You can set these variables from the command line. SPHINXOPTS = -SPHINXBUILD = sphinx-build +SPHINXBUILD = pipenv run sphinx-build PAPER = BUILDDIR = build diff --git a/docs/api.rst b/docs/api.rst index f23b26f3..b145077e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,6 +1,12 @@ API === +Actions +------- + +.. automodule:: actstream.actions + :members: follow, unfollow, is_following, action_handler + Action Manager -------------- @@ -19,6 +25,19 @@ Views .. automodule:: actstream.views :members: respond, follow_unfollow, stream, followers, following, user, detail, actor, model + +ReST API +-------- + +.. autoclass:: actstream.drf.views.ActionViewSet + :members: + +.. autoclass:: actstream.drf.views.FollowViewSet + :members: + +.. autoclass:: actstream.drf.serializers.ActionSerializer + :members: + Feeds ----- @@ -43,11 +62,6 @@ Compatible with `JSON Activity Streams 1.0 `_. +The terminology used in this app is based from the `Activity Streams Specification `_. The app currently supports the `version 1.0 `_ terminology. Introduction diff --git a/docs/conf.py b/docs/conf.py index 45faf4a2..365f3168 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -122,6 +122,7 @@ def init(): } html_static_path = ['_static'] html_css_files = ['style.css'] +html_extra_path = ['redoc'] # Output file base name for HTML help builder. htmlhelp_basename = 'DjangoActivityStreamdoc' @@ -138,8 +139,8 @@ def init(): # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'DjangoActivityStream.tex', 'Django Activity Stream Documentation', - 'Justin Quick', 'manual'), + ('index', 'DjangoActivityStream.tex', 'Django Activity Stream Documentation', + 'Justin Quick', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of diff --git a/docs/configuration.rst b/docs/configuration.rst index f42c3212..06067f2b 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -42,6 +42,7 @@ Here is an example of what you can set in your ``settings.py`` 'USE_PREFETCH': True, 'USE_JSONFIELD': True, 'GFK_FETCH_DEPTH': 1, + 'DRF': {'ENABLE': True} } .. note:: @@ -100,6 +101,13 @@ Lets you add custom data to any of your actions, see :ref:`custom-data` Defaults to ``False`` +DRF +*** + +Enable this group of settings to use the django-rest-framework integration. +Fore more information about the available settings see :ref:`drf` + + GFK_FETCH_DEPTH *************** diff --git a/docs/data.rst b/docs/data.rst index 82adb742..d5a17bf1 100644 --- a/docs/data.rst +++ b/docs/data.rst @@ -11,13 +11,6 @@ This uses a ``data`` JSONField on every Action where you can insert and delete v This behavior is disabled by default but just set ``ACTSTREAM_SETTINGS['USE_JSONFIELD'] = True`` in your settings.py to enable it. -.. note:: - - If you're running Django < 3.1 you must install django-jsonfield-backport or the extra ``jsonfield`` of this package. - "django_jsonfield_backport" gets dynamically added to the INSTALLED_APPS of your Django application if not yet done manually. - - Please make sure to remove both the django-jsonfield-backport package and the ``django_jsonfield_backport`` INSTALLED_APPS entry (if manually added) after upgrading to Django >= 3.1 - You can send the custom data as extra keyword arguments to the ``action`` signal. .. code-block:: python @@ -49,12 +42,6 @@ Adding to Existing Project If you start out your project with ``USE_JSONFIELD=False``, dont worry you can add it afterwards. -Make sure you have the latest JSONField implementation - -.. code-block:: - - pip install django-activity-stream[jsonfield] - Make sure ``USE_JSONFIELD`` is non-existent or set to False then do the initial migration .. code-block:: bash diff --git a/docs/drf.rst b/docs/drf.rst new file mode 100644 index 00000000..1715b14b --- /dev/null +++ b/docs/drf.rst @@ -0,0 +1,56 @@ +.. _drf: + +Django ReST Framework Integration +================================= + +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. + +Now with added DRF support, actstream models are hooked up to resources you can use with a standard API spec. + +Features +------------ + +Access actions +Follow/unfollow actors +Embed actor/target/action object as nested payloads in responses +Control the viesets/serializers/fields for other resources that are used when rendering the API + +Settings +------------- +.. _drf-conf: + +There are several parameters that you are able to set to control how django-activity-stream handles DRF integration. +You are able to customize or mixin the classes that DRF relies on to create the API resources. You + +The integration lets you customize the behavior of the following DRF objects: + +- `Serializers `_ +- `ViewSets `_ +- `Permissions `_ +- `Model Fields `_ + +Simply modify the `DRF` section of `ACTSTREAM_SETTINGS` to include a custom class for the component you want to customize. +The configuration is specific to each app/model pair. +You must have these app/models registered with actstream before configuring. + +.. code-block:: python + + ACTSTREAM_SETTINGS = { + ... + 'DRF': { + 'SERIALIZERS': { + 'auth.Group': 'testapp.drf.GroupSerializer', + }, + 'VIEWSETS': { + 'auth.Group': 'testapp.drf.GroupViewSet' + }, + 'PERMISSIONS': { + 'testapp.MyUser': ['rest_framework.permissions.IsAdminUser'] + }, + 'MODEL_FIELDS': { + 'sites.Site': ['id', 'domain'] + } + } + } \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 9a179e0e..6c3b95a3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,8 +1,8 @@ Django Activity Stream Documentation ==================================== -.. image:: https://github.com/justquick/django-activity-stream/workflows/Test%20and%20deploy/badge.svg - :target: https://github.com/justquick/django-activity-stream/actions?query=workflow%3A%22Test+and+deploy%22 +.. image:: https://github.com/justquick/django-activity-stream/actions/workflows/workflow.yaml/badge.svg + :target: https://github.com/justquick/django-activity-stream/actions/workflows/workflow.yaml .. image:: https://badges.gitter.im/django-activity-stream/Lobby.svg :alt: Join the chat at https://gitter.im/django-activity-stream/Lobby @@ -51,6 +51,7 @@ Contents: data follow streams + drf templatetags feeds development diff --git a/docs/installation.rst b/docs/installation.rst index a358d900..9d49af7d 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -20,7 +20,7 @@ or get it from source Basic app configuration ----------------------- -Then to add the Django Activity Stream to your project add the app ``actstream`` and ``django.contib.sites`` to your ``INSTALLED_APPS`` and urlconf. In addition to, add the setting ``SITE_ID = 1`` below the installed apps. +Then to add the Django Activity Stream to your project add the app ``actstream`` and ``django.contrib.sites`` to your ``INSTALLED_APPS`` and urlconf. In addition to, add the setting ``SITE_ID = 1`` below the installed apps. .. code-block:: python @@ -46,20 +46,6 @@ Add the activity urls to your urlconf The activity urls are not required for basic usage but provide activity :ref:`feeds` and handle following, unfollowing and querying of followers. - -Add extra data to actions -------------------------- - -If you want to use custom data on your actions and are running Django < 3.1, then make sure you have -`django-jsonfield-backport `_ installed. - -.. code-block:: bash - - $ pip install django-activity-stream[jsonfield] - -You can learn more at :ref:`custom-data` - - Supported Environments ---------------------- @@ -74,14 +60,13 @@ Make sure to pick the version of Django and django-activity-stream that supports Python ****** - * **Python 3**: 3.6 to 3.9 * **PyPy**: 3 Django ****** -* **Django**: 2.2+ only +* **Django**: 3.2+ only Databases ********* diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..9775a86e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +DJANGO_SETTINGS_MODULE = runtests.settings +python_files = tests.py test_*.py +addopts = --maxfail=1 -rf -s -l --code-highlight=yes --color=yes +console_output_style = progress +testpaths = runtests/ actstream/ diff --git a/runtests/requirements.txt b/runtests/requirements.txt index 363b4c4f..6adef1a6 100644 --- a/runtests/requirements.txt +++ b/runtests/requirements.txt @@ -1,3 +1,8 @@ -Django>=2.2.17 +Django>=3.2 django-jsonfield-backport -tblib +django-extensions +ipdb +werkzeug +rest-framework-generic-relations +django-debug-toolbar +django-rest-framework diff --git a/runtests/settings.py b/runtests/settings.py index 87b12ce8..b75eb29c 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -1,5 +1,17 @@ -import os - +from actstream import __version__ +from os import getenv +from pprint import pprint +try: + import debug_toolbar as DEBUG_TOOLBAR +except: + DEBUG_TOOLBAR = None + +try: + import rest_framework as DRF +except: + DRF = None + +# Always for debugging, dont use the runtests app in production! DEBUG = True ADMINS = ( @@ -15,44 +27,46 @@ } } -if os.environ.get('GITHUB_WORKFLOW', False): - DATABASE_ENGINE = os.environ.get('DATABASE_ENGINE', 'sqlite') +if getenv('GITHUB_WORKFLOW', False): + DATABASE_ENGINE = getenv('DATABASE_ENGINE', 'sqlite') if 'mysql' in DATABASE_ENGINE: DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'test', - 'USER': 'root', - 'PASSWORD': '', - 'HOST': '127.0.0.1', - 'PORT': '3306', + 'NAME': getenv('MYSQL_NAME', 'test'), + 'USER': getenv('MYSQL_USER', 'root'), + 'PASSWORD': getenv('MYSQL_PASSWORD', ''), + 'HOST': getenv('MYSQL_HOST', '127.0.0.1'), + 'PORT': getenv('MYSQL_PORT', '3306'), }, } elif 'postgres' in DATABASE_ENGINE: DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'postgres', - 'USER': 'postgres', - 'PASSWORD': 'postgres', - 'HOST': '127.0.0.1', - 'PORT': '5432', + 'NAME': getenv('POSTGRES_NAME', 'postgres'), + 'USER': getenv('POSTGRES_USER', 'postgres'), + 'PASSWORD': getenv('POSTGRES_PASSWORD', 'postgres'), + 'HOST': getenv('POSTGRES_HOST', '127.0.0.1'), + 'PORT': getenv('POSTGRES_PORT', '5432'), }, } elif 'file' in DATABASE_ENGINE: DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'db.sqlite3', + 'NAME': getenv('SQLITE_NAME', 'db.sqlite3'), }, } - + print('Running with DATABASE engine', DATABASE_ENGINE) + pprint(DATABASES) # Local time zone for this installation. Choices can be found here: -# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# http://en.wikipedia. org/wiki/List_of_tz_zones_by_name # although not all choices may be available on all operating systems. # If running in a Windows environment this must be set to the same as your # system time zone. TIME_ZONE = 'America/New_York' +USE_TZ = False # Language code for this installation. All choices can be found here: # http://www.i18nguy.com/unicode/language-identifiers.html @@ -65,12 +79,12 @@ USE_I18N = True # Absolute path to the directory that holds media. -# Example: "/home/media/media.lawrence.com/" +# Example: '/home/media/media.lawrence.com/' MEDIA_ROOT = 'media' # URL that handles the media served from MEDIA_ROOT. Make sure to use a # trailing slash if there is a path component (optional in other cases). -# Examples: "http://media.lawrence.com", "http://example.com/media/" +# Examples: 'http://media.lawrence.com', 'http://example.com/media/' MEDIA_URL = '/media/' # Make this unique, and don't share it with anybody. @@ -101,9 +115,10 @@ ROOT_URLCONF = 'urls' + DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' -INSTALLED_APPS = ( +INSTALLED_APPS = [ 'django.contrib.auth', 'django.contrib.admin', 'django.contrib.contenttypes', @@ -117,8 +132,27 @@ 'testapp', 'testapp_nested', - 'custom', -) + 'custom' +] + +try: + import debug_toolbar +except: + pass +else: + INSTALLED_APPS.append('debug_toolbar') + MIDDLEWARE.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware') + +if DRF: + INSTALLED_APPS.extend(['rest_framework', 'generic_relations']) + + +try: + import django_extensions +except: + pass +else: + INSTALLED_APPS.append('django_extensions') STATIC_URL = '/static/' STATIC_ROOT = 'static' @@ -129,6 +163,25 @@ 'USE_PREFETCH': True, 'USE_JSONFIELD': True, 'GFK_FETCH_DEPTH': 0, + 'DRF': { + 'SERIALIZERS': { + 'auth.Group': 'testapp.drf.GroupSerializer', + 'testapp.MyUser': 'testapp.drf.MyUserSerializer' + }, + 'VIEWSETS': { + 'auth.Group': 'testapp.drf.GroupViewSet' + }, + 'PERMISSIONS': { + 'testapp.MyUser': ['rest_framework.permissions.IsAdminUser'] + }, + 'MODEL_FIELDS': { + 'sites.Site': ['id', 'domain'] + } + } } AUTH_USER_MODEL = 'testapp.MyUser' + + +REST_FRAMEWORK = { +} diff --git a/runtests/testapp/__init__.py b/runtests/testapp/__init__.py index a25a486a..b39d03a2 100644 --- a/runtests/testapp/__init__.py +++ b/runtests/testapp/__init__.py @@ -1 +1,5 @@ -default_app_config = 'testapp.apps.TestappConfig' +import django + + +if django.VERSION < (3, 2): + default_app_config = 'testapp.apps.TestappConfig' diff --git a/runtests/testapp/apps.py b/runtests/testapp/apps.py index d3d34cff..eaaec68e 100644 --- a/runtests/testapp/apps.py +++ b/runtests/testapp/apps.py @@ -1,4 +1,4 @@ -from django.apps import AppConfig +from django.apps import AppConfig, apps class TestappConfig(AppConfig): @@ -6,6 +6,8 @@ class TestappConfig(AppConfig): def ready(self): from actstream.registry import register + register(apps.get_model('auth', 'group')) + register(apps.get_model('sites', 'site')) register(self.get_model('player')) myuser = self.get_model('myuser') if myuser: diff --git a/runtests/testapp/drf.py b/runtests/testapp/drf.py new file mode 100644 index 00000000..54f6bcef --- /dev/null +++ b/runtests/testapp/drf.py @@ -0,0 +1,28 @@ +from django.contrib.auth.models import Group + +from rest_framework import viewsets, serializers +from rest_framework.decorators import action +from rest_framework.response import Response + +from testapp.models import MyUser + + +class GroupSerializer(serializers.ModelSerializer): + class Meta: + model = Group + fields = ['id', 'name'] + + +class MyUserSerializer(serializers.ModelSerializer): + class Meta: + model = MyUser + fields = ['id', 'username', 'last_login'] + + +class GroupViewSet(viewsets.ModelViewSet): + queryset = Group.objects.all() + serializer_class = GroupSerializer + + @action(detail=False, methods=['HEAD']) + def foo(self, request): + return Response(['chill'], status=420) diff --git a/runtests/testapp/management/__init__.py b/runtests/testapp/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/runtests/testapp/management/commands/__init__.py b/runtests/testapp/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/runtests/testapp/management/commands/initdb.py b/runtests/testapp/management/commands/initdb.py new file mode 100644 index 00000000..aa0ad92e --- /dev/null +++ b/runtests/testapp/management/commands/initdb.py @@ -0,0 +1,22 @@ +from django.core.management.base import BaseCommand +from django.core.management import call_command + +from actstream.tests.base import DataTestCase +from actstream.tests.test_gfk import GFKManagerTestCase +from actstream.tests.test_zombies import ZombieTest + +from testapp.models import MyUser + + +class Command(BaseCommand): + help = 'Loads test actions for development' + + def handle(self, *args, **kwargs): + if MyUser.objects.count(): + print('Already loaded') + exit() + + call_command('createsuperuser') + + for testcase in (DataTestCase, GFKManagerTestCase, ZombieTest): + testcase().setUp() diff --git a/runtests/testapp/tests/__ini__.py b/runtests/testapp/tests/__ini__.py new file mode 100644 index 00000000..e69de29b diff --git a/runtests/testapp/tests.py b/runtests/testapp/tests/test_django.py similarity index 76% rename from runtests/testapp/tests.py rename to runtests/testapp/tests/test_django.py index 35428565..f2340f43 100644 --- a/runtests/testapp/tests.py +++ b/runtests/testapp/tests/test_django.py @@ -1,4 +1,5 @@ from datetime import datetime +from unittest import skipUnless from django.core.exceptions import ImproperlyConfigured @@ -8,12 +9,12 @@ from actstream.tests.base import render, ActivityBaseTestCase from actstream.settings import USE_JSONFIELD, get_action_model -from testapp.models import Abstract, Unregistered +from testapp.models import MyUser, Abstract, Unregistered class TestAppTests(ActivityBaseTestCase): def setUp(self): - super(TestAppTests, self).setUp() + super().setUp() self.user = self.User.objects.create(username='test') action.send(self.user, verb='was created') @@ -61,19 +62,17 @@ def test_tag_custom_activity_stream(self): ) def test_customuser(self): - from testapp.models import MyUser - self.assertEqual(self.User, MyUser) self.assertEqual(self.user.get_full_name(), 'test') - if USE_JSONFIELD: - def test_jsonfield(self): - action.send( - self.user, verb='said', text='foobar', - tags=['sayings'], - more_data={'pk': self.user.pk} - ) - 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}) + @skipUnless(USE_JSONFIELD, 'Django jsonfield disabled') + def test_jsonfield(self): + action.send( + self.user, verb='said', text='foobar', + tags=['sayings'], + more_data={'pk': self.user.pk} + ) + 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}) diff --git a/runtests/testapp/tests/test_drf.py b/runtests/testapp/tests/test_drf.py new file mode 100644 index 00000000..fb3d5ca5 --- /dev/null +++ b/runtests/testapp/tests/test_drf.py @@ -0,0 +1,25 @@ +from django.contrib.auth.models import Group +from django.contrib.sites.models import Site +from django.urls import reverse + +from actstream.tests.test_drf import BaseDRFTestCase +from actstream.drf.serializers import registered_serializers as serializers +from testapp.models import MyUser, Player +from testapp_nested.models.my_model import NestedModel +from testapp.drf import GroupSerializer + + +class DRFTestAppTests(BaseDRFTestCase): + + def test_urls(self): + self._check_urls('actions', 'follows', 'groups', 'sites', + 'players', 'nested-models', 'my-users') + + def test_serializers(self): + + models = (Group, MyUser, Player, Site, NestedModel) + self.assertSetEqual(serializers.keys(), models, domap=False) + + groups = self.get(reverse('group-list')) + assert len(groups) == 2 + self.assertSetEqual(GroupSerializer.Meta.fields, groups[0].keys()) diff --git a/runtests/testapp/urls.py b/runtests/testapp/urls.py index 6596bcfd..f154a81f 100644 --- a/runtests/testapp/urls.py +++ b/runtests/testapp/urls.py @@ -1,7 +1,9 @@ -from django.urls import re_path +from django.urls import re_path, path +from django.conf import settings from actstream import feeds + urlpatterns = [ re_path(r'custom/(?P[-\w\s]+)/', feeds.CustomJSONActivityFeed.as_view(name='testbar'), diff --git a/runtests/testapp_nested/__init__.py b/runtests/testapp_nested/__init__.py index b7afc328..77419811 100644 --- a/runtests/testapp_nested/__init__.py +++ b/runtests/testapp_nested/__init__.py @@ -1 +1,5 @@ -default_app_config = 'testapp_nested.apps.TestappNestedConfig' +import django + + +if django.VERSION < (3, 2): + default_app_config = 'testapp_nested.apps.TestappNestedConfig' diff --git a/runtests/urls.py b/runtests/urls.py index b24fee6b..4883474f 100644 --- a/runtests/urls.py +++ b/runtests/urls.py @@ -1,7 +1,8 @@ import os from django.contrib import admin from django.views.static import serve -from django.urls import include, re_path +from django.urls import include, re_path, path +from django.conf import settings urlpatterns = [ @@ -12,3 +13,15 @@ re_path(r'testapp/', include('testapp.urls')), re_path(r'', include('actstream.urls')), ] + +if settings.DRF: + from actstream.drf.urls import router + + urlpatterns += [ + path('api/', include(router.urls)), + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + ] + + +if 'debug_toolbar' in settings.INSTALLED_APPS: + urlpatterns += [re_path('__debug__/', include('debug_toolbar.urls'))] diff --git a/setup.py b/setup.py index 0a26ad0d..b7f13ae7 100644 --- a/setup.py +++ b/setup.py @@ -13,8 +13,10 @@ license='BSD 3-Clause', author_email='justquick@gmail.com', url='http://github.com/justquick/django-activity-stream', + install_requires=['Django>=3.2'], packages=['actstream', 'actstream.migrations', + 'actstream.drf', 'actstream.templatetags', 'actstream.tests', ], @@ -30,6 +32,6 @@ 'Programming Language :: Python :: 3', 'Topic :: Utilities'], extras_require={ - 'jsonfield': ['django-jsonfield-backport>=1.0.2,<2.0'], + 'drf': ['django-rest-framework', 'rest-framework-generic-relations'], }, ) diff --git a/tox.ini b/tox.ini index 70485b48..db60112d 100644 --- a/tox.ini +++ b/tox.ini @@ -2,25 +2,48 @@ envlist = py{37,38,39}-django32-{mysql,postgres,sqlite} py{38,39}-django40-{mysql,postgres,sqlite} + py{38,39}-django41-{mysql,postgres,sqlite} toxworkdir=/tmp/.tox [testenv] -commands = coverage run runtests/manage.py test -v3 --noinput actstream testapp testapp_nested +commands = pytest -s --cov --cov-append actstream/ runtests/testapp runtests/testapp_nested/ deps = + django-rest-framework + rest-framework-generic-relations + drf-spectacular + coverage + pytest + pytest-cov + pytest-django coverage>=4.5.1 django32: Django>=3.2,<4.0 django40: Django>=4.0,<4.1 + django41: Django>=4.1,<4.2 mysql: mysqlclient>=2.0.0 mysql: django-mysql>=2.4.1 postgres: psycopg2-binary>=2.8 setenv = - mysql: DATABASE_ENGINE=django.db.backends.mysql - postgres: DATABASE_ENGINE=django.db.backends.postgresql + ; GITHUB_WORKFLOW=true ; Set this to force enable mysql/postgres dbs + mysql: DATABASE_ENGINE=mysql + postgres: DATABASE_ENGINE=postgresql + +; Pass this to force enable mysql/postgres dbs +passenv = GITHUB_WORKFLOW MYSQL_HOST MYSQL_NAME MYSQL_USER MYSQL_PASSWORD MYSQL_PORT POSTGRES_HOST POSTGRES_NAME POSTGRES_PORT POSTGRES_USER POSTGRES_PASSWORD SQLITE_NAME usedevelop = True -passenv = TRAVIS + +[testenv:ipdb] +deps = {[testenv]deps} ipdb ipython +commands = {[testenv]commands} --pdb --pdbcls=IPython.terminal.debugger:TerminalPdb + +[testenv:report] +deps = coverage +skip_install = true +commands = + coverage report + coverage html [travis:env] DJANGO = @@ -30,3 +53,4 @@ DATABASE = mysql: mysql postgresql: postgresql sqlite: sqlite +