From 81ff104d599d5ff84dca5f30b89c86c4053e0ab9 Mon Sep 17 00:00:00 2001 From: Rivo Laks Date: Thu, 8 Mar 2018 16:07:34 +0200 Subject: [PATCH 01/10] Add API-specific 404 view --- HISTORY.rst | 6 ++++++ README.rst | 1 + example/example/urls.py | 9 ++++----- example/example/views.py | 18 ------------------ tg_apicore/views.py | 28 ++++++++++++++++++++++++++++ 5 files changed, 39 insertions(+), 23 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index c21c892..9f7dce8 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,12 @@ History ======= +Next version +------------------ + +* Added PageNotFoundView + + 0.1.0 (2018-03-08) ------------------ diff --git a/README.rst b/README.rst index 0435b5d..eb7bb41 100644 --- a/README.rst +++ b/README.rst @@ -34,6 +34,7 @@ Features * Not interactive yet * Integrates `JSON API `_ * Cursor pagination with configurable page size +* API-specific 404 view * Test utilities, e.g. for response validation * Versioning (WIP) * Transformer-based approach, inspired by diff --git a/example/example/urls.py b/example/example/urls.py index 0b6d284..07723cc 100644 --- a/example/example/urls.py +++ b/example/example/urls.py @@ -2,7 +2,9 @@ from django.contrib import admin from django.views.generic import TemplateView -from example.views import ExampleAPIDocumentationView, PageNotFoundView +from tg_apicore.views import PageNotFoundView + +from example.views import ExampleAPIDocumentationView urlpatterns = [ @@ -12,10 +14,7 @@ url(r'^api/(?P(\d{4}-\d{2}-\d{2}))/', include('example.urls_api')), # API-specific 404 for everything under api/ prefix - # This one is for when the version is valid - url(r'^api/(?P(\d{4}-\d{2}-\d{2}))/', PageNotFoundView.as_view()), - # This one is catch-all for everything else, including invalid versions - url(r'^api/', PageNotFoundView.as_view()), + url(r'^api/', include(PageNotFoundView.urlpatterns())), url(r'^admin/', admin.site.urls), ] diff --git a/example/example/views.py b/example/example/views.py index 5f72fcf..df1ebaf 100644 --- a/example/example/views.py +++ b/example/example/views.py @@ -2,9 +2,6 @@ from django.conf.urls import url from django.urls import include -from rest_framework.views import APIView -from rest_framework.exceptions import NotFound - from tg_apicore.views import APIDocumentationView @@ -29,18 +26,3 @@ def get_patterns(self) -> list: return [ url(r'^%s' % self.get_base_path(), include(urls_api)), ] - - -class PageNotFoundView(APIView): - """ 404 view for API urls. - - Django's standard 404 page returns HTML. We want everything under API url prefix to return 404 as JSON. - """ - authentication_classes = () - permission_classes = () - - def initial(self, request, *args, **kwargs): - # Overriding initial() seems to be like the easiest way that still keeps most of DRF's logic ala renderers. - super().initial(request, *args, **kwargs) - - raise NotFound() diff --git a/tg_apicore/views.py b/tg_apicore/views.py index 706519d..7545b03 100644 --- a/tg_apicore/views.py +++ b/tg_apicore/views.py @@ -1,6 +1,9 @@ +from django.conf.urls import url from django.views.generic.base import TemplateView from rest_framework.compat import pygments_css +from rest_framework.exceptions import NotFound +from rest_framework.views import APIView from tg_apicore.schemas import generate_api_docs @@ -50,3 +53,28 @@ def get_base_path(self) -> str: def get_patterns(self) -> list: """ Should return urlpatterns of your API """ raise NotImplementedError() + + +class PageNotFoundView(APIView): + """ 404 view for API urls. + + Django's standard 404 page returns HTML. We want everything under API url prefix to return 404 as JSON. + """ + + authentication_classes = () + permission_classes = () + + @classmethod + def urlpatterns(cls): + return [ + # This one is for when the version is valid + url(r'^(?P(\d{4}-\d{2}-\d{2}))/', cls.as_view()), + # This one is catch-all for everything else, including invalid versions + url(r'^', cls.as_view()), + ] + + def initial(self, request, *args, **kwargs): + # Overriding initial() seems to be like the easiest way that still keeps most of DRF's logic ala renderers. + super().initial(request, *args, **kwargs) + + raise NotFound() From 9f73d9157db9110dc992b30f0621e6cebdc77c5e Mon Sep 17 00:00:00 2001 From: Rivo Laks Date: Wed, 14 Mar 2018 12:05:16 +0200 Subject: [PATCH 02/10] Add DetailSerializerViewSet --- example/companies/views.py | 14 +++-- tg_apicore/viewsets.py | 104 +++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 tg_apicore/viewsets.py diff --git a/example/companies/views.py b/example/companies/views.py index ead02e0..510e670 100644 --- a/example/companies/views.py +++ b/example/companies/views.py @@ -3,8 +3,10 @@ from companies import api_docs from companies.models import Company, Employment -from companies.serializers import CompanySerializer, EmploymentSerializer +from companies.serializers import CompanySerializer, EmploymentSerializer, CompanySummarySerializer, \ + EmploymentSummarySerializer from tg_apicore.docs import add_api_docs, api_section_docs, api_method_docs +from tg_apicore.viewsets import DetailSerializerViewSet @add_api_docs( @@ -29,14 +31,15 @@ responses=api_docs.COMPANIES_DELETE_RESPONSES, ), ) -class CompanyViewSet(mixins.CreateModelMixin, mixins.DestroyModelMixin, ReadOnlyModelViewSet): +class CompanyViewSet(mixins.CreateModelMixin, mixins.DestroyModelMixin, ReadOnlyModelViewSet, DetailSerializerViewSet): """ Companies API - provides CRUD functionality for companies. Employees information is included in responses. """ queryset = Company.objects.all() - serializer_class = CompanySerializer + serializer_class = CompanySummarySerializer + serializer_detail_class = CompanySerializer # pylint: disable=useless-super-delegation def list(self, request, *args, **kwargs): @@ -60,12 +63,13 @@ def destroy(self, request, *args, **kwargs): @add_api_docs( ) -class EmploymentViewSet(ReadOnlyModelViewSet): +class EmploymentViewSet(ReadOnlyModelViewSet, DetailSerializerViewSet): """ Employments API """ queryset = Employment.objects.all() - serializer_class = EmploymentSerializer + serializer_class = EmploymentSummarySerializer + serializer_detail_class = EmploymentSerializer # pylint: disable=useless-super-delegation def list(self, request, *args, **kwargs): diff --git a/tg_apicore/viewsets.py b/tg_apicore/viewsets.py new file mode 100644 index 0000000..065e90b --- /dev/null +++ b/tg_apicore/viewsets.py @@ -0,0 +1,104 @@ +from django.db.models import QuerySet + +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import SAFE_METHODS + + +class DetailSerializerViewSet(GenericAPIView): + """ Use different serializers and querysets for list / detail / modify views. + + This is basically extended variant of DetailSerializerMixin from drf-extensions. + + It provides additional queryset/serializer options for unsafe methods (*_modify) and makes it easy to override + methods that return serializer classes / querysets so that you can add your own logic with minimal effort. + + The detail / modify variants of queryset / serializer are optional and fall back to each other in + modify -> detail -> list order. + """ + + ENDPOINT_TYPE_LIST = 1 + ENDPOINT_TYPE_DETAIL = 2 + ENDPOINT_TYPE_MODIFY = 3 + + serializer_detail_class = None + serializer_modify_class = None + queryset_detail = None + queryset_modify = None + + def get_endpoint_type(self): + """ Selects endpoint type of the current request - this will be used to select serializer and queryset. + """ + + if self.request and self.request.method not in SAFE_METHODS: + return self.ENDPOINT_TYPE_MODIFY + + if hasattr(self, 'lookup_url_kwarg'): + lookup = self.lookup_url_kwarg or self.lookup_field + if lookup and lookup in self.kwargs: + return self.ENDPOINT_TYPE_DETAIL + + return self.ENDPOINT_TYPE_LIST + + def get_serializer_class(self): + """ Selects serializer class, based on current request's endpoint type. + """ + + endpoint_type = self.get_endpoint_type() + + if endpoint_type == self.ENDPOINT_TYPE_MODIFY: + return self.get_modify_serializer_class() + elif endpoint_type == self.ENDPOINT_TYPE_DETAIL: + return self.get_detail_serializer_class() + + return self.get_list_serializer_class() + + def get_docs_serializer(self): + """ Returns serializer instance used to generate documentation. + + This defaults to the modify-serializer. + """ + + serializer_cls = self.get_modify_serializer_class() + return serializer_cls(context=self.get_serializer_context()) + + def get_queryset(self): + """ Selects queryset, based on current request's endpoint type. + """ + + assert self.queryset is not None, ( + "'%s' should either include a `queryset` attribute, " + "or override the `get_queryset()` method." + % self.__class__.__name__ + ) + + endpoint_type = self.get_endpoint_type() + + if endpoint_type == self.ENDPOINT_TYPE_MODIFY: + queryset = self.get_modify_queryset() + elif endpoint_type == self.ENDPOINT_TYPE_DETAIL: + queryset = self.get_detail_queryset() + else: + queryset = self.get_list_queryset() + + if isinstance(queryset, QuerySet): + # Ensure queryset is re-evaluated on each request. + queryset = queryset.all() + return queryset + + def get_list_serializer_class(self): + return self.serializer_class + + def get_detail_serializer_class(self): + return self.serializer_detail_class or self.get_list_serializer_class() + + def get_modify_serializer_class(self): + return self.serializer_modify_class or self.get_detail_serializer_class() + + def get_list_queryset(self): + return self.queryset + + def get_detail_queryset(self): + return self.queryset_detail or self.get_list_queryset() + + def get_modify_queryset(self): + return self.queryset_modify or self.get_detail_queryset() From fcc45cc40587f07f00f65560968b240213b0644a Mon Sep 17 00:00:00 2001 From: Rivo Laks Date: Wed, 14 Mar 2018 13:44:13 +0200 Subject: [PATCH 03/10] Add some serialization-related classes and mixins - CreateOnlyFieldsSerializerMixin adds support for fields that can only be specified when creating objects, not when editing them - ModelValidationSerializerMixin makes serializers use Django's model validation. - BaseModelSerializer combines the former two with JSON-API's model serializer. --- example/companies/serializers.py | 6 ++- tg_apicore/serializers.py | 74 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 tg_apicore/serializers.py diff --git a/example/companies/serializers.py b/example/companies/serializers.py index eda83c7..d66b8b7 100644 --- a/example/companies/serializers.py +++ b/example/companies/serializers.py @@ -1,9 +1,11 @@ from rest_framework_json_api import serializers +from tg_apicore.serializers import BaseModelSerializer + from companies.models import Company, Employment -class EmploymentSummarySerializer(serializers.ModelSerializer): +class EmploymentSummarySerializer(BaseModelSerializer): class Meta: model = Employment fields = ['id', 'url', 'created', 'name', 'email', 'role'] @@ -18,7 +20,7 @@ def get_email(self, obj): return obj.user.email -class CompanySummarySerializer(serializers.ModelSerializer): +class CompanySummarySerializer(BaseModelSerializer): class Meta: model = Company fields = ['id', 'url', 'created', 'name', 'email'] diff --git a/tg_apicore/serializers.py b/tg_apicore/serializers.py new file mode 100644 index 0000000..4c292ad --- /dev/null +++ b/tg_apicore/serializers.py @@ -0,0 +1,74 @@ +from django.db.models import Model + +from rest_framework.utils import model_meta +from rest_framework_json_api import serializers + + +class CreateOnlyFieldsSerializerMixin: + """ Adds support for fields that can only be specified at object creation time. + + Serializer fields can be marked as create-only by listing them in Meta.create_only_fields - these are + read-only for existing instances but can be specified at object creation time. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # If we have an existing instance, mark the create-only fields as read-only. + if not getattr(self, 'many', False) and isinstance(self.instance, Model) and self.instance.pk is not None: + create_only_fields = getattr(self.Meta, 'create_only_fields', []) + for field_name in create_only_fields: + self.fields[field_name].read_only = True + + +class ModelValidationSerializerMixin: + """ Uses validation logic defined in the model class + + By default, DRF has it's own validation logic, separate from the one defined in the model. + This tries to bridge the two, ensuring that's model's full_clean() also gets called. + + It's quite hacky as there doesn't seem to be a very straightforward way of accomplishing this. + + Also note that it makes an extra database query when validate() is called for an existing instance. + """ + + def validate(self, attrs): + attrs = super().validate(attrs) + + self.run_model_validation(attrs) + + return attrs + + def run_model_validation(self, attrs: dict): + ModelClass = self.Meta.model + + # Remove many-to-many relationships from validated_data. + # They are not valid arguments to the model's `.__init__()` method, + # as they require that the instance has already been saved. + attrs = attrs.copy() + info = model_meta.get_field_info(ModelClass) + for field_name, relation_info in info.relations.items(): + if relation_info.to_many and (field_name in attrs): + attrs.pop(field_name) + + # We don't want to modify self.instance, so either create new instance or fetch the existing one from DB + if self.instance is None: + obj = ModelClass(**attrs) + else: + try: + obj = ModelClass.objects.get(pk=self.instance.pk) + except ModelClass.DoesNotExist: + # If the model cannot be retrieved, just skip the extra validation step + return + + # Update the fetched object with values from attrs + for k, v in attrs.items(): + setattr(obj, k, v) + + # Run model validation logic + obj.full_clean() + + +class BaseModelSerializer(CreateOnlyFieldsSerializerMixin, ModelValidationSerializerMixin, serializers.ModelSerializer): + """ Combines JSON-API model serializer with create-only fields and model validation. + """ From 1c4575186da210ae3b10f124b23df33efd4ddd2c Mon Sep 17 00:00:00 2001 From: Rivo Laks Date: Fri, 16 Mar 2018 12:11:51 +0200 Subject: [PATCH 04/10] Add validate_jsonapi_error_response() to test utils --- tg_apicore/test.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tg_apicore/test.py b/tg_apicore/test.py index bdcdcaf..68cee29 100644 --- a/tg_apicore/test.py +++ b/tg_apicore/test.py @@ -151,6 +151,13 @@ def validate_response_status_code(resp: Response, expected_status_code: int = 20 getattr(resp, 'data', resp.content)) +def validate_jsonapi_error_response(resp: Response, expected_status_code: int): + validate_response_status_code(resp, expected_status_code) + data = resp.json() + + validate_keys(data, {'errors'}) + + def validate_jsonapi_list_response( resp: Response, *, expected_count: int = None, expected_attributes: Set[str] = None, expected_relationships: Set[str] = None From fc7db1e2f4ab1b99323c67818657f5b9a0287b29 Mon Sep 17 00:00:00 2001 From: Rivo Laks Date: Fri, 16 Mar 2018 12:24:30 +0200 Subject: [PATCH 05/10] Major improvements to the example app, making it a better demo as well as testcase - Now includes full API for companies+employees management. - Test cover, which is also used to test tg-apicore itself. --- example/companies/api_docs.py | 132 +++++++++- example/companies/factories.py | 45 ++++ .../management/commands/create_data.py | 49 +--- .../migrations/0002_auto_20180314_1355.py | 43 ++++ example/companies/models.py | 26 +- example/companies/serializers.py | 33 ++- example/companies/views.py | 90 ++++++- example/example/settings.py | 4 +- example/pytest.ini | 3 + example/tests/__init__.py | 0 example/tests/conftest.py | 38 +++ example/tests/test_company_views.py | 240 ++++++++++++++++++ example/tests/test_employment_views.py | 235 +++++++++++++++++ 13 files changed, 842 insertions(+), 96 deletions(-) create mode 100644 example/companies/factories.py create mode 100644 example/companies/migrations/0002_auto_20180314_1355.py create mode 100644 example/pytest.ini create mode 100644 example/tests/__init__.py create mode 100644 example/tests/conftest.py create mode 100644 example/tests/test_company_views.py create mode 100644 example/tests/test_employment_views.py diff --git a/example/companies/api_docs.py b/example/companies/api_docs.py index e50bbda..6f3999f 100644 --- a/example/companies/api_docs.py +++ b/example/companies/api_docs.py @@ -2,7 +2,9 @@ "type": "company", "id": "12", "attributes": { - "created": "2018-02-21T14:55:06.734781Z", + "created": "2018-03-16T09:38:01.531816Z", + "updated": "2018-03-16T09:38:01.531879Z", + "reg_code": "287-0513", "name": "Turner and Sons", "email": "turner@sons.com" }, @@ -38,14 +40,16 @@ "type": "company", "id": "12", "attributes": { - "created": "2018-02-21T14:55:06.734781Z", + "created": "2018-03-16T09:38:01.531816Z", + "updated": "2018-03-16T09:38:01.531879Z", + "reg_code": "287-0513", "name": "Turner and Sons", "email": "turner@sons.com" }, "relationships": { "employees": { "meta": { - "count": 3 + "count": 2 }, "data": [ { @@ -69,7 +73,8 @@ "type": "employment", "id": "162", "attributes": { - "created": "2018-02-21T14:55:06.756900Z", + "created": "2018-03-16T09:48:11.528352Z", + "updated": "2018-03-16T09:48:11.528494Z", "name": "Linda Burgess", "email": "carloswoods@griffin.com", "role": 1 @@ -82,7 +87,8 @@ "type": "employment", "id": "91", "attributes": { - "created": "2018-02-21T14:55:06.755331Z", + "created": "2018-03-16T09:48:11.528352Z", + "updated": "2018-03-16T09:48:11.528494Z", "name": "Crystal Turner", "email": "collinsheather@mendoza.biz", "role": 1 @@ -98,6 +104,7 @@ "data": { "type": "company", "attributes": { + "reg_code": "123-4567", "name": "Turner and Sons", "email": "turner@sons.com" } @@ -109,7 +116,8 @@ "type": "company", "id": "12", "attributes": { - "created": "2018-02-21T14:55:06.734781Z", + "created": "2018-03-16T09:38:01.531816Z", + "updated": "2018-03-16T09:38:01.531879Z", "name": "Turner and Sons", "email": "turner@sons.com" }, @@ -124,9 +132,9 @@ (400, { "errors": [ { - "detail": "Company with this name already exists.", + "detail": "Company with this reg_code already exists.", "source": { - "pointer": "/data/attributes/name" + "pointer": "/data/attributes/reg_code" }, "status": "400" } @@ -139,14 +147,16 @@ "type": "company", "id": "12", "attributes": { - "created": "2018-02-21T14:55:06.734781Z", + "created": "2018-03-16T09:38:01.531816Z", + "updated": "2018-03-16T09:38:01.531879Z", + "reg_code": "287-0513", "name": "Turner and Sons", "email": "turner@sons.com" }, "relationships": { "employees": { "meta": { - "count": 3 + "count": 2 }, "data": [ { @@ -161,7 +171,7 @@ } }, "links": { - "self": "%(API_ROOT)s/companies/70/" + "self": "%(API_ROOT)s/companies/12/" } }, "included": [ @@ -169,7 +179,8 @@ "type": "employment", "id": "162", "attributes": { - "created": "2018-02-21T14:55:06.756900Z", + "created": "2018-03-16T09:48:11.528352Z", + "updated": "2018-03-16T09:48:11.528494Z", "name": "Linda Burgess", "email": "carloswoods@griffin.com", "role": 1 @@ -182,7 +193,8 @@ "type": "employment", "id": "91", "attributes": { - "created": "2018-02-21T14:55:06.755331Z", + "created": "2018-03-16T09:48:11.528352Z", + "updated": "2018-03-16T09:48:11.528494Z", "name": "Crystal Turner", "email": "collinsheather@mendoza.biz", "role": 1 @@ -218,3 +230,97 @@ ] }), ] + + +EMPLOYMENTS_DATA = { + "type": "employment", + "id": "162", + "attributes": { + "created": "2018-03-16T09:48:11.528352Z", + "updated": "2018-03-16T09:48:11.528494Z", + "name": "Linda Burgess", + "email": "carloswoods@griffin.com", + "role": 1 + }, + "relationships": { + "company": { + "data": { + "type": "company", + "id": "12" + } + } + }, + "links": { + "self": "%(API_ROOT)s/employments/162/" + } +} + +EMPLOYMENTS_CREATE_REQUEST = { + "data": { + "type": "employment", + "attributes": { + "email": "carloswoods@griffin.com", + }, + "relationships": { + "company": { + "data": {"type": "company", "id": "12"} + } + } + } +} + +EMPLOYMENTS_CREATE_RESPONSE = { + "data": { + "type": "employment", + "id": "162", + "attributes": { + "created": "2018-03-16T09:48:11.528352Z", + "updated": "2018-03-16T09:48:11.528494Z", + "name": "Linda Burgess", + "email": "carloswoods@griffin.com", + "role": 1 + }, + "relationships": { + "company": { + "data": { + "type": "company", + "id": "12" + } + } + }, + "links": { + "self": "%(API_ROOT)s/employments/162/" + } + }, + "included": [ + { + "type": "company", + "id": "12", + "attributes": { + "created": "2018-03-16T09:38:01.531816Z", + "updated": "2018-03-16T09:38:01.531879Z", + "reg_code": "287-0513", + "name": "Turner and Sons", + "email": "turner@sons.com" + }, + "links": { + "self": "http://localhost:8330/api/2018-02-21/companies/12" + } + } + ] +} + +EMPLOYMENTS_CREATE_RESPONSES = [ + (201, EMPLOYMENTS_CREATE_RESPONSE), + (400, { + "errors": [ + { + "detail": "You are not admin in the specified company", + "source": { + "pointer": "/data/attributes/company" + }, + "status": "400" + } + ] + }), +] diff --git a/example/companies/factories.py b/example/companies/factories.py new file mode 100644 index 0000000..a230d0c --- /dev/null +++ b/example/companies/factories.py @@ -0,0 +1,45 @@ +import random + +import factory +from factory.django import DjangoModelFactory + +from companies.models import User, Company, Employment + + +class UserFactory(DjangoModelFactory): + class Meta: + model = User + + password = factory.PostGenerationMethodCall('set_password', 'test') + username = factory.Faker('user_name') + email = factory.Faker('email') + first_name = factory.Faker('first_name') + last_name = factory.Faker('last_name') + + +class CompanyFactory(DjangoModelFactory): + class Meta: + model = Company + + name = factory.Faker('company') + email = factory.Faker('email') + reg_code = factory.Faker('numerify', text='%##-####') + + +def create_full_example_data(): + UserFactory.create_batch(100) + CompanyFactory.create_batch(70) + + users = list(User.objects.all()) + companies = list(Company.objects.all()) + + # Generate 300 unique user-company pairs + user_company_pairs = set() + while len(user_company_pairs): + user_company_pairs.add((random.choice(users), random.choice(companies))) + + Employment.objects.bulk_create([ + Employment(user=user, company=company, + role=Employment.ROLE_ADMIN if random.random() < 0.25 else Employment.ROLE_NORMAL) + for user, company in user_company_pairs + ]) diff --git a/example/companies/management/commands/create_data.py b/example/companies/management/commands/create_data.py index 9907e7f..ab66318 100644 --- a/example/companies/management/commands/create_data.py +++ b/example/companies/management/commands/create_data.py @@ -1,55 +1,10 @@ -import random - from django.core.management.base import BaseCommand -import factory -from factory.django import DjangoModelFactory - -from companies.models import User, Company, Employment - - -def faker_with_max_length(provider: str, max_length: int): - return factory.LazyFunction(lambda: factory.Faker(provider).generate({})[:max_length]) - - -def faker_estonian_phone_number(): - """ Creates valid Estonian phone numbers. - Numbers beginning with '+37250', followed by 5 or 6 digits are always valid. - """ - return factory.LazyFunction(lambda: ('+37250%06d' % random.randint(0, 999999))) - - -class UserFactory(DjangoModelFactory): - class Meta: - model = User - - password = factory.PostGenerationMethodCall('set_password', 'test') - username = factory.Faker('user_name') - email = factory.Faker('email') - first_name = factory.Faker('first_name') - last_name = factory.Faker('last_name') - - -class CompanyFactory(DjangoModelFactory): - class Meta: - model = Company - - name = factory.Faker('company') - email = factory.Faker('email') +from companies.factories import create_full_example_data class Command(BaseCommand): help = "Create test data" def handle(self, *args, **options): - UserFactory.create_batch(100) - CompanyFactory.create_batch(70) - - users = list(User.objects.all()) - companies = list(Company.objects.all()) - - Employment.objects.bulk_create([ - Employment(user=random.choice(users), company=random.choice(companies), - role=Employment.ROLE_MANAGER if random.random() < 0.25 else Employment.ROLE_NORMAL) - for _ in range(300) - ]) + create_full_example_data() diff --git a/example/companies/migrations/0002_auto_20180314_1355.py b/example/companies/migrations/0002_auto_20180314_1355.py new file mode 100644 index 0000000..d290301 --- /dev/null +++ b/example/companies/migrations/0002_auto_20180314_1355.py @@ -0,0 +1,43 @@ +# Generated by Django 2.0.2 on 2018-03-14 13:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('companies', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='company', + name='reg_code', + field=models.CharField(default=1234, max_length=20, unique=True), + preserve_default=False, + ), + migrations.AddField( + model_name='company', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='employment', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='user', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AlterField( + model_name='employment', + name='role', + field=models.PositiveSmallIntegerField(choices=[(1, 'normal'), (2, 'admin')], default=1), + ), + migrations.AlterUniqueTogether( + name='employment', + unique_together={('user', 'company')}, + ), + ] diff --git a/example/companies/models.py b/example/companies/models.py index 7cf360c..fc936e7 100644 --- a/example/companies/models.py +++ b/example/companies/models.py @@ -2,26 +2,36 @@ from django.db import models -class User(AbstractUser): - created = models.DateTimeField(auto_now_add=True, editable=False) +class BaseModel(models.Model): + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + class Meta: + abstract = True -class Company(models.Model): + +class User(AbstractUser, BaseModel): + pass + + +class Company(BaseModel): + reg_code = models.CharField(max_length=20, unique=True) name = models.CharField(max_length=64) email = models.EmailField(blank=True) - created = models.DateTimeField(auto_now_add=True, editable=False) -class Employment(models.Model): +class Employment(BaseModel): ROLE_NORMAL = 1 - ROLE_MANAGER = 2 + ROLE_ADMIN = 2 ROLE_CHOICES = ( (ROLE_NORMAL, 'normal'), - (ROLE_MANAGER, 'manager'), + (ROLE_ADMIN, 'admin'), ) user = models.ForeignKey(User, related_name='employments', on_delete=models.CASCADE) company = models.ForeignKey(Company, related_name='employees', on_delete=models.CASCADE) role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=ROLE_NORMAL) - created = models.DateTimeField(auto_now_add=True, editable=False) + + class Meta: + unique_together = (('user', 'company'),) diff --git a/example/companies/serializers.py b/example/companies/serializers.py index d66b8b7..8b1bfe0 100644 --- a/example/companies/serializers.py +++ b/example/companies/serializers.py @@ -2,28 +2,24 @@ from tg_apicore.serializers import BaseModelSerializer -from companies.models import Company, Employment +from companies.models import Company, Employment, User class EmploymentSummarySerializer(BaseModelSerializer): class Meta: model = Employment - fields = ['id', 'url', 'created', 'name', 'email', 'role'] + fields = ['id', 'url', 'created', 'updated', 'name', 'email', 'role'] + create_only_fields = ['email'] - name = serializers.SerializerMethodField() - email = serializers.SerializerMethodField() - - def get_name(self, obj): - return obj.user.get_full_name() - - def get_email(self, obj): - return obj.user.email + name = serializers.CharField(source='user.get_full_name', read_only=True) + email = serializers.EmailField(source='user.email') class CompanySummarySerializer(BaseModelSerializer): class Meta: model = Company - fields = ['id', 'url', 'created', 'name', 'email'] + fields = ['id', 'url', 'created', 'updated', 'reg_code', 'name', 'email'] + create_only_fields = ['reg_code'] class EmploymentSerializer(EmploymentSummarySerializer): @@ -37,10 +33,25 @@ class JSONAPIMeta: 'company': CompanySummarySerializer, } + def validate_company(self, value): + user = self.context['request'].user + if not Employment.objects.filter(company=value, user=user, role=Employment.ROLE_ADMIN).exists(): + raise serializers.ValidationError("You are not admin in the specified company", code='user_not_admin') + + return value + + def validate(self, attrs): + # If user's email was given, use it to look up the actual object (creating it if necessary) + if attrs.get('user', {}).get('email'): + attrs['user'], _ = User.objects.get_or_create(email=attrs.pop('user')['email']) + + return super().validate(attrs) + class CompanySerializer(CompanySummarySerializer): class Meta(CompanySummarySerializer.Meta): fields = CompanySummarySerializer.Meta.fields + ['employees'] + read_only_fields = ['employees'] class JSONAPIMeta: included_resources = ['employees'] diff --git a/example/companies/views.py b/example/companies/views.py index 510e670..174088d 100644 --- a/example/companies/views.py +++ b/example/companies/views.py @@ -1,12 +1,13 @@ -from rest_framework import mixins -from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import IsAuthenticatedOrReadOnly, SAFE_METHODS, IsAuthenticated +from rest_framework.viewsets import ModelViewSet + +from tg_apicore.docs import add_api_docs, api_section_docs, api_method_docs +from tg_apicore.viewsets import DetailSerializerViewSet from companies import api_docs from companies.models import Company, Employment from companies.serializers import CompanySerializer, EmploymentSerializer, CompanySummarySerializer, \ EmploymentSummarySerializer -from tg_apicore.docs import add_api_docs, api_section_docs, api_method_docs -from tg_apicore.viewsets import DetailSerializerViewSet @add_api_docs( @@ -24,22 +25,54 @@ ), api_method_docs( 'retrieve', + doc="Retrieve details of a specific company. If you're an employee of that company, it will also include " + "employees info.", response_data=api_docs.COMPANIES_READ_RESPONSE, ), + api_method_docs( + 'partial_update', + doc="Updates company data. You need to be admin employee to do that.", + request_data=api_docs.COMPANIES_UPDATE_REQUEST, + responses=api_docs.COMPANIES_READ_RESPONSE, + ), api_method_docs( 'destroy', responses=api_docs.COMPANIES_DELETE_RESPONSES, ), ) -class CompanyViewSet(mixins.CreateModelMixin, mixins.DestroyModelMixin, ReadOnlyModelViewSet, DetailSerializerViewSet): +class CompanyViewSet(ModelViewSet, DetailSerializerViewSet): """ Companies API - provides CRUD functionality for companies. - Employees information is included in responses. + If a user creates a company, they'll automatically become employee of that company, in admin role. + + Basic information about companies can be viewed by everyone. + Employee info can be seen only by employees, in detail responses. + Changes can be made only by admins. """ + permission_classes = (IsAuthenticatedOrReadOnly,) queryset = Company.objects.all() serializer_class = CompanySummarySerializer - serializer_detail_class = CompanySerializer + serializer_detail_class = CompanySerializer # only for employees, see get_detail_serializer_class() + serializer_modify_class = CompanySerializer + + def get_detail_serializer_class(self): + # Detail serializer is only for employees + company = self.get_object() + user = self.request.user + if company is not None and user.is_authenticated and \ + Employment.objects.filter(company=company, user=user).exists(): + return self.serializer_detail_class + + return self.get_list_serializer_class() + + def check_object_permissions(self, request, obj): + super().check_object_permissions(request, obj) + + # Unsafe methods (= editing) can be used only by managers + if request.method not in SAFE_METHODS: + if not Employment.objects.filter(company=obj, user=request.user, role=Employment.ROLE_ADMIN).exists(): + self.permission_denied(request) # pylint: disable=useless-super-delegation def list(self, request, *args, **kwargs): @@ -60,20 +93,49 @@ def destroy(self, request, *args, **kwargs): """ return super().destroy(request, *args, **kwargs) + def perform_create(self, serializer): + """ Adds current user as admin of the created company. + """ + + super().perform_create(serializer) + + company = serializer.instance + Employment.objects.create(company=company, user=self.request.user, role=Employment.ROLE_ADMIN) + @add_api_docs( + api_section_docs( + data=api_docs.EMPLOYMENTS_DATA, + ), + api_method_docs( + 'list', + ), + api_method_docs( + 'create', + request_data=api_docs.EMPLOYMENTS_CREATE_REQUEST, + responses=api_docs.EMPLOYMENTS_CREATE_RESPONSES, + ), ) -class EmploymentViewSet(ReadOnlyModelViewSet, DetailSerializerViewSet): - """ Employments API +class EmploymentViewSet(ModelViewSet, DetailSerializerViewSet): + """ Employee management API. + + Employees can only be changed by admins of a company, and can be viewed by all employees of a company. """ + permission_classes = (IsAuthenticated,) queryset = Employment.objects.all() serializer_class = EmploymentSummarySerializer serializer_detail_class = EmploymentSerializer - # pylint: disable=useless-super-delegation - def list(self, request, *args, **kwargs): - """ List all employments. - """ + def check_object_permissions(self, request, obj): + super().check_object_permissions(request, obj) - return super().list(request, *args, **kwargs) + # Unsafe methods (= editing) can be used only by managers + if request.method not in SAFE_METHODS: + if not Employment.objects.filter(company_id=obj.company_id, user=request.user, role=Employment.ROLE_ADMIN) \ + .exists(): + self.permission_denied(request) + + def get_list_queryset(self): + user_companies = Employment.objects.filter(user=self.request.user).values_list('company_id', flat=True) + return super().get_list_queryset().filter(company__in=user_companies) diff --git a/example/example/settings.py b/example/example/settings.py index 1aa5f5d..f7d75db 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -42,13 +42,12 @@ 'django.contrib.staticfiles', ] -MIDDLEWARE_CLASSES = [ +MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -111,7 +110,6 @@ REST_FRAMEWORK = { 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', - 'DEFAULT_PAGINATION_CLASS': 'tg_apicore.pagination.CursorPagination', 'PAGE_SIZE': 20, diff --git a/example/pytest.ini b/example/pytest.ini new file mode 100644 index 0000000..17f9eb6 --- /dev/null +++ b/example/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE=example.settings +python_files=test_*.py tests/*.py tests.py diff --git a/example/tests/__init__.py b/example/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/tests/conftest.py b/example/tests/conftest.py new file mode 100644 index 0000000..3c9880b --- /dev/null +++ b/example/tests/conftest.py @@ -0,0 +1,38 @@ +import pytest + +from companies.factories import CompanyFactory +from companies.models import User, Employment + + +@pytest.fixture(scope='function') +def user(): + return User.objects.create_user( + username='testuser', email='asd@asd.asd', password='test', first_name='Test', last_name='User', + ) + + +@pytest.fixture(scope='function') +def other_user(): + return User.objects.create_user( + username='otheruser', email='other@asd.asd', password='test', first_name='Other', last_name='Person', + ) + + +@pytest.fixture(scope='function') +def company(): + return CompanyFactory.create() + + +@pytest.fixture(scope='function') +def other_company(): + return CompanyFactory.create() + + +@pytest.fixture(scope='function') +def employment(user, company): + return Employment.objects.create(user=user, company=company, role=Employment.ROLE_ADMIN) + + +@pytest.fixture(scope='function') +def other_employment(other_user, other_company): + return Employment.objects.create(user=other_user, company=other_company, role=Employment.ROLE_ADMIN) diff --git a/example/tests/test_company_views.py b/example/tests/test_company_views.py new file mode 100644 index 0000000..a8f3530 --- /dev/null +++ b/example/tests/test_company_views.py @@ -0,0 +1,240 @@ +from copy import deepcopy + +import pytest + +from tg_apicore.test import APIClient, validate_jsonapi_detail_response, validate_jsonapi_list_response, \ + validate_jsonapi_error_response, validate_response_status_code + +from companies.api_docs import COMPANIES_CREATE_REQUEST +from companies.factories import CompanyFactory +from companies.models import Company, Employment, User + + +ATTRIBUTES_LIST = {'created', 'updated', 'reg_code', 'name', 'email'} +RELATIONSHIPS_LIST = set() +ATTRIBUTES_PUBLIC = ATTRIBUTES_LIST +RELATIONSHIPS_PUBLIC = RELATIONSHIPS_LIST +ATTRIBUTES_FULL = ATTRIBUTES_LIST +RELATIONSHIPS_FULL = {'employees'} + + +def do_test_company_listing(client: APIClient, batch_size=5): + CompanyFactory.create_batch(batch_size) + + resp = client.get(client.reverse('company-list')) + validate_jsonapi_list_response( + resp, expected_count=batch_size, expected_attributes=ATTRIBUTES_LIST, + expected_relationships=RELATIONSHIPS_LIST, + ) + + +@pytest.mark.django_db +def test_create_company(user: User): + """ Users should be able to create companies. They should become admin of the created company. + """ + + client = APIClient() + client.force_authenticate(user) + + resp = client.post(client.reverse('company-list'), data=COMPANIES_CREATE_REQUEST) + data = validate_jsonapi_detail_response(resp, expected_status_code=201) + + assert Employment.objects.filter(user=user, company_id=data['data']['id'], role=Employment.ROLE_ADMIN).exists() + + +@pytest.mark.django_db +def test_create_company_public(): + """ Companies cannot be created by anonymous users. + """ + + client = APIClient() + + resp = client.post(client.reverse('company-list'), data=COMPANIES_CREATE_REQUEST) + validate_jsonapi_error_response(resp, expected_status_code=403) + + +@pytest.mark.django_db +def test_companies_list(user: User): + """ Companies can be listed by a user. + """ + + client = APIClient() + client.force_authenticate(user) + + do_test_company_listing(client) + + +@pytest.mark.django_db +def test_companies_list_public(): + """ Companies can also be listed anonymously. + """ + + client = APIClient() + do_test_company_listing(client) + + +@pytest.mark.django_db +def test_companies_details_employee(employment: Employment): + """ Company details can be viewed by an employee, and full information is returned. + """ + + client = APIClient() + client.force_authenticate(employment.user) + + resp = client.get(client.reverse('company-detail', pk=employment.company.pk)) + validate_jsonapi_detail_response( + resp, + expected_attributes=ATTRIBUTES_FULL, expected_relationships=RELATIONSHIPS_FULL, + ) + + +@pytest.mark.django_db +def test_companies_details_unrelated(user: User, other_company: Company): + """ Company details can be viewed by an unrelated user (non-employee), but only basic information is returned. + """ + + client = APIClient() + client.force_authenticate(user) + + resp = client.get(client.reverse('company-detail', pk=other_company.pk)) + validate_jsonapi_detail_response( + resp, + expected_attributes=ATTRIBUTES_PUBLIC, expected_relationships=RELATIONSHIPS_PUBLIC, + ) + + +@pytest.mark.django_db +def test_companies_details_public(company: Company): + """ Company details can also be viewed anonymously, only basic information is returned. + """ + + client = APIClient() + + resp = client.get(client.reverse('company-detail', pk=company.pk)) + validate_jsonapi_detail_response( + resp, + expected_attributes=ATTRIBUTES_PUBLIC, expected_relationships=RELATIONSHIPS_PUBLIC, + ) + + +@pytest.mark.django_db +def test_companies_update(employment: Employment): + assert employment.role == Employment.ROLE_ADMIN + user = employment.user + company = employment.company + + client = APIClient() + client.force_authenticate(user) + + other_company = CompanyFactory.create() + + patch_data = { + "data": { + "type": "company", + "id": str(company.id), + "attributes": {}, + }, + } + + # Part one - update the company where the user is admin + new_name = 'new name' + updated = company.updated + assert company.name != new_name + patch_data['data']['attributes'] = {'name': new_name} + resp = client.patch(client.reverse('company-detail', pk=company.pk), patch_data) + validate_jsonapi_detail_response( + resp, + expected_attributes=ATTRIBUTES_FULL, expected_relationships=RELATIONSHIPS_FULL, + ) + refreshed_company = Company.objects.get(id=company.id) + assert refreshed_company.name == new_name + assert refreshed_company.updated > updated + + # Part two - PUT should not be allowed + resp = client.put(client.reverse('company-detail', pk=company.pk), patch_data) + validate_jsonapi_error_response(resp, expected_status_code=405) + + # Part three - updating is only allowed for admins, so it should fail after user is demoted to non-admin + employment.role = Employment.ROLE_NORMAL + employment.save() + resp = client.patch(client.reverse('company-detail', pk=company.pk), patch_data) + validate_jsonapi_error_response(resp, expected_status_code=403) + + # Part four - try to patch company where we don't have permissions + patch_data['data']['id'] = str(other_company.id) + resp = client.patch(client.reverse('company-detail', pk=other_company.pk)) + validate_jsonapi_error_response(resp, expected_status_code=403) + + +@pytest.mark.django_db +def test_companies_create_only_fields(user: User): + """ Ensures that create-only fields cannot be updated for existing instances. + + It also acts as general test for the create-only fields functionality. + """ + + client = APIClient() + client.force_authenticate(user) + + # Part one - try creating a company without reg_code (required and create-only field) - this should fail + req_data = deepcopy(COMPANIES_CREATE_REQUEST) + del req_data['data']['attributes']['reg_code'] + resp = client.post(client.reverse('company-list'), data=req_data) + validate_jsonapi_error_response(resp, expected_status_code=400) + + # Part two - create a company with all the necessary fields + req_data = deepcopy(COMPANIES_CREATE_REQUEST) + resp = client.post(client.reverse('company-list'), data=req_data) + resp_data = validate_jsonapi_detail_response(resp, expected_status_code=201) + + # Ensure everything is as intended + req_data_attributes = req_data['data']['attributes'] + company = Company.objects.get(id=resp_data['data']['id']) + for attr_name in req_data_attributes: + assert getattr(company, attr_name) == req_data_attributes[attr_name] + + # Next, try updating the reg_code, which should be read-only + new_reg_code = 123456 + assert company.reg_code != new_reg_code + patch_data = { + "data": { + "type": "company", + "id": str(company.id), + "attributes": { + 'reg_code': new_reg_code, + }, + }, + } + + # Try to update the value - it should be no-op + resp = client.patch(client.reverse('company-detail', pk=company.pk), patch_data) + validate_jsonapi_detail_response( + resp, + expected_attributes=ATTRIBUTES_FULL, expected_relationships=RELATIONSHIPS_FULL, + ) + # Ensure the value in database hasn't been changed + refreshed_company = Company.objects.get(id=company.id) + assert refreshed_company.reg_code == company.reg_code + + +@pytest.mark.django_db +def test_companies_delete(employment: Employment, other_company: Company): + """ Ensures admins can delete companies but non-admin employees cannot. + """ + + assert employment.role == Employment.ROLE_ADMIN + user = employment.user + company = employment.company + + client = APIClient() + client.force_authenticate(user) + + # Part one - delete the company where the user is admin + resp = client.delete(client.reverse('company-detail', pk=company.id)) + validate_response_status_code(resp, 204) + assert not Company.objects.filter(id=company.id).exists() + + # Part two - try to delete an unrelated company - this should not be allowed + resp = client.delete(client.reverse('company-detail', pk=other_company.id)) + validate_jsonapi_error_response(resp, expected_status_code=403) + assert Company.objects.filter(id=other_company.id).exists() diff --git a/example/tests/test_employment_views.py b/example/tests/test_employment_views.py new file mode 100644 index 0000000..6c91c6d --- /dev/null +++ b/example/tests/test_employment_views.py @@ -0,0 +1,235 @@ +from copy import deepcopy + +import pytest + +from tg_apicore.test import APIClient, validate_jsonapi_detail_response, validate_jsonapi_list_response, \ + validate_jsonapi_error_response, validate_response_status_code + +from companies.api_docs import EMPLOYMENTS_CREATE_REQUEST +from companies.models import Company, Employment, User + + +ATTRIBUTES_LIST = {'created', 'updated', 'name', 'email', 'role'} +RELATIONSHIPS_LIST = set() +ATTRIBUTES_FULL = ATTRIBUTES_LIST +RELATIONSHIPS_FULL = {'company'} + + +def get_employment_create_data_for(company: Company, email: str): + req_data = deepcopy(EMPLOYMENTS_CREATE_REQUEST) + req_data['data']['relationships']['company']['data']['id'] = str(company.id) + req_data['data']['attributes']['email'] = email + + return req_data + + +@pytest.mark.django_db +def test_create_employment(employment: Employment): + """ Admin users should be able to create employments in the same company. + """ + + company = employment.company + user = employment.user + assert employment.role == Employment.ROLE_ADMIN + + email = 'anotheruser@foo.bar' + assert not User.objects.filter(email=email).exists() + + client = APIClient() + client.force_authenticate(user) + + req_data = get_employment_create_data_for(company, email) + resp = client.post(client.reverse('employment-list'), data=req_data) + validate_jsonapi_detail_response(resp, expected_status_code=201) + + assert User.objects.filter(email=email).exists() + assert Employment.objects.filter(user=user, company=company, role=Employment.ROLE_ADMIN).exists() + + +@pytest.mark.django_db +def test_create_employment_nonadmin(employment: Employment): + """ Users who are not admin in a company cannot create employments for that company. + """ + + company = employment.company + user = employment.user + employment.role = Employment.ROLE_NORMAL + employment.save() + + client = APIClient() + client.force_authenticate(user) + + req_data = get_employment_create_data_for(company, 'anotheruser@foo.bar') + + resp = client.post(client.reverse('employment-list'), data=req_data) + validate_jsonapi_error_response(resp, expected_status_code=400) + + +@pytest.mark.django_db +def test_create_employment_unrelated(user: User, other_company: Company): + """ Users who are not employees of a company cannot create employments for that company. + """ + + client = APIClient() + client.force_authenticate(user) + + req_data = get_employment_create_data_for(other_company, 'anotheruser@foo.bar') + + resp = client.post(client.reverse('employment-list'), data=req_data) + validate_jsonapi_error_response(resp, expected_status_code=400) + + +@pytest.mark.django_db +def test_create_employment_public(company: Company): + """ Employments cannot be created by anonymous users. + """ + + client = APIClient() + + req_data = get_employment_create_data_for(company, 'anotheruser@foo.bar') + + resp = client.post(client.reverse('employment-list'), data=req_data) + validate_jsonapi_error_response(resp, expected_status_code=403) + + +@pytest.mark.django_db +def test_employments_list(employment: Employment, other_user: User, other_company: Company): + """ Employments can be listed by existing employees of a company. + Users should see only employees of companies they themselves belong to. + """ + + user = employment.user + + client = APIClient() + client.force_authenticate(user) + + Employment.objects.create(company=other_company, user=other_user, role=Employment.ROLE_ADMIN) + assert Employment.objects.count() == 2 + + # Ensure we only get a single employment back - the one belonging to the company we're in. + resp = client.get(client.reverse('employment-list')) + resp_data = validate_jsonapi_list_response( + resp, expected_count=1, + expected_attributes=ATTRIBUTES_LIST, expected_relationships=RELATIONSHIPS_LIST, + ) + assert set(item['id'] for item in resp_data['data']) == {str(employment.company_id)} + + +@pytest.mark.django_db +def test_employments_list_public(): + """ Employees cannot be listed anonymously. + """ + + client = APIClient() + + resp = client.get(client.reverse('employment-list')) + validate_jsonapi_error_response(resp, 403) + + +@pytest.mark.django_db +def test_employments_details_employee(employment: Employment, other_user: User): + """ Employment details can be viewed by an employee, and full information is returned. + """ + + company = employment.company + other_employment = Employment.objects.create(company=company, user=other_user, role=Employment.ROLE_NORMAL) + + client = APIClient() + client.force_authenticate(employment.user) + + resp = client.get(client.reverse('employment-detail', pk=other_employment.pk)) + validate_jsonapi_detail_response( + resp, + expected_attributes=ATTRIBUTES_FULL, expected_relationships=RELATIONSHIPS_FULL, + ) + + +@pytest.mark.django_db +def test_employments_details_unrelated(user: User, other_employment: Employment): + """ Employment details cannot be viewed by an unrelated user (non-employee). + """ + + client = APIClient() + client.force_authenticate(user) + + resp = client.get(client.reverse('employment-detail', pk=other_employment.pk)) + validate_jsonapi_error_response(resp, 404) + + +@pytest.mark.django_db +def test_employments_details_public(other_employment: Employment): + """ Employment details cannot be viewed anonymously. + """ + + client = APIClient() + + resp = client.get(client.reverse('employment-detail', pk=other_employment.pk)) + validate_jsonapi_error_response(resp, 403) + + +@pytest.mark.django_db +def test_employments_update(employment: Employment, other_user: User): + """ Admins should be able to update employment info (= role) of companies where they are admins. + """ + + assert employment.role == Employment.ROLE_ADMIN + user = employment.user + company = employment.company + other_employment = Employment.objects.create(company=company, user=other_user, role=Employment.ROLE_NORMAL) + + client = APIClient() + client.force_authenticate(user) + + patch_data = { + "data": { + "type": "employment", + "id": str(other_employment.id), + "attributes": {}, + }, + } + + # Part one - update the employment, changing role to admin + updated = other_employment.updated + patch_data['data']['attributes'] = {'role': Employment.ROLE_ADMIN} + resp = client.patch(client.reverse('employment-detail', pk=other_employment.pk), patch_data) + validate_jsonapi_detail_response( + resp, + expected_attributes=ATTRIBUTES_FULL, expected_relationships=RELATIONSHIPS_FULL, + ) + refreshed_employment = Employment.objects.get(id=other_employment.id) + assert refreshed_employment.role == Employment.ROLE_ADMIN + assert refreshed_employment.updated > updated + + # Part two - PUT should not be allowed + resp = client.put(client.reverse('employment-detail', pk=other_employment.pk), patch_data) + validate_jsonapi_error_response(resp, expected_status_code=405) + + # Part three - updating is only allowed for admins, so it should fail after user is demoted to non-admin + employment.role = Employment.ROLE_NORMAL + employment.save() + resp = client.patch(client.reverse('employment-detail', pk=other_employment.pk), patch_data) + validate_jsonapi_error_response(resp, expected_status_code=403) + + +@pytest.mark.django_db +def test_employments_delete(employment: Employment, other_user: User, other_employment: Employment): + """ Ensures admins can delete employments but non-admin employees cannot. + """ + + assert employment.role == Employment.ROLE_ADMIN + user = employment.user + company = employment.company + target_employment = Employment.objects.create(company=company, user=other_user, role=Employment.ROLE_NORMAL) + + client = APIClient() + client.force_authenticate(user) + + # Part one - delete the company where the user is admin + resp = client.delete(client.reverse('employment-detail', pk=target_employment.id)) + validate_response_status_code(resp, 204) + assert not Employment.objects.filter(id=target_employment.id).exists() + + # Part two - try to delete an unrelated company - this should not be allowed + resp = client.delete(client.reverse('employment-detail', pk=other_employment.id)) + validate_jsonapi_error_response(resp, expected_status_code=404) + assert Employment.objects.filter(id=other_employment.id).exists() From e37d630258dd93268a990b6147cff4781a548c1f Mon Sep 17 00:00:00 2001 From: Rivo Laks Date: Fri, 16 Mar 2018 12:27:24 +0200 Subject: [PATCH 06/10] Rename APIDocumentationView.get_patterns() to .urlpatterns() --- example/example/views.py | 2 +- tg_apicore/views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/example/example/views.py b/example/example/views.py index df1ebaf..b042245 100644 --- a/example/example/views.py +++ b/example/example/views.py @@ -20,7 +20,7 @@ def get_base_path(self) -> str: docs_version = settings.API_VERSION_LATEST return '/api/%s/' % docs_version - def get_patterns(self) -> list: + def urlpatterns(self) -> list: from example import urls_api return [ diff --git a/tg_apicore/views.py b/tg_apicore/views.py index 7545b03..892eef8 100644 --- a/tg_apicore/views.py +++ b/tg_apicore/views.py @@ -25,7 +25,7 @@ class APIDocumentationView(TemplateView): def generate_docs(self): return generate_api_docs( title=self.title, description=self.get_description(), - site_url=self.get_site_url(), base_path=self.get_base_path(), patterns=self.get_patterns(), + site_url=self.get_site_url(), base_path=self.get_base_path(), patterns=self.urlpatterns(), ) def get_context_data(self, **kwargs): @@ -50,7 +50,7 @@ def get_base_path(self) -> str: """ Should return your API's base path (path prefix), e.g. /api/v1/ """ raise NotImplementedError() - def get_patterns(self) -> list: + def urlpatterns(self) -> list: """ Should return urlpatterns of your API """ raise NotImplementedError() From 84a949328eceb330fb85ddfa19925d9c91c76840 Mon Sep 17 00:00:00 2001 From: Rivo Laks Date: Fri, 16 Mar 2018 12:33:19 +0200 Subject: [PATCH 07/10] Run example app's tests in tox as well --- pytest.ini | 2 ++ tox.ini | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pytest.ini b/pytest.ini index f1e6d35..b6063d8 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,4 @@ [pytest] DJANGO_SETTINGS_MODULE=test_settings +python_files=tests/*.py +norecursedirs=example diff --git a/tox.ini b/tox.ini index 51c4ff8..6e93da2 100644 --- a/tox.ini +++ b/tox.ini @@ -21,6 +21,5 @@ deps = ; -r{toxinidir}/requirements.txt commands = pip install -U pip - py.test --basetemp={envtmpdir} - - + pytest --basetemp={envtmpdir} + pytest --basetemp={envtmpdir} example/ From a34f7695433812892ef50e652d16c0bea95f6efc Mon Sep 17 00:00:00 2001 From: Rivo Laks Date: Fri, 16 Mar 2018 14:16:03 +0200 Subject: [PATCH 08/10] Update history and readme --- HISTORY.rst | 6 +++++- README.rst | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 9f7dce8..f7bfdc1 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -5,7 +5,11 @@ History Next version ------------------ -* Added PageNotFoundView +* Added PageNotFoundView (JSON-based 404 views) +* Added DetailSerializerViewSet (different serializers and queryset for list/detail/edit views) +* Added CreateOnlyFieldsSerializerMixin, ModelValidationSerializerMixin and BaseModelSerializer +* Renamed APIDocumentationView.get_patterns() to .urlpatterns() +* Improved example app a lot. It now also includes tests that partially test tg-apicore itself 0.1.0 (2018-03-08) diff --git a/README.rst b/README.rst index eb7bb41..3742715 100644 --- a/README.rst +++ b/README.rst @@ -34,6 +34,7 @@ Features * Not interactive yet * Integrates `JSON API `_ * Cursor pagination with configurable page size +* Viewset classes for using different serializers and querysets for list/detail/edit endpoints * API-specific 404 view * Test utilities, e.g. for response validation * Versioning (WIP) From f162ebc28c294247c6b996180a0f84993ae9f0ba Mon Sep 17 00:00:00 2001 From: Rivo Laks Date: Wed, 21 Mar 2018 17:28:51 +0200 Subject: [PATCH 09/10] Make both 'make test' and 'setup.py test' work, update docs accordingly --- CONTRIBUTING.rst | 4 ++-- Makefile | 4 +++- setup.py | 5 ++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 4fbb1d0..597416a 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -79,8 +79,8 @@ Ready to contribute? Here's how to set up `tg-apicore` for local development. 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: - $ flake8 tg_apicore tests - $ python setup.py test or py.test + $ make test + $ make lint $ tox To get flake8 and tox, just pip install them into your virtualenv. diff --git a/Makefile b/Makefile index 7ec68be..a6d43cf 100644 --- a/Makefile +++ b/Makefile @@ -53,7 +53,9 @@ lint: ## check style with flake8 flake8 tg_apicore tests test: ## run tests quickly with the default Python - py.test + pip install -r requirements_dev.txt + pytest + pytest example/ test-all: ## run tests on every Python version with tox tox diff --git a/setup.py b/setup.py index e68c77e..051185d 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,10 @@ setup_requirements = ['pytest-runner', ] -test_requirements = ['pytest', ] +test_requirements = [ + 'pytest', + 'pytest-django', +] setup( author="Thorgate", From 07a11956931887b20088a74c8be7103c322a1cc5 Mon Sep 17 00:00:00 2001 From: Rivo Laks Date: Fri, 23 Mar 2018 11:11:11 +0200 Subject: [PATCH 10/10] Workaround for DRF issue with anonymous users & browsable API --- example/companies/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/example/companies/views.py b/example/companies/views.py index 174088d..3aca01f 100644 --- a/example/companies/views.py +++ b/example/companies/views.py @@ -137,5 +137,11 @@ def check_object_permissions(self, request, obj): self.permission_denied(request) def get_list_queryset(self): + # If user isn't authenticated, do a quick bailout. This is a workaround for + # https://github.com/encode/django-rest-framework/issues/5127 - DRF calling get_queryset() when rendering + # browsable API response, even when user didn't have permissions. + if not self.request.user.is_authenticated: + return Employment.objects.none() + user_companies = Employment.objects.filter(user=self.request.user).values_list('company_id', flat=True) return super().get_list_queryset().filter(company__in=user_companies)