Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Views with no model #161

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Table of contents
- [UpdateAPI](#updateapi)
- [ActionAPI](#actionapi)
- [DeleteAPI](#deleteapi)
- [WithoutModel](#withoutmodel)
- [Browsable API](#browsable-api)
- [Bundle loading](#bundle-loading)
- [Debugging](#debugging)
Expand Down Expand Up @@ -361,6 +362,35 @@ class BookDetailAPI(DeleteAPI, DetailAPI):
Deletes return a 204 no content response, no serializer is required.


### WithoutModel

If you want to build an API without any model, you just need to extend the `WithoutModel` class
and then one of the classes above - like [ListAPI](#listapi) - or overwrite one of the handles -
like `get` or `post`.

```python

class CustomInfoAPI(WithoutModel, ListAPI):

def get(self, request, *args, **kwargs):
return self.render_to_response(data={"field": value})

```

You can also overwrite the `get_queryset` method and define the `Serializer` class

```python

class CustomInfoAPI(WithoutModel, ListAPI):

serializer = MyCustomSerializer

def get_queryset(self):
return MyModel.objects.filter(...)

```


Browsable API
-------------

Expand Down
4 changes: 4 additions & 0 deletions tests/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,7 @@ class Meta:
class TeamSerializer(Serializer):
class Meta:
fields = ["id", "name"]


class DetachedModelSerializer(Serializer):
name = fields.String()
75 changes: 75 additions & 0 deletions tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

from datetime import timedelta
from unittest.mock import patch
from uuid import uuid4
Expand Down Expand Up @@ -507,3 +509,76 @@ def test_user_update(client, db, method, user):
assert response.status_code == 200, result
assert result["username"] == "testtest"
assert result["email"] == "[email protected]"


def test_when_getting_details_of_a_view_without_model_must_return_empty_result(
client, db):

response = client.get(f"/without-model/{uuid4()}")
assert response
assert response.status_code == 200, response
assert response.json() == {}


@parametrize("method", ["PATCH", "PUT"])
def test_when_changing_details_of_a_view_without_model_must_return_expected_result(client, db, method):
payload = dict(field="value")
response = client.generic(method, f"/without-model/{uuid4()}", payload)
result = response.json()
assert response.status_code == 200, result
assert result == {}


def test_when_deleting_details_of_a_view_without_model_must_return_expected_result(client, db, task):
response = client.delete(f"/without-model/{task.custom_id}")
assert response.status_code == 204, response.content
assert response.content == b""


def test_when_posting_to_a_view_without_model_must_return_empty_response(client, db):

response = client.post("/without-model/", dict(name="Task Name"))
assert response.status_code == 201
assert response.json() == {}


def test_when_using_a_list_view_without_model_but_with_queryset_must_return_expected_result(
client, db, task):

task_id = task.custom_id
task_name = task.name

response = client.get("/without-model/")
assert response
assert response.status_code == 200, response
assert response.json() == {
'pagination': {
'count': 0,
'page': 1,
'pages': 1
},
'data': []
}


def test_when_using_a_list_view_without_model_must_return_expected_result(client):
response = client.get("/without-model-overriding-handler/")
assert response
assert response.status_code == 200, response
assert response.json() == {"data": [{"field_name": "field_value"}]}


def test_when_using_a_list_view_without_model_with_custom_queryset_must_return_expected_result(
client, db, task):

task_name = task.name

response = client.get("/custom-tasks/")
assert response
assert response.status_code == 200, response
assert response.json() == {
"pagination": {"count": 1, "page": 1, "pages": 1},
"data": [{"name": task_name}]
}


4 changes: 4 additions & 0 deletions tests/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@
path("user/", views.UserSelf.as_view()),
path("users/", views.UserList.as_view()),
path("users/<int:id>/", views.UserDetail.as_view()),
path("without-model-overriding-handler/", views.ViewWithoutModelListOverridingHandler.as_view()),
path("without-model/<uuid:task_id>", views.ViewWithoutModelDetail.as_view()),
path("without-model/", views.ViewWithoutModelList.as_view()),
path("custom-tasks/", views.CustomTaskAPI.as_view()),
]
29 changes: 25 additions & 4 deletions tests/views.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db.models import F, Prefetch, Value
from django.db.models import F, Prefetch, Value, Model
from django.db.models.functions import Concat

from tests.models import Profile
from tests.serializers import ProfileSerializer, UserSerializer
from tests.models import Profile, Task
from tests.serializers import ProfileSerializer, UserSerializer, DetachedModelSerializer, TaskSerializer
from worf.exceptions import AuthenticationError
from worf.permissions import Authenticated, PublicEndpoint, Staff
from worf.views import ActionAPI, CreateAPI, DeleteAPI, DetailAPI, ListAPI, UpdateAPI
from worf.views import ActionAPI, CreateAPI, DeleteAPI, DetailAPI, ListAPI, UpdateAPI, WithoutModel


class ProfileList(CreateAPI, ListAPI):
Expand Down Expand Up @@ -110,3 +110,24 @@ def get_instance(self):
if not self.request.user.is_authenticated:
raise AuthenticationError("Log in with your username and password")
return self.request.user


class ViewWithoutModelDetail(WithoutModel, DeleteAPI, UpdateAPI, DetailAPI):
pass


class ViewWithoutModelList(WithoutModel, CreateAPI, ListAPI):
pass


class ViewWithoutModelListOverridingHandler(WithoutModel, ListAPI):

def get(self, *args, **kwargs):
return self.render_to_response(data={"data": [{"field_name": "field_value"}]})


class CustomTaskAPI(WithoutModel, ListAPI):
serializer = DetachedModelSerializer

def get_queryset(self):
return Task.objects.all()
7 changes: 5 additions & 2 deletions worf/assigns.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@


class AssignAttributes:

def get_model_attr(self, key):
return getattr(self.model, key)

def save(self, instance, bundle):
items = [
(key, getattr(self.model, key), value) for key, value in bundle.items()
(key, self.get_model_attr(key), value) for key, value in bundle.items()
]

for key, attr, value in items:
if isinstance(value, models.Model):
setattr(instance, key, value)
Expand Down
2 changes: 1 addition & 1 deletion worf/views/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from worf.views.action import ActionAPI # noqa: F401
from worf.views.base import AbstractBaseAPI, APIResponse # noqa: F401
from worf.views.base import AbstractBaseAPI, APIResponse, WithoutModel, NoModel # noqa: F401
from worf.views.create import CreateAPI # noqa: F401
from worf.views.delete import DeleteAPI # noqa: F401
from worf.views.detail import DetailAPI, DetailUpdateAPI # noqa: F401
Expand Down
34 changes: 33 additions & 1 deletion worf/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.cache import never_cache
from django.db import models

from worf.casing import camel_to_snake, snake_to_camel
from worf.conf import settings
Expand All @@ -25,10 +26,41 @@
WorfError,
)
from worf.renderers import render_response
from worf.serializers import SerializeModels
from worf.serializers import SerializeModels, Serializer
from worf.validators import ValidateFields


class NoModel(models.Model):
"""A dummy model to pass through all the code that is deeply coupled with DJango models
TODO remove inheritance with django model to avoid any side effect
"""

def refresh_from_db(self):
warnings.warn("Trying to 'refresh' a detached model")

def delete(self):
warnings.warn("Trying to 'delete' a detached model")


class WithoutModel:

model = NoModel
payload_key = "data"
serializer = Serializer

def validate(self):
warnings.warn("APIs without models have no validation")

def save(self, instance, bundle):
warnings.warn("When using an API without model you MUST implement save method")

def get_queryset(self):
return NoModel.objects.none()

def get_instance(self):
return NoModel()


@method_decorator(never_cache, name="dispatch")
class APIResponse(View):
def __init__(self, *args, **kwargs):
Expand Down