Add django rest framework with CRUD views

This commit is contained in:
TehomCD 2020-02-16 17:12:38 -05:00
parent 221fc560a7
commit abb8eaae13
10 changed files with 401 additions and 11 deletions

View file

@ -245,7 +245,7 @@ IN_GAME_ERRORS = True
# ENGINE - path to the the database backend. Possible choices are:
# 'django.db.backends.sqlite3', (default)
# 'django.db.backends.mysql',
# 'django.db.backends.postgresql_psycopg2',
# 'django.db.backends.postgresql',
# 'django.db.backends.oracle' (untested).
# NAME - database name, or path to the db file for sqlite3
# USER - db admin (unused in sqlite3)
@ -255,7 +255,9 @@ IN_GAME_ERRORS = True
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.getenv("TEST_DB_PATH", os.path.join(GAME_DIR, "server", "evennia.db3")),
"NAME": os.getenv(
"TEST_DB_PATH", os.path.join(GAME_DIR, "server", "evennia.db3")
),
"USER": "",
"PASSWORD": "",
"HOST": "",
@ -472,7 +474,12 @@ SERVER_SESSION_CLASS = "evennia.server.serversession.ServerSession"
# immediately entered path fail to find a typeclass. It allows for
# shorter input strings. They must either base off the game directory
# or start from the evennia library.
TYPECLASS_PATHS = ["typeclasses", "evennia", "evennia.contrib", "evennia.contrib.tutorial_examples"]
TYPECLASS_PATHS = [
"typeclasses",
"evennia",
"evennia.contrib",
"evennia.contrib.tutorial_examples",
]
# Typeclass for account objects (linked to a character) (fallback)
BASE_ACCOUNT_TYPECLASS = "typeclasses.accounts.Account"
@ -550,7 +557,11 @@ VALIDATOR_FUNC_MODULES = ["evennia.utils.validatorfuncs"]
# Python path to a directory to be searched for batch scripts
# for the batch processors (.ev and/or .py files).
BASE_BATCHPROCESS_PATHS = ["world", "evennia.contrib", "evennia.contrib.tutorial_examples"]
BASE_BATCHPROCESS_PATHS = [
"world",
"evennia.contrib",
"evennia.contrib.tutorial_examples",
]
######################################################################
# Game Time setup
@ -861,7 +872,9 @@ TEMPLATES = [
os.path.join(GAME_DIR, "web", "template_overrides"),
os.path.join(EVENNIA_DIR, "web", "website", "templates", WEBSITE_TEMPLATE),
os.path.join(EVENNIA_DIR, "web", "website", "templates"),
os.path.join(EVENNIA_DIR, "web", "webclient", "templates", WEBCLIENT_TEMPLATE),
os.path.join(
EVENNIA_DIR, "web", "webclient", "templates", WEBCLIENT_TEMPLATE
),
os.path.join(EVENNIA_DIR, "web", "webclient", "templates"),
],
"APP_DIRS": True,
@ -912,6 +925,8 @@ INSTALLED_APPS = [
"django.contrib.sites",
"django.contrib.staticfiles",
"django.contrib.messages",
"rest_framework",
"django_filters",
"sekizai",
"evennia.utils.idmapper",
"evennia.server",
@ -931,7 +946,9 @@ AUTH_USER_MODEL = "accounts.AccountDB"
# Password validation plugins
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
"OPTIONS": {"min_length": 8},
@ -944,8 +961,14 @@ AUTH_PASSWORD_VALIDATORS = [
# Username validation plugins
AUTH_USERNAME_VALIDATORS = [
{"NAME": "django.contrib.auth.validators.ASCIIUsernameValidator"},
{"NAME": "django.core.validators.MinLengthValidator", "OPTIONS": {"limit_value": 3}},
{"NAME": "django.core.validators.MaxLengthValidator", "OPTIONS": {"limit_value": 30}},
{
"NAME": "django.core.validators.MinLengthValidator",
"OPTIONS": {"limit_value": 3},
},
{
"NAME": "django.core.validators.MaxLengthValidator",
"OPTIONS": {"limit_value": 30},
},
{"NAME": "evennia.server.validators.EvenniaUsernameAvailabilityValidator"},
]
@ -956,6 +979,38 @@ TEST_RUNNER = "evennia.server.tests.testrunner.EvenniaTestSuiteRunner"
# messages.error() to Bootstrap 'danger' classes.
MESSAGE_TAGS = {messages.ERROR: "danger"}
# Django REST Framework settings
REST_FRAMEWORK = {
# django_filters allows you to specify search fields for models in an API View
'DEFAULT_FILTER_BACKENDS': (
'django_filters.rest_framework.DjangoFilterBackend',
),
# whether to paginate results and how many per page
"DEFAULT_PAGINATION_CLASS": 'rest_framework.pagination.LimitOffsetPagination',
'PAGE_SIZE': 25,
# require logged in users to call API so that access checks can work on them
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
# These are the different ways people can authenticate for API requests - via
# session or with user/password. Other ways are possible, such as via tokens
# or oauth, but require additional dependencies.
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
# default permission checks used by the EvenniaPermission class
"DEFAULT_CREATE_PERMISSION": "builder",
"DEFAULT_LIST_PERMISSION": "builder",
"DEFAULT_VIEW_LOCKS": ["examine"],
"DEFAULT_DESTROY_LOCKS": ["delete"],
"DEFAULT_UPDATE_LOCKS": ["control", "edit"],
# No throttle class set by default. Setting one also requires a cache backend to be specified.
}
# To enable the REST api, turn this to True
REST_API_ENABLED = False
######################################################################
# Django extensions
######################################################################

View file

View 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"]

View 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)

View 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
View 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
View 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
View 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

View file

@ -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"))]

View file

@ -4,6 +4,8 @@
django >= 2.2.5, < 2.3
twisted >= 19.2.1, < 20.0.0
pytz
djangorestframework >= 3.10.3
django-filter >= 2.2.0
django-sekizai
inflect
autobahn >= 17.9.3