Skip to content

Commit

Permalink
Support custom actions via ActionAPI
Browse files Browse the repository at this point in the history
  • Loading branch information
stevelacey committed Oct 6, 2022
1 parent d9afa2a commit a58127a
Show file tree
Hide file tree
Showing 18 changed files with 301 additions and 186 deletions.
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
max_line_length = 80
max_line_length = 88
trim_trailing_whitespace = true
insert_final_newline = true

Expand Down
52 changes: 46 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Table of contents
- [DetailAPI](#detailapi)
- [CreateAPI](#createapi)
- [UpdateAPI](#updateapi)
- [ActionAPI](#actionapi)
- [DeleteAPI](#deleteapi)
- [Browsable API](#browsable-api)
- [Bundle loading](#bundle-loading)
- [Debugging](#debugging)
Expand Down Expand Up @@ -115,24 +117,26 @@ class BookSerializer(Serializer):
```py
# views.py
from worf.permissions import Authenticated
from worf.views import DetailAPI, ListAPI, UpdateAPI
from worf.views import ActionAPI, DeleteAPI, DetailAPI, ListAPI, UpdateAPI

class BookList(CreateAPI, ListAPI):
model = Book
serializer = BookSerializer(only=["id", "title"])
permissions = [Authenticated]

class BookDetail(UpdateAPI, DetailAPI):
class BookDetail(ActionAPI, DeleteAPI, UpdateAPI, DetailAPI):
model = Book
serializer = BookSerializer
permissions = [Authenticated]
actions = ["publish"]
```

```py
# urls.py
path("api/", include([
path("books/", BookList.as_view()),
path("books/<int:id>/", BookDetail.as_view()),
path("books/<int:id>/<str:action>/", BookDetail.as_view()),
])),
```

Expand Down Expand Up @@ -206,19 +210,17 @@ Provides the basic functionality of API views.
| permissions | list | [] | List of permissions classes. |
| serializer | object | None | Serializer class or instance. |

*Note:* it is not recommended to use this abstract view directly.
**Note:** it is not recommended to use this abstract view directly.


### ListAPI

| Name | Type | Default | Description |
| ----------------- | ------ | ------------------- | -------------------------------------------------------------------------------------- |
| queryset | object | model.objects.all() | Queryset used to retrieve the results. |
| filters | dict | {} | Filters to apply to queryset. *Deprecated:* use `queryset` instead. |
| lookup_field | str | None | Filter `queryset` based on a URL param, `lookup_url_kwarg` is required if this is set. |
| lookup_url_kwarg | str | None | Filter `queryset` based on a URL param, `lookup_field` is required if this is set. |
| payload_key | str | verbose_name_plural | Use in order to rename the key for the results array. |
| list_serializer | object | serializer | Serializer class or instance. |
| ordering | list | [] | List of fields to default the queryset order by. |
| filter_fields | list | [] | List of fields to support filtering via query params. |
| search_fields | list | [] | List of fields to full text search via the `q` query param. |
Expand Down Expand Up @@ -251,7 +253,6 @@ Use `per_page` to set custom limit for pagination. Default 25.
| queryset | object | model.objects.all() | Queryset used to retrieve the results. |
| lookup_field | str | id | Lookup field used to filter the model. |
| lookup_url_kwarg | str | id | Name of the parameter passed to the view by the URL route. |
| detail_serializer | object | serializer | Serializer class or instance. |

This `get_instance()` method uses `lookup_field` and `lookup_url_kwargs` to return a model instance.

Expand Down Expand Up @@ -297,6 +298,45 @@ Validation of update fields is delegated to the serializer, any fields that are
writeable should be within the `fields` definition of the serializer, and not
marked as `dump_only` (read-only).

### ActionAPI

| Name | Type | Default | Description |
| ------------------- | ------ | ------------------- | ---------------------------------------------------------- |
| queryset | object | model.objects.all() | Queryset used to retrieve the results. |
| lookup_field | str | id | Lookup field used to filter the model. |
| lookup_url_kwarg | str | id | Name of the parameter passed to the view by the URL route. |
| actions | list | [] | List of action methods to support. |

Adds `put` endpoints keyed by a route param, mix this into a `DetailAPI` view:

```py
class BookDetailAPI(ActionAPI, DetailAPI):
model = Book
serializer = BookSerializer
actions = ["publish"]
```

Actions must exist as a method on either the model or the view, they are passed the
contents of the bundle as kwargs, and if the method accepts a `user` kwarg then
`request.user` will be passed through too.

### DeleteAPI

| Name | Type | Default | Description |
| ------------------- | ------ | ------------------- | ---------------------------------------------------------- |
| queryset | object | model.objects.all() | Queryset used to retrieve the results. |
| lookup_field | str | id | Lookup field used to filter the model. |
| lookup_url_kwarg | str | id | Name of the parameter passed to the view by the URL route. |

Adds a `delete` method to handle deletes, mix this into a `DetailAPI`.

```py
class BookDetailAPI(DeleteAPI, DetailAPI):
model = Book
```

Deletes return a 204 no content response, no serializer is required.


Browsable API
-------------
Expand Down
2 changes: 1 addition & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[pytest]
addopts =
--cov
--cov-fail-under 92
--cov-fail-under 93.5
--cov-report term:skip-covered
--cov-report html
--no-cov-on-fail
Expand Down
19 changes: 19 additions & 0 deletions tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from django.contrib.auth.models import User
from django.db import models
from django.utils.timezone import now


class Profile(models.Model):
Expand Down Expand Up @@ -33,6 +34,12 @@ class Profile(models.Model):
last_active = models.DateField(blank=True, null=True)
created_at = models.DateTimeField(blank=True, null=True)

is_subscribed = models.BooleanField(blank=True, null=True)
subscribed_at = models.DateTimeField(blank=True, null=True)
subscribed_by = models.ForeignKey(
User, blank=True, null=True, on_delete=models.SET_NULL
)

def get_avatar_url(self):
return self.avatar.url if self.avatar else self.get_gravatar_url()

Expand All @@ -42,6 +49,18 @@ def get_gravatar_hash(self):
def get_gravatar_url(self, default="identicon", size=512):
return f"https://www.gravatar.com/avatar/{self.get_gravatar_hash()}?d={default}&s={size}"

def subscribe(self, user=None):
self.is_subscribed = True
self.subscribed_at = now()
self.subscribed_by = user
self.save()

def unsubscribe(self):
self.is_subscribed = False
self.subscribed_at = None
self.subscribed_by = None
self.save()


class Role(models.Model):
name = models.CharField(max_length=100)
Expand Down
53 changes: 53 additions & 0 deletions tests/test_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from tests import parametrize


def test_action_model_func(user_client, profile):
response = user_client.put(f"/profiles/{profile.pk}/unsubscribe/")
result = response.json()
assert response.status_code == 200, result
profile.refresh_from_db()
assert profile.is_subscribed is False
assert profile.subscribed_at is None
assert profile.subscribed_by is None


def test_action_model_func_with_user_argument(user_client, profile, user):
response = user_client.put(f"/profiles/{profile.pk}/subscribe/")
result = response.json()
assert response.status_code == 200, result
profile.refresh_from_db()
assert profile.is_subscribed is True
assert profile.subscribed_at is not None
assert profile.subscribed_by == user


def test_action_view_func(user_client, profile, user):
response = user_client.put(f"/profiles/{profile.pk}/resubscribe/")
result = response.json()
assert response.status_code == 200, result
profile.refresh_from_db()
assert profile.is_subscribed is True
assert profile.subscribed_at is not None
assert profile.subscribed_by == user


def test_invalid_action(user_client, profile):
response = user_client.put(f"/profiles/{profile.pk}/invalid-action/")
result = response.json()
assert response.status_code == 400, result
assert result["message"] == "Invalid action: invalid-action"


def test_invalid_arguments(user_client, profile):
kwargs = dict(text="I love newsletters")
response = user_client.put(f"/profiles/{profile.pk}/subscribe/", kwargs)
result = response.json()
assert response.status_code == 400, result
message = "Invalid arguments: subscribe() got an unexpected keyword argument 'text'"
assert result["message"] == message


@parametrize("method", ["GET", "DELETE", "PATCH", "POST"])
def test_invalid_method(user_client, method, profile):
response = user_client.generic(method, f"/profiles/{profile.pk}/subscribe/")
assert response.status_code == 405
16 changes: 16 additions & 0 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from tests import parametrize
from worf import exceptions


@parametrize(
dict(e=exceptions.ActionError("test")),
dict(e=exceptions.AuthenticationError("test")),
dict(e=exceptions.DataConflict("test")),
dict(e=exceptions.NamingThingsError("test")),
dict(e=exceptions.NotFound("test")),
dict(e=exceptions.PermissionsError("test")),
dict(e=exceptions.SerializerError("test")),
dict(e=exceptions.WorfError("test")),
)
def test_exception(e):
assert e.message == str(e) == "test"
8 changes: 4 additions & 4 deletions tests/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from django.contrib.auth.models import AnonymousUser, User

from worf.exceptions import HTTP401, HTTP404
from worf.exceptions import AuthenticationError, NotFound
from worf.permissions import Authenticated, PublicEndpoint, Staff


Expand All @@ -11,7 +11,7 @@ def test_authenticated(db, rf):
request = rf.get("/")
request.user = AnonymousUser()

with pytest.raises(HTTP401):
with pytest.raises(AuthenticationError):
assert permission(request) is None

request.user = User.objects.create(username="test", password="test")
Expand All @@ -32,12 +32,12 @@ def test_staff(db, rf):
request = rf.get("/")
request.user = AnonymousUser()

with pytest.raises(HTTP404):
with pytest.raises(NotFound):
assert permission(request) is None

request.user = User.objects.create(username="test", password="test")

with pytest.raises(HTTP404):
with pytest.raises(NotFound):
assert permission(request) is None

request.user.is_staff = True
Expand Down
25 changes: 13 additions & 12 deletions tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def test_profile_not_found(client, db, profile, user):
response = client.get(f"/profiles/{uuid4()}/")
result = response.json()
assert response.status_code == 404, result
assert result["message"] == "Not Found"
assert result["message"] == "Not found"


def test_profile_delete(client, db, profile, user):
Expand All @@ -35,6 +35,14 @@ def test_profile_list(client, db, profile, user):
assert result["profiles"][0]["username"] == user.username


@parametrize("page", [-1, 0, 1, 2])
def test_profile_list_pages(client, db, page):
response = client.get("/profiles/", dict(page=page))
result = response.json()
assert response.status_code == 200, result
assert len(result["profiles"]) == 0


def test_profile_list_filters(client, db, profile, url, user):
response = client.get(url("/profiles/", {"name": user.name}))
result = response.json()
Expand Down Expand Up @@ -120,13 +128,6 @@ def test_profile_list_not_in_filters(client, db, profile, url, user):
assert len(result["profiles"]) == 0


def test_profile_list_subset_filters(client, db, profile, url, user):
response = client.get(url("/profiles/subset/", {"name": user.name}))
result = response.json()
assert response.status_code == 200, result
assert len(result["profiles"]) == 0


@patch("django.core.files.storage.FileSystemStorage.save")
def test_profile_multipart_create(mock_save, client, db, role, user):
avatar = SimpleUploadedFile("avatar.jpg", b"", content_type="image/jpeg")
Expand Down Expand Up @@ -290,21 +291,21 @@ def test_profile_update_m2m_through_required_fields(client, db, method, profile,


def test_staff_detail(admin_client, profile, user):
response = admin_client.get(f"/profiles/{profile.pk}/staff/")
response = admin_client.get(f"/staff/{profile.pk}/")
result = response.json()
assert response.status_code == 200, result
assert result["username"] == user.username


def test_staff_detail_is_not_found_for_user(user_client, profile, user):
response = user_client.get(f"/profiles/{profile.pk}/staff/")
response = user_client.get(f"/staff/{profile.pk}/")
result = response.json()
assert response.status_code == 404, result
assert result["message"] == "Not Found"
assert result["message"] == "Not found"


def test_staff_detail_is_unauthorized_for_guest(client, db, profile, user):
response = client.get(f"/profiles/{profile.pk}/staff/")
response = client.get(f"/staff/{profile.pk}/")
result = response.json()
assert response.status_code == 401, result
assert result["message"] == "Unauthorized"
Expand Down
4 changes: 2 additions & 2 deletions tests/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

urlpatterns = [
path("profiles/", views.ProfileList.as_view()),
path("profiles/subset/", views.ProfileListSubSet.as_view()),
path("profiles/<uuid:id>/", views.ProfileDetail.as_view()),
path("profiles/<uuid:id>/staff/", views.StaffDetail.as_view()),
path("profiles/<uuid:id>/<str:action>/", views.ProfileDetail.as_view()),
path("staff/<uuid:id>/", views.StaffDetail.as_view()),
path("user/", views.UserSelf.as_view()),
path("users/", views.UserList.as_view()),
path("users/<int:id>/", views.UserDetail.as_view()),
Expand Down
18 changes: 12 additions & 6 deletions tests/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from tests.serializers import ProfileSerializer, UserSerializer
from worf.exceptions import AuthenticationError
from worf.permissions import Authenticated, PublicEndpoint, Staff
from worf.views import CreateAPI, DeleteAPI, DetailAPI, ListAPI, UpdateAPI
from worf.views import ActionAPI, CreateAPI, DeleteAPI, DetailAPI, ListAPI, UpdateAPI


class ProfileList(CreateAPI, ListAPI):
Expand Down Expand Up @@ -35,14 +35,20 @@ class ProfileList(CreateAPI, ListAPI):
]


class ProfileListSubSet(ProfileList):
queryset = ProfileList.queryset.none()


class ProfileDetail(DeleteAPI, UpdateAPI, DetailAPI):
class ProfileDetail(ActionAPI, DeleteAPI, UpdateAPI, DetailAPI):
model = Profile
serializer = ProfileSerializer
permissions = [PublicEndpoint]
actions = [
"resubscribe",
"subscribe",
"unsubscribe",
]

def resubscribe(self, request, *args, **kwargs):
profile = self.get_instance()
profile.subscribe(user=request.user)
return profile

def validate_phone(self, value):
try:
Expand Down
Loading

0 comments on commit a58127a

Please sign in to comment.