diff --git a/README.md b/README.md index a86b01d..0e5d3c7 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 ------------- diff --git a/tests/serializers.py b/tests/serializers.py index 91122ab..db291bf 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -102,3 +102,7 @@ class Meta: class TeamSerializer(Serializer): class Meta: fields = ["id", "name"] + + +class DetachedModelSerializer(Serializer): + name = fields.String() diff --git a/tests/test_views.py b/tests/test_views.py index c8cf928..32a9052 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,3 +1,5 @@ +import pytest + from datetime import timedelta from unittest.mock import patch from uuid import uuid4 @@ -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"] == "something@example.com" + + +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}] + } + + diff --git a/tests/urls.py b/tests/urls.py index 2db0618..7cdeb26 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -10,4 +10,8 @@ path("user/", views.UserSelf.as_view()), path("users/", views.UserList.as_view()), path("users//", views.UserDetail.as_view()), + path("without-model-overriding-handler/", views.ViewWithoutModelListOverridingHandler.as_view()), + path("without-model/", views.ViewWithoutModelDetail.as_view()), + path("without-model/", views.ViewWithoutModelList.as_view()), + path("custom-tasks/", views.CustomTaskAPI.as_view()), ] diff --git a/tests/views.py b/tests/views.py index ee89e14..2acdfab 100644 --- a/tests/views.py +++ b/tests/views.py @@ -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): @@ -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() diff --git a/worf/assigns.py b/worf/assigns.py index 9a8daad..c1c1c2d 100644 --- a/worf/assigns.py +++ b/worf/assigns.py @@ -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) diff --git a/worf/views/__init__.py b/worf/views/__init__.py index b19661d..6d6ba50 100644 --- a/worf/views/__init__.py +++ b/worf/views/__init__.py @@ -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 diff --git a/worf/views/base.py b/worf/views/base.py index 373e2be..f1f3a6a 100644 --- a/worf/views/base.py +++ b/worf/views/base.py @@ -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 @@ -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):