From 0ccfe8f54e01d15c0d8ddb758b5e863fed609ef7 Mon Sep 17 00:00:00 2001 From: Dmitriy Boger Date: Wed, 6 Feb 2019 21:33:20 +0300 Subject: [PATCH 1/8] Add basic workspace models and relevant API endpoints --- Pipfile | 6 + Pipfile.lock | 70 +++++++--- postpost/api/middlewares.py | 16 +++ postpost/api/migrations/0003_workspaces.py | 46 +++++++ postpost/api/models/__init__.py | 6 +- ...{platform_settings.py => platform_post.py} | 0 .../{publications.py => publication.py} | 2 + postpost/api/models/workspace.py | 11 ++ postpost/api/models/workspace_member.py | 39 ++++++ postpost/api/permissions.py | 33 +++++ postpost/api/serializers.py | 121 ++++++++++++++++++ postpost/api/tasks.py | 4 +- postpost/api/urls.py | 12 +- postpost/api/views.py | 32 +++-- postpost/main/settings.py | 34 +++++ 15 files changed, 401 insertions(+), 31 deletions(-) create mode 100644 postpost/api/middlewares.py create mode 100644 postpost/api/migrations/0003_workspaces.py rename postpost/api/models/{platform_settings.py => platform_post.py} (100%) rename postpost/api/models/{publications.py => publication.py} (90%) create mode 100644 postpost/api/models/workspace.py create mode 100644 postpost/api/models/workspace_member.py create mode 100644 postpost/api/permissions.py diff --git a/Pipfile b/Pipfile index b688f24..7afc426 100644 --- a/Pipfile +++ b/Pipfile @@ -7,6 +7,8 @@ verify_ssl = true [packages] django = "==2.1.5" wemake-python-styleguide = "==0.6.3" +# see https://github.com/wemake-services/wemake-python-styleguide/pull/472#issuecomment-460057878 +flake8 = '==3.6.0' django-rest-framework = "*" pillow = "*" drf-writable-nested = "*" @@ -15,6 +17,10 @@ redis = "*" requests = "*" drf-yasg = "*" djangorestframework-camel-case = "*" +django-oauth-toolkit = "*" +django-cors-middleware = "*" +pyuploadcare = "*" +drf-nested-routers = "*" [requires] python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock index 7e24a23..f8165ac 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "7cb9ec158649d28880731bc7089c431b35101bf4234eebf2c2012ab369a912ac" + "sha256": "2db86cabd2fc8ab46f603a3aed749c4cfb28be8e2ac3fa0e2f152b1a4cc1392f" }, "pipfile-spec": 6, "requires": { @@ -91,6 +91,18 @@ ], "version": "==2.1.5" }, + "django-cors-middleware": { + "hashes": [ + "sha256:25d7e3132e9533be83f62767fca9dc92d66ac9aee414559144ccbce2c2913d70" + ], + "version": "==1.3.1" + }, + "django-oauth-toolkit": { + "hashes": [ + "sha256:ad1b76275950ebbff708222cec57bbdb879f89bac7df6b9dee0f4b9db485c264" + ], + "version": "==1.2.0" + }, "django-rest-framework": { "hashes": [ "sha256:47a8f496fa69e3b6bd79f68dd7a1527d907d6b77f009e9db7cf9bb21cc565e4a" @@ -106,9 +118,17 @@ }, "djangorestframework-camel-case": { "hashes": [ - "sha256:989c5c2d0324069fc1ecea4a5cb8913749d5f2f3c507b38977913ff1b76a719e" + "sha256:4bb2e41fb8a5d3745e20c5ee0842ebc6f6bac602b3286c3dd913b01760a2abb0", + "sha256:5b957f9cf16730f153a0ab4add9ff17fb41b7fceaa7edad29b0536b515bffd16" + ], + "version": "==1.0.3" + }, + "drf-nested-routers": { + "hashes": [ + "sha256:46e5c3abc15c782cafafd7d75028e8f9121bbc6228e3599bbb48a3daa4585034", + "sha256:60c1e1f5cc801e757d26a8138e61c44419ef800c213c3640c5b6138e77d46762" ], - "version": "==0.2.0" + "version": "==0.91" }, "drf-writable-nested": { "hashes": [ @@ -118,10 +138,10 @@ }, "drf-yasg": { "hashes": [ - "sha256:89c84779fb4bfe9c0704bdd40ad70b91fff13fa202696ce580de1c8615414f88", - "sha256:c37adfd3859d04827f971098227a54ef7229a79860860dae7b41abdc17e4e8cf" + "sha256:1d43928ab04d9224b2ce903baf9438cc4deec716711e5fe2d792423fb604d375", + "sha256:6ab4ec538e350cc0014da43d70a451db92714e193fb7c297e6c783e4d1bbbb46" ], - "version": "==1.12.1" + "version": "==1.13.0" }, "eradicate": { "hashes": [ @@ -360,6 +380,13 @@ ], "version": "==0.6.1" }, + "oauthlib": { + "hashes": [ + "sha256:0ce32c5d989a1827e3f1148f98b9085ed2370fc939bf524c9c851d8714797298", + "sha256:3e1e14f6cde7e5475128d30e97edc3bfb4dc857cb884d8714ec161fdbb3b358e" + ], + "version": "==3.0.1" + }, "pathmatch": { "hashes": [ "sha256:b35db907d0532c66132e5bc8aaa20dbfae916441987c8f0abd53ac538376d9a7" @@ -368,10 +395,10 @@ }, "pbr": { "hashes": [ - "sha256:f59d71442f9ece3dffc17bc36575768e1ee9967756e6b6535f0ee1f0054c3d68", - "sha256:f6d5b23f226a2ba58e14e49aa3b1bfaf814d0199144b95d78458212444de1387" + "sha256:a7953f66e1f82e4b061f43096a4bcc058f7d3d41de9b94ac871770e8bdd831a2", + "sha256:d717573351cfe09f49df61906cd272abaa759b3e91744396b804965ff7bff38b" ], - "version": "==5.1.1" + "version": "==5.1.2" }, "pep8-naming": { "hashes": [ @@ -437,6 +464,13 @@ ], "version": "==2.0.0" }, + "python-dateutil": { + "hashes": [ + "sha256:063df5763652e21de43de7d9e00ccf239f953a832941e37be541614732cdfc93", + "sha256:88f9287c0174266bb0d8cedd395cfba9c58e87e5ad86b2ce58859bc11be3cf02" + ], + "version": "==2.7.5" + }, "pytz": { "hashes": [ "sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9", @@ -444,6 +478,12 @@ ], "version": "==2018.9" }, + "pyuploadcare": { + "hashes": [ + "sha256:98c3e9de8c37d2afc31eff8f33b23f6f4d787b5dc8672775504d5bf76ba21544" + ], + "version": "==2.6.0" + }, "pyyaml": { "hashes": [ "sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b", @@ -462,10 +502,10 @@ }, "redis": { "hashes": [ - "sha256:2100750629beff143b6a200a2ea8e719fcf26420adabb81402895e144c5083cf", - "sha256:8e0bdd2de02e829b6225b25646f9fb9daffea99a252610d040409a6738541f0a" + "sha256:74c892041cba46078ae1ef845241548baa3bd3634f9a6f0f952f006eb1619c71", + "sha256:7ba8612bbfd966dea8c62322543fed0095da2834dbd5a7c124afbc617a156aa7" ], - "version": "==3.0.1" + "version": "==3.1.0" }, "requests": { "hashes": [ @@ -531,10 +571,10 @@ }, "testfixtures": { "hashes": [ - "sha256:969e967df5d8e12012b5c90986428919b1068c20841b0077b3e29e9a928605d3", - "sha256:b6c05222ce8d3c34a1353ff30c73da55f61ef58153229a5664ef7110ec340cdd" + "sha256:59df1b51118978400d9926d5c1efb295f900ae626a54113323732647e453a80f", + "sha256:cbd0f095d178de578709bcf4cc6eea896964635d2b41386d1cc7583674809b0e" ], - "version": "==6.4.3" + "version": "==6.5.0" }, "typing": { "hashes": [ diff --git a/postpost/api/middlewares.py b/postpost/api/middlewares.py new file mode 100644 index 0000000..2e6b2bd --- /dev/null +++ b/postpost/api/middlewares.py @@ -0,0 +1,16 @@ +from api.models import Workspace + + +class GlobalWorkspaceMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + return self.get_response() + + def process_view(self, request, view_func, view_args, view_kwargs): + workspace_name = view_kwargs.get('workspace_pk') or view_kwargs.get('workspace_name') + if workspace_name: + workspace = Workspace.objects.filter(name=workspace_name).first() + if workspace: + request.workspace = workspace diff --git a/postpost/api/migrations/0003_workspaces.py b/postpost/api/migrations/0003_workspaces.py new file mode 100644 index 0000000..eeeed4d --- /dev/null +++ b/postpost/api/migrations/0003_workspaces.py @@ -0,0 +1,46 @@ +# Generated by Django 2.1.5 on 2019-02-06 18:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0002_auto_20190128_2219'), + ] + + operations = [ + migrations.CreateModel( + name='Workspace', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.SlugField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='WorkspaceMember', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('publisher', 'Publisher: only create and edit publications'), ('admin', 'Admin: also can edit platforms and members')], default='publisher', max_length=64)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.Workspace')), + ], + ), + migrations.AddField( + model_name='publication', + name='workspace', + field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='api.Workspace'), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name='workspacemember', + unique_together={('member', 'workspace')}, + ), + ] diff --git a/postpost/api/models/__init__.py b/postpost/api/models/__init__.py index ffaf91c..02f69ac 100644 --- a/postpost/api/models/__init__.py +++ b/postpost/api/models/__init__.py @@ -1,2 +1,4 @@ -from api.models.platform_settings import PlatformPost # noqa: F401 -from api.models.publications import Publication # noqa: F401 +from api.models.platform_post import PlatformPost # noqa: F401 +from api.models.publication import Publication # noqa: F401 +from api.models.workspace import Workspace # noqa: F401 +from api.models.workspace_member import WorkspaceMember # noqa: F401 diff --git a/postpost/api/models/platform_settings.py b/postpost/api/models/platform_post.py similarity index 100% rename from postpost/api/models/platform_settings.py rename to postpost/api/models/platform_post.py diff --git a/postpost/api/models/publications.py b/postpost/api/models/publication.py similarity index 90% rename from postpost/api/models/publications.py rename to postpost/api/models/publication.py index 1188d62..bef4e2f 100644 --- a/postpost/api/models/publications.py +++ b/postpost/api/models/publication.py @@ -1,6 +1,7 @@ from django.db import models from api.models import PlatformPost +from api.models.workspace import Workspace class Publication(models.Model): @@ -11,6 +12,7 @@ class Publication(models.Model): text = models.TextField() picture = models.ImageField(blank=True, null=True) + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, null=False) scheduled_at = models.DateTimeField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True, editable=False) diff --git a/postpost/api/models/workspace.py b/postpost/api/models/workspace.py new file mode 100644 index 0000000..c7bf92b --- /dev/null +++ b/postpost/api/models/workspace.py @@ -0,0 +1,11 @@ +from django.db import models + + +class Workspace(models.Model): + """ + Workspace — space with members, publications and tuned platforms. Has a unique name. + """ + name = models.SlugField() + + created_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) diff --git a/postpost/api/models/workspace_member.py b/postpost/api/models/workspace_member.py new file mode 100644 index 0000000..f976cee --- /dev/null +++ b/postpost/api/models/workspace_member.py @@ -0,0 +1,39 @@ +from django.conf import settings +from django.db import models + +from api.models.workspace import Workspace + +PUBLISHER_ROLE = 'publisher' +ADMIN_ROLE = 'admin' +WORKSPACE_ROLES = [ + (PUBLISHER_ROLE, 'Publisher: only create and edit publications'), + (ADMIN_ROLE, 'Admin: also can edit platforms and members'), +] + + +class WorkspaceMember(models.Model): + """ + Many-to-many junction table user <-> workspace with role. + """ + member = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=False, + ) + workspace = models.ForeignKey( + Workspace, + on_delete=models.CASCADE, + null=False, + ) + role = models.CharField( + choices=WORKSPACE_ROLES, + default=PUBLISHER_ROLE, + null=False, + max_length=64, + ) + + created_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + + class Meta: + unique_together = ('member', 'workspace') diff --git a/postpost/api/permissions.py b/postpost/api/permissions.py new file mode 100644 index 0000000..379938b --- /dev/null +++ b/postpost/api/permissions.py @@ -0,0 +1,33 @@ +from rest_framework import permissions + +from api.models import WorkspaceMember +from api.models.workspace_member import ADMIN_ROLE + + +class IsWorkspaceAdmin(permissions.BasePermission): + """ + Permission check for workspace admin + """ + + def has_permission(self, request, view): + is_workspace_admin = WorkspaceMember.objects.filter( + workspace=request.workspace, + member=request.user, + role=ADMIN_ROLE, + ).exists() + return is_workspace_admin + + +class IsWorkspaceMember(permissions.BasePermission): + """ + Permission check for workspace member + """ + + def has_permission(self, request, view): + is_workspace_member = WorkspaceMember.objects.filter( + workspace=request.workspace, + member=request.user, + ).exists() + return is_workspace_member + + diff --git a/postpost/api/serializers.py b/postpost/api/serializers.py index e6c6e61..f1c43a9 100644 --- a/postpost/api/serializers.py +++ b/postpost/api/serializers.py @@ -1,9 +1,17 @@ +from datetime import timedelta from typing import Sequence +from django.contrib.auth import models as contrib_models +from django.utils import timezone from drf_writable_nested import WritableNestedModelSerializer +from oauth2_provider.models import AccessToken, Application, RefreshToken +from oauth2_provider.settings import oauth2_settings +from oauthlib import common from rest_framework import serializers +from rest_framework.validators import UniqueValidator from api import models +from api.models.workspace_member import ADMIN_ROLE class VKGroupSettingsSerializer(serializers.ModelSerializer): @@ -97,3 +105,116 @@ def validate_platform_posts(self, platform_posts: Sequence[models.PlatformPost]) """ if len(platform_posts) == 0: raise serializers.ValidationError('Must be set one or more platform settings') + + +class UserRegistrationSerializer(serializers.ModelSerializer): + """ + Serializer for user registration. + + Check user creds, create User and access/refresh token. + """ + + email = serializers.EmailField( + required=True, + allow_null=False, + validators=[ + UniqueValidator( + queryset=contrib_models.User.objects.all(), + lookup='iexact', + ), + ], + ) + username = serializers.SlugField( + required=True, + min_length=5, + allow_null=False, + validators=[ + UniqueValidator( + queryset=contrib_models.User.objects.all(), + lookup='iexact', + ), + ], + ) + password = serializers.CharField(required=True, allow_null=False, write_only=True, min_length=8) + client_id = serializers.CharField(required=True, allow_null=False, write_only=True) + + access_token = serializers.SerializerMethodField() + refresh_token = serializers.SerializerMethodField() + + def get_access_token(self, _): + """ + Getter for access token. + """ + return self._access_token + + def get_refresh_token(self, _): + """ + Getter for refresh token. + """ + return self._refresh_token + + def create(self, validated_data): + """ + Create User object and generate some oauth stuff like access/refresh token. + """ + try: + application = Application.objects.get(client_id=validated_data['client_id']) + except Application.DoesNotExist: + raise serializers.ValidationError('Invalid client id') + user = contrib_models.User.objects.create_user( + username=validated_data['username'], + email=validated_data['email'], + password=validated_data['password'], + ) + expires = timezone.now() + timedelta(seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + access_token = AccessToken( + user=user, + scope='', + expires=expires, + token=common.generate_token(), + application=application, + ) + access_token.save() + self._access_token = access_token.token + + refresh_token = RefreshToken( + user=user, + token=common.generate_token(), + application=application, + access_token=access_token, + ) + refresh_token.save() + self._refresh_token = refresh_token.token + + return user + + class Meta(object): + model = contrib_models.User + fields = [ + 'id', + 'email', + 'username', + 'password', + 'client_id', + 'access_token', + 'refresh_token', + ] + + +class WorkspaceSerializer(serializers.ModelSerializer): + def create(self, validated_data): + workspace = super().create(validated_data) + models.WorkspaceMember.objects.create( + workspace=workspace, + member=self.context['request'].user, + role=ADMIN_ROLE, + ) + return workspace + + class Meta(object): + model = models.Workspace + fields = [ + 'id', + 'name', + ] diff --git a/postpost/api/tasks.py b/postpost/api/tasks.py index 63f0967..fb4d7f0 100644 --- a/postpost/api/tasks.py +++ b/postpost/api/tasks.py @@ -1,11 +1,11 @@ import logging import os -from datetime import datetime import requests from celery import shared_task from celery.schedules import crontab from celery.task import periodic_task +from django.utils import timezone from api.models import PlatformPost @@ -49,7 +49,7 @@ def send_scheduled_posts(): """ unsent_posts = PlatformPost.objects.filter( current_status=PlatformPost.SCHEDULED_STATUS, - publication__scheduled_at__lte=datetime.now(), + publication__scheduled_at__lte=timezone.now(), ) logger.info('%s unsent platform-specific post found', unsent_posts.count()) scheduled_posts = list(unsent_posts) # get sql select before updating diff --git a/postpost/api/urls.py b/postpost/api/urls.py index 720a817..4d80800 100644 --- a/postpost/api/urls.py +++ b/postpost/api/urls.py @@ -1,8 +1,12 @@ from django.urls import path +from rest_framework_nested import routers from api import views -urlpatterns = [ - path('publications/', views.PublicationList.as_view(), name='publications_list'), - path('publications/', views.Publication.as_view(), name='publications_details'), -] +router = routers.SimpleRouter() +router.register('users', views.UserRegistration) +router.register('workspaces', views.Workspace) +workspaces_router = routers.NestedSimpleRouter(router, r'workspaces', lookup='workspace') +workspaces_router.register('publications', views.WorkspacePublication, base_name='workspaces-publication') + +urlpatterns = router.urls + workspaces_router.urls diff --git a/postpost/api/views.py b/postpost/api/views.py index e461223..1b32807 100644 --- a/postpost/api/views.py +++ b/postpost/api/views.py @@ -1,21 +1,37 @@ -from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView +from django.contrib.auth import models as contrib_models +from rest_framework import viewsets +from rest_framework.generics import CreateAPIView, ListCreateAPIView, RetrieveUpdateDestroyAPIView +from rest_framework.permissions import AllowAny, IsAuthenticated from api import models, serializers +from api.permissions import IsWorkspaceMember -class PublicationList(ListCreateAPIView): +class WorkspacePublication(viewsets.ModelViewSet): """ - Very basic view for Publications objects. + Workspace publications entity view. """ - queryset = models.Publication.objects.all() + permission_classes = [IsAuthenticated, IsWorkspaceMember] serializer_class = serializers.PublicationSerializer + def get_queryset(self): + return models.Publication.objects.filter( + workspace=self.request.workspace, + ) -class Publication(RetrieveUpdateDestroyAPIView): + +class Workspace(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = serializers.WorkspaceSerializer + queryset = models.Workspace.objects.all() + + +class UserRegistration(viewsets.ModelViewSet): """ - Very basic view for Publication object. + Register user and generate access/refresh token immediately. """ - queryset = models.Publication.objects.all() - serializer_class = serializers.PublicationSerializer + permission_classes = [AllowAny] + serializer_class = serializers.UserRegistrationSerializer + queryset = contrib_models.User.objects.all() diff --git a/postpost/main/settings.py b/postpost/main/settings.py index 4c2979a..1a4d540 100644 --- a/postpost/main/settings.py +++ b/postpost/main/settings.py @@ -39,10 +39,15 @@ 'django.contrib.staticfiles', 'rest_framework', 'drf_yasg', + 'oauth2_provider', + 'corsheaders', 'api', + 'pyuploadcare.dj', ] MIDDLEWARE = [ + 'api.middlewares.GlobalWorkspaceMiddleware', + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -130,4 +135,33 @@ 'DEFAULT_PARSER_CLASSES': ( 'djangorestframework_camel_case.parser.CamelCaseJSONParser', ), + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), +} + + +# Secrets + +UPLOADCARE = { + 'pub_key': os.environ['UPLOADCARE_PUBLIC_KEY'], + 'secret': os.environ['UPLOADCARE_SECRET'], } + +SWAGGER_SETTINGS = { + 'USE_SESSION_AUTH': False, + 'SECURITY_DEFINITIONS': { + 'API': { + 'type': 'oauth2', + 'authorizationUrl': '/oauth/authorize', + 'tokenUrl': '/oauth/token/', + 'flow': 'password', + }, + }, + 'OAUTH2_REDIRECT_URL': '/swagger', +} + +CORS_ORIGIN_ALLOW_ALL = True From 18a9b9230ae36142c6581e5153ce73d8c0f4826e Mon Sep 17 00:00:00 2001 From: Vladimir Shkoda Date: Mon, 4 Feb 2019 16:36:24 +0300 Subject: [PATCH 2/8] Add vk gate support (#12) * Add vk gate - Add function to obtain token - Add function to submit post to group (to be updated) - Group all Vk API requests into a small client - Add Vk API request exception --- postpost/api/tasks.py | 22 +++++++ postpost/api/vkontakte.py | 128 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 postpost/api/vkontakte.py diff --git a/postpost/api/tasks.py b/postpost/api/tasks.py index fb4d7f0..66c237a 100644 --- a/postpost/api/tasks.py +++ b/postpost/api/tasks.py @@ -7,6 +7,7 @@ from celery.task import periodic_task from django.utils import timezone +from api import vkontakte from api.models import PlatformPost logger = logging.getLogger(__name__) @@ -37,8 +38,29 @@ def send_post_to_telegram_channel(scheduled_post_id: int): post.save() +@shared_task +def send_post_to_vk_group(scheduled_post_id: int): + """ + Celery task which tries to send post to vk and changes status of platform post. + """ + post = PlatformPost.objects.select_related('publication').get(id=scheduled_post_id) + try: + vkontakte.send_post_to_group( + token=os.environ['VK_TOKEN'], + group_id=os.environ['VK_GROUP_ID'], + api_version=os.environ['VK_API_VERSION'], + post=post, + ) + post.current_status = PlatformPost.SUCCESS_STATUS + except vkontakte.VkAPIError as error: + logger.error('Error by vk API: %s', str(error)) + post.current_status = PlatformPost.FAILED_STATUS + post.save() + + PLATFORM_TASK_MAPPING = { PlatformPost.TELEGRAM_CHANNEL_TYPE: send_post_to_telegram_channel, + PlatformPost.VK_GROUP_TYPE: send_post_to_vk_group, } diff --git a/postpost/api/vkontakte.py b/postpost/api/vkontakte.py new file mode 100644 index 0000000..1a4ce72 --- /dev/null +++ b/postpost/api/vkontakte.py @@ -0,0 +1,128 @@ +from typing import IO, List + +import requests + +from api.models import PlatformPost + + +def get_authorization_url(client_id: int, api_version: float) -> str: + """ + Returns a string with url for authorization. + + By following the url, you will be asked by VK to give access to the application. + After agreement, a blank page will be displayed, and there will be an access token + in the address bar. This token is required by functions bellow. + """ + url = ( + 'https://oauth.vk.com/authorize?client_id={client_id}&response_type=token&' + 'scope=wall,offline,groups,photos,docs&v={api_version}&' + 'redirect_uri=https://oauth.vk.com/blank.html' + ).format( + client_id=client_id, api_version=api_version, + ) + return url + + +def send_post_to_group( + token: str, group_id: int, api_version: float, post: PlatformPost, +) -> requests.Response: + """ + Sends post to vk group on behalf of the group itself. + """ + vk_api = VkAPI(token, api_version) + attachments = [] + # TODO: Add attachments uploading according to PlatformPost changes + return vk_api.send_post_to_group_wall(group_id, post.text_for_posting, attachments) + + +class VkAPIError(Exception): + """ + Vk API base exception. + """ + + def __init__(self, method: str, payload: dict, response: bytes): + """ + Init error. + """ + message = '{0} {1} {2}'.format(method, payload, response) + super(VkAPIError, self).__init__(message) + + +class VkAPI(object): + """ + Local mini client for vk API. + + Its purpose is to share token, api version, and error handling among the api methods. + """ + + def __init__(self, token: str, api_version: float): + """ + Init client. + """ + self._token = token + self._api_version = api_version + self._url = 'https://api.vk.com/method/' + + def send_post_to_group_wall(self, group_id: int, message: str, attachments: List[str] = None): + """ + Sends post to vk group on behalf of the group itself. + """ + payload = { + 'owner_id': -group_id, + 'from_group': 1, + 'message': message, + } + if attachments: + payload['attachment'] = ','.join(attachments) + response = self._request('wall.post', payload=payload) + return response + + def upload_doc(self, doc: IO) -> str: + """ + Uploads and saves doc on the server. + """ + upload_url = self._request('docs.getWallUploadServer')['upload_url'] + + response = requests.post(upload_url, files={'file': doc}) + if response.status_code != requests.codes.ok or 'error' in response.json(): + raise VkAPIError(upload_url, {'file': doc}, response.content) + + doc = self._request( + 'docs.save', + {'file': response.json()['file']}, + )['doc'] + return 'doc{0}_{1}'.format(doc['owner_id'], doc['id']) + + def upload_photo(self, group_id: int, photo: IO) -> str: + """ + Uploads and saves photo in the community wall photos. + """ + upload_url = self._request( + 'photos.getWallUploadServer', + {'group_id': group_id}, + )['upload_url'] + + response = requests.post(upload_url, files={'file': photo}) + if response.status_code != requests.codes.ok or 'error' in response.json(): + raise VkAPIError(upload_url, {'file': photo}, response.content) + uploaded_photo = response.json() + + photo = self._request('photos.saveWallPhoto', { + 'group_id': group_id, + 'server': uploaded_photo['server'], + 'hash': uploaded_photo['hash'], + 'photo': uploaded_photo['photo'], + })[0] + return 'photo{0}_{1}'.format(photo['owner_id'], photo['id']) + + def _request(self, method: str, payload: dict = None): + if payload is None: + payload = {} + payload.update({ + 'v': self._api_version, + 'access_token': self._token, + }) + response = requests.post(self._url + method, data=payload) + if response.status_code != requests.codes.ok or 'error' in response.json(): + raise VkAPIError(method, payload, response.content) + return response.json()['response'] From 76606bf691c01ea9fa8f33551f9b9ea2b2925f77 Mon Sep 17 00:00:00 2001 From: Dima Boger Date: Wed, 6 Feb 2019 13:22:03 +0300 Subject: [PATCH 3/8] Add OAuth and registration (#13) * Add OAuth2 authentication * Fix trailing commas :see_no_evil: * Add instruction for oauth using and basic setting * Add user registration * Fix flake8-per-file-ignores with flake8 3.7.x issues :( more info: https://github.com/wemake-services/wemake-python-styleguide/pull/472#issuecomment-460057878 * Fix code for passing flake8 linter checks --- Pipfile | 3 ++- Pipfile.lock | 9 +-------- README.md | 15 +++++++++++++++ postpost/api/serializers.py | 19 ------------------- postpost/api/urls.py | 13 +++++-------- postpost/api/views.py | 26 +++++++++++--------------- postpost/main/settings.py | 1 - postpost/main/urls.py | 17 +---------------- 8 files changed, 35 insertions(+), 68 deletions(-) diff --git a/Pipfile b/Pipfile index 7afc426..bba84e5 100644 --- a/Pipfile +++ b/Pipfile @@ -7,8 +7,10 @@ verify_ssl = true [packages] django = "==2.1.5" wemake-python-styleguide = "==0.6.3" + # see https://github.com/wemake-services/wemake-python-styleguide/pull/472#issuecomment-460057878 flake8 = '==3.6.0' + django-rest-framework = "*" pillow = "*" drf-writable-nested = "*" @@ -20,7 +22,6 @@ djangorestframework-camel-case = "*" django-oauth-toolkit = "*" django-cors-middleware = "*" pyuploadcare = "*" -drf-nested-routers = "*" [requires] python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock index f8165ac..b352d2c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "2db86cabd2fc8ab46f603a3aed749c4cfb28be8e2ac3fa0e2f152b1a4cc1392f" + "sha256": "ccc0ffae47eb3b647f9e734c807a34e7376445b34f6c7160a64a5d1affadd3ac" }, "pipfile-spec": 6, "requires": { @@ -123,13 +123,6 @@ ], "version": "==1.0.3" }, - "drf-nested-routers": { - "hashes": [ - "sha256:46e5c3abc15c782cafafd7d75028e8f9121bbc6228e3599bbb48a3daa4585034", - "sha256:60c1e1f5cc801e757d26a8138e61c44419ef800c213c3640c5b6138e77d46762" - ], - "version": "==0.91" - }, "drf-writable-nested": { "hashes": [ "sha256:5c54eca93d74dc22014fb7ea54b6dbd559a058587596feba4d79078e32a83e16" diff --git a/README.md b/README.md index 52e2639..b47a60b 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,18 @@ python3.6, redis `pipenv run python manage.py runserver` `pipenv run celery -A main worker -B` + +Add basic user + +`python manage.py createsuperuser` + +Login to [admin interface](http://localhost:8000/admin/oauth2_provider/application/) and create OAuth Application with +these params: + + - User: `1` + - Client type: `Public` + - Grant type: `Resource owner password based` + - Name: e.g. `frontend` + +Congrats! Now, there your [swagger](http://localhost:8000/swagger) and [redoc](http://localhost:8000/redoc) + diff --git a/postpost/api/serializers.py b/postpost/api/serializers.py index f1c43a9..a4f0197 100644 --- a/postpost/api/serializers.py +++ b/postpost/api/serializers.py @@ -11,7 +11,6 @@ from rest_framework.validators import UniqueValidator from api import models -from api.models.workspace_member import ADMIN_ROLE class VKGroupSettingsSerializer(serializers.ModelSerializer): @@ -200,21 +199,3 @@ class Meta(object): 'access_token', 'refresh_token', ] - - -class WorkspaceSerializer(serializers.ModelSerializer): - def create(self, validated_data): - workspace = super().create(validated_data) - models.WorkspaceMember.objects.create( - workspace=workspace, - member=self.context['request'].user, - role=ADMIN_ROLE, - ) - return workspace - - class Meta(object): - model = models.Workspace - fields = [ - 'id', - 'name', - ] diff --git a/postpost/api/urls.py b/postpost/api/urls.py index 4d80800..6839615 100644 --- a/postpost/api/urls.py +++ b/postpost/api/urls.py @@ -1,12 +1,9 @@ from django.urls import path -from rest_framework_nested import routers from api import views -router = routers.SimpleRouter() -router.register('users', views.UserRegistration) -router.register('workspaces', views.Workspace) -workspaces_router = routers.NestedSimpleRouter(router, r'workspaces', lookup='workspace') -workspaces_router.register('publications', views.WorkspacePublication, base_name='workspaces-publication') - -urlpatterns = router.urls + workspaces_router.urls +urlpatterns = [ + path('users/', views.UserRegistration.as_view(), name='users_registration'), + path('publications/', views.PublicationList.as_view(), name='publications_list'), + path('publications/', views.Publication.as_view(), name='publications_details'), +] diff --git a/postpost/api/views.py b/postpost/api/views.py index 1b32807..df5c69e 100644 --- a/postpost/api/views.py +++ b/postpost/api/views.py @@ -1,37 +1,33 @@ -from django.contrib.auth import models as contrib_models -from rest_framework import viewsets from rest_framework.generics import CreateAPIView, ListCreateAPIView, RetrieveUpdateDestroyAPIView from rest_framework.permissions import AllowAny, IsAuthenticated from api import models, serializers -from api.permissions import IsWorkspaceMember -class WorkspacePublication(viewsets.ModelViewSet): +class PublicationList(ListCreateAPIView): """ - Workspace publications entity view. + Very basic view for Publications objects. """ - permission_classes = [IsAuthenticated, IsWorkspaceMember] + permission_classes = [IsAuthenticated] + queryset = models.Publication.objects.all() serializer_class = serializers.PublicationSerializer - def get_queryset(self): - return models.Publication.objects.filter( - workspace=self.request.workspace, - ) +class Publication(RetrieveUpdateDestroyAPIView): + """ + View for get, delete and change publication entity. + """ -class Workspace(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] - serializer_class = serializers.WorkspaceSerializer - queryset = models.Workspace.objects.all() + queryset = models.Publication.objects.all() + serializer_class = serializers.PublicationSerializer -class UserRegistration(viewsets.ModelViewSet): +class UserRegistration(CreateAPIView): """ Register user and generate access/refresh token immediately. """ permission_classes = [AllowAny] serializer_class = serializers.UserRegistrationSerializer - queryset = contrib_models.User.objects.all() diff --git a/postpost/main/settings.py b/postpost/main/settings.py index 1a4d540..ed7a1c8 100644 --- a/postpost/main/settings.py +++ b/postpost/main/settings.py @@ -46,7 +46,6 @@ ] MIDDLEWARE = [ - 'api.middlewares.GlobalWorkspaceMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/postpost/main/urls.py b/postpost/main/urls.py index 868d92d..5c3f526 100644 --- a/postpost/main/urls.py +++ b/postpost/main/urls.py @@ -1,19 +1,3 @@ -"""postpost URL Configuration. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/2.1/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) - -""" from django.contrib import admin from django.urls import include, path from drf_yasg import openapi @@ -37,4 +21,5 @@ path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), path('api/', include('api.urls')), path('admin/', admin.site.urls), + path('oauth/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] From 61e4e815467476f3e6c0b035d0b82c3c93a6ba9e Mon Sep 17 00:00:00 2001 From: Dmitriy Boger Date: Wed, 6 Feb 2019 22:10:22 +0300 Subject: [PATCH 4/8] Add documentation and other little things :moon_with_face: --- Pipfile.lock | 27 ++++++++++++------- postpost/api/middlewares.py | 31 +++++++++++++++++++++- postpost/api/migrations/0003_workspaces.py | 4 +-- postpost/api/models/workspace.py | 3 ++- postpost/api/models/workspace_member.py | 5 ++-- postpost/api/permissions.py | 12 ++++++--- 6 files changed, 62 insertions(+), 20 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index b352d2c..8826667 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ccc0ffae47eb3b647f9e734c807a34e7376445b34f6c7160a64a5d1affadd3ac" + "sha256": "2db86cabd2fc8ab46f603a3aed749c4cfb28be8e2ac3fa0e2f152b1a4cc1392f" }, "pipfile-spec": 6, "requires": { @@ -17,10 +17,10 @@ "default": { "amqp": { "hashes": [ - "sha256:9f181e4aef6562e6f9f45660578fc1556150ca06e836ecb9e733e6ea10b48464", - "sha256:c3d7126bfbc640d076a01f1f4f6e609c0e4348508150c1f61336b0d83c738d2b" + "sha256:16056c952e8029ce8db097edf0d7c2fe2ba9de15d30ba08aee2c5221273d8e23", + "sha256:6816eed27521293ee03aa9ace300a07215b11fee4e845588a9b863a7ba30addb" ], - "version": "==2.4.0" + "version": "==2.4.1" }, "astor": { "hashes": [ @@ -123,6 +123,13 @@ ], "version": "==1.0.3" }, + "drf-nested-routers": { + "hashes": [ + "sha256:46e5c3abc15c782cafafd7d75028e8f9121bbc6228e3599bbb48a3daa4585034", + "sha256:60c1e1f5cc801e757d26a8138e61c44419ef800c213c3640c5b6138e77d46762" + ], + "version": "==0.91" + }, "drf-writable-nested": { "hashes": [ "sha256:5c54eca93d74dc22014fb7ea54b6dbd559a058587596feba4d79078e32a83e16" @@ -328,10 +335,10 @@ }, "kombu": { "hashes": [ - "sha256:1ef049243aa05f29e988ab33444ec7f514375540eaa8e0b2e1f5255e81c5e56d", - "sha256:3c9dca2338c5d893f30c151f5d29bfb81196748ab426d33c362ab51f1e8dbf78" + "sha256:529df9e0ecc0bad9fc2b376c3ce4796c41b482cf697b78b71aea6ebe7ca353c8", + "sha256:7a2cbed551103db9a4e2efafe9b63222e012a61a18a881160ad797b9d4e1d0a1" ], - "version": "==4.2.2.post1" + "version": "==4.3.0" }, "markupsafe": { "hashes": [ @@ -459,10 +466,10 @@ }, "python-dateutil": { "hashes": [ - "sha256:063df5763652e21de43de7d9e00ccf239f953a832941e37be541614732cdfc93", - "sha256:88f9287c0174266bb0d8cedd395cfba9c58e87e5ad86b2ce58859bc11be3cf02" + "sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", + "sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e" ], - "version": "==2.7.5" + "version": "==2.8.0" }, "pytz": { "hashes": [ diff --git a/postpost/api/middlewares.py b/postpost/api/middlewares.py index 2e6b2bd..0a87067 100644 --- a/postpost/api/middlewares.py +++ b/postpost/api/middlewares.py @@ -1,14 +1,43 @@ from api.models import Workspace -class GlobalWorkspaceMiddleware: +class GlobalWorkspaceMiddleware(object): + """ + Add request-relevant workspace object to request object. + + Because most of requests to our API tied to specific workspace (e.g. + work with publications), the middleware saves us from a lot of repeated + code for extracting workspace by request data. + """ + def __init__(self, get_response): + """ + Standard interface of django middleware. + + See more: + https://docs.djangoproject.com/en/2.1/topics/http/middleware/#init-get-response + """ self.get_response = get_response def __call__(self, request): + """ + Executed before view and other middlewares are called. + + And this method does nothing. + """ return self.get_response() def process_view(self, request, view_func, view_args, view_kwargs): + """ + One of the django middleware hook. + + Uses here for inject relevant workspace to request objects. + Search Workspace instance by url params that a router usually + generates. + + See more about this middleware hook: + https://docs.djangoproject.com/en/2.1/topics/http/middleware/#process-view + """ workspace_name = view_kwargs.get('workspace_pk') or view_kwargs.get('workspace_name') if workspace_name: workspace = Workspace.objects.filter(name=workspace_name).first() diff --git a/postpost/api/migrations/0003_workspaces.py b/postpost/api/migrations/0003_workspaces.py index eeeed4d..52e4c56 100644 --- a/postpost/api/migrations/0003_workspaces.py +++ b/postpost/api/migrations/0003_workspaces.py @@ -1,4 +1,4 @@ -# Generated by Django 2.1.5 on 2019-02-06 18:29 +# Generated by Django 2.1.5 on 2019-02-06 19:09 from django.conf import settings from django.db import migrations, models @@ -17,7 +17,7 @@ class Migration(migrations.Migration): name='Workspace', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.SlugField()), + ('name', models.SlugField(unique=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ], diff --git a/postpost/api/models/workspace.py b/postpost/api/models/workspace.py index c7bf92b..e2f944a 100644 --- a/postpost/api/models/workspace.py +++ b/postpost/api/models/workspace.py @@ -5,7 +5,8 @@ class Workspace(models.Model): """ Workspace — space with members, publications and tuned platforms. Has a unique name. """ - name = models.SlugField() + + name = models.SlugField(unique=True) created_at = models.DateTimeField(auto_now_add=True, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False) diff --git a/postpost/api/models/workspace_member.py b/postpost/api/models/workspace_member.py index f976cee..6246b36 100644 --- a/postpost/api/models/workspace_member.py +++ b/postpost/api/models/workspace_member.py @@ -15,6 +15,7 @@ class WorkspaceMember(models.Model): """ Many-to-many junction table user <-> workspace with role. """ + member = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -29,11 +30,11 @@ class WorkspaceMember(models.Model): choices=WORKSPACE_ROLES, default=PUBLISHER_ROLE, null=False, - max_length=64, + max_length=64, # noqa: Z432 ) created_at = models.DateTimeField(auto_now_add=True, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False) - class Meta: + class Meta(object): unique_together = ('member', 'workspace') diff --git a/postpost/api/permissions.py b/postpost/api/permissions.py index 379938b..3d840ea 100644 --- a/postpost/api/permissions.py +++ b/postpost/api/permissions.py @@ -6,10 +6,13 @@ class IsWorkspaceAdmin(permissions.BasePermission): """ - Permission check for workspace admin + Permission check for workspace admin. """ def has_permission(self, request, view): + """ + Checks that the user role is admin role in current workspace. + """ is_workspace_admin = WorkspaceMember.objects.filter( workspace=request.workspace, member=request.user, @@ -20,14 +23,15 @@ def has_permission(self, request, view): class IsWorkspaceMember(permissions.BasePermission): """ - Permission check for workspace member + Permission check for workspace member. """ def has_permission(self, request, view): + """ + Just check user membership in current workspace. + """ is_workspace_member = WorkspaceMember.objects.filter( workspace=request.workspace, member=request.user, ).exists() return is_workspace_member - - From 53cc5acc009233f5676e2e16d5ebf70cb4d4ed45 Mon Sep 17 00:00:00 2001 From: Dmitriy Boger Date: Thu, 7 Feb 2019 00:15:13 +0300 Subject: [PATCH 5/8] Fix small issues after force-push :see_no_evil: --- postpost/api/middlewares.py | 2 +- postpost/api/serializers.py | 26 +++++++++++++++++++++++++ postpost/api/urls.py | 18 +++++++++++------ postpost/api/views.py | 39 ++++++++++++++++++++++++------------- postpost/main/settings.py | 1 + 5 files changed, 65 insertions(+), 21 deletions(-) diff --git a/postpost/api/middlewares.py b/postpost/api/middlewares.py index 0a87067..66c7972 100644 --- a/postpost/api/middlewares.py +++ b/postpost/api/middlewares.py @@ -25,7 +25,7 @@ def __call__(self, request): And this method does nothing. """ - return self.get_response() + return self.get_response(request) def process_view(self, request, view_func, view_args, view_kwargs): """ diff --git a/postpost/api/serializers.py b/postpost/api/serializers.py index a4f0197..9aad414 100644 --- a/postpost/api/serializers.py +++ b/postpost/api/serializers.py @@ -11,6 +11,7 @@ from rest_framework.validators import UniqueValidator from api import models +from api.models.workspace_member import ADMIN_ROLE class VKGroupSettingsSerializer(serializers.ModelSerializer): @@ -199,3 +200,28 @@ class Meta(object): 'access_token', 'refresh_token', ] + + +class WorkspaceSerializer(serializers.ModelSerializer): + """ + TODO: A. + """ + + def create(self, validated_data): + """ + TODO: A. + """ + workspace = super().create(validated_data) + models.WorkspaceMember.objects.create( + workspace=workspace, + member=self.context['request'].user, + role=ADMIN_ROLE, + ) + return workspace + + class Meta(object): + model = models.Workspace + fields = [ + 'id', + 'name', + ] diff --git a/postpost/api/urls.py b/postpost/api/urls.py index 6839615..f2883b6 100644 --- a/postpost/api/urls.py +++ b/postpost/api/urls.py @@ -1,9 +1,15 @@ -from django.urls import path +from rest_framework_nested import routers from api import views -urlpatterns = [ - path('users/', views.UserRegistration.as_view(), name='users_registration'), - path('publications/', views.PublicationList.as_view(), name='publications_list'), - path('publications/', views.Publication.as_view(), name='publications_details'), -] +router = routers.SimpleRouter() +router.register('users', views.UserRegistration) +router.register('workspaces', views.Workspace) +workspaces_router = routers.NestedSimpleRouter(router, r'workspaces', lookup='workspace') +workspaces_router.register( + 'publications', + views.WorkspacePublication, + base_name='workspace-publication', +) + +urlpatterns = router.urls + workspaces_router.urls diff --git a/postpost/api/views.py b/postpost/api/views.py index df5c69e..45ee161 100644 --- a/postpost/api/views.py +++ b/postpost/api/views.py @@ -1,33 +1,44 @@ -from rest_framework.generics import CreateAPIView, ListCreateAPIView, RetrieveUpdateDestroyAPIView +from django.contrib.auth import models as contrib_models +from rest_framework import viewsets from rest_framework.permissions import AllowAny, IsAuthenticated from api import models, serializers +from api.permissions import IsWorkspaceMember -class PublicationList(ListCreateAPIView): +class WorkspacePublication(viewsets.ModelViewSet): """ - Very basic view for Publications objects. + View for get, delete and change publication entity. """ - permission_classes = [IsAuthenticated] - queryset = models.Publication.objects.all() + permission_classes = [IsAuthenticated, IsWorkspaceMember] serializer_class = serializers.PublicationSerializer + def get_queryset(self): + """ + ASA. + """ + return models.Publication.objects.filter( + workspace=self.request.workspace, + ) -class Publication(RetrieveUpdateDestroyAPIView): + +class UserRegistration(viewsets.ModelViewSet): """ - View for get, delete and change publication entity. + Register user and generate access/refresh token immediately. """ - permission_classes = [IsAuthenticated] - queryset = models.Publication.objects.all() - serializer_class = serializers.PublicationSerializer + permission_classes = [AllowAny] + serializer_class = serializers.UserRegistrationSerializer + queryset = contrib_models.User.objects.all() -class UserRegistration(CreateAPIView): +class Workspace(viewsets.ModelViewSet): """ - Register user and generate access/refresh token immediately. + Aaa. """ + lookup_field = 'name' - permission_classes = [AllowAny] - serializer_class = serializers.UserRegistrationSerializer + permission_classes = [IsAuthenticated] + serializer_class = serializers.WorkspaceSerializer + queryset = models.Workspace.objects.all() diff --git a/postpost/main/settings.py b/postpost/main/settings.py index ed7a1c8..1a4d540 100644 --- a/postpost/main/settings.py +++ b/postpost/main/settings.py @@ -46,6 +46,7 @@ ] MIDDLEWARE = [ + 'api.middlewares.GlobalWorkspaceMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', From 5295cf45daf6489cebf4052161b5d9a01205ffb4 Mon Sep 17 00:00:00 2001 From: Dmitriy Boger Date: Wed, 13 Feb 2019 23:06:52 +0300 Subject: [PATCH 6/8] Add superuser permission --- postpost/api/permissions.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/postpost/api/permissions.py b/postpost/api/permissions.py index 3d840ea..83cefbd 100644 --- a/postpost/api/permissions.py +++ b/postpost/api/permissions.py @@ -35,3 +35,19 @@ def has_permission(self, request, view): member=request.user, ).exists() return is_workspace_member + + +class IsSuperuser(permissions.BasePermission): + """ + Permission check for manager of app. + """ + + def has_permission(self, request, view): + """ + Check standard django is_superuser flag :shrug:. + + More info: + https://docs.djangoproject.com/en/2.1/ref/contrib/auth/#django.contrib.auth.models.User.is_superuser + """ + is_superuser = request.user.is_authenticated() and request.user.is_superuser + return is_superuser From ce6c22ba68a94e21b87f35d5532009964305a7eb Mon Sep 17 00:00:00 2001 From: Dmitriy Boger Date: Wed, 13 Feb 2019 23:07:36 +0300 Subject: [PATCH 7/8] Add current workspace injection to publication creating --- postpost/api/serializers.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/postpost/api/serializers.py b/postpost/api/serializers.py index 9aad414..4df285f 100644 --- a/postpost/api/serializers.py +++ b/postpost/api/serializers.py @@ -106,6 +106,14 @@ def validate_platform_posts(self, platform_posts: Sequence[models.PlatformPost]) if len(platform_posts) == 0: raise serializers.ValidationError('Must be set one or more platform settings') + def create(self, validated_data): + """ + Add current workspace from context to data for creating object. + """ + workspace = self.context['request'].workspace + validated_data['workspace'] = workspace + return super().create(validated_data) + class UserRegistrationSerializer(serializers.ModelSerializer): """ From f980c135cb93f85707f80c354de73b85ec57f54f Mon Sep 17 00:00:00 2001 From: Dmitriy Boger Date: Thu, 14 Feb 2019 00:37:56 +0300 Subject: [PATCH 8/8] Rewrite to dry permission framework Also remove most of superuser endpoints, because I'm not sure about them. Maybe they should be in isolated prefix, like `/admin/workspace` --- Pipfile | 3 +- Pipfile.lock | 64 +++++++++---------- postpost/api/models/publication.py | 10 +++ postpost/api/models/workspace.py | 11 ++++ postpost/api/models/workspace_member.py | 6 ++ postpost/api/permissions.py | 64 ++++++++----------- postpost/api/urls.py | 27 ++++++-- postpost/api/views.py | 84 ++++++++++++++++++++++--- postpost/main/settings.py | 1 + 9 files changed, 184 insertions(+), 86 deletions(-) diff --git a/Pipfile b/Pipfile index bba84e5..078f9b9 100644 --- a/Pipfile +++ b/Pipfile @@ -7,10 +7,8 @@ verify_ssl = true [packages] django = "==2.1.5" wemake-python-styleguide = "==0.6.3" - # see https://github.com/wemake-services/wemake-python-styleguide/pull/472#issuecomment-460057878 flake8 = '==3.6.0' - django-rest-framework = "*" pillow = "*" drf-writable-nested = "*" @@ -22,6 +20,7 @@ djangorestframework-camel-case = "*" django-oauth-toolkit = "*" django-cors-middleware = "*" pyuploadcare = "*" +dry-rest-permissions = "*" [requires] python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock index 8826667..91d85bc 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "2db86cabd2fc8ab46f603a3aed749c4cfb28be8e2ac3fa0e2f152b1a4cc1392f" + "sha256": "7e1e14b7525ed646e9fe9508e038eabcf0b2849fce4243dac4783eba29fa0a8e" }, "pipfile-spec": 6, "requires": { @@ -123,13 +123,6 @@ ], "version": "==1.0.3" }, - "drf-nested-routers": { - "hashes": [ - "sha256:46e5c3abc15c782cafafd7d75028e8f9121bbc6228e3599bbb48a3daa4585034", - "sha256:60c1e1f5cc801e757d26a8138e61c44419ef800c213c3640c5b6138e77d46762" - ], - "version": "==0.91" - }, "drf-writable-nested": { "hashes": [ "sha256:5c54eca93d74dc22014fb7ea54b6dbd559a058587596feba4d79078e32a83e16" @@ -143,6 +136,13 @@ ], "version": "==1.13.0" }, + "dry-rest-permissions": { + "hashes": [ + "sha256:1f40461184063390e5b24e9c5602eb8cc8c3c2433c796f39a5332065bfbddd2b", + "sha256:f3fe685760004ce182801602819b43ebfa922e587036f1f5a5c10ffcfa646039" + ], + "version": "==0.1.10" + }, "eradicate": { "hashes": [ "sha256:f9af01c544ccd8f71bc2f7f3fa39dc363d842cfcb9c730a83676a59026ab5f24" @@ -516,30 +516,30 @@ }, "ruamel.yaml": { "hashes": [ - "sha256:18078354bfcf00d51bcc17984aded80840379aed36036f078479e191b59bc059", - "sha256:211e6ef2530f44fc3197c713892678e7fbfbc40a1db6741179d6981514be1674", - "sha256:2e8f7cee12a2372cec4480fe81086b1fdab163f4b56e58b5592a105c52973b78", - "sha256:48cc8e948a7ec4917bf94adff2cc1255e98f1eef5e1961889886acc4ff3a7194", - "sha256:4a0c7f970aa0e30bc541f690fbd14aca19de1cab70787180de5083b902ec40b5", - "sha256:5dd0ea7c5c703e8675f3caf2898a50b4dadaa52838f8e104637a452a05e03030", - "sha256:612fb4833f1978ceb7fd7a24d86a5ebd103bcc408394f3af621293194658cf1b", - "sha256:61c421a7a2b8e2886a94fbe29866df6b99451998abaa1584b9fdc9c10c33e40b", - "sha256:6483416847980aa7090b697d177a8754c4f340683cc84abd38da7b850826687d", - "sha256:6622f3b0cae7ed6fe5d3d6a6d8d8cb9413a05b408d69a789a57b77a616bb6562", - "sha256:80b2acde0d1b9d25e5c041960a9149480c15c6d9f4c24b8ddb381b14e9e70ea4", - "sha256:8f9ed94be17f306485df8fd0274a30f130a73f127798657d4dc65b1f89ec7a36", - "sha256:9a6b94cc9b6e738036426498ac9fe8ca05afea4249fb9dec1be32ce4823d5756", - "sha256:a4b11dfe421a9836c723107a4ccc9cab9674de611ba60b8212e85526ea8bf254", - "sha256:a55e55c6ecb5725ba472f9b811940e8d258a32fb36f5793dbc38582d6f377f3f", - "sha256:a736ab1d8c2d5566254a1a2ee38e7c5460520bcccd4a8f0feb25a4463735e5a7", - "sha256:c29d0a3cffa5a25f5259bfeac06ffdc5e7d1fd38a0a26a6664d160192730434f", - "sha256:c33458217a8c352b59c86065c4f05f3f1ac28b01c3e1a422845c306237446bf3", - "sha256:cc9bd3c3fa8a928f7b6e19fe8de13a61deb91f257eccbe0d16114ce8c54cdc81", - "sha256:d63b7c828a7358ce5b03a3e2c2a3e5a7058a954f8919334cb09b3d8541d1fff6", - "sha256:fbd301680a3563e84d667042dac1c5d50ef402ecf1f4b1763507a6877b8181ad", - "sha256:fc67e79e2f5083be6fd1000c4646e13a891585772a503f56f51f845b547fe621" - ], - "version": "==0.15.87" + "sha256:0289a685479d059b94683cd6cb47ffb790c05c20a6c4da395361025d52493d0a", + "sha256:0adf1d9b8e88dc6b151a3199b1dd7be0c8ee10d6c2ebd2a9e2a13224f4481cdf", + "sha256:13657c26780bba5824764cddb0f2933217fd59cfcca0e2ee1b2f759e7e58ef8e", + "sha256:3815f688de7316fcd3ba5ceda642e902044c5c1a8fb5e4dc245d99db3eb3121b", + "sha256:4e61c0b96805d1e2ec53cb1698ca6086a47aa1e1d09857144eb60216e7894ce3", + "sha256:4f0d57ead5414456cb899c3746a8d30f566c22bb90c97da76f76e79147cb2d61", + "sha256:51916929902ff054e189d29bc418788a5dc3a4e89a89065beed694f537383ca3", + "sha256:5b7ea0ee24680157666f730f3a8c173f386c66e8c103458af20d97276e7e54d3", + "sha256:6b1b1ee0a028b9cdc1bc3ec1f75480fc0d3fbcc9e0212a716b129b6f26e34587", + "sha256:6db27f789c7efdbc59b8650c37a09dde0db019560bc19a07c905b65158a18bba", + "sha256:7a8c8f825fd52f3586d583f621cdf3a03b9dc8833933ae401554b246b48026d5", + "sha256:7ab0c27094ef27a21e0094dc671c456bd4a62811c14e27407ae8bc3aa8cc8111", + "sha256:8e06bcf212b45dffe6c2415693c32b4c7d4ff55c03268a3033217f7a673d07ba", + "sha256:9826e3c85549b3fc87786466a7a96dcadec59802a9ed077b905349ef1cac7b14", + "sha256:ac56193c47a31c9efa151064a9e921865cdad0f7a991d229e7197e12fe8e0cd7", + "sha256:aef88ec2927b0454709026a761918c02b69e5df9c061b49634d7993c0848580d", + "sha256:d8591fdfd076d8121a456aaff0bbea6d5753023896f4559b710d4e56d1ac6418", + "sha256:e033423fd6b4ddfd47f0f5ebe81e896129d85fd5219c5e66effb4de06a1fea7a", + "sha256:e05017af8c1164fee33aa2677df7eaeb6d2fa76e22baf7960f9e8f1b04657151", + "sha256:e4cd2ccd4d455206826a7c59fda13a9008ae994de66a7b0df2c0bb81121fab01", + "sha256:e4f525efdecc075e6b0d96df0ae4bd2ad17c7280ebe66035f468c5c3da53fe0d", + "sha256:fd5f09c399cdc92586b54ee28f68f23f1d5649177d7ceb22ec975b5e69e1b722" + ], + "version": "==0.15.88" }, "six": { "hashes": [ diff --git a/postpost/api/models/publication.py b/postpost/api/models/publication.py index 306107f..852595b 100644 --- a/postpost/api/models/publication.py +++ b/postpost/api/models/publication.py @@ -1,6 +1,8 @@ from django.db import models from pyuploadcare.dj import models as uploadcare_models +from rest_framework.request import Request +from api import permissions from api.models import PlatformPost from api.models.workspace import Workspace @@ -36,3 +38,11 @@ def current_status(self) -> str: return PlatformPost.SENDING_STATUS else: return PlatformPost.SUCCESS_STATUS + + @staticmethod + def has_read_permission(request: Request) -> bool: + return permissions.check_workspace_member(request) + + @staticmethod + def has_write_permission(request: Request) -> bool: + return permissions.check_workspace_member(request) diff --git a/postpost/api/models/workspace.py b/postpost/api/models/workspace.py index e2f944a..7653f78 100644 --- a/postpost/api/models/workspace.py +++ b/postpost/api/models/workspace.py @@ -1,4 +1,7 @@ from django.db import models +from rest_framework.request import Request + +from api import permissions class Workspace(models.Model): @@ -10,3 +13,11 @@ class Workspace(models.Model): created_at = models.DateTimeField(auto_now_add=True, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False) + + @staticmethod + def has_list_permission(request: Request) -> bool: + return permissions.check_authenticated(request) + + @staticmethod + def has_create_permission(request: Request) -> bool: + return permissions.check_authenticated(request) \ No newline at end of file diff --git a/postpost/api/models/workspace_member.py b/postpost/api/models/workspace_member.py index 6246b36..718dfc5 100644 --- a/postpost/api/models/workspace_member.py +++ b/postpost/api/models/workspace_member.py @@ -1,6 +1,8 @@ from django.conf import settings from django.db import models +from rest_framework.request import Request +from api import permissions from api.models.workspace import Workspace PUBLISHER_ROLE = 'publisher' @@ -36,5 +38,9 @@ class WorkspaceMember(models.Model): created_at = models.DateTimeField(auto_now_add=True, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False) + @staticmethod + def has_list_permission(request: Request) -> bool: + return permissions.check_workspace_member(request) + class Meta(object): unique_together = ('member', 'workspace') diff --git a/postpost/api/permissions.py b/postpost/api/permissions.py index 83cefbd..e7e983a 100644 --- a/postpost/api/permissions.py +++ b/postpost/api/permissions.py @@ -1,53 +1,43 @@ from rest_framework import permissions +from rest_framework.request import Request from api.models import WorkspaceMember from api.models.workspace_member import ADMIN_ROLE -class IsWorkspaceAdmin(permissions.BasePermission): - """ - Permission check for workspace admin. - """ +def check_authenticated(request: Request): + return request.user.is_authenticated - def has_permission(self, request, view): - """ - Checks that the user role is admin role in current workspace. - """ - is_workspace_admin = WorkspaceMember.objects.filter( - workspace=request.workspace, - member=request.user, - role=ADMIN_ROLE, - ).exists() - return is_workspace_admin - -class IsWorkspaceMember(permissions.BasePermission): +def check_workspace_admin(request: Request): """ - Permission check for workspace member. + Checks that the user role is admin role in current workspace. """ - - def has_permission(self, request, view): - """ - Just check user membership in current workspace. - """ - is_workspace_member = WorkspaceMember.objects.filter( - workspace=request.workspace, - member=request.user, - ).exists() - return is_workspace_member + is_workspace_admin = WorkspaceMember.objects.filter( + workspace=request.workspace, + member=request.user, + role=ADMIN_ROLE, + ).exists() + return is_workspace_admin -class IsSuperuser(permissions.BasePermission): +def check_workspace_member(request: Request): """ - Permission check for manager of app. + Just check user membership in current workspace. """ + is_workspace_member = WorkspaceMember.objects.filter( + workspace=request.workspace, + member=request.user, + ).exists() + return is_workspace_member - def has_permission(self, request, view): - """ - Check standard django is_superuser flag :shrug:. - More info: - https://docs.djangoproject.com/en/2.1/ref/contrib/auth/#django.contrib.auth.models.User.is_superuser - """ - is_superuser = request.user.is_authenticated() and request.user.is_superuser - return is_superuser +def check_superuser(request: Request): + """ + Check standard django is_superuser flag :shrug:. + + More info: + https://docs.djangoproject.com/en/2.1/ref/contrib/auth/#django.contrib.auth.models.User.is_superuser + """ + is_superuser = request.user.is_authenticated() and request.user.is_superuser + return is_superuser diff --git a/postpost/api/urls.py b/postpost/api/urls.py index f2883b6..64311cd 100644 --- a/postpost/api/urls.py +++ b/postpost/api/urls.py @@ -4,12 +4,29 @@ router = routers.SimpleRouter() router.register('users', views.UserRegistration) -router.register('workspaces', views.Workspace) -workspaces_router = routers.NestedSimpleRouter(router, r'workspaces', lookup='workspace') +router.register('workspaces', views.WorkspaceListCreate) +workspaces_router = routers.NestedSimpleRouter(router, 'workspaces', lookup='workspace') + +# workspaces_router.register( +# 'publications', +# views.WorkspacePublication, +# base_name='workspace-publication', +# ) +workspaces_router.register( + 'members', + views.WorkspaceMemberCreate, + base_name='workspace-memberrr', +) workspaces_router.register( - 'publications', - views.WorkspacePublication, - base_name='workspace-publication', + 'members', + views.WorkspaceMemberList, + base_name='workspace-member', ) +# workspaces_router.register( +# 'members', +# views.WorkspaceMemberUpdateDestroy, +# base_name='workspace-member', +# ) + urlpatterns = router.urls + workspaces_router.urls diff --git a/postpost/api/views.py b/postpost/api/views.py index 45ee161..9215e02 100644 --- a/postpost/api/views.py +++ b/postpost/api/views.py @@ -1,9 +1,10 @@ from django.contrib.auth import models as contrib_models -from rest_framework import viewsets +from dry_rest_permissions.generics import DRYPermissions +from rest_framework import mixins, viewsets, generics +from rest_framework.generics import ListCreateAPIView from rest_framework.permissions import AllowAny, IsAuthenticated from api import models, serializers -from api.permissions import IsWorkspaceMember class WorkspacePublication(viewsets.ModelViewSet): @@ -11,19 +12,19 @@ class WorkspacePublication(viewsets.ModelViewSet): View for get, delete and change publication entity. """ - permission_classes = [IsAuthenticated, IsWorkspaceMember] + permission_classes = [DRYPermissions] serializer_class = serializers.PublicationSerializer def get_queryset(self): """ - ASA. + TODO: move this filter to `backend_filter`. """ return models.Publication.objects.filter( workspace=self.request.workspace, ) -class UserRegistration(viewsets.ModelViewSet): +class UserRegistration(mixins.CreateModelMixin, viewsets.GenericViewSet): """ Register user and generate access/refresh token immediately. """ @@ -33,12 +34,75 @@ class UserRegistration(viewsets.ModelViewSet): queryset = contrib_models.User.objects.all() -class Workspace(viewsets.ModelViewSet): +class WorkspaceListCreate(generics.ListCreateAPIView): """ - Aaa. + Workspace views. """ - lookup_field = 'name' - permission_classes = [IsAuthenticated] + permission_classes = [DRYPermissions] serializer_class = serializers.WorkspaceSerializer - queryset = models.Workspace.objects.all() + + def get_queryset(self): + workspace_ids = models.WorkspaceMember.objects.select_related( + 'workspace', + ).filter( + member=self.request.user, + ).values_list( + 'workspace', + flat=True, + ) + return models.Workspace.objects.filter(id__in=workspace_ids) + + +class WorkspaceMemberList(mixins.ListModelMixin, viewsets.GenericViewSet): + """ + List of members available for all of workspace members. + """ + + serializer_class = serializers.PublicationSerializer + permission_classes = [DRYPermissions] + + def get_queryset(self): + """ + Filter members by current workspace. + """ + return models.WorkspaceMember.objects.filter( + workspace=self.request.workspace, + ).select_related( + 'user', + ) + + +class WorkspaceMemberCreate(mixins.CreateModelMixin, viewsets.GenericViewSet): + """ + Superuser view for creating member. + + For example, can used for emergency restoring workspace. + """ + + permission_classes = [DRYPermissions] + serializer_class = serializers.PublicationSerializer # FIXME + + +class WorkspaceMemberUpdateDestroy( + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + """ + Allow removal membership and role changing for other members in workspace. + """ + + permission_classes = [DRYPermissions] + serializer_class = serializers.PublicationSerializer # FIXME + + def get_queryset(self): + """ + Filter members by current workspace. + """ + return models.WorkspaceMember.objects.filter( + workspace=self.request.workspace, + ).select_related( + 'user', + ) + diff --git a/postpost/main/settings.py b/postpost/main/settings.py index 1a4d540..d12f0d4 100644 --- a/postpost/main/settings.py +++ b/postpost/main/settings.py @@ -38,6 +38,7 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', + 'dry_rest_permissions', 'drf_yasg', 'oauth2_provider', 'corsheaders',