From 2c92c554a246e28e8939d036fa6d066985da4158 Mon Sep 17 00:00:00 2001 From: Nicholas D'Alfonso Date: Fri, 15 Nov 2019 12:37:04 -0500 Subject: [PATCH] DISCO-1414 org source of truth - modify organization serializer to allow creation, updating, and downloading of logo image. --- organizations/__init__.py | 2 +- organizations/permissions.py | 14 ++++++ organizations/serializers.py | 25 +++++++++- organizations/v0/tests/test_views.py | 69 +++++++++++++++++++++++++++- organizations/v0/views.py | 30 ++++++++++-- 5 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 organizations/permissions.py diff --git a/organizations/__init__.py b/organizations/__init__.py index 2977bc80..74f7a2b3 100644 --- a/organizations/__init__.py +++ b/organizations/__init__.py @@ -1,4 +1,4 @@ """ edx-organizations app initialization module """ -__version__ = '2.1.1' # pragma: no cover +__version__ = '2.2.0' # pragma: no cover diff --git a/organizations/permissions.py b/organizations/permissions.py new file mode 100644 index 00000000..2c8cc56e --- /dev/null +++ b/organizations/permissions.py @@ -0,0 +1,14 @@ +""" +Permissions for organization viewsets. +""" +from rest_framework.permissions import BasePermission + + +class UserIsStaff(BasePermission): + """ + Custom Permission class to check user is a publisher user or a staff user. + """ + def has_permission(self, request, view): + if request.method == 'PUT': + return request.user.is_staff or request.user.is_superuser + return True diff --git a/organizations/serializers.py b/organizations/serializers.py index f62482a4..4891c6cb 100644 --- a/organizations/serializers.py +++ b/organizations/serializers.py @@ -2,6 +2,9 @@ Data layer serialization operations. Converts querysets to simple python containers (mainly arrays and dicts). """ +import requests + +from django.core.files.base import ContentFile from rest_framework import serializers from organizations import models @@ -10,9 +13,29 @@ # pylint: disable=too-few-public-methods class OrganizationSerializer(serializers.ModelSerializer): """ Serializes the Organization object.""" + logo_url = serializers.CharField(write_only=True, required=False) + class Meta(object): # pylint: disable=missing-docstring model = models.Organization - fields = '__all__' + fields = ('id', 'created', 'modified', 'name', 'short_name', 'description', 'logo', + 'active', 'logo_url',) + + def update_logo(self, obj, logo_url): # pylint: disable=missing-docstring + if logo_url: + logo = requests.get(logo_url) + obj.logo.save(logo_url.split('/')[-1], ContentFile(logo.content)) + + def create(self, validated_data): + logo_url = validated_data.pop('logo_url', None) + obj = super(OrganizationSerializer, self).create(validated_data) + self.update_logo(obj, logo_url) + return obj + + def update(self, instance, validated_data): + logo_url = validated_data.pop('logo_url', None) + super(OrganizationSerializer, self).update(instance, validated_data) + self.update_logo(instance, logo_url) + return instance def serialize_organization(organization): diff --git a/organizations/v0/tests/test_views.py b/organizations/v0/tests/test_views.py index fb78f78a..32738df0 100644 --- a/organizations/v0/tests/test_views.py +++ b/organizations/v0/tests/test_views.py @@ -1,11 +1,13 @@ """ Organizations Views Test Cases. """ +import json from django.urls import reverse from django.test import TestCase from provider.constants import CONFIDENTIAL from provider.oauth2.models import AccessToken, Client +from organizations.models import Organization from organizations.serializers import OrganizationSerializer from organizations.tests.factories import UserFactory, OrganizationFactory @@ -17,7 +19,7 @@ def setUp(self): super(TestOrganizationsView, self).setUp() self.user_password = 'test' - self.user = UserFactory(password=self.user_password) + self.user = UserFactory(password=self.user_password, is_superuser=True) self.organization = OrganizationFactory.create() self.organization_list_url = reverse('v0:organization-list') self.client.login(username=self.user.username, password=self.user_password) @@ -81,3 +83,68 @@ def test_oauth2(self): HTTP_AUTHORIZATION='Bearer {}'.format('nonexistent-access-token') ) self.assertEqual(response.status_code, 401) + + def test_create_organization(self): + """ Verify Organization can be created via PUT endpoint. """ + data = { + 'name': 'example-name', + 'short_name': 'example-short-name', + 'description': 'example-description', + } + url = reverse('v0:organization-detail', kwargs={'short_name': data['short_name']}) + response = self.client.put(url, json.dumps(data), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['name'], data['name']) + self.assertEqual(response.data['short_name'], data['short_name']) + self.assertEqual(response.data['description'], data['description']) + orgs = Organization.objects.all() + self.assertEqual(len(orgs), 2) + + def test_update_organization(self): + """ Verify Organization can be updated via PUT endpoint. """ + org = OrganizationFactory() + data = { + 'name': 'changed-name', + 'short_name': org.short_name, + } + url = reverse('v0:organization-detail', kwargs={'short_name': org.short_name}) + response = self.client.put(url, json.dumps(data), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['name'], data['name']) + self.assertEqual(response.data['short_name'], org.short_name) + self.assertEqual(response.data['description'], org.description) + orgs = Organization.objects.all() + self.assertEqual(len(orgs), 2) + + def test_patch_endpoint(self): + """ Verify PATCH endpoint returns 405 because we use PUT for create and update""" + url = reverse('v0:organization-detail', kwargs={'short_name': 'dummy'}) + response = self.client.patch(url, json={}) + self.assertEqual(response.status_code, 405) + + def test_create_as_only_staff_user(self): + self.user.is_staff = True + self.user.is_superuser = False + self.user.save() + + data = { + 'name': 'example-name', + 'short_name': 'example-short-name', + 'description': 'example-description', + } + url = reverse('v0:organization-detail', kwargs={'short_name': data['short_name']}) + response = self.client.put(url, json.dumps(data), content_type='application/json') + self.assertEqual(response.status_code, 200) + + def test_create_as_non_staff_and_non_admin_user(self): + self.user.is_superuser = False + self.user.save() + + data = { + 'name': 'example-name', + 'short_name': 'example-short-name', + 'description': 'example-description', + } + url = reverse('v0:organization-detail', kwargs={'short_name': data['short_name']}) + response = self.client.put(url, json.dumps(data), content_type='application/json') + self.assertEqual(response.status_code, 403) diff --git a/organizations/v0/views.py b/organizations/v0/views.py index bcf04fbe..689db251 100644 --- a/organizations/v0/views.py +++ b/organizations/v0/views.py @@ -2,22 +2,44 @@ """ Views for organizations end points. """ +from django.http import Http404 from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework import mixins +from rest_framework import status from rest_framework import viewsets from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from rest_framework_oauth.authentication import OAuth2Authentication from organizations.models import Organization +from organizations.permissions import UserIsStaff from organizations.serializers import OrganizationSerializer -class OrganizationsViewSet(viewsets.ReadOnlyModelViewSet): - """Organization view to fetch list organization data or single organization - using organization short name. +class OrganizationsViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet): + """ + Organization view to: + - fetch list organization data or single organization using organization short name. + - create or update an organization via the PUT endpoint. """ queryset = Organization.objects.filter(active=True) # pylint: disable=no-member serializer_class = OrganizationSerializer lookup_field = 'short_name' authentication_classes = (OAuth2Authentication, JwtAuthentication, SessionAuthentication) - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, UserIsStaff) + + def update(self, request, *args, **kwargs): + """ We perform both Update and Create action via the PUT method. """ + try: + return super(OrganizationsViewSet, self).update(request, *args, **kwargs) + except Exception as e: # pylint: disable=broad-except, invalid-name + if isinstance(e, Http404): + serializer = OrganizationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + def partial_update(self, request, *args, **kwargs): + """ We disable PATCH because all updates and creates should use the PUT action above. """ + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)