From 28485e0ffef438033042044523d2750bf3b27109 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 20 Feb 2022 16:02:59 -0500 Subject: [PATCH 01/87] use python base image for simplicity --- Dockerfile | 42 ++++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 26 deletions(-) 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 From 22ebf59cc20c299641f610e2712bd79529dd7455 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 20 Feb 2022 16:03:25 -0500 Subject: [PATCH 02/87] updating dev env w/ helpers --- docker-compose.yml | 3 +-- runtests/requirements.txt | 9 +++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) 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/runtests/requirements.txt b/runtests/requirements.txt index 363b4c4f..b7a57ec0 100644 --- a/runtests/requirements.txt +++ b/runtests/requirements.txt @@ -1,3 +1,8 @@ -Django>=2.2.17 +Django>=4.0 django-jsonfield-backport -tblib +djangorestframework +django-extensions +ipdb +werkzeug +rest-framework-generic-relations +django-debug-toolbar From 27eeff17dd98841b8ab34bd8af2daddc8ab9e2ec Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 20 Feb 2022 16:04:18 -0500 Subject: [PATCH 03/87] wip: actstream/registered models as factories for DRF --- actstream/drf/__init__.py | 0 actstream/drf/serializers.py | 65 +++++++++++++++++++ actstream/drf/urls.py | 10 +++ actstream/drf/views.py | 35 ++++++++++ actstream/settings.py | 32 ++++++--- runtests/settings.py | 17 ++++- runtests/testapp/apps.py | 4 +- runtests/testapp/management/__init__.py | 0 .../testapp/management/commands/__init__.py | 0 .../management/commands/loadactions.py | 14 ++++ runtests/urls.py | 6 ++ 11 files changed, 171 insertions(+), 12 deletions(-) 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 runtests/testapp/management/__init__.py create mode 100644 runtests/testapp/management/commands/__init__.py create mode 100644 runtests/testapp/management/commands/loadactions.py 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..c461f715 --- /dev/null +++ b/actstream/drf/serializers.py @@ -0,0 +1,65 @@ +from rest_framework import serializers +from generic_relations.relations import GenericRelatedField + +from actstream.models import Follow, Action +from actstream.registry import registry +from actstream.settings import DRF_SETTINGS + + +class ExpandRelatedField(serializers.RelatedField): + def to_representation(self, value): + return registered_serializers[value.__class__](value).data + + +DEFAULT_SERIALIZER = serializers.ModelSerializer + + +def serializer_factory(model_class, **meta_opts): + meta_opts.setdefault('fields', '__all__') + meta_class = type('Meta', (), {'model': model_class, 'fields': '__all__'}) + serializer_class = DRF_SETTINGS['SERIALIZERS'].get(model_class, DEFAULT_SERIALIZER) + return type(f'{model_class.__name__}Serializer', (serializer_class,), {'Meta': meta_class}) + + +def related_field_factory(model_class, queryset=None): + 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'{model_class.__name__.lower()}-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): + return {model_class: factory(model_class) for model_class in registry} + + +def GRF(): + return GenericRelatedField(registry_factory(related_field_factory)) + + +registered_serializers = registry_factory(serializer_factory) + + +class ActionSerializer(serializers.ModelSerializer): + actor = GRF() + target = GRF() + action_object = GRF() + + class Meta: + model = Action + fields = ['id', 'verb', 'description', 'timestamp', 'actor', 'target', 'action_object'] + + +class FollowSerializer(serializers.ModelSerializer): + user = GRF() + follow_object = GRF() + + class Meta: + model = Follow + fields = ['id', 'flag', 'user', 'follow_object', 'started', 'actor_only'] diff --git a/actstream/drf/urls.py b/actstream/drf/urls.py new file mode 100644 index 00000000..932ea5c7 --- /dev/null +++ b/actstream/drf/urls.py @@ -0,0 +1,10 @@ +from rest_framework import routers + +from actstream.drf.views import FollowViewSet, ActionViewSet, registered_viewsets + +router = routers.DefaultRouter() +router.register(r'actions', ActionViewSet) +router.register(r'follows', FollowViewSet) + +for model_class, viewset in registered_viewsets.items(): + router.register(f'{model_class.__name__.lower()}s', viewset) diff --git a/actstream/drf/views.py b/actstream/drf/views.py new file mode 100644 index 00000000..93cce096 --- /dev/null +++ b/actstream/drf/views.py @@ -0,0 +1,35 @@ +from rest_framework import viewsets +from rest_framework import permissions + +from actstream.drf.serializers import FollowSerializer, ActionSerializer, registered_serializers, registry_factory +from actstream.models import Action, Follow +from actstream.settings import DRF_SETTINGS + +DEFAULT_VIEWSET = viewsets.ReadOnlyModelViewSet + + +class ActionViewSet(DEFAULT_VIEWSET): + queryset = Action.objects.public().prefetch_related() + serializer_class = ActionSerializer + permission_classes = [permissions.IsAuthenticated] + + +class FollowViewSet(DEFAULT_VIEWSET): + queryset = Follow.objects.prefetch_related() + serializer_class = FollowSerializer + permission_classes = [permissions.IsAuthenticated] + + +def viewset_factory(model_class, queryset=None): + if queryset is None: + queryset = model_class.objects.prefetch_related() + serializer_class = registered_serializers[model_class] + viewset_class = DRF_SETTINGS['VIEWSETS'].get(model_class, DEFAULT_VIEWSET) + return type(f'{model_class.__name__}ViewSet', (viewset_class,), { + 'queryset': queryset, + 'serializer_class': serializer_class, + 'permission_classes': [permissions.IsAuthenticated] + }) + + +registered_viewsets = registry_factory(viewset_factory) diff --git a/actstream/settings.py b/actstream/settings.py index a86c1884..f51fe114 100644 --- a/actstream/settings.py +++ b/actstream/settings.py @@ -4,22 +4,36 @@ SETTINGS = getattr(settings, 'ACTSTREAM_SETTINGS', {}) -def get_action_manager(): - """ - Returns the class of the action manager to use from ACTSTREAM_SETTINGS['MANAGER'] - """ - mod = SETTINGS.get('MANAGER', 'actstream.managers.ActionManager') +def import_obj(mod, msg=None): mod_path = mod.split('.') try: return getattr(__import__('.'.join(mod_path[:-1]), {}, {}, [mod_path[-1]]), mod_path[-1])() except ImportError: - raise ImportError( - 'Cannot import %s try fixing ACTSTREAM_SETTINGS[MANAGER]' - 'setting.' % mod - ) + raise ImportError(None) + + +def get_action_manager(): + """ + Returns the class of the action manager to use from ACTSTREAM_SETTINGS['MANAGER'] + """ + mod = SETTINGS.get('MANAGER', 'actstream.managers.ActionManager') + return import_obj(mod, f'Cannot import {mod} try fixing ACTSTREAM_SETTINGS[MANAGER] setting.') FETCH_RELATIONS = SETTINGS.get('FETCH_RELATIONS', True) USE_JSONFIELD = SETTINGS.get('USE_JSONFIELD', False) + +DRF_SETTINGS = SETTINGS.get('DRF', {}) + +DRF_DEFAULTS = { + 'EXPAND_FIELDS': False, + 'HYPERLINK_FIELDS': True, + 'SERIALIZERS': {}, + 'VIEWSETS': {} +} +for name, value in DRF_DEFAULTS.items(): + DRF_SETTINGS.setdefault(name, value) +for model_class, serializer in DRF_DEFAULTS['SERIALIZERS']: + DRF_DEFAULTS['SERIALIZERS'][model_class] = import_obj(serializer) diff --git a/runtests/settings.py b/runtests/settings.py index 5bc60cb5..91cee613 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -1,5 +1,6 @@ import os +# Always for debugging, dont use the runtests app in production! DEBUG = True ADMINS = ( @@ -65,12 +66,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. @@ -90,6 +91,7 @@ }] MIDDLEWARE = [ + 'debug_toolbar.middleware.DebugToolbarMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -101,6 +103,12 @@ ROOT_URLCONF = 'urls' +if DEBUG: + import os # only if you haven't already imported this + import socket # only if you haven't already imported this + hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) + INTERNAL_IPS = [ip[:-1] + '1' for ip in ips] + ['127.0.0.1', '10.0.2.2'] + DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' INSTALLED_APPS = ( @@ -113,6 +121,11 @@ 'django.contrib.staticfiles', 'django.contrib.messages', + 'django_extensions', + 'rest_framework', + 'generic_relations', + 'debug_toolbar', + 'actstream', 'testapp', 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/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/loadactions.py b/runtests/testapp/management/commands/loadactions.py new file mode 100644 index 00000000..6ff628ae --- /dev/null +++ b/runtests/testapp/management/commands/loadactions.py @@ -0,0 +1,14 @@ +from django.core.management.base import BaseCommand + +from actstream.tests.base import DataTestCase +from actstream.tests.test_gfk import GFKManagerTestCase +from actstream.tests.test_zombies import ZombieTest +from actstream.registry import registry + + +class Command(BaseCommand): + help = 'Loads test actions for development' + + def handle(self, *args, **kwargs): + for testcase in (DataTestCase, GFKManagerTestCase, ZombieTest): + testcase().setUp() diff --git a/runtests/urls.py b/runtests/urls.py index b24fee6b..fc4de748 100644 --- a/runtests/urls.py +++ b/runtests/urls.py @@ -3,6 +3,8 @@ from django.views.static import serve from django.urls import include, re_path +from actstream.drf.urls import router + urlpatterns = [ re_path(r'^admin/', admin.site.urls), @@ -10,5 +12,9 @@ {'document_root': os.path.join(os.path.dirname(__file__), 'media')}), re_path(r'auth/', include('django.contrib.auth.urls')), re_path(r'testapp/', include('testapp.urls')), + re_path('api/', include(router.urls)), + re_path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + re_path('__debug__/', include('debug_toolbar.urls')), re_path(r'', include('actstream.urls')), + ] From 5a3832bbad6f8c2195480f8579c805ebbc804389 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sat, 26 Feb 2022 20:28:00 -0500 Subject: [PATCH 04/87] DRF testing and updates --- .gitignore | 1 + actstream/drf/serializers.py | 43 ++++++++++++------ actstream/drf/urls.py | 5 ++- actstream/drf/views.py | 20 ++++++++- actstream/registry.py | 10 +++-- actstream/settings.py | 24 ++++++---- actstream/tests/base.py | 3 ++ runtests/requirements.txt | 2 +- runtests/settings.py | 28 +++++++++--- runtests/testapp/drf.py | 9 ++++ runtests/testapp/tests.py | 85 +++++++++++++++++++++++++++++------- runtests/testapp/urls.py | 4 +- runtests/urls.py | 6 ++- tox.ini | 2 + 14 files changed, 188 insertions(+), 54 deletions(-) create mode 100644 runtests/testapp/drf.py 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/actstream/drf/serializers.py b/actstream/drf/serializers.py index c461f715..d4ee2cdf 100644 --- a/actstream/drf/serializers.py +++ b/actstream/drf/serializers.py @@ -1,9 +1,10 @@ +from inspect import getmro from rest_framework import serializers from generic_relations.relations import GenericRelatedField from actstream.models import Follow, Action -from actstream.registry import registry -from actstream.settings import DRF_SETTINGS +from actstream.registry import registry, label +from actstream.settings import DRF_SETTINGS, import_obj class ExpandRelatedField(serializers.RelatedField): @@ -15,13 +16,21 @@ def to_representation(self, value): def serializer_factory(model_class, **meta_opts): + """ + Returns a subclass of `ModelSerializer` for each model_class in the registry + """ meta_opts.setdefault('fields', '__all__') meta_class = type('Meta', (), {'model': model_class, 'fields': '__all__'}) - serializer_class = DRF_SETTINGS['SERIALIZERS'].get(model_class, DEFAULT_SERIALIZER) - return type(f'{model_class.__name__}Serializer', (serializer_class,), {'Meta': meta_class}) + model_label = label(model_class).lower() + if model_label in DRF_SETTINGS['SERIALIZERS']: + return import_obj(DRF_SETTINGS['SERIALIZERS'][model_label]) + 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 @@ -36,30 +45,36 @@ def related_field_factory(model_class, queryset=None): 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 GRF(): +def get_grf(): + """ + Get a new `GenericRelatedField` instance for each use of the related field + """ return GenericRelatedField(registry_factory(related_field_factory)) registered_serializers = registry_factory(serializer_factory) -class ActionSerializer(serializers.ModelSerializer): - actor = GRF() - target = GRF() - action_object = GRF() +class ActionSerializer(DEFAULT_SERIALIZER): + actor = get_grf() + target = get_grf() + action_object = get_grf() class Meta: model = Action - fields = ['id', 'verb', 'description', 'timestamp', 'actor', 'target', 'action_object'] + fields = 'id verb description timestamp actor target action_object'.split() -class FollowSerializer(serializers.ModelSerializer): - user = GRF() - follow_object = GRF() +class FollowSerializer(DEFAULT_SERIALIZER): + user = get_grf() + follow_object = get_grf() class Meta: model = Follow - fields = ['id', 'flag', 'user', 'follow_object', 'started', 'actor_only'] + fields = 'id flag user follow_object started actor_only'.split() diff --git a/actstream/drf/urls.py b/actstream/drf/urls.py index 932ea5c7..98484f4e 100644 --- a/actstream/drf/urls.py +++ b/actstream/drf/urls.py @@ -2,9 +2,12 @@ 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(): - router.register(f'{model_class.__name__.lower()}s', viewset) + router.register(str(model_class._meta.verbose_name_plural), viewset) diff --git a/actstream/drf/views.py b/actstream/drf/views.py index 93cce096..02189766 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -1,8 +1,10 @@ from rest_framework import viewsets from rest_framework import permissions +from rest_framework.decorators import action from actstream.drf.serializers import FollowSerializer, ActionSerializer, registered_serializers, registry_factory from actstream.models import Action, Follow +from actstream.registry import label from actstream.settings import DRF_SETTINGS DEFAULT_VIEWSET = viewsets.ReadOnlyModelViewSet @@ -13,19 +15,33 @@ class ActionViewSet(DEFAULT_VIEWSET): serializer_class = ActionSerializer permission_classes = [permissions.IsAuthenticated] + @action(detail=False, methods=['POST']) + def send(self, request): + pass + class FollowViewSet(DEFAULT_VIEWSET): queryset = Follow.objects.prefetch_related() serializer_class = FollowSerializer permission_classes = [permissions.IsAuthenticated] + ordering_fields = ordering = ['started'] + + @action(detail=False, methods=['POST']) + def follow(self, request): + pass 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 = registered_serializers[model_class] - viewset_class = DRF_SETTINGS['VIEWSETS'].get(model_class, DEFAULT_VIEWSET) - return type(f'{model_class.__name__}ViewSet', (viewset_class,), { + model_label = label(model_class) + if model_label in DRF_SETTINGS['VIEWSETS']: + return DRF_SETTINGS['VIEWSETS'][model_label] + return type(f'{model_class.__name__}ViewSet', (DEFAULT_VIEWSET,), { 'queryset': queryset, 'serializer_class': serializer_class, 'permission_classes': [permissions.IsAuthenticated] diff --git a/actstream/registry.py b/actstream/registry.py index 3b145ca5..f44dd385 100644 --- a/actstream/registry.py +++ b/actstream/registry.py @@ -24,7 +24,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'): @@ -44,18 +44,20 @@ 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): """ Returns True if a model_class is installed. - model_class._meta.installed is only reliable in Django 1.7+ """ return model_class._meta.installed diff --git a/actstream/settings.py b/actstream/settings.py index f51fe114..93cc851b 100644 --- a/actstream/settings.py +++ b/actstream/settings.py @@ -1,16 +1,13 @@ from django.conf import settings +from django.core.exceptions import ImproperlyConfigured SETTINGS = getattr(settings, 'ACTSTREAM_SETTINGS', {}) -def import_obj(mod, msg=None): +def import_obj(mod): mod_path = mod.split('.') - try: - return getattr(__import__('.'.join(mod_path[:-1]), {}, {}, - [mod_path[-1]]), mod_path[-1])() - except ImportError: - raise ImportError(None) + return getattr(__import__('.'.join(mod_path[:-1]), {}, {}, [mod_path[-1]]), mod_path[-1]) def get_action_manager(): @@ -18,7 +15,10 @@ def get_action_manager(): Returns the class of the action manager to use from ACTSTREAM_SETTINGS['MANAGER'] """ mod = SETTINGS.get('MANAGER', 'actstream.managers.ActionManager') - return import_obj(mod, f'Cannot import {mod} try fixing ACTSTREAM_SETTINGS[MANAGER] setting.') + try: + return import_obj(mod)() + except ImportError: + raise ImproperlyConfigured(f'Cannot import {mod} try fixing ACTSTREAM_SETTINGS[MANAGER] setting.') FETCH_RELATIONS = SETTINGS.get('FETCH_RELATIONS', True) @@ -28,6 +28,7 @@ def get_action_manager(): DRF_SETTINGS = SETTINGS.get('DRF', {}) DRF_DEFAULTS = { + 'ENABLE': False, 'EXPAND_FIELDS': False, 'HYPERLINK_FIELDS': True, 'SERIALIZERS': {}, @@ -35,5 +36,10 @@ def get_action_manager(): } for name, value in DRF_DEFAULTS.items(): DRF_SETTINGS.setdefault(name, value) -for model_class, serializer in DRF_DEFAULTS['SERIALIZERS']: - DRF_DEFAULTS['SERIALIZERS'][model_class] = import_obj(serializer) + +USE_DRF = DRF_SETTINGS['ENABLE'] + +for item in ('SERIALIZERS', 'VIEWSETS'): + DRF_SETTINGS[item] = { + label.lower(): obj for label, obj in DRF_SETTINGS[item].items() + } diff --git a/actstream/tests/base.py b/actstream/tests/base.py index 5ee88b0e..7691a248 100644 --- a/actstream/tests/base.py +++ b/actstream/tests/base.py @@ -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) diff --git a/runtests/requirements.txt b/runtests/requirements.txt index b7a57ec0..3ce16610 100644 --- a/runtests/requirements.txt +++ b/runtests/requirements.txt @@ -1,8 +1,8 @@ Django>=4.0 django-jsonfield-backport -djangorestframework 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 91cee613..cb844656 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -91,7 +91,6 @@ }] MIDDLEWARE = [ - 'debug_toolbar.middleware.DebugToolbarMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -111,7 +110,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' -INSTALLED_APPS = ( +INSTALLED_APPS = [ 'django.contrib.auth', 'django.contrib.admin', 'django.contrib.contenttypes', @@ -121,16 +120,29 @@ 'django.contrib.staticfiles', 'django.contrib.messages', - 'django_extensions', 'rest_framework', 'generic_relations', - 'debug_toolbar', 'actstream', 'testapp', 'testapp_nested', -) +] + +try: + import debug_toolbar +except: + pass +else: + INSTALLED_APPS.append('debug_toolbar') + +try: + import django_extensions +except: + pass +else: + INSTALLED_APPS.append('django_extensions') + MIDDLEWARE.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware') STATIC_URL = '/static/' STATIC_ROOT = 'static' @@ -141,6 +153,12 @@ 'USE_PREFETCH': True, 'USE_JSONFIELD': True, 'GFK_FETCH_DEPTH': 0, + 'DRF': { + 'ENABLE': True, + 'SERIALIZERS': { + 'auth.Group': 'testapp.drf.GroupSerializer' + } + } } AUTH_USER_MODEL = 'testapp.MyUser' diff --git a/runtests/testapp/drf.py b/runtests/testapp/drf.py new file mode 100644 index 00000000..a6887736 --- /dev/null +++ b/runtests/testapp/drf.py @@ -0,0 +1,9 @@ +from django.contrib.auth.models import Group + +from rest_framework import serializers + + +class GroupSerializer(serializers.ModelSerializer): + class Meta: + model = Group + fields = ['id', 'name'] diff --git a/runtests/testapp/tests.py b/runtests/testapp/tests.py index 611e9770..b6ecf75e 100644 --- a/runtests/testapp/tests.py +++ b/runtests/testapp/tests.py @@ -1,19 +1,23 @@ from datetime import datetime +from unittest import skipUnless from django.core.exceptions import ImproperlyConfigured +from django.contrib.auth.models import Group +from django.contrib.sites.models import Site from actstream.signals import action from actstream.registry import register, unregister from actstream.models import Action, actor_stream, model_stream -from actstream.tests.base import render, ActivityBaseTestCase -from actstream.settings import USE_JSONFIELD +from actstream.tests.base import render, ActivityBaseTestCase, DataTestCase +from actstream.settings import USE_JSONFIELD, USE_DRF -from testapp.models import Abstract, Unregistered +from testapp.models import MyUser, Player, Abstract, Unregistered +from testapp_nested.models.my_model import NestedModel 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') @@ -66,14 +70,65 @@ def test_customuser(self): 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 = Action.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 = Action.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_DRF, 'Django rest framework disabled') +class DRFTestCase(DataTestCase): + def setUp(self): + from rest_framework.test import APIClient + + super().setUp() + self.client = APIClient() + self.client.login(username='admin', password='admin') + + def get(self, *args, **kwargs): + return self.assertJSON(self.client.get(*args, **kwargs).content.decode()) + + def test_actstream(self): + actions = self.get('/api/actions/') + self.assertEqual(len(actions), 11) + follows = self.get('/api/follows/') + self.assertEqual(len(follows), 6) + + def test_hyperlink(self): + action = self.get('/api/actions/1/') + self.assertEqual(action['timestamp'], '2000-01-01T00:00:00') + self.assertStartsWith(action['actor'], 'http') + + def test_urls(self): + from actstream.drf.urls import router + + registerd = [url[0] for url in router.registry] + names = ['actions', 'follows', 'groups', 'sites', + 'players', 'my users', 'nested models'] + self.assertSetEqual(registerd, names) + endpoints = self.get('/api/') + self.assertSetEqual(registerd, endpoints.keys()) + for url in registerd: + objs = self.get(f'/api/{url}/') + self.assertIsInstance(objs, list) + if len(objs): + obj = self.get(f'/api/{url}/{objs[0]["id"]}/') + self.assertEqual(objs[0], obj) + + def test_serializers(self): + from actstream.drf.serializers import registered_serializers as serializers + from testapp.drf import GroupSerializer + + models = (Group, MyUser, Player, Site, NestedModel) + self.assertSetEqual(serializers.keys(), models, domap=False) + + groups = self.get('/api/groups/') + self.assertEqual(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..89523d96 100644 --- a/runtests/testapp/urls.py +++ b/runtests/testapp/urls.py @@ -1,8 +1,10 @@ -from django.urls import re_path +from django.urls import re_path, include, path from actstream import feeds +from actstream.drf.urls import router urlpatterns = [ + path('api/', include(router.urls)), re_path(r'custom/(?P[-\w\s]+)/', feeds.CustomJSONActivityFeed.as_view(name='testbar'), name='testapp_custom_feed'), diff --git a/runtests/urls.py b/runtests/urls.py index fc4de748..6eb1b622 100644 --- a/runtests/urls.py +++ b/runtests/urls.py @@ -2,6 +2,7 @@ from django.contrib import admin from django.views.static import serve from django.urls import include, re_path +from django.conf import settings from actstream.drf.urls import router @@ -14,7 +15,8 @@ re_path(r'testapp/', include('testapp.urls')), re_path('api/', include(router.urls)), re_path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), - re_path('__debug__/', include('debug_toolbar.urls')), re_path(r'', include('actstream.urls')), - ] + +if 'debug_toolbar' in settings.INSTALLED_APPS: + urlpatterns.insert(0, re_path('__debug__/', include('debug_toolbar.urls'))) diff --git a/tox.ini b/tox.ini index 8bd29961..ed48edbc 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,8 @@ toxworkdir=/tmp/.tox commands = coverage run runtests/manage.py test -v3 --noinput actstream testapp testapp_nested deps = + django-rest-framework + rest-framework-generic-relations coverage>=4.5.1 django-jsonfield-backport>=1.0.3 django22: Django>=2.2,<3.0 From ccff3c698f99bf04a24dbe450290e3d07e6f53aa Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sat, 26 Feb 2022 20:28:23 -0500 Subject: [PATCH 05/87] drf extras for package --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 0a26ad0d..31563c15 100644 --- a/setup.py +++ b/setup.py @@ -31,5 +31,6 @@ 'Topic :: Utilities'], extras_require={ 'jsonfield': ['django-jsonfield-backport>=1.0.2,<2.0'], + 'drf': ['django-rest-framework', 'rest-framework-generic-relations'], }, ) From 53a9e7eba45737162751af0d7ac0340c0b705115 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sat, 26 Feb 2022 23:27:12 -0500 Subject: [PATCH 06/87] default permissions, custom viewsets --- actstream/drf/urls.py | 5 ++- actstream/drf/views.py | 20 ++++++----- actstream/settings.py | 15 +++++---- actstream/tests/test_drf.py | 66 +++++++++++++++++++++++++++++++++++++ runtests/settings.py | 3 ++ runtests/testapp/drf.py | 13 +++++++- runtests/testapp/tests.py | 62 ++-------------------------------- 7 files changed, 109 insertions(+), 75 deletions(-) create mode 100644 actstream/tests/test_drf.py diff --git a/actstream/drf/urls.py b/actstream/drf/urls.py index 98484f4e..53eb7680 100644 --- a/actstream/drf/urls.py +++ b/actstream/drf/urls.py @@ -1,3 +1,5 @@ +from django.utils.text import slugify + from rest_framework import routers from actstream.drf.views import FollowViewSet, ActionViewSet, registered_viewsets @@ -10,4 +12,5 @@ # register a router for each model_class in the registry for model_class, viewset in registered_viewsets.items(): - router.register(str(model_class._meta.verbose_name_plural), viewset) + name = str(slugify(model_class._meta.verbose_name_plural)) + router.register(name, viewset) diff --git a/actstream/drf/views.py b/actstream/drf/views.py index 02189766..b03b9305 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -5,22 +5,26 @@ from actstream.drf.serializers import FollowSerializer, ActionSerializer, registered_serializers, registry_factory from actstream.models import Action, Follow from actstream.registry import label -from actstream.settings import DRF_SETTINGS +from actstream.settings import DRF_SETTINGS, import_obj -DEFAULT_VIEWSET = viewsets.ReadOnlyModelViewSet +class DefaultModelViewSet(viewsets.ReadOnlyModelViewSet): + def get_permissions(self): + print(DRF_SETTINGS) + return [import_obj(permission)() for permission in DRF_SETTINGS['PERMISSIONS']] -class ActionViewSet(DEFAULT_VIEWSET): + +class ActionViewSet(DefaultModelViewSet): queryset = Action.objects.public().prefetch_related() serializer_class = ActionSerializer - permission_classes = [permissions.IsAuthenticated] + # permission_classes = [permissions.IsAuthenticated] @action(detail=False, methods=['POST']) def send(self, request): pass -class FollowViewSet(DEFAULT_VIEWSET): +class FollowViewSet(DefaultModelViewSet): queryset = Follow.objects.prefetch_related() serializer_class = FollowSerializer permission_classes = [permissions.IsAuthenticated] @@ -40,11 +44,11 @@ def viewset_factory(model_class, queryset=None): serializer_class = registered_serializers[model_class] model_label = label(model_class) if model_label in DRF_SETTINGS['VIEWSETS']: - return DRF_SETTINGS['VIEWSETS'][model_label] - return type(f'{model_class.__name__}ViewSet', (DEFAULT_VIEWSET,), { + return import_obj(DRF_SETTINGS['VIEWSETS'][model_label]) + return type(f'{model_class.__name__}ViewSet', (DefaultModelViewSet,), { 'queryset': queryset, 'serializer_class': serializer_class, - 'permission_classes': [permissions.IsAuthenticated] + # 'permission_classes': [permissions.IsAuthenticated] }) diff --git a/actstream/settings.py b/actstream/settings.py index 93cc851b..607e3200 100644 --- a/actstream/settings.py +++ b/actstream/settings.py @@ -7,7 +7,11 @@ def import_obj(mod): mod_path = mod.split('.') - return getattr(__import__('.'.join(mod_path[:-1]), {}, {}, [mod_path[-1]]), mod_path[-1]) + 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(): @@ -25,17 +29,16 @@ def get_action_manager(): USE_JSONFIELD = SETTINGS.get('USE_JSONFIELD', False) -DRF_SETTINGS = SETTINGS.get('DRF', {}) -DRF_DEFAULTS = { +DRF_SETTINGS = { 'ENABLE': False, 'EXPAND_FIELDS': False, 'HYPERLINK_FIELDS': True, 'SERIALIZERS': {}, - 'VIEWSETS': {} + 'VIEWSETS': {}, + 'PERMISSIONS': ['rest_framework.permissions.IsAuthenticated'] } -for name, value in DRF_DEFAULTS.items(): - DRF_SETTINGS.setdefault(name, value) +DRF_SETTINGS.update(SETTINGS.get('DRF', {})) USE_DRF = DRF_SETTINGS['ENABLE'] diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py new file mode 100644 index 00000000..e9d76eef --- /dev/null +++ b/actstream/tests/test_drf.py @@ -0,0 +1,66 @@ +from unittest import skipUnless + +from django.contrib.auth.models import Group +from django.contrib.sites.models import Site + +from actstream.tests.base import DataTestCase +from actstream.settings import USE_DRF + +from testapp.models import MyUser, Player +from testapp_nested.models.my_model import NestedModel + + +@skipUnless(USE_DRF, 'Django rest framework disabled') +class DRFTestCase(DataTestCase): + def setUp(self): + from rest_framework.test import APIClient + + super().setUp() + self.client = APIClient() + # self.client.login(username='admin', password='admin') + + def get(self, *args, **kwargs): + return self.client.get(*args, **kwargs).data + + def test_actstream(self): + actions = self.get('/api/actions/') + self.assertEqual(len(actions), 11) + follows = self.get('/api/follows/') + self.assertEqual(len(follows), 6) + + def test_hyperlink(self): + action = self.get('/api/actions/1/') + self.assertEqual(action['timestamp'], '2000-01-01T00:00:00') + self.assertStartsWith(action['actor'], 'http') + + def test_urls(self): + from actstream.drf.urls import router + + registerd = [url[0] for url in router.registry] + names = ['actions', 'follows', 'groups', 'sites', + 'players', 'my-users', 'nested-models'] + self.assertSetEqual(registerd, names) + endpoints = self.get('/api/') + self.assertSetEqual(registerd, endpoints.keys()) + for url in registerd: + objs = self.get(f'/api/{url}/') + self.assertIsInstance(objs, list) + if len(objs): + obj = self.get(f'/api/{url}/{objs[0]["id"]}/') + self.assertEqual(objs[0], obj) + + def test_serializers(self): + from actstream.drf.serializers import registered_serializers as serializers + from testapp.drf import GroupSerializer + + models = (Group, MyUser, Player, Site, NestedModel) + self.assertSetEqual(serializers.keys(), models, domap=False) + + groups = self.get('/api/groups/') + self.assertEqual(len(groups), 2) + self.assertSetEqual(GroupSerializer.Meta.fields, groups[0].keys()) + + def test_viewset(self): + resp = self.client.head('/api/groups/foo/') + self.assertEqual(resp.status_code, 420) + self.assertEqual(resp.data, ['chill']) diff --git a/runtests/settings.py b/runtests/settings.py index cb844656..a546f6c5 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -157,6 +157,9 @@ 'ENABLE': True, 'SERIALIZERS': { 'auth.Group': 'testapp.drf.GroupSerializer' + }, + 'VIEWSETS': { + 'auth.Group': 'testapp.drf.GroupViewSet' } } } diff --git a/runtests/testapp/drf.py b/runtests/testapp/drf.py index a6887736..f76f8549 100644 --- a/runtests/testapp/drf.py +++ b/runtests/testapp/drf.py @@ -1,9 +1,20 @@ from django.contrib.auth.models import Group -from rest_framework import serializers +from rest_framework import viewsets, serializers +from rest_framework.decorators import action +from rest_framework.response import Response class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group fields = ['id', 'name'] + + +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/tests.py b/runtests/testapp/tests.py index b6ecf75e..528384b1 100644 --- a/runtests/testapp/tests.py +++ b/runtests/testapp/tests.py @@ -2,17 +2,14 @@ from unittest import skipUnless from django.core.exceptions import ImproperlyConfigured -from django.contrib.auth.models import Group -from django.contrib.sites.models import Site from actstream.signals import action from actstream.registry import register, unregister from actstream.models import Action, actor_stream, model_stream -from actstream.tests.base import render, ActivityBaseTestCase, DataTestCase -from actstream.settings import USE_JSONFIELD, USE_DRF +from actstream.tests.base import render, ActivityBaseTestCase +from actstream.settings import USE_JSONFIELD -from testapp.models import MyUser, Player, Abstract, Unregistered -from testapp_nested.models.my_model import NestedModel +from testapp.models import MyUser, Abstract, Unregistered class TestAppTests(ActivityBaseTestCase): @@ -65,8 +62,6 @@ 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') @@ -81,54 +76,3 @@ def test_jsonfield(self): self.assertEqual(newaction.data['text'], 'foobar') self.assertEqual(newaction.data['tags'], ['sayings']) self.assertEqual(newaction.data['more_data'], {'pk': self.user.pk}) - - -@skipUnless(USE_DRF, 'Django rest framework disabled') -class DRFTestCase(DataTestCase): - def setUp(self): - from rest_framework.test import APIClient - - super().setUp() - self.client = APIClient() - self.client.login(username='admin', password='admin') - - def get(self, *args, **kwargs): - return self.assertJSON(self.client.get(*args, **kwargs).content.decode()) - - def test_actstream(self): - actions = self.get('/api/actions/') - self.assertEqual(len(actions), 11) - follows = self.get('/api/follows/') - self.assertEqual(len(follows), 6) - - def test_hyperlink(self): - action = self.get('/api/actions/1/') - self.assertEqual(action['timestamp'], '2000-01-01T00:00:00') - self.assertStartsWith(action['actor'], 'http') - - def test_urls(self): - from actstream.drf.urls import router - - registerd = [url[0] for url in router.registry] - names = ['actions', 'follows', 'groups', 'sites', - 'players', 'my users', 'nested models'] - self.assertSetEqual(registerd, names) - endpoints = self.get('/api/') - self.assertSetEqual(registerd, endpoints.keys()) - for url in registerd: - objs = self.get(f'/api/{url}/') - self.assertIsInstance(objs, list) - if len(objs): - obj = self.get(f'/api/{url}/{objs[0]["id"]}/') - self.assertEqual(objs[0], obj) - - def test_serializers(self): - from actstream.drf.serializers import registered_serializers as serializers - from testapp.drf import GroupSerializer - - models = (Group, MyUser, Player, Site, NestedModel) - self.assertSetEqual(serializers.keys(), models, domap=False) - - groups = self.get('/api/groups/') - self.assertEqual(len(groups), 2) - self.assertSetEqual(GroupSerializer.Meta.fields, groups[0].keys()) From 253b93a25038221be30d6728afb887ff99ee8489 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 27 Feb 2022 21:08:20 -0500 Subject: [PATCH 07/87] allow a 'my actions' view as viewset action --- actstream/drf/views.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/actstream/drf/views.py b/actstream/drf/views.py index b03b9305..1699abd4 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -1,28 +1,40 @@ from rest_framework import viewsets from rest_framework import permissions from rest_framework.decorators import action +from rest_framework.response import Response + from actstream.drf.serializers import FollowSerializer, ActionSerializer, registered_serializers, registry_factory -from actstream.models import Action, Follow +from actstream.models import Action, Follow, actor_stream from actstream.registry import label from actstream.settings import DRF_SETTINGS, import_obj class DefaultModelViewSet(viewsets.ReadOnlyModelViewSet): def get_permissions(self): - print(DRF_SETTINGS) return [import_obj(permission)() for permission in DRF_SETTINGS['PERMISSIONS']] class ActionViewSet(DefaultModelViewSet): queryset = Action.objects.public().prefetch_related() serializer_class = ActionSerializer - # permission_classes = [permissions.IsAuthenticated] @action(detail=False, methods=['POST']) def send(self, request): pass + def get_stream(self, stream): + 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) + + @action(detail=False, permission_classes=[permissions.IsAuthenticated], name='My Actions') + def me(self, request): + return self.get_stream(actor_stream(request.user)) + class FollowViewSet(DefaultModelViewSet): queryset = Follow.objects.prefetch_related() @@ -48,7 +60,6 @@ def viewset_factory(model_class, queryset=None): return type(f'{model_class.__name__}ViewSet', (DefaultModelViewSet,), { 'queryset': queryset, 'serializer_class': serializer_class, - # 'permission_classes': [permissions.IsAuthenticated] }) From 4fc902e6649e000dda289069b67cedee9b99ee07 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 27 Feb 2022 21:08:30 -0500 Subject: [PATCH 08/87] settings refactor --- actstream/settings.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/actstream/settings.py b/actstream/settings.py index 607e3200..ab0c1658 100644 --- a/actstream/settings.py +++ b/actstream/settings.py @@ -29,6 +29,7 @@ def get_action_manager(): USE_JSONFIELD = SETTINGS.get('USE_JSONFIELD', False) +USE_DRF = 'DRF' in SETTINGS DRF_SETTINGS = { 'ENABLE': False, @@ -38,11 +39,11 @@ def get_action_manager(): 'VIEWSETS': {}, 'PERMISSIONS': ['rest_framework.permissions.IsAuthenticated'] } -DRF_SETTINGS.update(SETTINGS.get('DRF', {})) -USE_DRF = DRF_SETTINGS['ENABLE'] +if USE_DRF: + DRF_SETTINGS.update(SETTINGS.get('DRF', {})) -for item in ('SERIALIZERS', 'VIEWSETS'): - DRF_SETTINGS[item] = { - label.lower(): obj for label, obj in DRF_SETTINGS[item].items() - } + for item in ('SERIALIZERS', 'VIEWSETS'): + DRF_SETTINGS[item] = { + label.lower(): obj for label, obj in DRF_SETTINGS[item].items() + } From 5bb3c7b540d0f64927af7348323473fafb9312e6 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 27 Feb 2022 21:12:36 -0500 Subject: [PATCH 09/87] testing fix, user serializer to hide password --- actstream/tests/test_drf.py | 12 ++++++++++-- runtests/settings.py | 7 ++++--- runtests/testapp/drf.py | 8 ++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index e9d76eef..fb2676d3 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -17,10 +17,13 @@ def setUp(self): super().setUp() self.client = APIClient() - # self.client.login(username='admin', password='admin') + self.auth_client = APIClient() + self.auth_client.login(username='admin', password='admin') def get(self, *args, **kwargs): - return self.client.get(*args, **kwargs).data + auth = kwargs.pop('auth', False) + client = self.auth_client if auth else self.client + return client.get(*args, **kwargs).data def test_actstream(self): actions = self.get('/api/actions/') @@ -64,3 +67,8 @@ def test_viewset(self): resp = self.client.head('/api/groups/foo/') self.assertEqual(resp.status_code, 420) self.assertEqual(resp.data, ['chill']) + + def test_me(self): + actions = self.get('/api/actions/me/', auth=True) + self.assertEqual(len(actions), 3) + self.assertEqual(actions[0]['verb'], 'joined') diff --git a/runtests/settings.py b/runtests/settings.py index a546f6c5..3d5397c2 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -154,13 +154,14 @@ 'USE_JSONFIELD': True, 'GFK_FETCH_DEPTH': 0, 'DRF': { - 'ENABLE': True, 'SERIALIZERS': { - 'auth.Group': 'testapp.drf.GroupSerializer' + 'auth.Group': 'testapp.drf.GroupSerializer', + 'testapp.MyUser': 'testapp.drf.MyUserSerializer' }, 'VIEWSETS': { 'auth.Group': 'testapp.drf.GroupViewSet' - } + }, + 'PERMISSIONS': [] } } diff --git a/runtests/testapp/drf.py b/runtests/testapp/drf.py index f76f8549..54f6bcef 100644 --- a/runtests/testapp/drf.py +++ b/runtests/testapp/drf.py @@ -4,6 +4,8 @@ from rest_framework.decorators import action from rest_framework.response import Response +from testapp.models import MyUser + class GroupSerializer(serializers.ModelSerializer): class Meta: @@ -11,6 +13,12 @@ class Meta: 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 From 803ada108d6422a97d6b8ccacb5b3b17c6b0da21 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 27 Feb 2022 21:25:38 -0500 Subject: [PATCH 10/87] dont require drf for testing app --- runtests/settings.py | 10 +++++++--- runtests/testapp/urls.py | 4 +--- runtests/urls.py | 12 ++++++++---- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/runtests/settings.py b/runtests/settings.py index 3d5397c2..6c7142a7 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -120,9 +120,6 @@ 'django.contrib.staticfiles', 'django.contrib.messages', - 'rest_framework', - 'generic_relations', - 'actstream', 'testapp', @@ -136,6 +133,13 @@ else: INSTALLED_APPS.append('debug_toolbar') +try: + import rest_framework +except: + pass +else: + INSTALLED_APPS.extend(['rest_framework', 'generic_relations']) + try: import django_extensions except: diff --git a/runtests/testapp/urls.py b/runtests/testapp/urls.py index 89523d96..6596bcfd 100644 --- a/runtests/testapp/urls.py +++ b/runtests/testapp/urls.py @@ -1,10 +1,8 @@ -from django.urls import re_path, include, path +from django.urls import re_path from actstream import feeds -from actstream.drf.urls import router urlpatterns = [ - path('api/', include(router.urls)), re_path(r'custom/(?P[-\w\s]+)/', feeds.CustomJSONActivityFeed.as_view(name='testbar'), name='testapp_custom_feed'), diff --git a/runtests/urls.py b/runtests/urls.py index 6eb1b622..94d48d73 100644 --- a/runtests/urls.py +++ b/runtests/urls.py @@ -4,8 +4,6 @@ from django.urls import include, re_path from django.conf import settings -from actstream.drf.urls import router - urlpatterns = [ re_path(r'^admin/', admin.site.urls), @@ -13,10 +11,16 @@ {'document_root': os.path.join(os.path.dirname(__file__), 'media')}), re_path(r'auth/', include('django.contrib.auth.urls')), re_path(r'testapp/', include('testapp.urls')), - re_path('api/', include(router.urls)), - re_path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + re_path(r'', include('actstream.urls')), ] if 'debug_toolbar' in settings.INSTALLED_APPS: urlpatterns.insert(0, re_path('__debug__/', include('debug_toolbar.urls'))) + +if 'rest_framework' in settings.INSTALLED_APPS: + from actstream.drf.urls import router + urlpatterns = [ + re_path('api/', include(router.urls)), + re_path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + ] + urlpatterns From 99a4a404b0bbf74019491d4fb5a455d616d568b6 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 28 Feb 2022 19:44:08 -0500 Subject: [PATCH 11/87] install drf for actions --- .github/workflows/workflow.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml index d0f8a05f..599ae2d5 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -106,7 +106,9 @@ jobs: - name: Install django-jsonfield-backport if: matrix.django == 2.2 run: pip install "django-jsonfield-backport>=1.0.2,<2.0" - + - name: Install Django ReST framework + run: pip install django-rest-framework + # install our package - name: Install package run: pip install -e . From 9ede96d04077ad9ec789ab568cc9bb05e18f3d75 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 28 Feb 2022 19:50:31 -0500 Subject: [PATCH 12/87] install drf generics for actions --- .github/workflows/workflow.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml index 599ae2d5..c2ce0602 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -106,9 +106,9 @@ jobs: - name: Install django-jsonfield-backport if: matrix.django == 2.2 run: pip install "django-jsonfield-backport>=1.0.2,<2.0" - - name: Install Django ReST framework - run: pip install django-rest-framework - + - name: Install Django ReST framework libraries + run: pip install django-rest-framework rest-framework-generic-relations + # install our package - name: Install package run: pip install -e . From 322d27c747037205dbfc4a75c7470f64f2241012 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 28 Feb 2022 20:39:26 -0500 Subject: [PATCH 13/87] action posting and model/object drf feeds --- actstream/drf/serializers.py | 1 - actstream/drf/views.py | 48 +++++++++++++++++++++++++++++++++--- actstream/tests/test_drf.py | 27 ++++++++++++++++++++ runtests/settings.py | 4 ++- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/actstream/drf/serializers.py b/actstream/drf/serializers.py index d4ee2cdf..543257fc 100644 --- a/actstream/drf/serializers.py +++ b/actstream/drf/serializers.py @@ -1,4 +1,3 @@ -from inspect import getmro from rest_framework import serializers from generic_relations.relations import GenericRelatedField diff --git a/actstream/drf/views.py b/actstream/drf/views.py index 1699abd4..e7f6feab 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -1,3 +1,6 @@ +from django.contrib.contenttypes.models import ContentType +from django.shortcuts import get_object_or_404 + from rest_framework import viewsets from rest_framework import permissions from rest_framework.decorators import action @@ -5,23 +8,49 @@ from actstream.drf.serializers import FollowSerializer, ActionSerializer, registered_serializers, registry_factory -from actstream.models import Action, Follow, actor_stream +from actstream.models import Action, Follow, actor_stream, model_stream, any_stream from actstream.registry import label from actstream.settings import DRF_SETTINGS, import_obj +from actstream.signals import action as action_signal class DefaultModelViewSet(viewsets.ReadOnlyModelViewSet): + _permission_cache = {} + def get_permissions(self): - return [import_obj(permission)() for permission in DRF_SETTINGS['PERMISSIONS']] + 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()} + model_label = label(self.get_serializer().Meta.model).lower() + if model_label in self._permission_cache: + return self._permission_cache[model_label] + 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] + self._permission_cache[model_label] = permissions + return permissions + return [] class ActionViewSet(DefaultModelViewSet): queryset = Action.objects.public().prefetch_related() serializer_class = ActionSerializer - @action(detail=False, methods=['POST']) + @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST']) def send(self, request): - pass + data = request.data.dict() + + for name in ('target', 'action_object'): + if f'{name}_content_type_id' in data and f'{name}_object_id' in data: + ctype = get_object_or_404(ContentType, id=data.pop(f'{name}_content_type_id')) + data[name] = ctype.get_object_for_this_type(pk=data.pop(f'{name}_object_id')) + + action_signal.send(sender=request.user, **data) + return Response(status=201) def get_stream(self, stream): page = self.paginate_queryset(stream) @@ -35,6 +64,17 @@ def get_stream(self, stream): def me(self, request): return self.get_stream(actor_stream(request.user)) + @action(detail=False, url_path='model/(?P[^/.]+)', name='Model activity stream') + def model(self, request, content_type_id): + content_type = get_object_or_404(ContentType, id=content_type_id) + return self.get_stream(model_stream(content_type.model_class())) + + @action(detail=False, url_path='object/(?P[^/.]+)/(?P[^/.]+)', name='Object activity stream') + def object(self, request, content_type_id, object_id): + 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(any_stream(obj)) + class FollowViewSet(DefaultModelViewSet): queryset = Follow.objects.prefetch_related() diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index fb2676d3..7aa85e60 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -5,6 +5,7 @@ from actstream.tests.base import DataTestCase from actstream.settings import USE_DRF +from actstream.models import Action from testapp.models import MyUser, Player from testapp_nested.models.my_model import NestedModel @@ -72,3 +73,29 @@ def test_me(self): actions = self.get('/api/actions/me/', auth=True) self.assertEqual(len(actions), 3) self.assertEqual(actions[0]['verb'], 'joined') + + def test_model(self): + actions = self.get(f'/api/actions/model/{self.group_ct.id}/', auth=True) + self.assertEqual(len(actions), 7) + self.assertEqual(actions[0]['verb'], 'joined') + + def test_object(self): + url = f'/api/actions/object/{self.group_ct.id}/{self.group.id}/' + actions = self.get(url, auth=True) + self.assertEqual(len(actions), 5) + self.assertEqual(actions[0]['verb'], 'responded to') + + 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('/api/actions/send/', body) + self.assertEqual(post.status_code, 201) + action = Action.objects.first() + self.assertEqual(action.description, body['description']) + self.assertEqual(action.verb, body['verb']) + self.assertEqual(action.actor, self.user1) + self.assertEqual(action.target, self.group) diff --git a/runtests/settings.py b/runtests/settings.py index 6c7142a7..0ec734e9 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -165,7 +165,9 @@ 'VIEWSETS': { 'auth.Group': 'testapp.drf.GroupViewSet' }, - 'PERMISSIONS': [] + 'PERMISSIONS': { + + } } } From 72f386fefc5b1696397e70e7c8775e1518a5f763 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 28 Feb 2022 21:05:19 -0500 Subject: [PATCH 14/87] weird ordering issues in tests --- actstream/tests/test_drf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index 7aa85e60..dbdf222e 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -83,7 +83,6 @@ def test_object(self): url = f'/api/actions/object/{self.group_ct.id}/{self.group.id}/' actions = self.get(url, auth=True) self.assertEqual(len(actions), 5) - self.assertEqual(actions[0]['verb'], 'responded to') def test_action_send(self): body = { From ea9701a66ab40ce621d9fc45d70adc104b84b84f Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 28 Feb 2022 21:13:06 -0500 Subject: [PATCH 15/87] viewset ordering, test debugging for gh actions --- actstream/drf/views.py | 3 ++- actstream/tests/test_drf.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/actstream/drf/views.py b/actstream/drf/views.py index e7f6feab..58dcc1ac 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -39,6 +39,7 @@ def get_permissions(self): class ActionViewSet(DefaultModelViewSet): queryset = Action.objects.public().prefetch_related() serializer_class = ActionSerializer + ordering_fields = ordering = ['-timestamp'] @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST']) def send(self, request): @@ -80,7 +81,7 @@ class FollowViewSet(DefaultModelViewSet): queryset = Follow.objects.prefetch_related() serializer_class = FollowSerializer permission_classes = [permissions.IsAuthenticated] - ordering_fields = ordering = ['started'] + ordering_fields = ordering = ['-started'] @action(detail=False, methods=['POST']) def follow(self, request): diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index dbdf222e..1c48f3cb 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -34,6 +34,7 @@ def test_actstream(self): def test_hyperlink(self): action = self.get('/api/actions/1/') + print('DEBUG',action) self.assertEqual(action['timestamp'], '2000-01-01T00:00:00') self.assertStartsWith(action['actor'], 'http') From 954181a26e12f883f421dda827874a549fe54917 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 28 Feb 2022 21:23:03 -0500 Subject: [PATCH 16/87] weird db auto id bug? hope this works --- actstream/drf/views.py | 1 + actstream/tests/test_drf.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/actstream/drf/views.py b/actstream/drf/views.py index 58dcc1ac..3d9ff6a8 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -85,6 +85,7 @@ class FollowViewSet(DefaultModelViewSet): @action(detail=False, methods=['POST']) def follow(self, request): + # TODO: implement follow action pass diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index 1c48f3cb..0a05469f 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -33,8 +33,8 @@ def test_actstream(self): self.assertEqual(len(follows), 6) def test_hyperlink(self): - action = self.get('/api/actions/1/') - print('DEBUG',action) + actions = self.get('/api/actions/') + action = self.get(f'/api/actions/{actions[0]["id"]}/') self.assertEqual(action['timestamp'], '2000-01-01T00:00:00') self.assertStartsWith(action['actor'], 'http') From 0500bf98cb22ad3273aabdb1c37bb175729daed5 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 28 Feb 2022 22:18:54 -0500 Subject: [PATCH 17/87] drf following --- actstream/drf/views.py | 37 ++++++++++++++++++++++++++++++++++--- actstream/tests/test_drf.py | 14 +++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/actstream/drf/views.py b/actstream/drf/views.py index 3d9ff6a8..137094fa 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -12,6 +12,7 @@ from actstream.registry import label from actstream.settings import DRF_SETTINGS, import_obj from actstream.signals import action as action_signal +from actstream.actions import follow as follow_action class DefaultModelViewSet(viewsets.ReadOnlyModelViewSet): @@ -43,7 +44,14 @@ class ActionViewSet(DefaultModelViewSet): @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST']) 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.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: @@ -54,6 +62,9 @@ def send(self, request): 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) @@ -63,15 +74,26 @@ def get_stream(self, stream): @action(detail=False, permission_classes=[permissions.IsAuthenticated], name='My Actions') def me(self, request): + """ + Returns the actor_stream for the current user + """ return self.get_stream(actor_stream(request.user)) @action(detail=False, url_path='model/(?P[^/.]+)', name='Model activity stream') def model(self, request, content_type_id): + """ + Returns all actions for a given content type. + See model_stream + """ content_type = get_object_or_404(ContentType, id=content_type_id) return self.get_stream(model_stream(content_type.model_class())) @action(detail=False, url_path='object/(?P[^/.]+)/(?P[^/.]+)', name='Object activity stream') def object(self, request, content_type_id, object_id): + """ + Returns all actions for a given object. + See any_stream + """ 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(any_stream(obj)) @@ -83,10 +105,19 @@ class FollowViewSet(DefaultModelViewSet): permission_classes = [permissions.IsAuthenticated] ordering_fields = ordering = ['-started'] - @action(detail=False, methods=['POST']) + @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST']) def follow(self, request): - # TODO: implement follow action - pass + """ + 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) def viewset_factory(model_class, queryset=None): diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index 0a05469f..358abe6d 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -2,10 +2,11 @@ from django.contrib.auth.models import Group from django.contrib.sites.models import Site +from django.contrib.contenttypes.models import ContentType from actstream.tests.base import DataTestCase from actstream.settings import USE_DRF -from actstream.models import Action +from actstream.models import Action, Follow from testapp.models import MyUser, Player from testapp_nested.models.my_model import NestedModel @@ -99,3 +100,14 @@ def test_action_send(self): self.assertEqual(action.verb, body['verb']) self.assertEqual(action.actor, self.user1) self.assertEqual(action.target, self.group) + + def test_follow(self): + body = { + 'content_type_id': ContentType.objects.get_for_model(self.comment).id, + 'object_id': self.comment.id + } + post = self.auth_client.post('/api/follows/follow/', body) + self.assertEqual(post.status_code, 201) + follow = Follow.objects.order_by('-id').first() + self.assertEqual(follow.follow_object, self.comment) + self.assertEqual(follow.user, self.user1) From 908c58de26a1dbd70d5598641e338106a3f33ca5 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Tue, 1 Mar 2022 07:58:41 -0500 Subject: [PATCH 18/87] add drf urlconf to actstream.urls --- actstream/drf/urls.py | 2 ++ actstream/urls.py | 12 ++++++++++-- runtests/urls.py | 7 ------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/actstream/drf/urls.py b/actstream/drf/urls.py index 53eb7680..f0a04162 100644 --- a/actstream/drf/urls.py +++ b/actstream/drf/urls.py @@ -14,3 +14,5 @@ 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/urls.py b/actstream/urls.py index ce88d388..ad52645f 100644 --- a/actstream/urls.py +++ b/actstream/urls.py @@ -1,9 +1,17 @@ -from django.urls import re_path +from django.urls import re_path, include from actstream import feeds, views +from actstream.settings import USE_DRF +urlpatterns = [] -urlpatterns = [ +if USE_DRF: + from actstream.drf.urls import urlpatterns + urlpatterns += [ + re_path('api/', include(urlpatterns)), + ] + +urlpatterns += [ # User feeds re_path(r'^feed/$', feeds.UserActivityFeed(), name='actstream_feed'), re_path(r'^feed/atom/$', feeds.AtomUserActivityFeed(), diff --git a/runtests/urls.py b/runtests/urls.py index 94d48d73..82456aa1 100644 --- a/runtests/urls.py +++ b/runtests/urls.py @@ -17,10 +17,3 @@ if 'debug_toolbar' in settings.INSTALLED_APPS: urlpatterns.insert(0, re_path('__debug__/', include('debug_toolbar.urls'))) - -if 'rest_framework' in settings.INSTALLED_APPS: - from actstream.drf.urls import router - urlpatterns = [ - re_path('api/', include(router.urls)), - re_path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), - ] + urlpatterns From ea5ad3947b8d2ef903409bfd2ca28c7f15c5ee89 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Tue, 1 Mar 2022 08:02:29 -0500 Subject: [PATCH 19/87] drf-spectacular in runtests --- runtests/requirements.txt | 1 + runtests/settings.py | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/runtests/requirements.txt b/runtests/requirements.txt index 3ce16610..174019f4 100644 --- a/runtests/requirements.txt +++ b/runtests/requirements.txt @@ -6,3 +6,4 @@ werkzeug rest-framework-generic-relations django-debug-toolbar django-rest-framework +drf-spectacular diff --git a/runtests/settings.py b/runtests/settings.py index 0ec734e9..1fbcfcf9 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -1,5 +1,5 @@ import os - +from actstream import __version__ # Always for debugging, dont use the runtests app in production! DEBUG = True @@ -120,6 +120,7 @@ 'django.contrib.staticfiles', 'django.contrib.messages', + 'actstream', 'testapp', @@ -140,6 +141,7 @@ else: INSTALLED_APPS.extend(['rest_framework', 'generic_relations']) + try: import django_extensions except: @@ -172,3 +174,23 @@ } AUTH_USER_MODEL = 'testapp.MyUser' + + +REST_FRAMEWORK = { +} + +try: + import drf_spectacular +except: + pass +else: + INSTALLED_APPS.extend(['drf_spectacular']) + REST_FRAMEWORK['DEFAULT_SCHEMA_CLASS'] = 'drf_spectacular.openapi.AutoSchema' + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Django Activity Streams API', + 'DESCRIPTION': 'Your project description', + 'VERSION': __version__, + 'EXTERNAL_DOCS': {'url': '', 'description': ''}, + 'CONTACT': {'name': '', 'email': ''}, +} From 05aa909fa650bc79b0ca7f92f6f628e4aee8da89 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Tue, 1 Mar 2022 08:10:00 -0500 Subject: [PATCH 20/87] weird recursion error on urls --- actstream/urls.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/actstream/urls.py b/actstream/urls.py index ad52645f..15cdccb1 100644 --- a/actstream/urls.py +++ b/actstream/urls.py @@ -1,4 +1,4 @@ -from django.urls import re_path, include +from django.urls import re_path, include, path from actstream import feeds, views from actstream.settings import USE_DRF @@ -6,9 +6,9 @@ urlpatterns = [] if USE_DRF: - from actstream.drf.urls import urlpatterns + from actstream.drf.urls import router urlpatterns += [ - re_path('api/', include(urlpatterns)), + path('api/', include(router.urls)), ] urlpatterns += [ From 704f1f37566e36f9e7ddff782e3a322bbc240960 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Tue, 1 Mar 2022 08:14:49 -0500 Subject: [PATCH 21/87] use expand fields as default behavior --- actstream/settings.py | 4 ++-- actstream/tests/test_drf.py | 13 +++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/actstream/settings.py b/actstream/settings.py index ab0c1658..10dfc8a7 100644 --- a/actstream/settings.py +++ b/actstream/settings.py @@ -33,8 +33,8 @@ def get_action_manager(): DRF_SETTINGS = { 'ENABLE': False, - 'EXPAND_FIELDS': False, - 'HYPERLINK_FIELDS': True, + 'EXPAND_FIELDS': True, + 'HYPERLINK_FIELDS': False, 'SERIALIZERS': {}, 'VIEWSETS': {}, 'PERMISSIONS': ['rest_framework.permissions.IsAuthenticated'] diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index 358abe6d..84a5a935 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType from actstream.tests.base import DataTestCase -from actstream.settings import USE_DRF +from actstream.settings import USE_DRF, DRF_SETTINGS from actstream.models import Action, Follow from testapp.models import MyUser, Player @@ -33,12 +33,21 @@ def test_actstream(self): follows = self.get('/api/follows/') self.assertEqual(len(follows), 6) - def test_hyperlink(self): + @skipUnless(DRF_SETTINGS['HYPERLINK_FIELDS'], 'Related hyperlinks disabled') + def test_hyperlink_fields(self): actions = self.get('/api/actions/') action = self.get(f'/api/actions/{actions[0]["id"]}/') self.assertEqual(action['timestamp'], '2000-01-01T00:00:00') self.assertStartsWith(action['actor'], 'http') + @skipUnless(DRF_SETTINGS['EXPAND_FIELDS'], 'Related expanded fields disabled') + def test_expand_fields(self): + actions = self.get('/api/actions/') + action = self.get(f'/api/actions/{actions[0]["id"]}/') + self.assertEqual(action['timestamp'], '2000-01-01T00:00:00') + self.assertIsInstance(action['target'], dict) + self.assertEqual(action['target']['username'], 'Three') + def test_urls(self): from actstream.drf.urls import router From 9e427a3d8eaeae721ba73573067936bbda0cfa51 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Tue, 1 Mar 2022 21:29:08 -0500 Subject: [PATCH 22/87] use pytest finally --- actstream/tests/test_drf.py | 46 ++++++++++++++++++------------------- tox.ini | 27 ++++++++++++---------- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index 84a5a935..deea1371 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -29,24 +29,24 @@ def get(self, *args, **kwargs): def test_actstream(self): actions = self.get('/api/actions/') - self.assertEqual(len(actions), 11) + assert len(actions) == 11 follows = self.get('/api/follows/') - self.assertEqual(len(follows), 6) + assert len(follows) == 6 @skipUnless(DRF_SETTINGS['HYPERLINK_FIELDS'], 'Related hyperlinks disabled') def test_hyperlink_fields(self): actions = self.get('/api/actions/') action = self.get(f'/api/actions/{actions[0]["id"]}/') - self.assertEqual(action['timestamp'], '2000-01-01T00:00:00') - self.assertStartsWith(action['actor'], 'http') + assert action['timestamp'] == '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('/api/actions/') action = self.get(f'/api/actions/{actions[0]["id"]}/') - self.assertEqual(action['timestamp'], '2000-01-01T00:00:00') + assert action['timestamp'] == '2000-01-01T00:00:00' self.assertIsInstance(action['target'], dict) - self.assertEqual(action['target']['username'], 'Three') + assert action['target']['username'] == 'Three' def test_urls(self): from actstream.drf.urls import router @@ -62,7 +62,7 @@ def test_urls(self): self.assertIsInstance(objs, list) if len(objs): obj = self.get(f'/api/{url}/{objs[0]["id"]}/') - self.assertEqual(objs[0], obj) + assert objs[0] == obj def test_serializers(self): from actstream.drf.serializers import registered_serializers as serializers @@ -72,28 +72,28 @@ def test_serializers(self): self.assertSetEqual(serializers.keys(), models, domap=False) groups = self.get('/api/groups/') - self.assertEqual(len(groups), 2) + assert len(groups) == 2 self.assertSetEqual(GroupSerializer.Meta.fields, groups[0].keys()) def test_viewset(self): resp = self.client.head('/api/groups/foo/') - self.assertEqual(resp.status_code, 420) - self.assertEqual(resp.data, ['chill']) + assert resp.status_code == 420 + assert resp.data == ['chill'] def test_me(self): actions = self.get('/api/actions/me/', auth=True) - self.assertEqual(len(actions), 3) - self.assertEqual(actions[0]['verb'], 'joined') + assert len(actions) == 3 + assert actions[0]['verb'] == 'joined' def test_model(self): actions = self.get(f'/api/actions/model/{self.group_ct.id}/', auth=True) - self.assertEqual(len(actions), 7) - self.assertEqual(actions[0]['verb'], 'joined') + assert len(actions) == 7 + assert actions[0]['verb'] == 'joined' def test_object(self): url = f'/api/actions/object/{self.group_ct.id}/{self.group.id}/' actions = self.get(url, auth=True) - self.assertEqual(len(actions), 5) + assert len(actions) == 5 def test_action_send(self): body = { @@ -103,12 +103,12 @@ def test_action_send(self): 'target_object_id': self.group.id } post = self.auth_client.post('/api/actions/send/', body) - self.assertEqual(post.status_code, 201) + assert post.status_code == 201 action = Action.objects.first() - self.assertEqual(action.description, body['description']) - self.assertEqual(action.verb, body['verb']) - self.assertEqual(action.actor, self.user1) - self.assertEqual(action.target, self.group) + assert action.description == body['description'] + assert action.verb == body['verb'] + assert action.actor == self.user1 + assert action.target == self.group def test_follow(self): body = { @@ -116,7 +116,7 @@ def test_follow(self): 'object_id': self.comment.id } post = self.auth_client.post('/api/follows/follow/', body) - self.assertEqual(post.status_code, 201) + assert post.status_code == 201 follow = Follow.objects.order_by('-id').first() - self.assertEqual(follow.follow_object, self.comment) - self.assertEqual(follow.user, self.user1) + assert follow.follow_object == self.comment + assert follow.user == self.user1 diff --git a/tox.ini b/tox.ini index ed48edbc..c5561e7c 100644 --- a/tox.ini +++ b/tox.ini @@ -7,12 +7,15 @@ envlist = 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 - coverage>=4.5.1 + coverage + pytest + pytest-cov + pytest-django django-jsonfield-backport>=1.0.3 django22: Django>=2.2,<3.0 django30: Django>=3.0,<3.1 @@ -28,14 +31,14 @@ setenv = postgres: DATABASE_ENGINE=django.db.backends.postgresql usedevelop = True -passenv = TRAVIS -[travis:env] -DJANGO = - 2.2: django22 - 3.0: django30 - 3.1: django31 -DATABASE = - mysql: mysql - postgresql: postgresql - sqlite: sqlite +[testenv:ipdb] +deps = {[testenv]deps} ipdb +commands = {[testenv]commands} --pdb --pdbcls=IPython.terminal.debugger:TerminalPdb + +[testenv:report] +deps = coverage +skip_install = true +commands = + coverage report + coverage html From ca45077fd60ca01c8b64fd94946c206e0e1b4ee1 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Tue, 1 Mar 2022 21:29:29 -0500 Subject: [PATCH 23/87] set sane ordering in qs, not on drf ordering fields --- actstream/drf/views.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/actstream/drf/views.py b/actstream/drf/views.py index 137094fa..b09fef1a 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -38,9 +38,8 @@ def get_permissions(self): class ActionViewSet(DefaultModelViewSet): - queryset = Action.objects.public().prefetch_related() + queryset = Action.objects.public().order_by('-timestamp', '-id').prefetch_related() serializer_class = ActionSerializer - ordering_fields = ordering = ['-timestamp'] @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST']) def send(self, request): @@ -100,10 +99,9 @@ def object(self, request, content_type_id, object_id): class FollowViewSet(DefaultModelViewSet): - queryset = Follow.objects.prefetch_related() + queryset = Follow.objects.order_by('-started', '-id').prefetch_related() serializer_class = FollowSerializer permission_classes = [permissions.IsAuthenticated] - ordering_fields = ordering = ['-started'] @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST']) def follow(self, request): From 2d0391d6d1e19982d7534360606992e8817ce662 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Fri, 4 Mar 2022 21:37:01 -0500 Subject: [PATCH 24/87] runtest project changes, more granular custom permissions/viewsets --- actstream/drf/serializers.py | 12 +++-- actstream/drf/views.py | 3 +- actstream/settings.py | 7 +-- runtests/settings.py | 52 ++++++++++++++----- .../commands/{loadactions.py => initdb.py} | 0 runtests/urls.py | 18 ++++--- 6 files changed, 65 insertions(+), 27 deletions(-) rename runtests/testapp/management/commands/{loadactions.py => initdb.py} (100%) diff --git a/actstream/drf/serializers.py b/actstream/drf/serializers.py index c461f715..d9b36518 100644 --- a/actstream/drf/serializers.py +++ b/actstream/drf/serializers.py @@ -3,7 +3,7 @@ from actstream.models import Follow, Action from actstream.registry import registry -from actstream.settings import DRF_SETTINGS +from actstream.settings import DRF_SETTINGS, import_obj class ExpandRelatedField(serializers.RelatedField): @@ -15,9 +15,13 @@ def to_representation(self, value): def serializer_factory(model_class, **meta_opts): - meta_opts.setdefault('fields', '__all__') - meta_class = type('Meta', (), {'model': model_class, 'fields': '__all__'}) - serializer_class = DRF_SETTINGS['SERIALIZERS'].get(model_class, DEFAULT_SERIALIZER) + model_label = f'{model_class._meta.app_label}.{model_class._meta.model_name}' + model_attrs = DRF_SETTINGS['MODEL_FIELDS'].get(model_label, {'fields': '__all__'}) + model_attrs['model'] = model_class + meta_class = type('Meta', (), model_attrs) + serializer_class = DEFAULT_SERIALIZER + if model_label in DRF_SETTINGS['SERIALIZERS']: + serializer_class = import_obj(DRF_SETTINGS['SERIALIZERS'][model_label]) return type(f'{model_class.__name__}Serializer', (serializer_class,), {'Meta': meta_class}) diff --git a/actstream/drf/views.py b/actstream/drf/views.py index 93cce096..9492e816 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -24,7 +24,8 @@ def viewset_factory(model_class, queryset=None): if queryset is None: queryset = model_class.objects.prefetch_related() serializer_class = registered_serializers[model_class] - viewset_class = DRF_SETTINGS['VIEWSETS'].get(model_class, DEFAULT_VIEWSET) + model_label = f'{model_class._meta.app_label}.{model_class._meta.model_name}' + viewset_class = DRF_SETTINGS['VIEWSETS'].get(model_label, DEFAULT_VIEWSET) return type(f'{model_class.__name__}ViewSet', (viewset_class,), { 'queryset': queryset, 'serializer_class': serializer_class, diff --git a/actstream/settings.py b/actstream/settings.py index f51fe114..5079c8d1 100644 --- a/actstream/settings.py +++ b/actstream/settings.py @@ -25,13 +25,14 @@ def get_action_manager(): USE_JSONFIELD = SETTINGS.get('USE_JSONFIELD', False) -DRF_SETTINGS = SETTINGS.get('DRF', {}) +DRF_SETTINGS = SETTINGS.get('DRF_SETTINGS', {}) DRF_DEFAULTS = { 'EXPAND_FIELDS': False, - 'HYPERLINK_FIELDS': True, + 'HYPERLINK_FIELDS': False, 'SERIALIZERS': {}, - 'VIEWSETS': {} + 'VIEWSETS': {}, + 'MODEL_FIELDS': {} } for name, value in DRF_DEFAULTS.items(): DRF_SETTINGS.setdefault(name, value) diff --git a/runtests/settings.py b/runtests/settings.py index 91cee613..59d2dd4c 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -1,5 +1,15 @@ import os +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 @@ -49,7 +59,7 @@ } # 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. @@ -91,7 +101,6 @@ }] MIDDLEWARE = [ - 'debug_toolbar.middleware.DebugToolbarMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -103,15 +112,10 @@ ROOT_URLCONF = 'urls' -if DEBUG: - import os # only if you haven't already imported this - import socket # only if you haven't already imported this - hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) - INTERNAL_IPS = [ip[:-1] + '1' for ip in ips] + ['127.0.0.1', '10.0.2.2'] DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' -INSTALLED_APPS = ( +INSTALLED_APPS = [ 'django.contrib.auth', 'django.contrib.admin', 'django.contrib.contenttypes', @@ -121,16 +125,14 @@ 'django.contrib.staticfiles', 'django.contrib.messages', - 'django_extensions', - 'rest_framework', - 'generic_relations', - 'debug_toolbar', 'actstream', 'testapp', 'testapp_nested', -) +] +if DRF: + INSTALLED_APPS.extend(['rest_framework', 'generic_relations']) STATIC_URL = '/static/' STATIC_ROOT = 'static' @@ -141,6 +143,30 @@ 'USE_PREFETCH': True, 'USE_JSONFIELD': True, 'GFK_FETCH_DEPTH': 0, + 'DRF_SETTINGS': { + 'EXPAND_FIELDS': True, + 'MODEL_FIELDS': { + 'testapp.myuser': { + 'exclude': ['password'] + } + } + } } AUTH_USER_MODEL = 'testapp.MyUser' + + +if DEBUG_TOOLBAR: + INSTALLED_APPS.extend(('debug_toolbar', 'django_extensions')) + MIDDLEWARE.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware') + import os # only if you haven't already imported this + import socket # only if you haven't already imported this + hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) + INTERNAL_IPS = [ip[:-1] + '1' for ip in ips] + ['127.0.0.1', '10.0.2.2'] + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', + ] +} diff --git a/runtests/testapp/management/commands/loadactions.py b/runtests/testapp/management/commands/initdb.py similarity index 100% rename from runtests/testapp/management/commands/loadactions.py rename to runtests/testapp/management/commands/initdb.py diff --git a/runtests/urls.py b/runtests/urls.py index fc4de748..879a7150 100644 --- a/runtests/urls.py +++ b/runtests/urls.py @@ -1,10 +1,9 @@ import os from django.contrib import admin +from django.conf import settings from django.views.static import serve from django.urls import include, re_path -from actstream.drf.urls import router - urlpatterns = [ re_path(r'^admin/', admin.site.urls), @@ -12,9 +11,16 @@ {'document_root': os.path.join(os.path.dirname(__file__), 'media')}), re_path(r'auth/', include('django.contrib.auth.urls')), re_path(r'testapp/', include('testapp.urls')), - re_path('api/', include(router.urls)), - re_path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), - re_path('__debug__/', include('debug_toolbar.urls')), - re_path(r'', include('actstream.urls')), + re_path(r'streams/', include('actstream.urls')), ] + +if settings.DRF: + from actstream.drf.urls import router + urlpatterns += [ + re_path('api/', include(router.urls)), + re_path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + ] + +if settings.DEBUG_TOOLBAR: + urlpatterns.append(re_path('__debug__/', include('debug_toolbar.urls'))) From 31e5913f63c3d8a2ca1719f9e557b2c53fc6bc90 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Fri, 4 Mar 2022 21:37:14 -0500 Subject: [PATCH 25/87] dont load test data 2x --- runtests/testapp/management/commands/initdb.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/runtests/testapp/management/commands/initdb.py b/runtests/testapp/management/commands/initdb.py index 6ff628ae..aa0ad92e 100644 --- a/runtests/testapp/management/commands/initdb.py +++ b/runtests/testapp/management/commands/initdb.py @@ -1,14 +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 actstream.registry import registry + +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() From 1ded0cf6d7258f07c15d404e90de06eb93fe6f2e Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Fri, 4 Mar 2022 22:08:17 -0500 Subject: [PATCH 26/87] fugly settings remvoed --- runtests/settings.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/runtests/settings.py b/runtests/settings.py index 7bd83256..2a626a29 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -154,7 +154,6 @@ pass else: INSTALLED_APPS.append('django_extensions') - MIDDLEWARE.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware') STATIC_URL = '/static/' STATIC_ROOT = 'static' @@ -182,15 +181,6 @@ AUTH_USER_MODEL = 'testapp.MyUser' -if DEBUG_TOOLBAR: - INSTALLED_APPS.extend(('debug_toolbar', 'django_extensions')) - MIDDLEWARE.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware') - import os # only if you haven't already imported this - import socket # only if you haven't already imported this - hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) - INTERNAL_IPS = [ip[:-1] + '1' for ip in ips] + ['127.0.0.1', '10.0.2.2'] - - REST_FRAMEWORK = { } From ca57f7012c6fb7b2299442404501110607674a17 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Fri, 4 Mar 2022 23:01:43 -0500 Subject: [PATCH 27/87] label model url names --- actstream/drf/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actstream/drf/serializers.py b/actstream/drf/serializers.py index 543257fc..3d7f91b3 100644 --- a/actstream/drf/serializers.py +++ b/actstream/drf/serializers.py @@ -36,7 +36,7 @@ def related_field_factory(model_class, queryset=None): kwargs = {'queryset': queryset} if DRF_SETTINGS['HYPERLINK_FIELDS']: related_field_class = serializers.HyperlinkedRelatedField - kwargs['view_name'] = f'{model_class.__name__.lower()}-detail' + 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,), {}) From 913952137e3b5a57e4bde4906d37150cc7f5d9d1 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Fri, 4 Mar 2022 23:22:08 -0500 Subject: [PATCH 28/87] testing restricted permissions --- actstream/drf/serializers.py | 2 +- actstream/drf/views.py | 4 ---- actstream/tests/test_drf.py | 12 +++++++++--- runtests/settings.py | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/actstream/drf/serializers.py b/actstream/drf/serializers.py index 3d7f91b3..0d200361 100644 --- a/actstream/drf/serializers.py +++ b/actstream/drf/serializers.py @@ -40,7 +40,7 @@ def related_field_factory(model_class, queryset=None): elif DRF_SETTINGS['EXPAND_FIELDS']: related_field_class = ExpandRelatedField field = type(f'{model_class.__name__}RelatedField', (related_field_class,), {}) - return field(**kwargs) # , ) + return field(**kwargs) def registry_factory(factory): diff --git a/actstream/drf/views.py b/actstream/drf/views.py index b09fef1a..5080b733 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -16,7 +16,6 @@ class DefaultModelViewSet(viewsets.ReadOnlyModelViewSet): - _permission_cache = {} def get_permissions(self): if isinstance(DRF_SETTINGS['PERMISSIONS'], (tuple, list)): @@ -24,15 +23,12 @@ def get_permissions(self): if isinstance(DRF_SETTINGS['PERMISSIONS'], dict): lookup = {key.lower(): value for key, value in DRF_SETTINGS['PERMISSIONS'].items()} model_label = label(self.get_serializer().Meta.model).lower() - if model_label in self._permission_cache: - return self._permission_cache[model_label] 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] - self._permission_cache[model_label] = permissions return permissions return [] diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index deea1371..1816acea 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -53,17 +53,23 @@ def test_urls(self): registerd = [url[0] for url in router.registry] names = ['actions', 'follows', 'groups', 'sites', - 'players', 'my-users', 'nested-models'] - self.assertSetEqual(registerd, names) + 'players', 'nested-models'] + self.assertSetEqual(registerd, names + ['my-users']) endpoints = self.get('/api/') self.assertSetEqual(registerd, endpoints.keys()) - for url in registerd: + for url in names: objs = self.get(f'/api/{url}/') self.assertIsInstance(objs, list) if len(objs): obj = self.get(f'/api/{url}/{objs[0]["id"]}/') assert objs[0] == obj + def test_permissions(self): + users = self.get('/api/my-users/') + assert str(users['detail']) == 'Authentication credentials were not provided.' + users = self.get('/api/my-users/', auth=True) + assert len(users) == 4 + def test_serializers(self): from actstream.drf.serializers import registered_serializers as serializers from testapp.drf import GroupSerializer diff --git a/runtests/settings.py b/runtests/settings.py index 2a626a29..ea812915 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -173,7 +173,7 @@ 'auth.Group': 'testapp.drf.GroupViewSet' }, 'PERMISSIONS': { - + 'testapp.MyUser': ['rest_framework.permissions.IsAdminUser'] } } } From d7af261278b1e2f49b21920136edc7ce3fde3ed3 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Fri, 4 Mar 2022 23:31:19 -0500 Subject: [PATCH 29/87] MODEL_FIELDS setting/testing --- actstream/drf/serializers.py | 6 +++--- actstream/settings.py | 2 +- actstream/tests/test_drf.py | 4 ++++ runtests/settings.py | 3 +++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/actstream/drf/serializers.py b/actstream/drf/serializers.py index 0d200361..09ba7077 100644 --- a/actstream/drf/serializers.py +++ b/actstream/drf/serializers.py @@ -14,15 +14,15 @@ def to_representation(self, value): DEFAULT_SERIALIZER = serializers.ModelSerializer -def serializer_factory(model_class, **meta_opts): +def serializer_factory(model_class): """ Returns a subclass of `ModelSerializer` for each model_class in the registry """ - meta_opts.setdefault('fields', '__all__') - meta_class = type('Meta', (), {'model': model_class, 'fields': '__all__'}) 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}) diff --git a/actstream/settings.py b/actstream/settings.py index 10dfc8a7..0220b4eb 100644 --- a/actstream/settings.py +++ b/actstream/settings.py @@ -43,7 +43,7 @@ def get_action_manager(): if USE_DRF: DRF_SETTINGS.update(SETTINGS.get('DRF', {})) - for item in ('SERIALIZERS', 'VIEWSETS'): + for item in ('SERIALIZERS', 'VIEWSETS', 'MODEL_FIELDS'): DRF_SETTINGS[item] = { label.lower(): obj for label, obj in DRF_SETTINGS[item].items() } diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index 1816acea..a3d005be 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -70,6 +70,10 @@ def test_permissions(self): users = self.get('/api/my-users/', auth=True) assert len(users) == 4 + def test_model_fields(self): + sites = self.get('/api/sites/') + self.assertSetEqual(sites[0].keys(), ['domain']) + def test_serializers(self): from actstream.drf.serializers import registered_serializers as serializers from testapp.drf import GroupSerializer diff --git a/runtests/settings.py b/runtests/settings.py index ea812915..16ccf92e 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -174,6 +174,9 @@ }, 'PERMISSIONS': { 'testapp.MyUser': ['rest_framework.permissions.IsAdminUser'] + }, + 'MODEL_FIELDS': { + 'sites.Site': ['domain'] } } } From 6487dffac40d44def345642f31fbb2f4632430aa Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Fri, 4 Mar 2022 23:36:27 -0500 Subject: [PATCH 30/87] test fix --- actstream/tests/test_drf.py | 2 +- runtests/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index a3d005be..7a48d029 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -72,7 +72,7 @@ def test_permissions(self): def test_model_fields(self): sites = self.get('/api/sites/') - self.assertSetEqual(sites[0].keys(), ['domain']) + self.assertSetEqual(sites[0].keys(), ['id', 'domain']) def test_serializers(self): from actstream.drf.serializers import registered_serializers as serializers diff --git a/runtests/settings.py b/runtests/settings.py index 16ccf92e..d7e7bf7e 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -176,7 +176,7 @@ 'testapp.MyUser': ['rest_framework.permissions.IsAdminUser'] }, 'MODEL_FIELDS': { - 'sites.Site': ['domain'] + 'sites.Site': ['id', 'domain'] } } } From 2a9327d36d34dee4ebaa78029f01cd7e9d125e88 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Fri, 4 Mar 2022 23:37:38 -0500 Subject: [PATCH 31/87] +pytest.ini --- pytest.ini | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 pytest.ini diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..f5ea267a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = runtests.settings +python_files = tests.py test_*.py From 46b67c7db8d97bc774625106beb09a673f7e7b52 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sat, 5 Mar 2022 17:28:39 -0500 Subject: [PATCH 32/87] renamed to streams --- actstream/decorators.py | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 actstream/decorators.py diff --git a/actstream/decorators.py b/actstream/decorators.py deleted file mode 100644 index 50aa8f7f..00000000 --- a/actstream/decorators.py +++ /dev/null @@ -1,30 +0,0 @@ -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 f883751e6c96f582a57e1f078bc57853cc66a1fa Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sat, 5 Mar 2022 17:28:54 -0500 Subject: [PATCH 33/87] renamed to streams --- actstream/streams.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 actstream/streams.py 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 From b0e0035493e84d21beae598ffdfb0f45861854c9 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 6 Mar 2022 15:45:43 -0500 Subject: [PATCH 34/87] old decorators for compat. --- actstream/decorators.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 actstream/decorators.py diff --git a/actstream/decorators.py b/actstream/decorators.py new file mode 100644 index 00000000..e461bd5f --- /dev/null +++ b/actstream/decorators.py @@ -0,0 +1 @@ +from actstream.streams import stream From ef6ae8fa9f9f599122af85cb56b2020706a67530 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 6 Mar 2022 15:45:55 -0500 Subject: [PATCH 35/87] readonly + public --- actstream/drf/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actstream/drf/serializers.py b/actstream/drf/serializers.py index 09ba7077..8371df76 100644 --- a/actstream/drf/serializers.py +++ b/actstream/drf/serializers.py @@ -54,7 +54,7 @@ def get_grf(): """ Get a new `GenericRelatedField` instance for each use of the related field """ - return GenericRelatedField(registry_factory(related_field_factory)) + return GenericRelatedField(registry_factory(related_field_factory), read_only=True) registered_serializers = registry_factory(serializer_factory) @@ -67,7 +67,7 @@ class ActionSerializer(DEFAULT_SERIALIZER): class Meta: model = Action - fields = 'id verb description timestamp actor target action_object'.split() + fields = 'id verb public description timestamp actor target action_object'.split() class FollowSerializer(DEFAULT_SERIALIZER): From db6687d528cbd2629d5d39e5bd7bad125bff9a22 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 6 Mar 2022 16:09:08 -0500 Subject: [PATCH 36/87] manager arg typing --- actstream/managers.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/actstream/managers.py b/actstream/managers.py index 1cdcf28f..ec0f263a 100644 --- a/actstream/managers.py +++ b/actstream/managers.py @@ -1,6 +1,8 @@ +from typing import Type + from django.apps import apps from django.contrib.contenttypes.models import ContentType -from django.db.models import Q +from django.db.models import Q, Model from actstream.gfk import GFKManager @@ -21,7 +23,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 +32,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 +41,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 +50,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 +64,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 +83,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() From df4b56904ccd540c71e38f3491e89dfd8b5caf93 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 6 Mar 2022 16:25:14 -0500 Subject: [PATCH 37/87] fixing test warnings --- actstream/feeds.py | 3 +-- actstream/tests/base.py | 5 +++-- actstream/tests/test_drf.py | 4 ++-- runtests/settings.py | 1 + runtests/testapp/__init__.py | 6 +++++- runtests/testapp_nested/__init__.py | 6 +++++- 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/actstream/feeds.py b/actstream/feeds.py index d1b05ac8..c29030c2 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 @@ -45,7 +44,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/tests/base.py b/actstream/tests/base.py index 7691a248..8dac7466 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 @@ -89,7 +89,8 @@ def setUp(self): 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_drf.py b/actstream/tests/test_drf.py index 7a48d029..bcf06caa 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -37,14 +37,14 @@ def test_actstream(self): def test_hyperlink_fields(self): actions = self.get('/api/actions/') action = self.get(f'/api/actions/{actions[0]["id"]}/') - assert action['timestamp'] == '2000-01-01T00:00:00' + 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('/api/actions/') action = self.get(f'/api/actions/{actions[0]["id"]}/') - assert action['timestamp'] == '2000-01-01T00:00:00' + assert action['timestamp'].startswith('2000-01-01T00:00:00') self.assertIsInstance(action['target'], dict) assert action['target']['username'] == 'Three' diff --git a/runtests/settings.py b/runtests/settings.py index d7e7bf7e..72a18816 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -64,6 +64,7 @@ # 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 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_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' From ff88265d60e8e147eaef3a1a8754198d8dd9993d Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 6 Mar 2022 19:11:44 -0500 Subject: [PATCH 38/87] adding more streams from managers to views --- actstream/drf/views.py | 76 +++++++++++++++++++++++++-------- actstream/tests/base.py | 1 + actstream/tests/test_drf.py | 85 +++++++++++++++++++++++++------------ 3 files changed, 117 insertions(+), 45 deletions(-) diff --git a/actstream/drf/views.py b/actstream/drf/views.py index 5080b733..570e7104 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -8,7 +8,7 @@ from actstream.drf.serializers import FollowSerializer, ActionSerializer, registered_serializers, registry_factory -from actstream.models import Action, Follow, actor_stream, model_stream, any_stream +from actstream import models # import Action, Follow, actor_stream, model_stream, any_stream from actstream.registry import label from actstream.settings import DRF_SETTINGS, import_obj from actstream.signals import action as action_signal @@ -34,7 +34,7 @@ def get_permissions(self): class ActionViewSet(DefaultModelViewSet): - queryset = Action.objects.public().order_by('-timestamp', '-id').prefetch_related() + queryset = models.Action.objects.public().order_by('-timestamp', '-id').prefetch_related() serializer_class = ActionSerializer @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST']) @@ -67,35 +67,75 @@ def get_stream(self, stream): serializer = self.get_serializer(stream, many=True) return Response(serializer.data) - @action(detail=False, permission_classes=[permissions.IsAuthenticated], name='My Actions') - def me(self, request): + def get_detail_stream(self, stream, content_type_id, object_id): """ - Returns the actor_stream for the current user + Helper for returning a stream that takes a content type/object id to lookup an instance """ - return self.get_stream(actor_stream(request.user)) + 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='model/(?P[^/.]+)', name='Model activity stream') - def model(self, request, content_type_id): + @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 model_stream + See models.model_stream """ content_type = get_object_or_404(ContentType, id=content_type_id) - return self.get_stream(model_stream(content_type.model_class())) + return self.get_stream(models.model_stream(content_type.model_class())) - @action(detail=False, url_path='object/(?P[^/.]+)/(?P[^/.]+)', name='Object activity stream') - def object(self, request, content_type_id, object_id): + @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. - See any_stream + Returns all actions for a given object where the object is the actor + See models.actor_stream """ - 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(any_stream(obj)) + 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 = Follow.objects.order_by('-started', '-id').prefetch_related() + queryset = models.Follow.objects.order_by('-started', '-id').prefetch_related() serializer_class = FollowSerializer permission_classes = [permissions.IsAuthenticated] diff --git a/actstream/tests/base.py b/actstream/tests/base.py index 8dac7466..4965d774 100644 --- a/actstream/tests/base.py +++ b/actstream/tests/base.py @@ -86,6 +86,7 @@ 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') diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index bcf06caa..6147f451 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -2,25 +2,30 @@ from django.contrib.auth.models import Group from django.contrib.sites.models import Site -from django.contrib.contenttypes.models import ContentType +from django.urls import reverse from actstream.tests.base import DataTestCase from actstream.settings import USE_DRF, DRF_SETTINGS from actstream.models import Action, Follow +from actstream import signals from testapp.models import MyUser, Player from testapp_nested.models.my_model import NestedModel -@skipUnless(USE_DRF, 'Django rest framework disabled') -class DRFTestCase(DataTestCase): +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='admin', password='admin') + self.auth_client.login(username=self.user1.username, password='admin') + + +@skipUnless(USE_DRF, 'Django rest framework disabled') +class DRFActionTestCase(BaseDRFTestCase): def get(self, *args, **kwargs): auth = kwargs.pop('auth', False) @@ -28,22 +33,22 @@ def get(self, *args, **kwargs): return client.get(*args, **kwargs).data def test_actstream(self): - actions = self.get('/api/actions/') + actions = self.get(reverse('action-list')) assert len(actions) == 11 - follows = self.get('/api/follows/') + 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('/api/actions/') - action = self.get(f'/api/actions/{actions[0]["id"]}/') + 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('/api/actions/') - action = self.get(f'/api/actions/{actions[0]["id"]}/') + 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' @@ -55,23 +60,24 @@ def test_urls(self): names = ['actions', 'follows', 'groups', 'sites', 'players', 'nested-models'] self.assertSetEqual(registerd, names + ['my-users']) - endpoints = self.get('/api/') + root = reverse('api-root') + endpoints = self.get(root) self.assertSetEqual(registerd, endpoints.keys()) for url in names: - objs = self.get(f'/api/{url}/') + objs = self.get(f'{root}{url}/') self.assertIsInstance(objs, list) if len(objs): - obj = self.get(f'/api/{url}/{objs[0]["id"]}/') + obj = self.get(f'{root}{url}/{objs[0]["id"]}/') assert objs[0] == obj def test_permissions(self): - users = self.get('/api/my-users/') + users = self.get(reverse('myuser-list')) assert str(users['detail']) == 'Authentication credentials were not provided.' - users = self.get('/api/my-users/', auth=True) + users = self.get(reverse('myuser-list'), auth=True) assert len(users) == 4 def test_model_fields(self): - sites = self.get('/api/sites/') + sites = self.get(reverse('site-list')) self.assertSetEqual(sites[0].keys(), ['id', 'domain']) def test_serializers(self): @@ -81,29 +87,49 @@ def test_serializers(self): models = (Group, MyUser, Player, Site, NestedModel) self.assertSetEqual(serializers.keys(), models, domap=False) - groups = self.get('/api/groups/') + groups = self.get(reverse('group-list')) assert len(groups) == 2 self.assertSetEqual(GroupSerializer.Meta.fields, groups[0].keys()) def test_viewset(self): - resp = self.client.head('/api/groups/foo/') + resp = self.client.head(reverse('group-foo')) assert resp.status_code == 420 assert resp.data == ['chill'] - def test_me(self): - actions = self.get('/api/actions/me/', auth=True) + 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(f'/api/actions/model/{self.group_ct.id}/', auth=True) + 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_object(self): - url = f'/api/actions/object/{self.group_ct.id}/{self.group.id}/' + def test_target(self): + actions = self.get(reverse('action-target-stream', args=[self.group_ct.id, self.comment.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) == 5 + 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 = { @@ -112,7 +138,7 @@ def test_action_send(self): 'target_content_type_id': self.group_ct.id, 'target_object_id': self.group.id } - post = self.auth_client.post('/api/actions/send/', body) + post = self.auth_client.post(reverse('action-send'), body) assert post.status_code == 201 action = Action.objects.first() assert action.description == body['description'] @@ -120,13 +146,18 @@ def test_action_send(self): 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': ContentType.objects.get_for_model(self.comment).id, + 'content_type_id': self.site_ct.id, 'object_id': self.comment.id } - post = self.auth_client.post('/api/follows/follow/', body) + post = self.auth_client.post(reverse('follow-follow'), body) assert post.status_code == 201 follow = Follow.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 From 81f202b234253367944f2386e5a5fad830675e4d Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 6 Mar 2022 21:09:51 -0500 Subject: [PATCH 39/87] views for following/follows/is_following --- actstream/drf/serializers.py | 8 +++++ actstream/drf/views.py | 57 +++++++++++++++++++++++++++++++----- actstream/managers.py | 5 ++-- actstream/tests/test_drf.py | 22 ++++++++++++++ 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/actstream/drf/serializers.py b/actstream/drf/serializers.py index 8371df76..925d45ac 100644 --- a/actstream/drf/serializers.py +++ b/actstream/drf/serializers.py @@ -77,3 +77,11 @@ class FollowSerializer(DEFAULT_SERIALIZER): class Meta: model = Follow fields = 'id flag user follow_object started actor_only'.split() + + +class FollowingSerializer(DEFAULT_SERIALIZER): + following = get_grf() + + class Meta: + model = Follow + fields = ['following'] diff --git a/actstream/drf/views.py b/actstream/drf/views.py index 570e7104..04e0e857 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -1,20 +1,29 @@ +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 - -from actstream.drf.serializers import FollowSerializer, ActionSerializer, registered_serializers, registry_factory -from actstream import models # import Action, Follow, actor_stream, model_stream, any_stream +from actstream.drf import serializers +from actstream import models from actstream.registry import label from actstream.settings import DRF_SETTINGS, import_obj from actstream.signals import action as action_signal from actstream.actions import follow as follow_action +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): @@ -35,7 +44,7 @@ def get_permissions(self): class ActionViewSet(DefaultModelViewSet): queryset = models.Action.objects.public().order_by('-timestamp', '-id').prefetch_related() - serializer_class = ActionSerializer + serializer_class = serializers.ActionSerializer @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST']) def send(self, request): @@ -136,7 +145,7 @@ def any_stream(self, request, content_type_id, object_id): class FollowViewSet(DefaultModelViewSet): queryset = models.Follow.objects.order_by('-started', '-id').prefetch_related() - serializer_class = FollowSerializer + serializer_class = serializers.FollowSerializer permission_classes = [permissions.IsAuthenticated] @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST']) @@ -153,6 +162,40 @@ def follow(self, request): 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 = models.Follow.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 = models.Follow.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 = models.Follow.objects.followers(request.user) + return Response(serializer(followers, many=True).data) + def viewset_factory(model_class, queryset=None): """ @@ -160,7 +203,7 @@ def viewset_factory(model_class, queryset=None): """ if queryset is None: queryset = model_class.objects.prefetch_related() - serializer_class = registered_serializers[model_class] + 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]) @@ -170,4 +213,4 @@ def viewset_factory(model_class, queryset=None): }) -registered_viewsets = registry_factory(viewset_factory) +registered_viewsets = serializers.registry_factory(viewset_factory) diff --git a/actstream/managers.py b/actstream/managers.py index ec0f263a..c44ca251 100644 --- a/actstream/managers.py +++ b/actstream/managers.py @@ -3,7 +3,7 @@ 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 @@ -174,7 +174,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/tests/test_drf.py b/actstream/tests/test_drf.py index 6147f451..a655363b 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -1,4 +1,5 @@ from unittest import skipUnless +from json import loads from django.contrib.auth.models import Group from django.contrib.sites.models import Site @@ -161,3 +162,24 @@ def test_follow(self): 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]['following']['username'] == 'Two' From 9f61e59ff12e4ff8649d0b1c0848f96aee91997c Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 6 Mar 2022 23:43:35 -0500 Subject: [PATCH 40/87] testfix for missing field --- actstream/drf/serializers.py | 4 ++-- actstream/tests/test_drf.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/actstream/drf/serializers.py b/actstream/drf/serializers.py index 925d45ac..0fa9150f 100644 --- a/actstream/drf/serializers.py +++ b/actstream/drf/serializers.py @@ -80,8 +80,8 @@ class Meta: class FollowingSerializer(DEFAULT_SERIALIZER): - following = get_grf() + follow_object = get_grf() class Meta: model = Follow - fields = ['following'] + fields = ['follow_object'] diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index a655363b..603cd3af 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -182,4 +182,4 @@ def test_followers(self): def test_following(self): following = self.auth_client.get(reverse('follow-following')).data assert len(following) == 1 - assert following[0]['following']['username'] == 'Two' + assert following[0]['follow_object']['username'] == 'Two' From 5eaee08bdc0310649ba2ced9c0c12359b440cd6c Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 6 Mar 2022 23:43:56 -0500 Subject: [PATCH 41/87] check post vs get for action sending --- actstream/drf/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/actstream/drf/views.py b/actstream/drf/views.py index 04e0e857..8d204070 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -53,7 +53,9 @@ def send(self, request): 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.dict() + data = request.data + if hasattr(data, 'dict'): + data = data.dict() if 'verb' not in data: return Response(status=400) From c29787cf1dad72e82b49e0917ed1346561a8cce0 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 6 Mar 2022 23:44:23 -0500 Subject: [PATCH 42/87] spectacular in runtests --- runtests/settings.py | 7 +++++-- runtests/urls.py | 12 +++++++++--- tox.ini | 3 ++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/runtests/settings.py b/runtests/settings.py index 72a18816..1c662e84 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -1,3 +1,4 @@ +from actstream import __version__ import os try: @@ -196,10 +197,12 @@ INSTALLED_APPS.extend(['drf_spectacular']) REST_FRAMEWORK['DEFAULT_SCHEMA_CLASS'] = 'drf_spectacular.openapi.AutoSchema' + SPECTACULAR_SETTINGS = { 'TITLE': 'Django Activity Streams API', - 'DESCRIPTION': 'Your project description', - 'VERSION': '0.0.0', + 'DESCRIPTION': 'Generate generic activity streams from the actions on your ' + 'site. Users can follow any actors\' activities for personalized streams.', + 'VERSION': __version__, 'EXTERNAL_DOCS': {'url': '', 'description': ''}, 'CONTACT': {'name': '', 'email': ''}, } diff --git a/runtests/urls.py b/runtests/urls.py index 834809ed..c9e5d035 100644 --- a/runtests/urls.py +++ b/runtests/urls.py @@ -1,7 +1,7 @@ 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 @@ -16,9 +16,15 @@ if settings.DRF: from actstream.drf.urls import router + from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView urlpatterns += [ - re_path('api/', include(router.urls)), - re_path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + # YOUR PATTERNS + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + # Optional UI: + path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), + path('api/', include(router.urls)), + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), ] diff --git a/tox.ini b/tox.ini index c5561e7c..a8452491 100644 --- a/tox.ini +++ b/tox.ini @@ -12,6 +12,7 @@ commands = pytest -s --cov --cov-append actstream/ runtests/testapp runtests/tes deps = django-rest-framework rest-framework-generic-relations + drf-spectacular coverage pytest pytest-cov @@ -33,7 +34,7 @@ setenv = usedevelop = True [testenv:ipdb] -deps = {[testenv]deps} ipdb +deps = {[testenv]deps} ipdb ipython commands = {[testenv]commands} --pdb --pdbcls=IPython.terminal.debugger:TerminalPdb [testenv:report] From 3a11e1bfcd0d3f72d4f15cac9ac6c0127b77aebf Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 7 Mar 2022 00:00:23 -0500 Subject: [PATCH 43/87] dont require drf-spectacular for runtests --- runtests/settings.py | 6 +----- runtests/urls.py | 15 +++++++++------ tox.ini | 1 - 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/runtests/settings.py b/runtests/settings.py index 1c662e84..a4b68e0d 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -142,11 +142,7 @@ INSTALLED_APPS.append('debug_toolbar') MIDDLEWARE.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware') -try: - import rest_framework -except: - pass -else: +if DRF: INSTALLED_APPS.extend(['rest_framework', 'generic_relations']) diff --git a/runtests/urls.py b/runtests/urls.py index c9e5d035..a9fd79f6 100644 --- a/runtests/urls.py +++ b/runtests/urls.py @@ -16,13 +16,16 @@ if settings.DRF: from actstream.drf.urls import router - from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + if 'drf_spectacular' in settings.INSTALLED_APPS: + from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + urlpatterns += [ + # YOUR PATTERNS + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + # Optional UI: + path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), + ] urlpatterns += [ - # YOUR PATTERNS - path('api/schema/', SpectacularAPIView.as_view(), name='schema'), - # Optional UI: - path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), - path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), path('api/', include(router.urls)), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), ] diff --git a/tox.ini b/tox.ini index a8452491..d977a43b 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,6 @@ commands = pytest -s --cov --cov-append actstream/ runtests/testapp runtests/tes deps = django-rest-framework rest-framework-generic-relations - drf-spectacular coverage pytest pytest-cov From a572c336cd60fccdc04b9c76ff49ade87085ab10 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 7 Mar 2022 00:02:29 -0500 Subject: [PATCH 44/87] only test on push to main --- .github/workflows/workflow.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml index c2ce0602..46d90d0a 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -2,8 +2,10 @@ name: Tests # define when to run the action on: - - push - - pull_request + push: + branches: + - main + pull_request: env: GITHUB_WORKFLOW: true From d8da187ee1f00917212d57e5ee2b01f02e6c43b2 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 7 Mar 2022 02:16:53 -0500 Subject: [PATCH 45/87] test fix --- actstream/tests/test_drf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index 603cd3af..de837c5c 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -108,7 +108,7 @@ def test_model(self): assert actions[0]['verb'] == 'joined' def test_target(self): - actions = self.get(reverse('action-target-stream', args=[self.group_ct.id, self.comment.id]), auth=True) + 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' From 20201ab9fa20c91919c382d54bf003ed4809ab1d Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 7 Mar 2022 02:17:12 -0500 Subject: [PATCH 46/87] local db setting overrides --- runtests/settings.py | 33 +++++++++++++++++---------------- tox.ini | 8 ++++++-- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/runtests/settings.py b/runtests/settings.py index a4b68e0d..c28adcec 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -1,6 +1,6 @@ from actstream import __version__ -import os - +from os import getenv +from pprint import pprint try: import debug_toolbar as DEBUG_TOOLBAR except: @@ -27,38 +27,39 @@ } } -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 # although not all choices may be available on all operating systems. diff --git a/tox.ini b/tox.ini index d977a43b..40d34666 100644 --- a/tox.ini +++ b/tox.ini @@ -27,8 +27,12 @@ deps = postgres: psycopg2-binary>=2.6 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 From 8856b3708c7c4850a1c62951bab142261cf7e45f Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 7 Mar 2022 06:06:55 -0500 Subject: [PATCH 47/87] move testapp tests to testapp and out of actstream --- actstream/tests/test_drf.py | 54 +++++++++++++------------------------ runtests/settings.py | 1 - runtests/testapp/tests.py | 26 +++++++++++++++++- 3 files changed, 44 insertions(+), 37 deletions(-) diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index de837c5c..b89c7933 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -1,8 +1,7 @@ from unittest import skipUnless from json import loads -from django.contrib.auth.models import Group -from django.contrib.sites.models import Site + from django.urls import reverse from actstream.tests.base import DataTestCase @@ -10,9 +9,6 @@ from actstream.models import Action, Follow from actstream import signals -from testapp.models import MyUser, Player -from testapp_nested.models.my_model import NestedModel - class BaseDRFTestCase(DataTestCase): def setUp(self): @@ -24,15 +20,28 @@ def setUp(self): self.auth_client = APIClient() self.auth_client.login(username=self.user1.username, password='admin') - -@skipUnless(USE_DRF, 'Django rest framework disabled') -class DRFActionTestCase(BaseDRFTestCase): - 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 @@ -55,21 +64,7 @@ def test_expand_fields(self): assert action['target']['username'] == 'Three' def test_urls(self): - from actstream.drf.urls import router - - registerd = [url[0] for url in router.registry] - names = ['actions', 'follows', 'groups', 'sites', - 'players', 'nested-models'] - self.assertSetEqual(registerd, names + ['my-users']) - root = reverse('api-root') - endpoints = self.get(root) - self.assertSetEqual(registerd, endpoints.keys()) - for url in names: - objs = self.get(f'{root}{url}/') - self.assertIsInstance(objs, list) - if len(objs): - obj = self.get(f'{root}{url}/{objs[0]["id"]}/') - assert objs[0] == obj + self._check_urls('actions', 'follows') def test_permissions(self): users = self.get(reverse('myuser-list')) @@ -81,17 +76,6 @@ def test_model_fields(self): sites = self.get(reverse('site-list')) self.assertSetEqual(sites[0].keys(), ['id', 'domain']) - def test_serializers(self): - from actstream.drf.serializers import registered_serializers as serializers - from testapp.drf import GroupSerializer - - 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()) - def test_viewset(self): resp = self.client.head(reverse('group-foo')) assert resp.status_code == 420 diff --git a/runtests/settings.py b/runtests/settings.py index c28adcec..88f20952 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -128,7 +128,6 @@ 'django.contrib.staticfiles', 'django.contrib.messages', - 'actstream', 'testapp', diff --git a/runtests/testapp/tests.py b/runtests/testapp/tests.py index 528384b1..405c4a0c 100644 --- a/runtests/testapp/tests.py +++ b/runtests/testapp/tests.py @@ -2,14 +2,22 @@ from unittest import skipUnless from django.core.exceptions import ImproperlyConfigured +from django.contrib.auth.models import Group +from django.contrib.sites.models import Site +from django.urls import reverse from actstream.signals import action from actstream.registry import register, unregister from actstream.models import Action, actor_stream, model_stream from actstream.tests.base import render, ActivityBaseTestCase +from actstream.tests.test_drf import BaseDRFTestCase from actstream.settings import USE_JSONFIELD +from actstream.drf.urls import router +from actstream.drf.serializers import registered_serializers as serializers -from testapp.models import MyUser, Abstract, Unregistered +from testapp.models import MyUser, Player, Abstract, Unregistered +from testapp_nested.models.my_model import NestedModel +from testapp.drf import GroupSerializer class TestAppTests(ActivityBaseTestCase): @@ -76,3 +84,19 @@ def test_jsonfield(self): self.assertEqual(newaction.data['text'], 'foobar') self.assertEqual(newaction.data['tags'], ['sayings']) self.assertEqual(newaction.data['more_data'], {'pk': self.user.pk}) + + +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()) From 06e43fc22f3d97712aa5e162c40589ca1b90e600 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sat, 19 Mar 2022 14:51:36 -0400 Subject: [PATCH 48/87] wip: using drf-spectacular to autogen api docs --- actstream/drf/serializers.py | 25 ++++++++ actstream/drf/views.py | 37 ++++++++---- actstream/urls.py | 4 +- pytest.ini | 5 +- runtests/testapp/urls.py | 8 ++- runtests/testapp/views.py | 108 +++++++++++++++++++++++++++++++++++ runtests/urls.py | 2 +- tox.ini | 1 + 8 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 runtests/testapp/views.py diff --git a/actstream/drf/serializers.py b/actstream/drf/serializers.py index 0fa9150f..991c559e 100644 --- a/actstream/drf/serializers.py +++ b/actstream/drf/serializers.py @@ -7,6 +7,9 @@ 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 @@ -61,6 +64,9 @@ def get_grf(): class ActionSerializer(DEFAULT_SERIALIZER): + """ + Serializer for actstream.Action models in the activity feeds + """ actor = get_grf() target = get_grf() action_object = get_grf() @@ -70,7 +76,23 @@ class Meta: 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() @@ -80,6 +102,9 @@ class Meta: class FollowingSerializer(DEFAULT_SERIALIZER): + """ + Serializer for actstream.Follow models in the "following" activity feeds + """ follow_object = get_grf() class Meta: diff --git a/actstream/drf/views.py b/actstream/drf/views.py index 8d204070..bd6b3f8e 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -8,7 +8,7 @@ from rest_framework import permissions from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.exceptions import APIException +from rest_framework.exceptions import APIException, NotFound from actstream.drf import serializers from actstream import models @@ -18,6 +18,13 @@ 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' @@ -31,14 +38,16 @@ def get_permissions(self): 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()} - model_label = label(self.get_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 + 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 [] @@ -46,7 +55,7 @@ class ActionViewSet(DefaultModelViewSet): queryset = models.Action.objects.public().order_by('-timestamp', '-id').prefetch_related() serializer_class = serializers.ActionSerializer - @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST']) + @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST'], serializer_class=serializers.SendActionSerializer) def send(self, request): """ Sends the action signal on POST @@ -61,8 +70,12 @@ def send(self, request): for name in ('target', 'action_object'): if f'{name}_content_type_id' in data and f'{name}_object_id' in data: - ctype = get_object_or_404(ContentType, id=data.pop(f'{name}_content_type_id')) - data[name] = ctype.get_object_for_this_type(pk=data.pop(f'{name}_object_id')) + 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) diff --git a/actstream/urls.py b/actstream/urls.py index 15cdccb1..22150342 100644 --- a/actstream/urls.py +++ b/actstream/urls.py @@ -7,8 +7,10 @@ if USE_DRF: from actstream.drf.urls import router + from testapp.views import SchemaExtender + urlpatterns += [ - path('api/', include(router.urls)), + path('api/', include(SchemaExtender(router).urls)), ] urlpatterns += [ diff --git a/pytest.ini b/pytest.ini index f5ea267a..9775a86e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,6 @@ [pytest] DJANGO_SETTINGS_MODULE = runtests.settings -python_files = tests.py test_*.py +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/testapp/urls.py b/runtests/testapp/urls.py index 6596bcfd..29c2cdf4 100644 --- a/runtests/testapp/urls.py +++ b/runtests/testapp/urls.py @@ -1,9 +1,15 @@ -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'), name='testapp_custom_feed'), ] + +if 'drf_spectacular' in settings.INSTALLED_APPS: + from .views import SpectacularRapiDocView + urlpatterns += [path('rapidoc/', SpectacularRapiDocView.as_view())] diff --git a/runtests/testapp/views.py b/runtests/testapp/views.py new file mode 100644 index 00000000..47d77d55 --- /dev/null +++ b/runtests/testapp/views.py @@ -0,0 +1,108 @@ +from drf_spectacular.views import AUTHENTICATION_CLASSES +from drf_spectacular.settings import spectacular_settings +from drf_spectacular.plumbing import get_relative_url, set_query_parameters +from rest_framework.views import APIView +from rest_framework.reverse import reverse +from rest_framework.response import Response +from rest_framework.renderers import TemplateHTMLRenderer +from drf_spectacular.utils import extend_schema, extend_schema_serializer, \ + OpenApiParameter, OpenApiTypes, OpenApiExample, OpenApiResponse + + +class SchemaExtender: + def __init__(self, router): + self.router = router + + def example(self, name, request_only=True, **value): + return OpenApiExample(name, request_only=request_only, value=value) + + def ctype(self, prefix=None, help=None, path=True, required=True): + return { + 'name': f'{prefix}_content_type_id' if prefix else 'content_type_id', + 'description': f'Primay key of the ContentType {help}', + 'required': required, + 'location': OpenApiParameter.PATH if path else OpenApiParameter.QUERY + } + + def objid(self, prefix=None, help=None, path=True, required=True): + return { + 'name': f'{prefix}_object_id' if prefix else 'object_id', + 'description': f'Primay key of the instance {help}', + 'required': required, + 'location': OpenApiParameter.PATH if path else OpenApiParameter.QUERY + } + + def action_model_stream(self): + return { + 'parameters': [self.ctype(help='to show *all* actions from the specific model')], + 'responses': { + 200: OpenApiResponse(OpenApiTypes.JSON_PTR) + } + } + + def action_send(self): + return { + 'responses': { + 201: OpenApiResponse(None, 'No content if sucessful'), + 400: OpenApiResponse(None, 'Bad user input. Missing verb'), + 404: OpenApiResponse(None, 'target/action_object not found'), + }, + 'examples': [ + self.example( + 'Join a group', + request_only=True, + verb='join', + target_object_id=1, + target_content_type_id=10, + public=True + ) + ] + } + + def extend_viewset(self, viewset, basename): + for name, obj in vars(viewset).items(): + if hasattr(obj, 'detail') and hasattr(obj, 'url_name'): + action_attr = f'{basename}_{name}' + if hasattr(self, action_attr): + schema = getattr(self, action_attr)() + params = schema.pop('parameters', None) + if params: + schema['parameters'] = [OpenApiParameter(**param) for param in params] + obj = extend_schema(**schema)(obj) + setattr(viewset, name, obj) + if name == 'serializer_class': + serializer_attr = f'{basename}_serializer' + if hasattr(self, serializer_attr): + schema = getattr(self, serializer_attr)(obj) + obj = extend_schema_serializer(**schema) + setattr(viewset, name, obj) + return viewset + + @property + def urls(self): + for prefix, viewset, basename in self.router.registry: + self.extend_viewset(viewset, basename) + return self.router.urls + + +class SpectacularRapiDocView(APIView): + renderer_classes = [TemplateHTMLRenderer] + permission_classes = spectacular_settings.SERVE_PERMISSIONS + authentication_classes = AUTHENTICATION_CLASSES + url_name = 'schema' + url = None + template_name = 'rapidoc.html' + title = spectacular_settings.TITLE + + @extend_schema(exclude=True) + def get(self, request, *args, **kwargs): + schema_url = self.url or get_relative_url(reverse(self.url_name, request=request)) + schema_url = set_query_parameters(schema_url, lang=request.GET.get('lang')) + return Response( + data={ + 'title': self.title, + 'dist': 'https://cdn.jsdelivr.net/npm/rapidoc@latest', + 'schema_url': schema_url, + }, + template_name=self.template_name, + ) diff --git a/runtests/urls.py b/runtests/urls.py index a9fd79f6..334ca212 100644 --- a/runtests/urls.py +++ b/runtests/urls.py @@ -32,4 +32,4 @@ if 'debug_toolbar' in settings.INSTALLED_APPS: - urlpatterns.insert(0, re_path('__debug__/', include('debug_toolbar.urls'))) + urlpatterns += [re_path('__debug__/', include('debug_toolbar.urls'))] diff --git a/tox.ini b/tox.ini index 40d34666..a83ff5f2 100644 --- a/tox.ini +++ b/tox.ini @@ -12,6 +12,7 @@ commands = pytest -s --cov --cov-append actstream/ runtests/testapp runtests/tes deps = django-rest-framework rest-framework-generic-relations + drf-spectacular coverage pytest pytest-cov From 4edf4a286a793c4708d4f6a105fe8b498d0a6f6e Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Thu, 19 May 2022 21:55:10 -0400 Subject: [PATCH 49/87] use drf-spectacular --- .github/workflows/workflow.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml index 46d90d0a..0f237a3e 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -109,7 +109,7 @@ jobs: if: matrix.django == 2.2 run: pip install "django-jsonfield-backport>=1.0.2,<2.0" - name: Install Django ReST framework libraries - run: pip install django-rest-framework rest-framework-generic-relations + run: pip install django-rest-framework rest-framework-generic-relations drf-spectacular # install our package - name: Install package From bb5c80b8e9de93b1021b2c030ebffcbff9cd88c2 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Tue, 14 Jun 2022 19:39:00 +0600 Subject: [PATCH 50/87] Django>=3.2 --- runtests/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtests/requirements.txt b/runtests/requirements.txt index 174019f4..623ad981 100644 --- a/runtests/requirements.txt +++ b/runtests/requirements.txt @@ -1,4 +1,4 @@ -Django>=4.0 +Django>=3.2 django-jsonfield-backport django-extensions ipdb From 2875fd1502479ab14c88890fa130fec7e714c310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20B=C3=BClter?= Date: Tue, 5 Jul 2022 09:45:28 +0200 Subject: [PATCH 51/87] 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 --- actstream/jsonfield.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/actstream/jsonfield.py b/actstream/jsonfield.py index 9a309097..79f3ec17 100644 --- a/actstream/jsonfield.py +++ b/actstream/jsonfield.py @@ -2,12 +2,13 @@ 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+) +These are the options: + - With recent Django >= 3.1 use Django's builtin JSONField. + - For Django versions < 3.1 we need django-jsonfield-backport + and will use its JSONField instead. -Raises an ImportError if USE_JSONFIELD is True but none of these are -installed. +Raises an ImportError if USE_JSONFIELD is True, but none of the above +apply. Falls back to a simple Django TextField if USE_JSONFIELD is False, however that field will be removed by migration 0002 directly From 91154945502bcdd5f4941e4ba8c884112bbfd26d Mon Sep 17 00:00:00 2001 From: Tim Gates Date: Sun, 17 Jul 2022 13:08:57 +1000 Subject: [PATCH 52/87] 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 --- actstream/actions.py | 2 +- docs/concepts.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/actstream/actions.py b/actstream/actions.py index 48a0a49e..1243f917 100644 --- a/actstream/actions.py +++ b/actstream/actions.py @@ -109,7 +109,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/docs/concepts.rst b/docs/concepts.rst index 257166fd..fb8cee72 100644 --- a/docs/concepts.rst +++ b/docs/concepts.rst @@ -3,7 +3,7 @@ Activity Stream Concepts ======================== -The terminiology used in this app is based from the `Activity Streams Specification `_. +The terminology used in this app is based from the `Activity Streams Specification `_. The app currently supports the `version 1.0 `_ terminology. Introduction From 5cfbf9a708337ff30fb6d7aa218a7352c92b8bdd Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 7 Aug 2022 09:21:10 -0400 Subject: [PATCH 53/87] +drf module to setup --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 31563c15..de4c7045 100644 --- a/setup.py +++ b/setup.py @@ -15,6 +15,7 @@ url='http://github.com/justquick/django-activity-stream', packages=['actstream', 'actstream.migrations', + 'actstream.drf', 'actstream.templatetags', 'actstream.tests', ], From 304f10fd01b027c16c618859a44cf8f7f262c3fd Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 7 Aug 2022 09:21:59 -0400 Subject: [PATCH 54/87] new api docs --- docs/Makefile | 2 +- docs/api.rst | 24 +++++++++++++++++++----- docs/conf.py | 1 + 3 files changed, 21 insertions(+), 6 deletions(-) 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 Date: Sun, 7 Aug 2022 09:35:42 -0400 Subject: [PATCH 55/87] s/master/main/g trying coverage --- .github/workflows/codeql-analysis.yml | 4 +-- .github/workflows/workflow.yaml | 42 +++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 461ae101..57809e35 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +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 ] + branches: [ main ] schedule: - cron: '23 23 * * 4' diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml index 52767409..eb4f05fd 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' @@ -123,3 +122,36 @@ 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@v2 + with: + name: coverage + path: .coverage.${{ matrix.python_version }} + + coverage: + name: Coverage + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v2 + + - uses: actions/download-artifact@v2 + id: download + with: + name: 'coverage' + + - name: Coverage comment + id: coverage_comment + uses: ewjoachim/python-coverage-comment-action@v2 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MERGE_COVERAGE_FILES: true + + - name: Store Pull Request comment to be posted + uses: actions/upload-artifact@v2 + if: steps.coverage_comment.outputs.COMMENT_FILE_WRITTEN == 'true' + with: + name: python-coverage-comment-action + path: python-coverage-comment-action.txt From 4eed6739a344ff8becdcb4c569ca03f86391140d Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 7 Aug 2022 09:47:52 -0400 Subject: [PATCH 56/87] v2 codeql, no coverage --- .github/workflows/codeql-analysis.yml | 6 +++--- .github/workflows/workflow.yaml | 26 -------------------------- 2 files changed, 3 insertions(+), 29 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 57809e35..c07e112f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # 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 +53,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 +67,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 eb4f05fd..00c93069 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -129,29 +129,3 @@ jobs: with: name: coverage path: .coverage.${{ matrix.python_version }} - - coverage: - name: Coverage - runs-on: ubuntu-latest - needs: test - steps: - - uses: actions/checkout@v2 - - - uses: actions/download-artifact@v2 - id: download - with: - name: 'coverage' - - - name: Coverage comment - id: coverage_comment - uses: ewjoachim/python-coverage-comment-action@v2 - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - MERGE_COVERAGE_FILES: true - - - name: Store Pull Request comment to be posted - uses: actions/upload-artifact@v2 - if: steps.coverage_comment.outputs.COMMENT_FILE_WRITTEN == 'true' - with: - name: python-coverage-comment-action - path: python-coverage-comment-action.txt From 4cb8a83199bf60053d39ed179217cecac35925d6 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 7 Aug 2022 10:00:52 -0400 Subject: [PATCH 57/87] new github workflow badge query --- README.rst | 4 ++-- docs/index.rst | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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/docs/index.rst b/docs/index.rst index 9a179e0e..cdee707d 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 From c796efc95cf08f0ddc89f9296dbb2d2d265b271c Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 7 Aug 2022 10:58:22 -0400 Subject: [PATCH 58/87] github actions + coveralls? --- .github/workflows/workflow.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml index 492113a1..b96e0fb0 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -120,3 +120,8 @@ jobs: run: coverage run runtests/manage.py test -v3 --noinput actstream testapp testapp_nested env: DATABASE_ENGINE: ${{ matrix.database }} + + - name: Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} From 89d8eb2ebb12684d6dd6132365fffe3eb38f0774 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 7 Aug 2022 11:02:46 -0400 Subject: [PATCH 59/87] coveralls? --- .github/workflows/codeql-analysis.yml | 10 +++++----- .github/workflows/workflow.yaml | 17 +++++++++++------ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 461ae101..c07e112f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +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 ] + branches: [ main ] schedule: - cron: '23 23 * * 4' @@ -42,7 +42,7 @@ jobs: # 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 +53,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 +67,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 b96e0fb0..47026934 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' @@ -110,7 +109,6 @@ jobs: if: matrix.database == 'postgres' run: pip install psycopg2-binary>=2.8.6 - # install our package - name: Install package run: pip install -e . @@ -120,6 +118,13 @@ 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@v2 + with: + name: coverage + path: .coverage.${{ matrix.python_version }} - name: Coveralls uses: coverallsapp/github-action@master From c0e4efef6f07a2007c9d233c289e09f1539a332a Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 7 Aug 2022 11:06:37 -0400 Subject: [PATCH 60/87] parallel coverage? --- .coveragerc | 1 + .github/workflows/workflow.yaml | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) 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/workflows/workflow.yaml b/.github/workflows/workflow.yaml index 47026934..962ba860 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -127,6 +127,16 @@ jobs: path: .coverage.${{ matrix.python_version }} - name: Coveralls - uses: coverallsapp/github-action@master + uses: AndreMiras/coveralls-python-action@develop with: - github-token: ${{ secrets.GITHUB_TOKEN }} + 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 From c39808d0582f9cdd70e3fd4d31f3c68e99162c39 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 7 Aug 2022 11:14:42 -0400 Subject: [PATCH 61/87] no custom coverage file name --- .github/workflows/workflow.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml index 962ba860..c5732be1 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -118,13 +118,13 @@ 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 }}" + # COVERAGE_FILE: ".coverage.${{ matrix.python_version }}" - name: Store coverage file uses: actions/upload-artifact@v2 with: name: coverage - path: .coverage.${{ matrix.python_version }} + path: .coverage #.${{ matrix.python_version }} - name: Coveralls uses: AndreMiras/coveralls-python-action@develop From 25b6e8383ac0d7b0e2f9e10f8bf0b881b2c5d589 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 7 Aug 2022 11:20:30 -0400 Subject: [PATCH 62/87] coveralls --- .github/workflows/workflow.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml index 18df5dd2..37aa4046 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -122,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@v2 + 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 From 78149ff2f2f7ea90f3d32c49d937e53c989261d7 Mon Sep 17 00:00:00 2001 From: Marcus Aram Date: Tue, 16 Aug 2022 14:36:45 +0200 Subject: [PATCH 63/87] Fixes #515 - Make it work with Django 4.1 --- actstream/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actstream/registry.py b/actstream/registry.py index 3b145ca5..e69f0d79 100644 --- a/actstream/registry.py +++ b/actstream/registry.py @@ -57,7 +57,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): From 550348391791728f0da2f853009ca073d79526ae Mon Sep 17 00:00:00 2001 From: Marcus Aram Date: Tue, 16 Aug 2022 15:26:02 +0200 Subject: [PATCH 64/87] Fixes #515 - Change comment to the change --- actstream/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actstream/registry.py b/actstream/registry.py index e69f0d79..73dec9d7 100644 --- a/actstream/registry.py +++ b/actstream/registry.py @@ -55,7 +55,7 @@ def label(model_class): def is_installed(model_class): """ Returns True if a model_class is installed. - model_class._meta.installed is only reliable in Django 1.7+ + model_class._meta.app_config is only reliable in Django 1.7+ """ return model_class._meta.app_config is not None From 7a5f3e82264dcde82aa0d8c3a16f1446f8634051 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Wed, 17 Aug 2022 20:55:09 -0400 Subject: [PATCH 65/87] django 4.1 in tox --- actstream/registry.py | 4 ++-- tox.ini | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/actstream/registry.py b/actstream/registry.py index 3b145ca5..73dec9d7 100644 --- a/actstream/registry.py +++ b/actstream/registry.py @@ -55,9 +55,9 @@ def label(model_class): def is_installed(model_class): """ Returns True if a model_class is installed. - model_class._meta.installed is only reliable in Django 1.7+ + model_class._meta.app_config 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/tox.ini b/tox.ini index 70485b48..d8fade11 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ 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] @@ -11,6 +12,7 @@ deps = 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 From 93940ba86cb8ce7ebcd0f7d89fb187b33ffe64bb Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sat, 15 Oct 2022 04:51:55 -0400 Subject: [PATCH 66/87] separate django from drf tests --- runtests/testapp/tests/__ini__.py | 0 .../{tests.py => tests/test_django.py} | 26 +------------------ runtests/testapp/tests/test_drf.py | 25 ++++++++++++++++++ 3 files changed, 26 insertions(+), 25 deletions(-) create mode 100644 runtests/testapp/tests/__ini__.py rename runtests/testapp/{tests.py => tests/test_django.py} (73%) create mode 100644 runtests/testapp/tests/test_drf.py 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 73% rename from runtests/testapp/tests.py rename to runtests/testapp/tests/test_django.py index 405c4a0c..528384b1 100644 --- a/runtests/testapp/tests.py +++ b/runtests/testapp/tests/test_django.py @@ -2,22 +2,14 @@ from unittest import skipUnless from django.core.exceptions import ImproperlyConfigured -from django.contrib.auth.models import Group -from django.contrib.sites.models import Site -from django.urls import reverse from actstream.signals import action from actstream.registry import register, unregister from actstream.models import Action, actor_stream, model_stream from actstream.tests.base import render, ActivityBaseTestCase -from actstream.tests.test_drf import BaseDRFTestCase from actstream.settings import USE_JSONFIELD -from actstream.drf.urls import router -from actstream.drf.serializers import registered_serializers as serializers -from testapp.models import MyUser, Player, Abstract, Unregistered -from testapp_nested.models.my_model import NestedModel -from testapp.drf import GroupSerializer +from testapp.models import MyUser, Abstract, Unregistered class TestAppTests(ActivityBaseTestCase): @@ -84,19 +76,3 @@ def test_jsonfield(self): self.assertEqual(newaction.data['text'], 'foobar') self.assertEqual(newaction.data['tags'], ['sayings']) self.assertEqual(newaction.data['more_data'], {'pk': self.user.pk}) - - -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/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()) From b0affd70cc289f24ae1863d998eeb9ae3c2d880e Mon Sep 17 00:00:00 2001 From: khial mustapha Date: Wed, 19 Oct 2022 15:05:42 +0100 Subject: [PATCH 67/87] fix typo in installation docs. --- docs/installation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation.rst b/docs/installation.rst index a358d900..28d5c6f2 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 From 848d46d632639e57ca43c2ed2f15358c6e44e2bc Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 20 Nov 2022 12:56:59 -0500 Subject: [PATCH 68/87] strip out drf spectacular for now --- actstream/urls.py | 3 +- runtests/requirements.txt | 1 - runtests/settings.py | 18 ------- runtests/testapp/urls.py | 4 -- runtests/testapp/views.py | 108 -------------------------------------- runtests/urls.py | 10 +--- 6 files changed, 2 insertions(+), 142 deletions(-) delete mode 100644 runtests/testapp/views.py diff --git a/actstream/urls.py b/actstream/urls.py index 22150342..d6cb6270 100644 --- a/actstream/urls.py +++ b/actstream/urls.py @@ -7,10 +7,9 @@ if USE_DRF: from actstream.drf.urls import router - from testapp.views import SchemaExtender urlpatterns += [ - path('api/', include(SchemaExtender(router).urls)), + path('api/', include(router.urls)), ] urlpatterns += [ diff --git a/runtests/requirements.txt b/runtests/requirements.txt index 623ad981..6adef1a6 100644 --- a/runtests/requirements.txt +++ b/runtests/requirements.txt @@ -6,4 +6,3 @@ werkzeug rest-framework-generic-relations django-debug-toolbar django-rest-framework -drf-spectacular diff --git a/runtests/settings.py b/runtests/settings.py index 88f20952..209ab6e1 100644 --- a/runtests/settings.py +++ b/runtests/settings.py @@ -184,21 +184,3 @@ REST_FRAMEWORK = { } - -try: - import drf_spectacular -except: - pass -else: - INSTALLED_APPS.extend(['drf_spectacular']) - REST_FRAMEWORK['DEFAULT_SCHEMA_CLASS'] = 'drf_spectacular.openapi.AutoSchema' - - -SPECTACULAR_SETTINGS = { - 'TITLE': 'Django Activity Streams API', - 'DESCRIPTION': 'Generate generic activity streams from the actions on your ' - 'site. Users can follow any actors\' activities for personalized streams.', - 'VERSION': __version__, - 'EXTERNAL_DOCS': {'url': '', 'description': ''}, - 'CONTACT': {'name': '', 'email': ''}, -} diff --git a/runtests/testapp/urls.py b/runtests/testapp/urls.py index 29c2cdf4..f154a81f 100644 --- a/runtests/testapp/urls.py +++ b/runtests/testapp/urls.py @@ -9,7 +9,3 @@ feeds.CustomJSONActivityFeed.as_view(name='testbar'), name='testapp_custom_feed'), ] - -if 'drf_spectacular' in settings.INSTALLED_APPS: - from .views import SpectacularRapiDocView - urlpatterns += [path('rapidoc/', SpectacularRapiDocView.as_view())] diff --git a/runtests/testapp/views.py b/runtests/testapp/views.py deleted file mode 100644 index 47d77d55..00000000 --- a/runtests/testapp/views.py +++ /dev/null @@ -1,108 +0,0 @@ -from drf_spectacular.views import AUTHENTICATION_CLASSES -from drf_spectacular.settings import spectacular_settings -from drf_spectacular.plumbing import get_relative_url, set_query_parameters -from rest_framework.views import APIView -from rest_framework.reverse import reverse -from rest_framework.response import Response -from rest_framework.renderers import TemplateHTMLRenderer -from drf_spectacular.utils import extend_schema, extend_schema_serializer, \ - OpenApiParameter, OpenApiTypes, OpenApiExample, OpenApiResponse - - -class SchemaExtender: - def __init__(self, router): - self.router = router - - def example(self, name, request_only=True, **value): - return OpenApiExample(name, request_only=request_only, value=value) - - def ctype(self, prefix=None, help=None, path=True, required=True): - return { - 'name': f'{prefix}_content_type_id' if prefix else 'content_type_id', - 'description': f'Primay key of the ContentType {help}', - 'required': required, - 'location': OpenApiParameter.PATH if path else OpenApiParameter.QUERY - } - - def objid(self, prefix=None, help=None, path=True, required=True): - return { - 'name': f'{prefix}_object_id' if prefix else 'object_id', - 'description': f'Primay key of the instance {help}', - 'required': required, - 'location': OpenApiParameter.PATH if path else OpenApiParameter.QUERY - } - - def action_model_stream(self): - return { - 'parameters': [self.ctype(help='to show *all* actions from the specific model')], - 'responses': { - 200: OpenApiResponse(OpenApiTypes.JSON_PTR) - } - } - - def action_send(self): - return { - 'responses': { - 201: OpenApiResponse(None, 'No content if sucessful'), - 400: OpenApiResponse(None, 'Bad user input. Missing verb'), - 404: OpenApiResponse(None, 'target/action_object not found'), - }, - 'examples': [ - self.example( - 'Join a group', - request_only=True, - verb='join', - target_object_id=1, - target_content_type_id=10, - public=True - ) - ] - } - - def extend_viewset(self, viewset, basename): - for name, obj in vars(viewset).items(): - if hasattr(obj, 'detail') and hasattr(obj, 'url_name'): - action_attr = f'{basename}_{name}' - if hasattr(self, action_attr): - schema = getattr(self, action_attr)() - params = schema.pop('parameters', None) - if params: - schema['parameters'] = [OpenApiParameter(**param) for param in params] - obj = extend_schema(**schema)(obj) - setattr(viewset, name, obj) - if name == 'serializer_class': - serializer_attr = f'{basename}_serializer' - if hasattr(self, serializer_attr): - schema = getattr(self, serializer_attr)(obj) - obj = extend_schema_serializer(**schema) - setattr(viewset, name, obj) - return viewset - - @property - def urls(self): - for prefix, viewset, basename in self.router.registry: - self.extend_viewset(viewset, basename) - return self.router.urls - - -class SpectacularRapiDocView(APIView): - renderer_classes = [TemplateHTMLRenderer] - permission_classes = spectacular_settings.SERVE_PERMISSIONS - authentication_classes = AUTHENTICATION_CLASSES - url_name = 'schema' - url = None - template_name = 'rapidoc.html' - title = spectacular_settings.TITLE - - @extend_schema(exclude=True) - def get(self, request, *args, **kwargs): - schema_url = self.url or get_relative_url(reverse(self.url_name, request=request)) - schema_url = set_query_parameters(schema_url, lang=request.GET.get('lang')) - return Response( - data={ - 'title': self.title, - 'dist': 'https://cdn.jsdelivr.net/npm/rapidoc@latest', - 'schema_url': schema_url, - }, - template_name=self.template_name, - ) diff --git a/runtests/urls.py b/runtests/urls.py index 334ca212..4883474f 100644 --- a/runtests/urls.py +++ b/runtests/urls.py @@ -16,15 +16,7 @@ if settings.DRF: from actstream.drf.urls import router - if 'drf_spectacular' in settings.INSTALLED_APPS: - from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView - urlpatterns += [ - # YOUR PATTERNS - path('api/schema/', SpectacularAPIView.as_view(), name='schema'), - # Optional UI: - path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), - path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), - ] + urlpatterns += [ path('api/', include(router.urls)), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), From 1995c659bd807d8bf9111aeca1b53e87eb318be7 Mon Sep 17 00:00:00 2001 From: Jens Nistler Date: Thu, 24 Nov 2022 16:02:55 +0100 Subject: [PATCH 69/87] remove json field compatibility, force django 3.2, refs #480 --- actstream/apps.py | 11 ----------- actstream/jsonfield.py | 38 ++++++-------------------------------- docs/changelog.rst | 6 ++++++ docs/data.rst | 13 ------------- docs/installation.rst | 17 +---------------- runtests/requirements.txt | 3 +-- setup.py | 4 +--- 7 files changed, 15 insertions(+), 77 deletions(-) diff --git a/actstream/apps.py b/actstream/apps.py index 2fb053bc..edb2d612 100644 --- a/actstream/apps.py +++ b/actstream/apps.py @@ -25,14 +25,3 @@ def ready(self): DataField(blank=True, null=True).contribute_to_class( 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) diff --git a/actstream/jsonfield.py b/actstream/jsonfield.py index 79f3ec17..231ba60d 100644 --- a/actstream/jsonfield.py +++ b/actstream/jsonfield.py @@ -1,23 +1,12 @@ ''' -Decide on a JSONField implementation based on available packages. - -These are the options: - - With recent Django >= 3.1 use Django's builtin JSONField. - - For Django versions < 3.1 we need django-jsonfield-backport - and will use its JSONField instead. - -Raises an ImportError if USE_JSONFIELD is True, but none of the above -apply. - -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 @@ -25,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/docs/changelog.rst b/docs/changelog.rst index ec1ecdea..6c366855 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,6 +3,12 @@ Changelog ========= +Unreleased +---------- + +- BREAKING: Drop support for EOL versions of Django (< 3.2) +- Remove JSONField compatibility, extras and autoloading + 1.4.0 ------ 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/installation.rst b/docs/installation.rst index 28d5c6f2..9d49af7d 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -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/runtests/requirements.txt b/runtests/requirements.txt index 363b4c4f..8c4a038d 100644 --- a/runtests/requirements.txt +++ b/runtests/requirements.txt @@ -1,3 +1,2 @@ -Django>=2.2.17 -django-jsonfield-backport +Django>=3.2 tblib diff --git a/setup.py b/setup.py index 0a26ad0d..48f9c9e6 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,7 @@ 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.templatetags', @@ -29,7 +30,4 @@ 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Topic :: Utilities'], - extras_require={ - 'jsonfield': ['django-jsonfield-backport>=1.0.2,<2.0'], - }, ) From 75e2310ce07a01e42e656b14adc1c5f41920641f Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 19 Dec 2022 00:14:59 -0500 Subject: [PATCH 70/87] start of drf docs --- CHANGELOG.rst | 2 +- actstream/settings.py | 1 + docs/changelog.rst | 5 +++++ docs/configuration.rst | 8 ++++++++ 4 files changed, 15 insertions(+), 1 deletion(-) 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/actstream/settings.py b/actstream/settings.py index 0220b4eb..9a37f5c9 100644 --- a/actstream/settings.py +++ b/actstream/settings.py @@ -36,6 +36,7 @@ def get_action_manager(): 'EXPAND_FIELDS': True, 'HYPERLINK_FIELDS': False, 'SERIALIZERS': {}, + 'MODEL_FIELDS': {}, 'VIEWSETS': {}, 'PERMISSIONS': ['rest_framework.permissions.IsAuthenticated'] } diff --git a/docs/changelog.rst b/docs/changelog.rst index ec1ecdea..4e9090a9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,6 +3,11 @@ Changelog ========= +1.5.0 +----- + + - Added django-rest-framework support + 1.4.0 ------ 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 *************** From 7927e975c36d2f5a9bbc107dcf6f107ee5521a69 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 19 Dec 2022 00:32:07 -0500 Subject: [PATCH 71/87] 1.4.2 prep --- actstream/__init__.py | 2 +- docs/changelog.rst | 6 +++--- docs/conf.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/actstream/__init__.py b/actstream/__init__.py index 93f38a2c..43d03e3e 100644 --- a/actstream/__init__.py +++ b/actstream/__init__.py @@ -12,5 +12,5 @@ default_app_config = 'actstream.apps.ActstreamConfig' -__version__ = '1.4.1' +__version__ = '1.4.2' __author__ = 'Asif Saif Uddin, Justin Quick ' diff --git a/docs/changelog.rst b/docs/changelog.rst index 6c366855..1c97a6f3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,11 +3,11 @@ Changelog ========= -Unreleased +1.4.2 ---------- -- BREAKING: Drop support for EOL versions of Django (< 3.2) -- Remove JSONField compatibility, extras and autoloading + - Django 4.1 support, dropping support for EOL versions of Django - 3.2 and older + - Remove JSONField compatibility, extras and autoloading 1.4.0 ------ diff --git a/docs/conf.py b/docs/conf.py index 45faf4a2..a4879913 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -138,8 +138,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 From 890d13fd23d5c165a9f28841bd8df9b92f6ad662 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Mon, 19 Dec 2022 00:32:35 -0500 Subject: [PATCH 72/87] updating authors and changelog ln --- AUTHORS.txt | 109 ++++++++++++++++++++++++++------------------------ CHANGELOG.rst | 2 +- 2 files changed, 57 insertions(+), 54 deletions(-) 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 From 1d7b225d7c204b8c15b881d939336e9c987cbed3 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Fri, 10 Feb 2023 21:01:39 -0500 Subject: [PATCH 73/87] updating drf docs --- docs/changelog.rst | 2 +- docs/drf.rst | 56 ++++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 docs/drf.rst diff --git a/docs/changelog.rst b/docs/changelog.rst index 6570a78c..160a4c7f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,7 +3,7 @@ Changelog ========= -1.5.0 +2.0.0 ----- - Added django-rest-framework support diff --git a/docs/drf.rst b/docs/drf.rst new file mode 100644 index 00000000..6e977339 --- /dev/null +++ b/docs/drf.rst @@ -0,0 +1,56 @@ +.. _drf: + +Django ReST Framework Integration +================================= + +As of version 2.0.0, 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 cdee707d..6c3b95a3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -51,6 +51,7 @@ Contents: data follow streams + drf templatetags feeds development From c58a28162eb77b87ced927531519045585661a39 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Fri, 24 Mar 2023 16:39:45 +0600 Subject: [PATCH 74/87] Create SECURITY.md --- SECURITY.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 SECURITY.md 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 From 2561372dbb73cd6d21376e3204c179801817fafd Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Fri, 24 Mar 2023 22:44:42 -0400 Subject: [PATCH 75/87] fix for #524 datetime_safe removal --- actstream/feeds.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/actstream/feeds.py b/actstream/feeds.py index d1b05ac8..c29030c2 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 @@ -45,7 +44,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)) From d06e7e244899c377d0a34977eec8fb0d2672c5ae Mon Sep 17 00:00:00 2001 From: David Guillot Date: Wed, 21 Dec 2022 12:46:49 +0100 Subject: [PATCH 76/87] feat(follows): delete object-orphaned Follows... ... based on Django pre_delete signal --- actstream/apps.py | 4 ++++ actstream/follows.py | 16 ++++++++++++++++ actstream/tests/test_activity.py | 5 ++++- 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 actstream/follows.py diff --git a/actstream/apps.py b/actstream/apps.py index edb2d612..48faa830 100644 --- a/actstream/apps.py +++ b/actstream/apps.py @@ -4,6 +4,7 @@ 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 @@ -25,3 +26,6 @@ def ready(self): DataField(blank=True, null=True).contribute_to_class( action_class, 'data' ) + + from actstream.follows import delete_orphaned_follows + pre_delete.connect(delete_orphaned_follows) diff --git a/actstream/follows.py b/actstream/follows.py new file mode 100644 index 00000000..acde3782 --- /dev/null +++ b/actstream/follows.py @@ -0,0 +1,16 @@ +from django.core.exceptions import ImproperlyConfigured + +from actstream.models import Follow + + +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: + Follow.objects.for_object(instance).delete() + except ImproperlyConfigured: # raised by actstream for irrelevant models + pass diff --git a/actstream/tests/test_activity.py b/actstream/tests/test_activity.py index 9f0502c6..47bd5cc5 100644 --- a/actstream/tests/test_activity.py +++ b/actstream/tests/test_activity.py @@ -144,7 +144,10 @@ def test_following_models_OR_query(self): def test_y_no_orphaned_follows(self): follows = Follow.objects.count() self.user2.delete() - self.assertEqual(follows - 1, Follow.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, Follow.objects.count()) def test_z_no_orphaned_actions(self): actions = self.user1.actor_actions.count() From 58aa17890d97a6153e4f5a592cdee46700313d6d Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Fri, 19 May 2023 10:25:33 +0600 Subject: [PATCH 77/87] Update codeql-analysis.yml --- .github/workflows/codeql-analysis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c07e112f..8fa6c389 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -17,8 +17,6 @@ on: pull_request: # The branches below must be a subset of the branches above branches: [ main ] - schedule: - - cron: '23 23 * * 4' jobs: analyze: From ca778971dfd01543bf02e5fb976edb1bc36a57a0 Mon Sep 17 00:00:00 2001 From: Justin Quick Date: Sun, 21 May 2023 22:26:26 -0400 Subject: [PATCH 78/87] Create dependabot.yml --- .github/dependabot.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/dependabot.yml 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" From 38985760452dce88299e949d9247ab388039bc88 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 May 2023 02:26:49 +0000 Subject: [PATCH 79/87] 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] --- .github/workflows/codeql-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8fa6c389..b9a40588 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,7 +36,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL From b86fa9109594d7b22d5a93f5c256944fd0805c7c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 May 2023 02:26:45 +0000 Subject: [PATCH 80/87] 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] --- .github/workflows/workflow.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml index 37aa4046..78575239 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -125,7 +125,7 @@ jobs: # COVERAGE_FILE: ".coverage.${{ matrix.python_version }}" - name: Store coverage file - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: coverage path: .coverage #.${{ matrix.python_version }} From 6f9d6345a4fdb121157e736dbcce24a54238713c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Sep 2023 00:18:41 +0000 Subject: [PATCH 81/87] 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] --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/workflow.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b9a40588..90ebd476 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,7 +36,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml index 78575239..bcc7cf26 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -80,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 }} From cacfd7bedca853dffa6c62213dd0b26049249ece Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Wed, 4 Oct 2023 13:39:37 +0600 Subject: [PATCH 82/87] changelog entry for v2.0.0 release --- docs/changelog.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 160a4c7f..6544a83c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,7 +6,9 @@ Changelog 2.0.0 ----- - - Added django-rest-framework support + - Added django-rest-framework API support + - Follows: delete object-orphaned Follows, based on Django pre_delete signal. + - Fix deprecated warning 1.4.2 ---------- From a7df85e3aec2ddc7e512d05a405d038528a00c36 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Wed, 4 Oct 2023 13:44:51 +0600 Subject: [PATCH 83/87] Bump version to v2.0.0 --- actstream/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actstream/__init__.py b/actstream/__init__.py index 43d03e3e..192fd774 100644 --- a/actstream/__init__.py +++ b/actstream/__init__.py @@ -12,5 +12,5 @@ default_app_config = 'actstream.apps.ActstreamConfig' -__version__ = '1.4.2' +__version__ = '2.0.0' __author__ = 'Asif Saif Uddin, Justin Quick ' From 370037cd5a684606aa9fbad3c6f79658927d1c68 Mon Sep 17 00:00:00 2001 From: Artush Ghazaryan Date: Fri, 24 Nov 2023 13:03:56 +0400 Subject: [PATCH 84/87] remove unused imports --- actstream/actions.py | 2 -- actstream/apps.py | 5 ----- actstream/tests/test_activity.py | 1 - 3 files changed, 8 deletions(-) diff --git a/actstream/actions.py b/actstream/actions.py index 77df766b..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 diff --git a/actstream/apps.py b/actstream/apps.py index bd8215c7..29db5de6 100644 --- a/actstream/apps.py +++ b/actstream/apps.py @@ -1,9 +1,4 @@ -from collections import OrderedDict - -import django -from django.apps import apps from django.apps import AppConfig -from django.conf import settings from django.db.models.signals import pre_delete from actstream import settings as actstream_settings diff --git a/actstream/tests/test_activity.py b/actstream/tests/test_activity.py index 45a847a9..c9733046 100644 --- a/actstream/tests/test_activity.py +++ b/actstream/tests/test_activity.py @@ -145,7 +145,6 @@ 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 From 04aeac123e2e2ef57b2a0882bae766e132fa0642 Mon Sep 17 00:00:00 2001 From: Artush Ghazaryan Date: Mon, 27 Nov 2023 16:06:52 +0400 Subject: [PATCH 85/87] add swappable models for drf part --- actstream/admin.py | 6 +++--- actstream/drf/serializers.py | 8 ++++---- actstream/drf/views.py | 12 ++++++------ actstream/follows.py | 4 ++-- actstream/tests/test_drf.py | 7 +++---- actstream/tests/test_views.py | 13 +++++++------ 6 files changed, 25 insertions(+), 25 deletions(-) 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/drf/serializers.py b/actstream/drf/serializers.py index 991c559e..26e44a0b 100644 --- a/actstream/drf/serializers.py +++ b/actstream/drf/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from generic_relations.relations import GenericRelatedField -from actstream.models import Follow, Action +from actstream import settings as actstream_settings from actstream.registry import registry, label from actstream.settings import DRF_SETTINGS, import_obj @@ -72,7 +72,7 @@ class ActionSerializer(DEFAULT_SERIALIZER): action_object = get_grf() class Meta: - model = Action + model = actstream_settings.get_action_model() fields = 'id verb public description timestamp actor target action_object'.split() @@ -97,7 +97,7 @@ class FollowSerializer(DEFAULT_SERIALIZER): follow_object = get_grf() class Meta: - model = Follow + model = actstream_settings.get_follow_model() fields = 'id flag user follow_object started actor_only'.split() @@ -108,5 +108,5 @@ class FollowingSerializer(DEFAULT_SERIALIZER): follow_object = get_grf() class Meta: - model = Follow + model = actstream_settings.get_follow_model() fields = ['follow_object'] diff --git a/actstream/drf/views.py b/actstream/drf/views.py index bd6b3f8e..241bc52e 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -13,7 +13,7 @@ from actstream.drf import serializers from actstream import models from actstream.registry import label -from actstream.settings import DRF_SETTINGS, import_obj +from actstream.settings import DRF_SETTINGS, import_obj, get_action_model, get_follow_model from actstream.signals import action as action_signal from actstream.actions import follow as follow_action @@ -52,7 +52,7 @@ def get_permissions(self): class ActionViewSet(DefaultModelViewSet): - queryset = models.Action.objects.public().order_by('-timestamp', '-id').prefetch_related() + queryset = get_action_model().objects.public().order_by('-timestamp', '-id').prefetch_related() serializer_class = serializers.ActionSerializer @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST'], serializer_class=serializers.SendActionSerializer) @@ -159,7 +159,7 @@ def any_stream(self, request, content_type_id, object_id): class FollowViewSet(DefaultModelViewSet): - queryset = models.Follow.objects.order_by('-started', '-id').prefetch_related() + queryset = get_follow_model().objects.order_by('-started', '-id').prefetch_related() serializer_class = serializers.FollowSerializer permission_classes = [permissions.IsAuthenticated] @@ -185,7 +185,7 @@ def is_following(self, request, content_type_id, object_id): """ ctype = get_object_or_404(ContentType, id=content_type_id) instance = ctype.get_object_for_this_type(pk=object_id) - following = models.Follow.objects.is_following(request.user, instance) + following = get_follow_model().objects.is_following(request.user, instance) data = {'is_following': following} return Response(json.dumps(data)) @@ -195,7 +195,7 @@ def following(self, request): """ Returns a JSON response whether the current user is following the object from content_type_id/object_id pair """ - qs = models.Follow.objects.following_qs(request.user) + qs = get_follow_model().objects.following_qs(request.user) return Response(serializers.FollowingSerializer(qs, many=True).data) @action(detail=False, permission_classes=[permissions.IsAuthenticated], @@ -208,7 +208,7 @@ def followers(self, request): if user_model not in serializers.registered_serializers: raise ModelNotRegistered(f'Auth user "{user_model.__name__}" not registered with actstream') serializer = serializers.registered_serializers[user_model] - followers = models.Follow.objects.followers(request.user) + followers = get_follow_model().objects.followers(request.user) return Response(serializer(followers, many=True).data) diff --git a/actstream/follows.py b/actstream/follows.py index acde3782..6707a4fd 100644 --- a/actstream/follows.py +++ b/actstream/follows.py @@ -1,6 +1,6 @@ from django.core.exceptions import ImproperlyConfigured -from actstream.models import Follow +from actstream.settings import get_follow_model def delete_orphaned_follows(sender, instance=None, **kwargs): @@ -11,6 +11,6 @@ def delete_orphaned_follows(sender, instance=None, **kwargs): return try: - Follow.objects.for_object(instance).delete() + get_follow_model().objects.for_object(instance).delete() except ImproperlyConfigured: # raised by actstream for irrelevant models pass diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index b89c7933..41b3d89e 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -5,8 +5,7 @@ from django.urls import reverse from actstream.tests.base import DataTestCase -from actstream.settings import USE_DRF, DRF_SETTINGS -from actstream.models import Action, Follow +from actstream.settings import USE_DRF, DRF_SETTINGS, get_action_model, get_follow_model from actstream import signals @@ -125,7 +124,7 @@ def test_action_send(self): } post = self.auth_client.post(reverse('action-send'), body) assert post.status_code == 201 - action = Action.objects.first() + action = get_action_model().objects.first() assert action.description == body['description'] assert action.verb == body['verb'] assert action.actor == self.user1 @@ -141,7 +140,7 @@ def test_follow(self): } post = self.auth_client.post(reverse('follow-follow'), body) assert post.status_code == 201 - follow = Follow.objects.order_by('-id').first() + follow = get_follow_model().objects.order_by('-id').first() assert follow.follow_object == self.comment assert follow.user == self.user1 assert follow.user == self.user1 diff --git a/actstream/tests/test_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) From 3a9370080eeb8611a0ce096843da2905ddfcbdea Mon Sep 17 00:00:00 2001 From: Artush Ghazaryan Date: Mon, 27 Nov 2023 22:53:24 +0400 Subject: [PATCH 86/87] add swappable models for drf part --- actstream/admin.py | 6 +++--- actstream/drf/serializers.py | 8 ++++---- actstream/drf/views.py | 12 ++++++------ actstream/follows.py | 4 ++-- actstream/tests/test_drf.py | 7 +++---- actstream/tests/test_views.py | 13 +++++++------ 6 files changed, 25 insertions(+), 25 deletions(-) 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/drf/serializers.py b/actstream/drf/serializers.py index 991c559e..26e44a0b 100644 --- a/actstream/drf/serializers.py +++ b/actstream/drf/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from generic_relations.relations import GenericRelatedField -from actstream.models import Follow, Action +from actstream import settings as actstream_settings from actstream.registry import registry, label from actstream.settings import DRF_SETTINGS, import_obj @@ -72,7 +72,7 @@ class ActionSerializer(DEFAULT_SERIALIZER): action_object = get_grf() class Meta: - model = Action + model = actstream_settings.get_action_model() fields = 'id verb public description timestamp actor target action_object'.split() @@ -97,7 +97,7 @@ class FollowSerializer(DEFAULT_SERIALIZER): follow_object = get_grf() class Meta: - model = Follow + model = actstream_settings.get_follow_model() fields = 'id flag user follow_object started actor_only'.split() @@ -108,5 +108,5 @@ class FollowingSerializer(DEFAULT_SERIALIZER): follow_object = get_grf() class Meta: - model = Follow + model = actstream_settings.get_follow_model() fields = ['follow_object'] diff --git a/actstream/drf/views.py b/actstream/drf/views.py index bd6b3f8e..241bc52e 100644 --- a/actstream/drf/views.py +++ b/actstream/drf/views.py @@ -13,7 +13,7 @@ from actstream.drf import serializers from actstream import models from actstream.registry import label -from actstream.settings import DRF_SETTINGS, import_obj +from actstream.settings import DRF_SETTINGS, import_obj, get_action_model, get_follow_model from actstream.signals import action as action_signal from actstream.actions import follow as follow_action @@ -52,7 +52,7 @@ def get_permissions(self): class ActionViewSet(DefaultModelViewSet): - queryset = models.Action.objects.public().order_by('-timestamp', '-id').prefetch_related() + queryset = get_action_model().objects.public().order_by('-timestamp', '-id').prefetch_related() serializer_class = serializers.ActionSerializer @action(detail=False, permission_classes=[permissions.IsAuthenticated], methods=['POST'], serializer_class=serializers.SendActionSerializer) @@ -159,7 +159,7 @@ def any_stream(self, request, content_type_id, object_id): class FollowViewSet(DefaultModelViewSet): - queryset = models.Follow.objects.order_by('-started', '-id').prefetch_related() + queryset = get_follow_model().objects.order_by('-started', '-id').prefetch_related() serializer_class = serializers.FollowSerializer permission_classes = [permissions.IsAuthenticated] @@ -185,7 +185,7 @@ def is_following(self, request, content_type_id, object_id): """ ctype = get_object_or_404(ContentType, id=content_type_id) instance = ctype.get_object_for_this_type(pk=object_id) - following = models.Follow.objects.is_following(request.user, instance) + following = get_follow_model().objects.is_following(request.user, instance) data = {'is_following': following} return Response(json.dumps(data)) @@ -195,7 +195,7 @@ def following(self, request): """ Returns a JSON response whether the current user is following the object from content_type_id/object_id pair """ - qs = models.Follow.objects.following_qs(request.user) + qs = get_follow_model().objects.following_qs(request.user) return Response(serializers.FollowingSerializer(qs, many=True).data) @action(detail=False, permission_classes=[permissions.IsAuthenticated], @@ -208,7 +208,7 @@ def followers(self, request): if user_model not in serializers.registered_serializers: raise ModelNotRegistered(f'Auth user "{user_model.__name__}" not registered with actstream') serializer = serializers.registered_serializers[user_model] - followers = models.Follow.objects.followers(request.user) + followers = get_follow_model().objects.followers(request.user) return Response(serializer(followers, many=True).data) diff --git a/actstream/follows.py b/actstream/follows.py index acde3782..6707a4fd 100644 --- a/actstream/follows.py +++ b/actstream/follows.py @@ -1,6 +1,6 @@ from django.core.exceptions import ImproperlyConfigured -from actstream.models import Follow +from actstream.settings import get_follow_model def delete_orphaned_follows(sender, instance=None, **kwargs): @@ -11,6 +11,6 @@ def delete_orphaned_follows(sender, instance=None, **kwargs): return try: - Follow.objects.for_object(instance).delete() + get_follow_model().objects.for_object(instance).delete() except ImproperlyConfigured: # raised by actstream for irrelevant models pass diff --git a/actstream/tests/test_drf.py b/actstream/tests/test_drf.py index b89c7933..41b3d89e 100644 --- a/actstream/tests/test_drf.py +++ b/actstream/tests/test_drf.py @@ -5,8 +5,7 @@ from django.urls import reverse from actstream.tests.base import DataTestCase -from actstream.settings import USE_DRF, DRF_SETTINGS -from actstream.models import Action, Follow +from actstream.settings import USE_DRF, DRF_SETTINGS, get_action_model, get_follow_model from actstream import signals @@ -125,7 +124,7 @@ def test_action_send(self): } post = self.auth_client.post(reverse('action-send'), body) assert post.status_code == 201 - action = Action.objects.first() + action = get_action_model().objects.first() assert action.description == body['description'] assert action.verb == body['verb'] assert action.actor == self.user1 @@ -141,7 +140,7 @@ def test_follow(self): } post = self.auth_client.post(reverse('follow-follow'), body) assert post.status_code == 201 - follow = Follow.objects.order_by('-id').first() + follow = get_follow_model().objects.order_by('-id').first() assert follow.follow_object == self.comment assert follow.user == self.user1 assert follow.user == self.user1 diff --git a/actstream/tests/test_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) From 96cac8338cb3990cad61e173cb49137a8c1b066d Mon Sep 17 00:00:00 2001 From: Artush Ghazaryan Date: Wed, 13 Dec 2023 16:29:37 +0400 Subject: [PATCH 87/87] release 2.0.1 --- actstream/__init__.py | 2 +- docs/changelog.rst | 5 +++++ docs/drf.rst | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/actstream/__init__.py b/actstream/__init__.py index 192fd774..7bbe37fe 100644 --- a/actstream/__init__.py +++ b/actstream/__init__.py @@ -12,5 +12,5 @@ default_app_config = 'actstream.apps.ActstreamConfig' -__version__ = '2.0.0' +__version__ = '2.0.1' __author__ = 'Asif Saif Uddin, Justin Quick ' diff --git a/docs/changelog.rst b/docs/changelog.rst index 6544a83c..78e4cfc6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,6 +3,11 @@ Changelog ========= +2.0.1 +----- + + - Custom Action and Follow models support + 2.0.0 ----- diff --git a/docs/drf.rst b/docs/drf.rst index 6e977339..1715b14b 100644 --- a/docs/drf.rst +++ b/docs/drf.rst @@ -3,7 +3,7 @@ Django ReST Framework Integration ================================= -As of version 2.0.0, django-activity-stream now supports integration with `Django ReST Framework `_. +As of version 2.0.1, django-activity-stream now supports integration with `Django ReST Framework `_. DRF provides a standardized way of interacting with models stored in Django. It provides standard create/update/get operations using http standard methods.