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/HISTORY.rst b/HISTORY.rst
index c21c892..f7bfdc1 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -2,6 +2,16 @@
History
=======
+Next version
+------------------
+
+* 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/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/README.rst b/README.rst
index 0435b5d..3742715 100644
--- a/README.rst
+++ b/README.rst
@@ -34,6 +34,8 @@ 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)
* Transformer-based approach, inspired by
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 eda83c7..8b1bfe0 100644
--- a/example/companies/serializers.py
+++ b/example/companies/serializers.py
@@ -1,27 +1,25 @@
from rest_framework_json_api import serializers
-from companies.models import Company, Employment
+from tg_apicore.serializers import BaseModelSerializer
+from companies.models import Company, Employment, User
-class EmploymentSummarySerializer(serializers.ModelSerializer):
+
+class EmploymentSummarySerializer(BaseModelSerializer):
class Meta:
model = Employment
- fields = ['id', 'url', 'created', 'name', 'email', 'role']
-
- name = serializers.SerializerMethodField()
- email = serializers.SerializerMethodField()
+ fields = ['id', 'url', 'created', 'updated', 'name', 'email', 'role']
+ create_only_fields = ['email']
- def get_name(self, obj):
- return obj.user.get_full_name()
+ name = serializers.CharField(source='user.get_full_name', read_only=True)
+ email = serializers.EmailField(source='user.email')
- 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']
+ fields = ['id', 'url', 'created', 'updated', 'reg_code', 'name', 'email']
+ create_only_fields = ['reg_code']
class EmploymentSerializer(EmploymentSummarySerializer):
@@ -35,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 ead02e0..3aca01f 100644
--- a/example/companies/views.py
+++ b/example/companies/views.py
@@ -1,10 +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
-from tg_apicore.docs import add_api_docs, api_section_docs, api_method_docs
+from companies.serializers import CompanySerializer, EmploymentSerializer, CompanySummarySerializer, \
+ EmploymentSummarySerializer
@add_api_docs(
@@ -22,21 +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):
+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 = CompanySerializer
+ serializer_class = CompanySummarySerializer
+ 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):
@@ -57,19 +93,55 @@ 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):
- """ 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 = EmploymentSerializer
+ 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):
+ # 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)
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/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..b042245 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
@@ -23,24 +20,9 @@ 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 [
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/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()
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/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",
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.
+ """
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
diff --git a/tg_apicore/views.py b/tg_apicore/views.py
index 706519d..892eef8 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
@@ -22,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):
@@ -47,6 +50,31 @@ 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()
+
+
+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()
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()
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/