diff --git a/evennia/utils/picklefield.py b/evennia/utils/picklefield.py index c2b5abb98a..7308f2f2d5 100644 --- a/evennia/utils/picklefield.py +++ b/evennia/utils/picklefield.py @@ -277,7 +277,7 @@ class PickledObjectField(models.Field): return value def value_to_string(self, obj): - value = self._get_val_from_obj(obj) + value = self.value_from_object(obj) return self.get_db_prep_value(value) def get_internal_type(self): diff --git a/evennia/web/api/serializers.py b/evennia/web/api/serializers.py index 30cebb1075..7060df75ee 100644 --- a/evennia/web/api/serializers.py +++ b/evennia/web/api/serializers.py @@ -1,3 +1,14 @@ +""" +Serializers in the Django Rest Framework are similar to Forms in normal django. +They're used for transmitting and validating data, both going to clients and +coming to the server. However, where forms often contained presentation logic, +such as specifying widgets to use for selection, serializers typically leave +those decisions in the hands of clients, and are more focused on converting +data from the server to JSON (serialization) for a response, and validating +and converting JSON data sent from clients to our enpoints into python objects, +often django model instances, that we can use (deserialization). +""" + from rest_framework import serializers from evennia.objects.models import ObjectDB diff --git a/evennia/web/api/tests.py b/evennia/web/api/tests.py index bac5126018..2afd5310b8 100644 --- a/evennia/web/api/tests.py +++ b/evennia/web/api/tests.py @@ -110,3 +110,24 @@ class TestEvenniaRESTApi(EvenniaTest): # check success when sending the required data response = self.client.post(view_url, data=view.create_data) self.assertEqual(response.status_code, 201, f"Response was {response.data}") + + def test_set_attribute(self): + views = self.get_view_details("set-attribute") + for view in views: + with self.subTest(msg=f"Testing {view.view_name}"): + view_url = reverse(f"api:{view.view_name}", kwargs={"pk": view.obj.pk}) + # check failures from not sending required fields + response = self.client.post(view_url) + self.assertEqual(response.status_code, 400, f"Response was: {response.data}") + # test adding an attribute + self.assertEqual(view.obj.db.some_test_attr, None) + attr_name = "some_test_attr" + attr_data = {"db_key": attr_name, "db_value": "test_value"} + response = self.client.post(view_url, data=attr_data) + self.assertEqual(response.status_code, 200, f"Response was: {response.data}") + self.assertEquals(view.obj.attributes.get(attr_name), "test_value") + # now test removing it + attr_data = {"db_key": attr_name} + response = self.client.post(view_url, data=attr_data) + self.assertEqual(response.status_code, 200, f"Response was: {response.data}") + self.assertEquals(view.obj.attributes.get(attr_name), None) diff --git a/evennia/web/api/urls.py b/evennia/web/api/urls.py index 22d62a3537..7eea5d01b8 100644 --- a/evennia/web/api/urls.py +++ b/evennia/web/api/urls.py @@ -1,3 +1,20 @@ +""" +The Django Rest Framework provides a way of generating urls for different +views that implement standard CRUD operations in a quick way, using 'routers' +and 'viewsets'. A viewset implements standard CRUD actions and any custom actions +that you want, and then a router will automatically generate URLs based on the +actions that it detects for a viewset. For example, below we create a DefaultRouter. +We then register ObjectDBViewSet, a viewset for CRUD operations for ObjectDB +instances, to the 'objects' base endpoint. That will generate a number of URLs +like the following: +list objects: action: GET, url: /objects/, view name: object-list +create object: action: POST, url: /objects/, view name: object-list +retrieve object: action: GET, url: /objects/<:pk>, view name: object-detail +update object: action: POST, url: /objects/<:pk>, view name: object-detail +delete object: action: DELETE, url: /objects/<:pk>, view name: object-detail +set attribute: action: POST, url: /objects/<:pk>/set-attribute, view name: object-set-attribute +""" + from rest_framework import routers from evennia.web.api.views import ( ObjectDBViewSet, diff --git a/evennia/web/api/views.py b/evennia/web/api/views.py index 50bb71d653..0055431e0d 100644 --- a/evennia/web/api/views.py +++ b/evennia/web/api/views.py @@ -5,6 +5,8 @@ can generate a number of views for the common CRUD operations. """ from rest_framework.viewsets import ModelViewSet from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework import status from django_filters.rest_framework import DjangoFilterBackend @@ -18,17 +20,28 @@ from evennia.web.api.permissions import EvenniaPermission class TypeclassViewSetMixin(object): + """ + This mixin adds some shared functionality to each viewset of a typeclass. They all use the same + permission classes and filter backend. You can override any of these in your own viewsets. + """ + # permission classes determine who is authorized to call the view permission_classes = [EvenniaPermission] + # the filter backend allows for retrieval views to have filter arguments passed to it filter_backends = [DjangoFilterBackend] - -class ObjectDBViewSet(TypeclassViewSetMixin, ModelViewSet): - serializer_class = ObjectDBSerializer - queryset = ObjectDB.objects.all() - filterset_class = ObjectDBFilterSet - @action(detail=True, methods=["put", "post"]) - def add_attribute(self, request, pk=None): + def set_attribute(self, request, pk=None): + """ + This is an example of a custom action added to a viewset. Based on the name of the + method, it will create a default url_name (used for reversing) and url_path. + The 'pk' argument is automatically passed to this action because it has a url path + of the format /:pk/set-attribute. The get_object method is automatically + set in the expected viewset classes that will inherit this, using the pk that's + passed along to retrieve the object. + + This action will set an attribute if the db_value is defined, or remove it + if no db_value is provided. + """ attr = AttributeSerializer(data=request.data) obj = self.get_object() if attr.is_valid(raise_exception=True): @@ -44,27 +57,49 @@ class ObjectDBViewSet(TypeclassViewSetMixin, ModelViewSet): handler.add(key=key, value=value, category=category) else: handler.remove(key=key, category=category) + return Response(AttributeSerializer(obj.db_attributes.all(), many=True).data, status=status.HTTP_200_OK) + return Response(attr.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ObjectDBViewSet(TypeclassViewSetMixin, ModelViewSet): + """ + An example of a basic viewset for all ObjectDB instances. It declares the + serializer to use for both retrieving and changing/creating/deleting + instances. Serializers are similar to django forms, used for the + transmitting of data (typically json). + """ + serializer_class = ObjectDBSerializer + queryset = ObjectDB.objects.all() + filterset_class = ObjectDBFilterSet class CharacterViewSet(ObjectDBViewSet): + """ + This overrides the queryset to only retrieve Character objects + based on your DefaultCharacter typeclass path. + """ queryset = DefaultCharacter.objects.typeclass_search(DefaultCharacter.path, include_children=True) class RoomViewSet(ObjectDBViewSet): + """Viewset for Room objects""" queryset = DefaultRoom.objects.typeclass_search(DefaultRoom.path, include_children=True) class ExitViewSet(ObjectDBViewSet): + """Viewset for Exit objects""" queryset = DefaultExit.objects.typeclass_search(DefaultExit.path, include_children=True) class AccountDBViewSet(TypeclassViewSetMixin, ModelViewSet): + """Viewset for Account objects""" serializer_class = AccountDBSerializer queryset = AccountDB.objects.all() filterset_class = AccountDBFilterSet class ScriptDBViewSet(TypeclassViewSetMixin, ModelViewSet): + """Viewset for Script objects""" serializer_class = ScriptDBSerializer queryset = ScriptDB.objects.all() filterset_class = ScriptDBFilterSet