Add django rest framework with CRUD views
This commit is contained in:
parent
221fc560a7
commit
abb8eaae13
10 changed files with 401 additions and 11 deletions
0
evennia/web/api/__init__.py
Normal file
0
evennia/web/api/__init__.py
Normal file
27
evennia/web/api/filters.py
Normal file
27
evennia/web/api/filters.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from django_filters.rest_framework.filterset import FilterSet
|
||||
|
||||
from evennia.objects.models import ObjectDB
|
||||
from evennia.accounts.models import AccountDB
|
||||
from evennia.scripts.models import ScriptDB
|
||||
|
||||
SHARED_FIELDS = ["db_key", "db_typeclass_path", "db_tags__db_key", "db_tags__db_category"]
|
||||
|
||||
|
||||
class ObjectDBFilterSet(FilterSet):
|
||||
class Meta:
|
||||
model = ObjectDB
|
||||
fields = SHARED_FIELDS + ["db_location__db_key", "db_home__db_key", "db_location__id",
|
||||
"db_home__id"]
|
||||
|
||||
|
||||
class AccountDBFilterSet(FilterSet):
|
||||
class Meta:
|
||||
model = AccountDB
|
||||
fields = SHARED_FIELDS + ["username", "db_is_connected", "db_is_bot"]
|
||||
|
||||
|
||||
class ScriptDBFilterSet(FilterSet):
|
||||
class Meta:
|
||||
model = ScriptDB
|
||||
fields = SHARED_FIELDS + ["db_desc", "db_obj__db_key", "db_obj__id", "db_account__id",
|
||||
"db_account__username", "db_is_active", "db_persistent"]
|
||||
59
evennia/web/api/permissions.py
Normal file
59
evennia/web/api/permissions.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from rest_framework import permissions
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class EvenniaPermission(permissions.BasePermission):
|
||||
"""
|
||||
A Django Rest Framework permission class that allows us to use
|
||||
Evennia's permission structure. Based on the action in a given
|
||||
view, we'll check a corresponding Evennia access/lock check.
|
||||
"""
|
||||
# subclass this to change these permissions
|
||||
MINIMUM_LIST_PERMISSION = settings.REST_FRAMEWORK["DEFAULT_LIST_PERMISSION"]
|
||||
MINIMUM_CREATE_PERMISSION = settings.REST_FRAMEWORK["DEFAULT_CREATE_PERMISSION"]
|
||||
view_locks = settings.REST_FRAMEWORK["DEFAULT_VIEW_LOCKS"]
|
||||
destroy_locks = settings.REST_FRAMEWORK["DEFAULT_DESTROY_LOCKS"]
|
||||
update_locks = settings.REST_FRAMEWORK["DEFAULT_UPDATE_LOCKS"]
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
This method is a check that always happens first. If there's an object involved,
|
||||
such as with retrieve, update, or delete, then the has_object_permission method
|
||||
is also called if this returns True. If we return False, a permission denied
|
||||
error is raised.
|
||||
"""
|
||||
# Only allow authenticated users to call the API
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
if request.user.is_superuser:
|
||||
return True
|
||||
# these actions don't support object-level permissions, so use the above definitions
|
||||
if view.action == "list":
|
||||
return request.user.has_permistring(self.MINIMUM_LIST_PERMISSION)
|
||||
if view.action == "create":
|
||||
return request.user.has_permistring(self.MINIMUM_CREATE_PERMISSION)
|
||||
return True # this means we'll check object-level permissions
|
||||
|
||||
@staticmethod
|
||||
def check_locks(obj, user, locks):
|
||||
return any([obj.access(user, lock) for lock in locks])
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""
|
||||
This method assumes that has_permission has already returned True. We check
|
||||
equivalent Evennia permissions in the request.user to determine if they can
|
||||
complete the action. If so, we return True. Otherwise we return False, and
|
||||
a permission denied error will be raised.
|
||||
"""
|
||||
if request.user.is_superuser:
|
||||
return True
|
||||
if view.action in ("list", "retrieve"):
|
||||
# access_type is based on the examine command
|
||||
return self.check_locks(obj, request.user, self.view_locks)
|
||||
if view.action == "destroy":
|
||||
# access type based on the destroy command
|
||||
return self.check_locks(obj, request.user, self.destroy_locks)
|
||||
if view.action in ("update", "partial_update"):
|
||||
# access type based on set command
|
||||
return self.check_locks(obj, request.user, self.update_locks)
|
||||
58
evennia/web/api/serializers.py
Normal file
58
evennia/web/api/serializers.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from evennia.objects.models import ObjectDB
|
||||
from evennia.accounts.models import AccountDB
|
||||
from evennia.scripts.models import ScriptDB
|
||||
from evennia.typeclasses.attributes import Attribute
|
||||
from evennia.typeclasses.tags import Tag
|
||||
|
||||
|
||||
class AttributeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Attribute
|
||||
fields = ["db_key", "db_value", "db_category", "db_attrtype"]
|
||||
|
||||
|
||||
class TagSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ["db_key", "db_category", "db_data", "db_tagtype"]
|
||||
|
||||
|
||||
class SimpleObjectDBSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ObjectDB
|
||||
fields = ["id", "db_key"]
|
||||
|
||||
|
||||
class TypeclassSerializerMixin(object):
|
||||
db_attributes = AttributeSerializer(many=True)
|
||||
db_tags = TagSerializer(many=True)
|
||||
|
||||
shared_fields = ["id", "db_key", "db_attributes", "db_tags", "db_typeclass_path"]
|
||||
|
||||
|
||||
class ObjectDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer):
|
||||
contents = SimpleObjectDBSerializer(source="locations_set", many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ObjectDB
|
||||
fields = ["db_location", "db_home", "contents"] + TypeclassSerializerMixin.shared_fields
|
||||
read_only_fields = ["id", "db_attributes", "db_tags"]
|
||||
|
||||
|
||||
class AccountDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer):
|
||||
db_key = serializers.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = AccountDB
|
||||
fields = ["username"] + TypeclassSerializerMixin.shared_fields
|
||||
read_only_fields = ["id", "db_attributes", "db_tags"]
|
||||
|
||||
|
||||
class ScriptDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ScriptDB
|
||||
fields = ["db_interval", "db_persistent", "db_start_delay",
|
||||
"db_is_active", "db_repeats"] + TypeclassSerializerMixin.shared_fields
|
||||
read_only_fields = ["id", "db_attributes", "db_tags"]
|
||||
93
evennia/web/api/tests.py
Normal file
93
evennia/web/api/tests.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Tests for the REST API"""
|
||||
from evennia.utils.test_resources import EvenniaTest
|
||||
from evennia.web.api import serializers
|
||||
from rest_framework.test import APIClient
|
||||
from django.urls import reverse
|
||||
from django.test import override_settings
|
||||
from collections import namedtuple
|
||||
from django.conf.urls import url, include
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
urlpatterns = [
|
||||
url(r"^", include("evennia.web.website.urls")),
|
||||
url(r"^api/", include("evennia.web.api.urls", namespace="api")),
|
||||
]
|
||||
|
||||
|
||||
@override_settings(
|
||||
REST_API_ENABLED=True, ROOT_URLCONF=__name__, AUTH_USERNAME_VALIDATORS=[]
|
||||
)
|
||||
class TestEvenniaRESTApi(EvenniaTest):
|
||||
client_class = APIClient
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.account.is_superuser = True
|
||||
self.account.save()
|
||||
self.client.force_login(self.account)
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
super().tearDown()
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
def get_view_details(self, action):
|
||||
"""Helper function for generating list of named tuples"""
|
||||
View = namedtuple("View", ["view_name", "obj", "list", "serializer"])
|
||||
views = [
|
||||
View("object-%s" % action, self.obj1, [self.obj1, self.char1, self.exit, self.room1, self.room2, self.obj2,
|
||||
self.char2], serializers.ObjectDBSerializer),
|
||||
View("character-%s" % action, self.char1, [self.char1, self.char2], serializers.ObjectDBSerializer),
|
||||
View("exit-%s" % action, self.exit, [self.exit], serializers.ObjectDBSerializer),
|
||||
View("room-%s" % action, self.room1, [self.room1, self.room2], serializers.ObjectDBSerializer),
|
||||
View("script-%s" % action, self.script, [self.script], serializers.ScriptDBSerializer),
|
||||
View("account-%s" % action, self.account2, [self.account, self.account2], serializers.AccountDBSerializer),
|
||||
]
|
||||
return views
|
||||
|
||||
def test_retrieve(self):
|
||||
views = self.get_view_details("detail")
|
||||
for view in views:
|
||||
with self.subTest(msg="Testing {} retrieve".format(view.view_name)):
|
||||
view_url = reverse(
|
||||
"api:{}".format(view.view_name), kwargs={"pk": view.obj.pk}
|
||||
)
|
||||
response = self.client.get(view_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_update(self):
|
||||
views = self.get_view_details("detail")
|
||||
for view in views:
|
||||
with self.subTest(msg="Testing {} update".format(view.view_name)):
|
||||
view_url = reverse(
|
||||
"api:{}".format(view.view_name), kwargs={"pk": view.obj.pk}
|
||||
)
|
||||
# test both PUT (update) and PATCH (partial update) here
|
||||
for new_key, method in (("foobar", "put"), ("fizzbuzz", "patch")):
|
||||
field = "username" if "account" in view.view_name else "db_key"
|
||||
data = {field: new_key}
|
||||
response = getattr(self.client, method)(view_url, data=data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
view.obj.refresh_from_db()
|
||||
self.assertEqual(getattr(view.obj, field), new_key)
|
||||
|
||||
def test_delete(self):
|
||||
views = self.get_view_details("detail")
|
||||
for view in views:
|
||||
with self.subTest(msg="Testing {} delete".format(view.view_name)):
|
||||
view_url = reverse(
|
||||
"api:{}".format(view.view_name), kwargs={"pk": view.obj.pk}
|
||||
)
|
||||
response = self.client.delete(view_url)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
with self.assertRaises(ObjectDoesNotExist):
|
||||
view.obj.refresh_from_db()
|
||||
|
||||
def test_list(self):
|
||||
views = self.get_view_details("list")
|
||||
for view in views:
|
||||
with self.subTest(msg=f"Testing {view.view_name} "):
|
||||
view_url = reverse(f"api:{view.view_name}")
|
||||
response = self.client.get(view_url)
|
||||
self.assertCountEqual(response.data['results'], [view.serializer(obj).data for obj in view.list])
|
||||
21
evennia/web/api/urls.py
Normal file
21
evennia/web/api/urls.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from rest_framework import routers
|
||||
from evennia.web.api.views import (
|
||||
ObjectDBViewSet,
|
||||
AccountDBViewSet,
|
||||
CharacterViewSet,
|
||||
ExitViewSet,
|
||||
RoomViewSet,
|
||||
ScriptDBViewSet
|
||||
)
|
||||
|
||||
app_name = "api"
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.register(r'accounts', AccountDBViewSet, basename="account")
|
||||
router.register(r'objects', ObjectDBViewSet, basename="object")
|
||||
router.register(r'characters', CharacterViewSet, basename="character")
|
||||
router.register(r'exits', ExitViewSet, basename="exit")
|
||||
router.register(r'rooms', RoomViewSet, basename="room")
|
||||
router.register(r'scripts', ScriptDBViewSet, basename="script")
|
||||
|
||||
urlpatterns = router.urls
|
||||
70
evennia/web/api/views.py
Normal file
70
evennia/web/api/views.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""
|
||||
Views are the functions that are called by different url endpoints.
|
||||
The Django Rest Framework provides collections called 'ViewSets', which
|
||||
can generate a number of views for the common CRUD operations.
|
||||
"""
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.decorators import action
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
||||
from evennia.objects.models import ObjectDB
|
||||
from evennia.objects.objects import DefaultCharacter, DefaultExit, DefaultRoom
|
||||
from evennia.accounts.models import AccountDB
|
||||
from evennia.scripts.models import ScriptDB
|
||||
from evennia.web.api.serializers import ObjectDBSerializer, AccountDBSerializer, ScriptDBSerializer, AttributeSerializer
|
||||
from evennia.web.api.filters import ObjectDBFilterSet, AccountDBFilterSet, ScriptDBFilterSet
|
||||
from evennia.web.api.permissions import EvenniaPermission
|
||||
|
||||
|
||||
class TypeclassViewSetMixin(object):
|
||||
permission_classes = [EvenniaPermission]
|
||||
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):
|
||||
attr = AttributeSerializer(data=request.data)
|
||||
obj = self.get_object()
|
||||
if attr.is_valid(raise_exception=True):
|
||||
key = attr.validated_data["db_key"]
|
||||
value = attr.validated_data.get("db_value")
|
||||
category = attr.validated_data.get("db_category")
|
||||
attr_type = attr.validated_data.get("db_attrtype")
|
||||
if attr_type == "nick":
|
||||
handler = obj.nicks
|
||||
else:
|
||||
handler = obj.attributes
|
||||
if value:
|
||||
handler.add(key=key, value=value, category=category)
|
||||
else:
|
||||
handler.remove(key=key, category=category)
|
||||
|
||||
|
||||
class CharacterViewSet(ObjectDBViewSet):
|
||||
queryset = DefaultCharacter.objects.typeclass_search(DefaultCharacter.path, include_children=True)
|
||||
|
||||
|
||||
class RoomViewSet(ObjectDBViewSet):
|
||||
queryset = DefaultRoom.objects.typeclass_search(DefaultRoom.path, include_children=True)
|
||||
|
||||
|
||||
class ExitViewSet(ObjectDBViewSet):
|
||||
queryset = DefaultExit.objects.typeclass_search(DefaultExit.path, include_children=True)
|
||||
|
||||
|
||||
class AccountDBViewSet(TypeclassViewSetMixin, ModelViewSet):
|
||||
serializer_class = AccountDBSerializer
|
||||
queryset = AccountDB.objects.all()
|
||||
filterset_class = AccountDBFilterSet
|
||||
|
||||
|
||||
class ScriptDBViewSet(TypeclassViewSetMixin, ModelViewSet):
|
||||
serializer_class = ScriptDBSerializer
|
||||
queryset = ScriptDB.objects.all()
|
||||
filterset_class = ScriptDBFilterSet
|
||||
|
|
@ -6,7 +6,9 @@
|
|||
# http://diveintopython.org/regular_expressions/street_addresses.html#re.matching.2.3
|
||||
#
|
||||
|
||||
from django.urls import path
|
||||
from django.conf.urls import url, include
|
||||
from django.conf import settings
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
# Setup the root url tree from /
|
||||
|
|
@ -14,9 +16,12 @@ from django.views.generic import RedirectView
|
|||
urlpatterns = [
|
||||
# Front page (note that we shouldn't specify namespace here since we will
|
||||
# not be able to load django-auth/admin stuff (will probably work in Django>1.9)
|
||||
url(r"^", include("evennia.web.website.urls")), # , namespace='website', app_name='website')),
|
||||
path("", include("evennia.web.website.urls")),
|
||||
# webclient
|
||||
url(r"^webclient/", include("evennia.web.webclient.urls", namespace="webclient")),
|
||||
path("webclient/", include("evennia.web.webclient.urls")),
|
||||
# favicon
|
||||
url(r"^favicon\.ico$", RedirectView.as_view(url="/media/images/favicon.ico", permanent=False)),
|
||||
path("favicon.ico", RedirectView.as_view(url="/media/images/favicon.ico", permanent=False)),
|
||||
]
|
||||
|
||||
if settings.REST_API_ENABLED:
|
||||
urlpatterns += [url(r'^api/', include("evennia.web.api.urls", namespace="api"))]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue