From 3e6cd375e80f54ea31cb991f91976f7d7125e55d Mon Sep 17 00:00:00 2001 From: Wiser Software Engineer Date: Sun, 26 Mar 2023 08:58:48 -0300 Subject: [PATCH 1/5] #56: Test to simulate a view without model - test written to simulate a view with a dummy model to validate the scenario --- tests/serializers.py | 4 ++++ tests/test_views.py | 7 +++++++ tests/urls.py | 1 + tests/views.py | 15 +++++++++++++-- 4 files changed, 25 insertions(+), 2 deletions(-) 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..8bf2f65 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -507,3 +507,10 @@ 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_using_a_view_without_model_must_return_expected_result(client): + response = client.get("/without-model/") + assert response + assert response.status_code == 200, response + assert response.json() == {"field_name": "field_value"} diff --git a/tests/urls.py b/tests/urls.py index 2db0618..285ab7f 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -10,4 +10,5 @@ path("user/", views.UserSelf.as_view()), path("users/", views.UserList.as_view()), path("users//", views.UserDetail.as_view()), + path("without-model/", views.ViewWithoutModel.as_view()) ] diff --git a/tests/views.py b/tests/views.py index ee89e14..806128b 100644 --- a/tests/views.py +++ b/tests/views.py @@ -1,10 +1,10 @@ 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.serializers import ProfileSerializer, UserSerializer, DetachedModelSerializer from worf.exceptions import AuthenticationError from worf.permissions import Authenticated, PublicEndpoint, Staff from worf.views import ActionAPI, CreateAPI, DeleteAPI, DetailAPI, ListAPI, UpdateAPI @@ -110,3 +110,14 @@ 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 ViewWithoutModel(DetailAPI): + model = Model + serializer = None + + def get_instance(self): + return None + + def get(self, *args, **kwargs): + return self.render_to_response(data={"field_name": "field_value"}) From 4d82b945792ecf46d80154947be9d77d1be543c1 Mon Sep 17 00:00:00 2001 From: Wiser Software Engineer Date: Mon, 27 Mar 2023 05:51:04 -0300 Subject: [PATCH 2/5] #56: Test to simulate a view without model - Increasing the test scenarios --- tests/test_views.py | 19 +++++++++++++++++-- tests/urls.py | 4 +++- tests/views.py | 18 ++++++++++++------ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/tests/test_views.py b/tests/test_views.py index 8bf2f65..7200657 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 @@ -509,8 +511,21 @@ def test_user_update(client, db, method, user): assert result["email"] == "something@example.com" -def test_when_using_a_view_without_model_must_return_expected_result(client): - response = client.get("/without-model/") +def test_when_using_a_detail_view_without_model_must_return_expected_result(client): + response = client.get("/without-model/detail") assert response assert response.status_code == 200, response assert response.json() == {"field_name": "field_value"} + + +def test_when_using_a_list_view_without_model_must_return_expected_result(client): + with pytest.raises(AttributeError): + response = client.get("/without-model/") + + +def test_when_using_a_list_view_without_model_but_with_queryset_must_return_expected_result(client): + with pytest.raises(AttributeError): + response = client.get("/without-model-queryset/") + + + diff --git a/tests/urls.py b/tests/urls.py index 285ab7f..9e78e85 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -10,5 +10,7 @@ path("user/", views.UserSelf.as_view()), path("users/", views.UserList.as_view()), path("users//", views.UserDetail.as_view()), - path("without-model/", views.ViewWithoutModel.as_view()) + path("without-model/", views.ViewWithoutModelList.as_view()), + path("without-model/detail", views.ViewWithoutModelDetail.as_view()), + path("without-model-queryset/", views.ViewWithoutModelListWithQuerySet.as_view()), ] diff --git a/tests/views.py b/tests/views.py index 806128b..0aa730b 100644 --- a/tests/views.py +++ b/tests/views.py @@ -112,12 +112,18 @@ def get_instance(self): return self.request.user -class ViewWithoutModel(DetailAPI): - model = Model - serializer = None - - def get_instance(self): - return None +class ViewWithoutModelDetail(DetailAPI): + model = None def get(self, *args, **kwargs): return self.render_to_response(data={"field_name": "field_value"}) + + +class ViewWithoutModelList(ListAPI): + + def get(self, *args, **kwargs): + return self.render_to_response(data={"data": [{"field_name": "field_value"}]}) + + +class ViewWithoutModelListWithQuerySet(ListAPI): + pass From 6915503ad7fd8f1ce2afd902fa8f117af28e14bd Mon Sep 17 00:00:00 2001 From: Wiser Software Engineer Date: Mon, 27 Mar 2023 10:47:15 -0300 Subject: [PATCH 3/5] #56: Implement a view without model - Created a workaround to user a fake model - updated documentation explaining how to use it --- README.md | 30 ++++++++++++++++++ tests/test_views.py | 70 +++++++++++++++++++++++++++++++++++++----- tests/urls.py | 5 +-- tests/views.py | 24 +++++++++------ worf/assigns.py | 7 +++-- worf/views/__init__.py | 2 +- worf/views/base.py | 34 +++++++++++++++++++- 7 files changed, 148 insertions(+), 24 deletions(-) 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/test_views.py b/tests/test_views.py index 7200657..6f1f20a 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -511,21 +511,75 @@ def test_user_update(client, db, method, user): assert result["email"] == "something@example.com" -def test_when_using_a_detail_view_without_model_must_return_expected_result(client): - response = client.get("/without-model/detail") +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() == {"field_name": "field_value"} + 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): - with pytest.raises(AttributeError): - response = client.get("/without-model/") + 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): -def test_when_using_a_list_view_without_model_but_with_queryset_must_return_expected_result(client): - with pytest.raises(AttributeError): - response = client.get("/without-model-queryset/") + task_id = task.custom_id + 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": [{"id": str(task_id), "name": task_name}] + } diff --git a/tests/urls.py b/tests/urls.py index 9e78e85..7cdeb26 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -10,7 +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("without-model/detail", views.ViewWithoutModelDetail.as_view()), - path("without-model-queryset/", views.ViewWithoutModelListWithQuerySet.as_view()), + path("custom-tasks/", views.CustomTaskAPI.as_view()), ] diff --git a/tests/views.py b/tests/views.py index 0aa730b..21455cb 100644 --- a/tests/views.py +++ b/tests/views.py @@ -3,11 +3,11 @@ 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, DetachedModelSerializer +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): @@ -112,18 +112,22 @@ def get_instance(self): return self.request.user -class ViewWithoutModelDetail(DetailAPI): - model = None +class ViewWithoutModelDetail(WithoutModel, DeleteAPI, UpdateAPI, DetailAPI): + pass - def get(self, *args, **kwargs): - return self.render_to_response(data={"field_name": "field_value"}) + +class ViewWithoutModelList(WithoutModel, CreateAPI, ListAPI): + pass -class ViewWithoutModelList(ListAPI): +class ViewWithoutModelListOverridingHandler(WithoutModel, ListAPI): def get(self, *args, **kwargs): return self.render_to_response(data={"data": [{"field_name": "field_value"}]}) -class ViewWithoutModelListWithQuerySet(ListAPI): - pass +class CustomTaskAPI(WithoutModel, ListAPI): + serializer = TaskSerializer + + 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): From 64c2cb54396facafd8e70400d09956362877255d Mon Sep 17 00:00:00 2001 From: Wiser Software Engineer Date: Mon, 27 Mar 2023 10:59:12 -0300 Subject: [PATCH 4/5] #56: Implement a view without model - Created a workaround to user a fake model - updated documentation explaining how to use it --- tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_views.py b/tests/test_views.py index 6f1f20a..05cc332 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -578,7 +578,7 @@ def test_when_using_a_list_view_without_model_with_custom_queryset_must_return_e assert response assert response.status_code == 200, response assert response.json() == { - "pagination": {"count": 1, "page": 1, "pages": 1 }, + "pagination": {"count": 1, "page": 1, "pages": 1}, "data": [{"id": str(task_id), "name": task_name}] } From abe59976a1424b84148dc110ce0021e35a8fecb7 Mon Sep 17 00:00:00 2001 From: Wiser Software Engineer Date: Mon, 27 Mar 2023 11:18:31 -0300 Subject: [PATCH 5/5] #56: Implement a view without model - using a custom serializer to have an example for the users --- tests/test_views.py | 3 +-- tests/views.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_views.py b/tests/test_views.py index 05cc332..32a9052 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -571,7 +571,6 @@ def test_when_using_a_list_view_without_model_must_return_expected_result(client def test_when_using_a_list_view_without_model_with_custom_queryset_must_return_expected_result( client, db, task): - task_id = task.custom_id task_name = task.name response = client.get("/custom-tasks/") @@ -579,7 +578,7 @@ def test_when_using_a_list_view_without_model_with_custom_queryset_must_return_e assert response.status_code == 200, response assert response.json() == { "pagination": {"count": 1, "page": 1, "pages": 1}, - "data": [{"id": str(task_id), "name": task_name}] + "data": [{"name": task_name}] } diff --git a/tests/views.py b/tests/views.py index 21455cb..2acdfab 100644 --- a/tests/views.py +++ b/tests/views.py @@ -127,7 +127,7 @@ def get(self, *args, **kwargs): class CustomTaskAPI(WithoutModel, ListAPI): - serializer = TaskSerializer + serializer = DetachedModelSerializer def get_queryset(self): return Task.objects.all()