From abb8eaae138d3c168344974007d9502ac9aff55c Mon Sep 17 00:00:00 2001 From: TehomCD Date: Sun, 16 Feb 2020 17:12:38 -0500 Subject: [PATCH 01/15] Add django rest framework with CRUD views --- evennia/settings_default.py | 71 +++++++++++++++++++++++--- evennia/web/api/__init__.py | 0 evennia/web/api/filters.py | 27 ++++++++++ evennia/web/api/permissions.py | 59 +++++++++++++++++++++ evennia/web/api/serializers.py | 58 +++++++++++++++++++++ evennia/web/api/tests.py | 93 ++++++++++++++++++++++++++++++++++ evennia/web/api/urls.py | 21 ++++++++ evennia/web/api/views.py | 70 +++++++++++++++++++++++++ evennia/web/urls.py | 11 ++-- requirements.txt | 2 + 10 files changed, 401 insertions(+), 11 deletions(-) create mode 100644 evennia/web/api/__init__.py create mode 100644 evennia/web/api/filters.py create mode 100644 evennia/web/api/permissions.py create mode 100644 evennia/web/api/serializers.py create mode 100644 evennia/web/api/tests.py create mode 100644 evennia/web/api/urls.py create mode 100644 evennia/web/api/views.py diff --git a/evennia/settings_default.py b/evennia/settings_default.py index b2a06e328e..d7b6cd1836 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -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 ###################################################################### diff --git a/evennia/web/api/__init__.py b/evennia/web/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/web/api/filters.py b/evennia/web/api/filters.py new file mode 100644 index 0000000000..df580ba2d2 --- /dev/null +++ b/evennia/web/api/filters.py @@ -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"] diff --git a/evennia/web/api/permissions.py b/evennia/web/api/permissions.py new file mode 100644 index 0000000000..56f90592d8 --- /dev/null +++ b/evennia/web/api/permissions.py @@ -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) diff --git a/evennia/web/api/serializers.py b/evennia/web/api/serializers.py new file mode 100644 index 0000000000..30cebb1075 --- /dev/null +++ b/evennia/web/api/serializers.py @@ -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"] diff --git a/evennia/web/api/tests.py b/evennia/web/api/tests.py new file mode 100644 index 0000000000..92a2e56ab1 --- /dev/null +++ b/evennia/web/api/tests.py @@ -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]) diff --git a/evennia/web/api/urls.py b/evennia/web/api/urls.py new file mode 100644 index 0000000000..22d62a3537 --- /dev/null +++ b/evennia/web/api/urls.py @@ -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 diff --git a/evennia/web/api/views.py b/evennia/web/api/views.py new file mode 100644 index 0000000000..50bb71d653 --- /dev/null +++ b/evennia/web/api/views.py @@ -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 diff --git a/evennia/web/urls.py b/evennia/web/urls.py index 6d9e28f6df..a256a8c796 100644 --- a/evennia/web/urls.py +++ b/evennia/web/urls.py @@ -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"))] diff --git a/requirements.txt b/requirements.txt index cfbf6e5af3..7559491ac3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 From 83d4737027e5b4272cd41d0a33418fafa9366968 Mon Sep 17 00:00:00 2001 From: TehomCD Date: Sun, 16 Feb 2020 18:10:21 -0500 Subject: [PATCH 02/15] Pin attrs version due to 3.7 conflict --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 7559491ac3..3da37476d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # Evennia dependencies # general +attrs >= 19.2.0 django >= 2.2.5, < 2.3 twisted >= 19.2.1, < 20.0.0 pytz From 6f350c60c210e64ed14601e8cc45c87c5b14f2af Mon Sep 17 00:00:00 2001 From: TehomCD Date: Fri, 21 Feb 2020 22:48:13 -0500 Subject: [PATCH 03/15] Add test for create --- evennia/web/api/tests.py | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/evennia/web/api/tests.py b/evennia/web/api/tests.py index 92a2e56ab1..bac5126018 100644 --- a/evennia/web/api/tests.py +++ b/evennia/web/api/tests.py @@ -34,15 +34,21 @@ class TestEvenniaRESTApi(EvenniaTest): def get_view_details(self, action): """Helper function for generating list of named tuples""" - View = namedtuple("View", ["view_name", "obj", "list", "serializer"]) + View = namedtuple("View", ["view_name", "obj", "list", "serializer", "create_data"]) 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), + self.char2], serializers.ObjectDBSerializer, + {"db_key": "object-create-test-name"}), + View("character-%s" % action, self.char1, [self.char1, self.char2], serializers.ObjectDBSerializer, + {"db_key": "character-create-test-name"}), + View("exit-%s" % action, self.exit, [self.exit], serializers.ObjectDBSerializer, + {"db_key": "exit-create-test-name"}), + View("room-%s" % action, self.room1, [self.room1, self.room2], serializers.ObjectDBSerializer, + {"db_key": "room-create-test-name"}), + View("script-%s" % action, self.script, [self.script], serializers.ScriptDBSerializer, + {"db_key": "script-create-test-name"}), + View("account-%s" % action, self.account2, [self.account, self.account2], serializers.AccountDBSerializer, + {"username": "account-create-test-name"}), ] return views @@ -91,3 +97,16 @@ class TestEvenniaRESTApi(EvenniaTest): 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]) + + def test_create(self): + views = self.get_view_details("list") + for view in views: + with self.subTest(msg=f"Testing {view.view_name} create"): + # create is a POST request off of -list + view_url = reverse(f"api:{view.view_name}") + # check failures from not sending required fields + response = self.client.post(view_url) + self.assertEqual(response.status_code, 400) + # 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}") From 2798dfb712cc4bc289342ae469f0510623e858cb Mon Sep 17 00:00:00 2001 From: TehomCD Date: Sat, 22 Feb 2020 12:15:13 -0500 Subject: [PATCH 04/15] Add action for setting attributes via endpoint --- evennia/utils/picklefield.py | 2 +- evennia/web/api/serializers.py | 11 ++++++++ evennia/web/api/tests.py | 21 +++++++++++++++ evennia/web/api/urls.py | 17 ++++++++++++ evennia/web/api/views.py | 49 +++++++++++++++++++++++++++++----- 5 files changed, 92 insertions(+), 8 deletions(-) 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 From e4fc50d2541cd6a5f57890d5635a1b917859a94c Mon Sep 17 00:00:00 2001 From: TehomCD Date: Sat, 22 Feb 2020 13:32:12 -0500 Subject: [PATCH 05/15] Add set_attribute to default permission class --- evennia/web/api/permissions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/web/api/permissions.py b/evennia/web/api/permissions.py index 56f90592d8..e56cd1cf23 100644 --- a/evennia/web/api/permissions.py +++ b/evennia/web/api/permissions.py @@ -54,6 +54,6 @@ class EvenniaPermission(permissions.BasePermission): 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"): + if view.action in ("update", "partial_update", "set_attribute"): # access type based on set command return self.check_locks(obj, request.user, self.update_locks) From e687f63ab7eb1897e654db264e6a0d0e5bdb7476 Mon Sep 17 00:00:00 2001 From: TehomCD Date: Sat, 29 Feb 2020 21:31:57 -0500 Subject: [PATCH 06/15] Add README --- evennia/web/api/README.md | 103 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 evennia/web/api/README.md diff --git a/evennia/web/api/README.md b/evennia/web/api/README.md new file mode 100644 index 0000000000..7b564a9edc --- /dev/null +++ b/evennia/web/api/README.md @@ -0,0 +1,103 @@ +# Evennia API + +## What's an API? +An API, or [Application Programming Interface][wiki-api], is a way of establishing rules +through which external services can use your program. In web development, it's +often that case that the 'frontend' of a web app is written in HTML and Javascript +and communicates with the 'backend' server through an API so that it can retrieve +information to populate web pages or process actions when users click buttons on +a web page. + + +The API contained within the web/api/ package is an implementation of the +[Django Rest Framework][drf]. It provides tools to allow you to quickly process +requests for resources and generate responses. URLs, called endpoints, are +mapped to python classes called views, which handle requests for resources. +Requests might contain data that is formatted as [JSON strings][json], which DRF +can convert into python objects for you, a process called deserialization. +When returning a response, it can also convert python objects into JSON +strings to send back to a client, which is called serialization. Because it's +such a common task to want to handle [CRUD][crud] operations for the django models that you use to represent database +objects (such as your Character typeclass, Room typeclass, etc), DRF makes +this process very easy by letting you define [Serializers][serializers] +that largely automate the process of serializing your in-game objects into +JSON representations for sending them to a client, or for turning a JSON string +into a model for updating or creating it. + +## What is it for? + +Having an API can allow you to have richer interactions with client applications. For +example, suppose you want to allow players to send and receive in-game messages from +outside the game. You might define an endpoint that will retrieve all of a character's +messages and returns it as a JSON response. Then in a webpage, you have a button that +the user can click to make an [AJAX][ajax] request to that endpoint, retrieves the data, and +displays it on the page. You also provide a form to let them send messages, where the +submit button uses AJAX to make a POST request to that endpoint, sending along the +JSON data from the form, and then returns the response of the results. This works, +but then a tech-savvy player might ask if they can have their own application that +will retrieve messages periodically for their own computer. By having a [REST][REST] API that +they can use, they can create client applications of their own to retrieve or change +data. + +Other examples of what you might use a RESTful API for would be players managing +tasks out-of-game like crafting, guild management, retrieving stats on their +characters, building rooms/grids, editing character details, etc. Any task that +doesn't require real-time 2-way interaction is a good candidate for an API endpoint. + +## Sample requests + +The API contains a number of views already defined. If the API is enabled, by +setting `REST_API_ENABLED = True` in your `settings.py`, endpoints will be +accessible by users who make authenticated requests as users with builder +permissions. Individual objects will check lockstrings to determine if the +user has permission to perform retrieve/update/delete actions upon them. + +To view an object, you might make a request by using the +excellent [requests library][requests]: +```pythonstub +>>> import requests +>>> response = requests.get("https://www.mygame.com/api/objects/57", + auth=("Myusername", "password123")) +>>> response.json() +{"db_key": "A rusty longsword", "id": 57, "db_location": 213, ...} +``` +The above example makes a GET request to the /objects/ endpoint to retrieve the +object with an ID of 57, retrieving basic data for it. Now suppose that you want +to use the API to create an object: +```pythonstub +>>> data = {"db_key": "A shiny sword"} +>>> response = requests.post("https://www.mygame.com/api/objects", + data=data, auth=("Anotherusername", "sekritpassword")) +>>> response.json() +{"db_key": "A shiny sword", "id": 214, "db_location": None, ...} +``` +In the above example, you make a POST request to the /objects/ endpoint with +the name of the object you wish to create passed along as data. Now suppose you +decided you didn't like the name, and wanted to change it for the newly created +object: +```pythonstub +>>> data = {"db_key": "An even SHINIER sword", "db_location": 50} +>>> response = requests.post("https://www.mygame.com/api/objects/214", + data=data, auth=("Alsoauser", "Badpassword")) +>>> response.json() +{"db_key": "An even SHINIER sword", "id": 214, "db_location": 50, ...} +``` +By making a POST request to the endpoint that includes the object ID, it becomes +a request to update the object with the specified data you pass along. + +In most cases, you won't be making API requests to the backend with python, +but with Javascript from your frontend application. +There are many Javascript libraries which are meant to make this process +easier for requests from the frontend, such as [AXIOS][AXIOS], or using +the native [Fetch][Fetch]. + +[wiki-api]: https://en.wikipedia.org/wiki/Application_programming_interface +[drf]: https://www.django-rest-framework.org/ +[json]: https://en.wikipedia.org/wiki/JSON +[crud]: https://en.wikipedia.org/wiki/Create,_read,_update_and_delete +[serializers]: https://www.django-rest-framework.org/api-guide/serializers/ +[ajax]: https://en.wikipedia.org/wiki/Ajax_(programming) +[REST]: https://en.wikipedia.org/wiki/Representational_state_transfer +[requests]: https://requests.readthedocs.io/en/master/ +[AXIOS]: https://github.com/axios/axios +[Fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API \ No newline at end of file From c83567c95e8be3c7df5add4249f365263f07072d Mon Sep 17 00:00:00 2001 From: TehomCD Date: Sun, 1 Mar 2020 01:07:00 -0500 Subject: [PATCH 07/15] Add filter for aliases and permissions --- evennia/web/api/README.md | 17 ++++++------ evennia/web/api/filters.py | 48 +++++++++++++++++++++++++++++++--- evennia/web/api/permissions.py | 10 +++---- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/evennia/web/api/README.md b/evennia/web/api/README.md index 7b564a9edc..3f1b26ce07 100644 --- a/evennia/web/api/README.md +++ b/evennia/web/api/README.md @@ -1,6 +1,6 @@ # Evennia API -## What's an API? +## Synopsis An API, or [Application Programming Interface][wiki-api], is a way of establishing rules through which external services can use your program. In web development, it's often that case that the 'frontend' of a web app is written in HTML and Javascript @@ -8,7 +8,6 @@ and communicates with the 'backend' server through an API so that it can retriev information to populate web pages or process actions when users click buttons on a web page. - The API contained within the web/api/ package is an implementation of the [Django Rest Framework][drf]. It provides tools to allow you to quickly process requests for resources and generate responses. URLs, called endpoints, are @@ -24,7 +23,7 @@ that largely automate the process of serializing your in-game objects into JSON representations for sending them to a client, or for turning a JSON string into a model for updating or creating it. -## What is it for? +## Motivations For Using An API Having an API can allow you to have richer interactions with client applications. For example, suppose you want to allow players to send and receive in-game messages from @@ -35,7 +34,7 @@ displays it on the page. You also provide a form to let them send messages, wher submit button uses AJAX to make a POST request to that endpoint, sending along the JSON data from the form, and then returns the response of the results. This works, but then a tech-savvy player might ask if they can have their own application that -will retrieve messages periodically for their own computer. By having a [REST][REST] API that +will retrieve messages periodically for their own computer. By having a [REST][rest] API that they can use, they can create client applications of their own to retrieve or change data. @@ -88,8 +87,8 @@ a request to update the object with the specified data you pass along. In most cases, you won't be making API requests to the backend with python, but with Javascript from your frontend application. There are many Javascript libraries which are meant to make this process -easier for requests from the frontend, such as [AXIOS][AXIOS], or using -the native [Fetch][Fetch]. +easier for requests from the frontend, such as [AXIOS][axios], or using +the native [Fetch][fetch]. [wiki-api]: https://en.wikipedia.org/wiki/Application_programming_interface [drf]: https://www.django-rest-framework.org/ @@ -97,7 +96,7 @@ the native [Fetch][Fetch]. [crud]: https://en.wikipedia.org/wiki/Create,_read,_update_and_delete [serializers]: https://www.django-rest-framework.org/api-guide/serializers/ [ajax]: https://en.wikipedia.org/wiki/Ajax_(programming) -[REST]: https://en.wikipedia.org/wiki/Representational_state_transfer +[rest]: https://en.wikipedia.org/wiki/Representational_state_transfer [requests]: https://requests.readthedocs.io/en/master/ -[AXIOS]: https://github.com/axios/axios -[Fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API \ No newline at end of file +[axios]: https://github.com/axios/axios +[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API \ No newline at end of file diff --git a/evennia/web/api/filters.py b/evennia/web/api/filters.py index df580ba2d2..6888b5fbb1 100644 --- a/evennia/web/api/filters.py +++ b/evennia/web/api/filters.py @@ -1,27 +1,67 @@ +""" +FilterSets allow clients to specify querystrings that will determine the data +that is retrieved in GET requests. By default, Django Rest Framework uses the +'django-filter' package as its backend. Django-filter also has a section in its +documentation specifically regarding DRF integration. + +https://django-filter.readthedocs.io/en/latest/guide/rest_framework.html +""" +from django.db.models import Q from django_filters.rest_framework.filterset import FilterSet +from django_filters.filters import CharFilter from evennia.objects.models import ObjectDB from evennia.accounts.models import AccountDB from evennia.scripts.models import ScriptDB + +class TagTypeFilter(CharFilter): + """ + This class lets you create different filters for tags of a specified db_tagtype. + """ + tag_type = None + + def filter(self, qs, value): + return qs.filter(Q(db_tags__db_tagtype=self.tag_type) & Q(db_tags__db_key=value)) + + +class AliasFilter(TagTypeFilter): + """A filter for objects by their aliases (tags with a tagtype of 'alias'""" + tag_type = "alias" + + +class PermissionFilter(TagTypeFilter): + """A filter for objects by their permissions (tags with a tagtype of 'permission'""" + tag_type = "permission" + + SHARED_FIELDS = ["db_key", "db_typeclass_path", "db_tags__db_key", "db_tags__db_category"] -class ObjectDBFilterSet(FilterSet): +class BaseTypeclassFilterSet(FilterSet): + """A parent class with filters for aliases and permissions""" + alias = AliasFilter(lookup_expr="iexact") + permission = PermissionFilter(lookup_expr="iexact") + + +class ObjectDBFilterSet(BaseTypeclassFilterSet): + """This adds filters for ObjectDB instances - characters, rooms, exits, etc""" 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 AccountDBFilterSet(BaseTypeclassFilterSet): + """This adds filters for Account objects""" class Meta: model = AccountDB fields = SHARED_FIELDS + ["username", "db_is_connected", "db_is_bot"] -class ScriptDBFilterSet(FilterSet): +class ScriptDBFilterSet(BaseTypeclassFilterSet): + """This adds filters for Script objects""" 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"] + "db_account__username", "db_is_active", "db_persistent", "db_interval"] diff --git a/evennia/web/api/permissions.py b/evennia/web/api/permissions.py index e56cd1cf23..dd1a52c952 100644 --- a/evennia/web/api/permissions.py +++ b/evennia/web/api/permissions.py @@ -10,11 +10,11 @@ class EvenniaPermission(permissions.BasePermission): 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"] + MINIMUM_LIST_PERMISSION = settings.REST_FRAMEWORK.get("DEFAULT_LIST_PERMISSION", "builder") + MINIMUM_CREATE_PERMISSION = settings.REST_FRAMEWORK.get("DEFAULT_CREATE_PERMISSION", "builder") + view_locks = settings.REST_FRAMEWORK.get("DEFAULT_VIEW_LOCKS", ["examine"]) + destroy_locks = settings.REST_FRAMEWORK.get("DEFAULT_DESTROY_LOCKS", ["delete"]) + update_locks = settings.REST_FRAMEWORK.get("DEFAULT_UPDATE_LOCKS", ["control", "edit"]) def has_permission(self, request, view): """ From ec570a17cdd4027d6308fcca0a57aed70ea73ebf Mon Sep 17 00:00:00 2001 From: TehomCD Date: Sun, 1 Mar 2020 10:02:23 -0500 Subject: [PATCH 08/15] Fix filtering --- evennia/web/api/filters.py | 6 +++++- evennia/web/api/tests.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/evennia/web/api/filters.py b/evennia/web/api/filters.py index 6888b5fbb1..0c93aa3650 100644 --- a/evennia/web/api/filters.py +++ b/evennia/web/api/filters.py @@ -8,7 +8,7 @@ https://django-filter.readthedocs.io/en/latest/guide/rest_framework.html """ from django.db.models import Q from django_filters.rest_framework.filterset import FilterSet -from django_filters.filters import CharFilter +from django_filters.filters import CharFilter, EMPTY_VALUES from evennia.objects.models import ObjectDB from evennia.accounts.models import AccountDB @@ -22,6 +22,10 @@ class TagTypeFilter(CharFilter): tag_type = None def filter(self, qs, value): + # if no value is specified, we don't use the filter + if value in EMPTY_VALUES: + return qs + # if they enter a value, we filter objects by having a tag of this type with the given name return qs.filter(Q(db_tags__db_tagtype=self.tag_type) & Q(db_tags__db_key=value)) diff --git a/evennia/web/api/tests.py b/evennia/web/api/tests.py index 2afd5310b8..50b35a8bbe 100644 --- a/evennia/web/api/tests.py +++ b/evennia/web/api/tests.py @@ -19,6 +19,7 @@ urlpatterns = [ ) class TestEvenniaRESTApi(EvenniaTest): client_class = APIClient + maxDiff = None def setUp(self): super().setUp() @@ -96,6 +97,7 @@ class TestEvenniaRESTApi(EvenniaTest): with self.subTest(msg=f"Testing {view.view_name} "): view_url = reverse(f"api:{view.view_name}") response = self.client.get(view_url) + self.assertEqual(response.status_code, 200) self.assertCountEqual(response.data['results'], [view.serializer(obj).data for obj in view.list]) def test_create(self): From 1c4fabbfa2cc726dbd8bd3ea191b8fd346e586fb Mon Sep 17 00:00:00 2001 From: TehomCD Date: Sun, 1 Mar 2020 19:11:04 -0500 Subject: [PATCH 09/15] Add more fields to serializers --- evennia/web/api/permissions.py | 50 +++++++++++---- evennia/web/api/serializers.py | 112 +++++++++++++++++++++++++++++---- evennia/web/api/tests.py | 26 +++++--- evennia/web/api/views.py | 7 ++- requirements.txt | 4 +- 5 files changed, 161 insertions(+), 38 deletions(-) diff --git a/evennia/web/api/permissions.py b/evennia/web/api/permissions.py index dd1a52c952..b68fa0769c 100644 --- a/evennia/web/api/permissions.py +++ b/evennia/web/api/permissions.py @@ -17,11 +17,20 @@ class EvenniaPermission(permissions.BasePermission): update_locks = settings.REST_FRAMEWORK.get("DEFAULT_UPDATE_LOCKS", ["control", "edit"]) 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. + """Checks for permissions + + Args: + request (Request): The incoming request object. + view (View): The django view we are checking permission for. + + Returns: + bool: If permission is granted or not. If we return False here, a PermissionDenied + error will be raised from the view. + + Notes: + 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 called after this, assuming this returns `True`. """ # Only allow authenticated users to call the API if not request.user.is_authenticated: @@ -37,17 +46,34 @@ class EvenniaPermission(permissions.BasePermission): @staticmethod def check_locks(obj, user, locks): + """Checks access for user for object with given locks + Args: + obj: Object instance we're checking + user (Account): User who we're checking permissions + locks (list): list of lockstrings + + Returns: + bool: True if they have access, False if they don't + """ return any([obj.access(user, lock) for lock in locks]) def has_object_permission(self, request, view, obj): + """Checks object-level permissions after has_permission + + Args: + request (Request): The incoming request object. + view (View): The django view we are checking permission for. + obj: Object we're checking object-level permissions for + + Returns: + bool: If permission is granted or not. If we return False here, a PermissionDenied + error will be raised from the view. + + Notes: + 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. """ - 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) diff --git a/evennia/web/api/serializers.py b/evennia/web/api/serializers.py index 7060df75ee..117ca0a816 100644 --- a/evennia/web/api/serializers.py +++ b/evennia/web/api/serializers.py @@ -11,8 +11,8 @@ often django model instances, that we can use (deserialization). from rest_framework import serializers -from evennia.objects.models import ObjectDB -from evennia.accounts.models import AccountDB +from evennia.objects.objects import DefaultObject +from evennia.accounts.accounts import DefaultAccount from evennia.scripts.models import ScriptDB from evennia.typeclasses.attributes import Attribute from evennia.typeclasses.tags import Tag @@ -32,36 +32,122 @@ class TagSerializer(serializers.ModelSerializer): class SimpleObjectDBSerializer(serializers.ModelSerializer): class Meta: - model = ObjectDB + model = DefaultObject fields = ["id", "db_key"] class TypeclassSerializerMixin(object): + """Mixin that contains types shared by typeclasses. A note about tags, aliases, and permissions. You + might note that the methods and fields are defined here, but they're included explicitly in each child + class. What gives? It's a DRF error: serializer method fields which are inherited do not resolve correctly + in child classes, and as of this current version (3.11) you must have them in the child classes explicitly + to avoid field errors. + """ db_attributes = AttributeSerializer(many=True) - db_tags = TagSerializer(many=True) - shared_fields = ["id", "db_key", "db_attributes", "db_tags", "db_typeclass_path"] + shared_fields = ["id", "db_key", "db_attributes", "db_typeclass_path", "aliases", "tags", "permissions"] + + def get_tags(self, obj): + """ + Serializes tags from the object's Tagshandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of TagSerializer data + """ + return TagSerializer(obj.tags.get(return_tagobj=True, return_list=True), many=True).data + + def get_aliases(self, obj): + """ + Serializes tags from the object's Aliashandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of TagSerializer data + """ + return TagSerializer(obj.aliases.get(return_tagobj=True, return_list=True), many=True).data + + def get_permissions(self, obj): + """ + Serializes tags from the object's Permissionshandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of TagSerializer data + """ + return TagSerializer(obj.permissions.get(return_tagobj=True, return_list=True), many=True).data class ObjectDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): - contents = SimpleObjectDBSerializer(source="locations_set", many=True, read_only=True) + contents = serializers.SerializerMethodField() + exits = serializers.SerializerMethodField() + tags = serializers.SerializerMethodField() + aliases = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField() class Meta: - model = ObjectDB - fields = ["db_location", "db_home", "contents"] + TypeclassSerializerMixin.shared_fields - read_only_fields = ["id", "db_attributes", "db_tags"] + model = DefaultObject + fields = ["db_location", "db_home", "contents", "exits"] + TypeclassSerializerMixin.shared_fields + read_only_fields = ["id", "db_attributes"] + + def get_exits(self, obj): + """ + Gets exits for the object + Args: + obj: Object being serialized + + Returns: + List of data from SimpleObjectDBSerializer + """ + exits = [ob for ob in obj.contents if ob.destination] + return SimpleObjectDBSerializer(exits, many=True).data + + def get_contents(self, obj): + """ + Gets non-exits for the object + Args: + obj: Object being serialized + + Returns: + List of data from SimpleObjectDBSerializer + """ + non_exits = [ob for ob in obj.contents if not ob.destination] + return SimpleObjectDBSerializer(non_exits, many=True).data -class AccountDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): +class AccountSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): + """This uses the DefaultAccount object to have access to the sessions property""" db_key = serializers.CharField(required=False) + session_ids = serializers.SerializerMethodField() + tags = serializers.SerializerMethodField() + aliases = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField() + + def get_session_ids(self, obj): + """ + Gets a list of session IDs connected to this Account + Args: + obj (DefaultAccount): Account we're grabbing sessions from + + Returns: + List of session IDs + """ + return [sess.sessid for sess in obj.sessions.all() if hasattr(sess, "sessid")] class Meta: - model = AccountDB - fields = ["username"] + TypeclassSerializerMixin.shared_fields - read_only_fields = ["id", "db_attributes", "db_tags"] + model = DefaultAccount + fields = ["username", "session_ids"] + TypeclassSerializerMixin.shared_fields + read_only_fields = ["id", "db_attributes", "db_tags", "session_ids"] class ScriptDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): + tags = serializers.SerializerMethodField() + aliases = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField() + class Meta: model = ScriptDB fields = ["db_interval", "db_persistent", "db_start_delay", diff --git a/evennia/web/api/tests.py b/evennia/web/api/tests.py index 50b35a8bbe..4e17b424b9 100644 --- a/evennia/web/api/tests.py +++ b/evennia/web/api/tests.py @@ -26,6 +26,8 @@ class TestEvenniaRESTApi(EvenniaTest): self.account.is_superuser = True self.account.save() self.client.force_login(self.account) + # scripts do not have default locks. Without them, even superuser access check fails + self.script.locks.add("edit: perm(Admin); examine: perm(Admin); delete: perm(Admin)") def tearDown(self): try: @@ -35,21 +37,27 @@ class TestEvenniaRESTApi(EvenniaTest): def get_view_details(self, action): """Helper function for generating list of named tuples""" - View = namedtuple("View", ["view_name", "obj", "list", "serializer", "create_data"]) + View = namedtuple("View", ["view_name", "obj", "list", "serializer", "create_data", "retrieve_data"]) views = [ View("object-%s" % action, self.obj1, [self.obj1, self.char1, self.exit, self.room1, self.room2, self.obj2, self.char2], serializers.ObjectDBSerializer, - {"db_key": "object-create-test-name"}), + {"db_key": "object-create-test-name"}, + serializers.ObjectDBSerializer(self.obj1).data), View("character-%s" % action, self.char1, [self.char1, self.char2], serializers.ObjectDBSerializer, - {"db_key": "character-create-test-name"}), + {"db_key": "character-create-test-name"}, + serializers.ObjectDBSerializer(self.char1).data), View("exit-%s" % action, self.exit, [self.exit], serializers.ObjectDBSerializer, - {"db_key": "exit-create-test-name"}), + {"db_key": "exit-create-test-name"}, + serializers.ObjectDBSerializer(self.exit).data), View("room-%s" % action, self.room1, [self.room1, self.room2], serializers.ObjectDBSerializer, - {"db_key": "room-create-test-name"}), + {"db_key": "room-create-test-name"}, + serializers.ObjectDBSerializer(self.room1).data), View("script-%s" % action, self.script, [self.script], serializers.ScriptDBSerializer, - {"db_key": "script-create-test-name"}), - View("account-%s" % action, self.account2, [self.account, self.account2], serializers.AccountDBSerializer, - {"username": "account-create-test-name"}), + {"db_key": "script-create-test-name"}, + serializers.ScriptDBSerializer(self.script).data), + View("account-%s" % action, self.account2, [self.account, self.account2], serializers.AccountSerializer, + {"username": "account-create-test-name"}, + serializers.AccountSerializer(self.account2).data), ] return views @@ -62,6 +70,7 @@ class TestEvenniaRESTApi(EvenniaTest): ) response = self.client.get(view_url) self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.data, view.retrieve_data) def test_update(self): views = self.get_view_details("detail") @@ -78,6 +87,7 @@ class TestEvenniaRESTApi(EvenniaTest): self.assertEqual(response.status_code, 200) view.obj.refresh_from_db() self.assertEqual(getattr(view.obj, field), new_key) + self.assertEqual(response.data[field], new_key) def test_delete(self): views = self.get_view_details("detail") diff --git a/evennia/web/api/views.py b/evennia/web/api/views.py index 0055431e0d..1c5988f1db 100644 --- a/evennia/web/api/views.py +++ b/evennia/web/api/views.py @@ -14,7 +14,7 @@ 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.serializers import ObjectDBSerializer, AccountSerializer, ScriptDBSerializer, AttributeSerializer from evennia.web.api.filters import ObjectDBFilterSet, AccountDBFilterSet, ScriptDBFilterSet from evennia.web.api.permissions import EvenniaPermission @@ -26,7 +26,8 @@ class TypeclassViewSetMixin(object): """ # 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 + # the filter backend allows for retrieval views to have filter arguments passed to it, + # for example: mygame.com/api/objects?db_key=bob to find matches based on objects having a db_key of bob filter_backends = [DjangoFilterBackend] @action(detail=True, methods=["put", "post"]) @@ -93,7 +94,7 @@ class ExitViewSet(ObjectDBViewSet): class AccountDBViewSet(TypeclassViewSetMixin, ModelViewSet): """Viewset for Account objects""" - serializer_class = AccountDBSerializer + serializer_class = AccountSerializer queryset = AccountDB.objects.all() filterset_class = AccountDBFilterSet diff --git a/requirements.txt b/requirements.txt index 3da37476d4..8ebbe7cce7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,8 +5,8 @@ attrs >= 19.2.0 django >= 2.2.5, < 2.3 twisted >= 19.2.1, < 20.0.0 pytz -djangorestframework >= 3.10.3 -django-filter >= 2.2.0 +djangorestframework >= 3.10.3, < 3.12 +django-filter >= 2.2.0, < 2.3 django-sekizai inflect autobahn >= 17.9.3 From 6da42319399cd5fe94ea0c5ff29c44f2e55ca29d Mon Sep 17 00:00:00 2001 From: TehomCD Date: Sun, 8 Mar 2020 00:04:40 -0500 Subject: [PATCH 10/15] Resolve trailing slash issue, fix typeclass manager --- evennia/typeclasses/managers.py | 2 +- evennia/web/api/README.md | 52 +++++++++++++++++++++++++++++---- evennia/web/api/urls.py | 1 + 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index e9ad42ed11..c886a67400 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -573,7 +573,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): for parent in (parent for parent in parents if hasattr(parent, "path")): query = query | Q(db_typeclass_path__exact=parent.path) # actually query the database - return self.filter(query) + return super().filter(query) class TypeclassManager(TypedObjectManager): diff --git a/evennia/web/api/README.md b/evennia/web/api/README.md index 3f1b26ce07..d06838fbf4 100644 --- a/evennia/web/api/README.md +++ b/evennia/web/api/README.md @@ -50,9 +50,21 @@ setting `REST_API_ENABLED = True` in your `settings.py`, endpoints will be accessible by users who make authenticated requests as users with builder permissions. Individual objects will check lockstrings to determine if the user has permission to perform retrieve/update/delete actions upon them. +To start with, you can view a synopsis of endpoints by making a GET request +to the `yourgame/api/` endpoint by using the excellent [requests library][requests]: +```pythonstub +>>> import requests +>>> r = requests.get("https://www.mygame.com/api", auth=("user", "pw")) +>>> r.json() +{'accounts': 'http://www.mygame.com/api/accounts/', + 'objects': 'http://www.mygame.com/api/objects/', +'characters': 'http://www.mygame.comg/api/characters/', +'exits': 'http://www.mygame.com/api/exits/', +'rooms': 'http://www.mygame.com/api/rooms/', +'scripts': 'http://www.mygame.com/api/scripts/'} +``` -To view an object, you might make a request by using the -excellent [requests library][requests]: +To view an object, you might make a request like this: ```pythonstub >>> import requests >>> response = requests.get("https://www.mygame.com/api/objects/57", @@ -61,7 +73,35 @@ excellent [requests library][requests]: {"db_key": "A rusty longsword", "id": 57, "db_location": 213, ...} ``` The above example makes a GET request to the /objects/ endpoint to retrieve the -object with an ID of 57, retrieving basic data for it. Now suppose that you want +object with an ID of 57, retrieving basic data for it. + +For listing a number of objects, you might do this: +```pythonstub +>>> response = requests.get("https://www.mygame.com/api/objects", + auth=("Myusername", "password123")) +>>> response.json() +{ +"count": 125, +"next": "https://www.mygame.com/api/objects/?limit=25&offset=25", +"previous": null, +"results" : [{"db_key": "A rusty longsword", "id": 57, "db_location": 213, ...}]} +``` +In the above example, it now displays the objects inside the "results" array, while it has a "count" value +for the number of total objects, and "next" and "previous" links for the next and previous page, if any. +This is called [pagination][pagination], and the link displays "limit" and "offset" as query parameters that +can be added to the url to control the output. Other query parameters can be defined as [filters][filters] which +allow you to further narrow the results. For example, to only get accounts with developer permissions: +```pythonstub +>>> response = requests.get("https://www.mygame.com/api/accounts/?permission=developer", + auth=("user", "pw")) +>>> response.json() +{ +"count": 1, +"results": [{"username": "bob",...}] +} +``` + +Now suppose that you want to use the API to create an object: ```pythonstub >>> data = {"db_key": "A shiny sword"} @@ -76,12 +116,12 @@ decided you didn't like the name, and wanted to change it for the newly created object: ```pythonstub >>> data = {"db_key": "An even SHINIER sword", "db_location": 50} ->>> response = requests.post("https://www.mygame.com/api/objects/214", +>>> response = requests.put("https://www.mygame.com/api/objects/214", data=data, auth=("Alsoauser", "Badpassword")) >>> response.json() {"db_key": "An even SHINIER sword", "id": 214, "db_location": 50, ...} ``` -By making a POST request to the endpoint that includes the object ID, it becomes +By making a PUT request to the endpoint that includes the object ID, it becomes a request to update the object with the specified data you pass along. In most cases, you won't be making API requests to the backend with python, @@ -92,6 +132,8 @@ the native [Fetch][fetch]. [wiki-api]: https://en.wikipedia.org/wiki/Application_programming_interface [drf]: https://www.django-rest-framework.org/ +[pagination]: https://www.django-rest-framework.org/api-guide/pagination/ +[filters]: https://www.django-rest-framework.org/api-guide/filtering/#filtering [json]: https://en.wikipedia.org/wiki/JSON [crud]: https://en.wikipedia.org/wiki/Create,_read,_update_and_delete [serializers]: https://www.django-rest-framework.org/api-guide/serializers/ diff --git a/evennia/web/api/urls.py b/evennia/web/api/urls.py index 7eea5d01b8..0594e26d16 100644 --- a/evennia/web/api/urls.py +++ b/evennia/web/api/urls.py @@ -28,6 +28,7 @@ from evennia.web.api.views import ( app_name = "api" router = routers.DefaultRouter() +router.trailing_slash = "/?" router.register(r'accounts', AccountDBViewSet, basename="account") router.register(r'objects', ObjectDBViewSet, basename="object") router.register(r'characters', CharacterViewSet, basename="character") From 75812dcdd5f2ab0b1aad169ac9ecf575e8f7f143 Mon Sep 17 00:00:00 2001 From: TehomCD Date: Fri, 13 Mar 2020 21:52:03 -0400 Subject: [PATCH 11/15] Fix attribute serializer, add name filter --- evennia/web/api/filters.py | 34 +++++++++++++++++++++++++++++++++- evennia/web/api/serializers.py | 26 +++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/evennia/web/api/filters.py b/evennia/web/api/filters.py index 0c93aa3650..93622c6b85 100644 --- a/evennia/web/api/filters.py +++ b/evennia/web/api/filters.py @@ -6,6 +6,7 @@ documentation specifically regarding DRF integration. https://django-filter.readthedocs.io/en/latest/guide/rest_framework.html """ +from typing import Union from django.db.models import Q from django_filters.rest_framework.filterset import FilterSet from django_filters.filters import CharFilter, EMPTY_VALUES @@ -15,6 +16,19 @@ from evennia.accounts.models import AccountDB from evennia.scripts.models import ScriptDB +def get_tag_query(tag_type: Union[str, None], key: str) -> Q: + """ + + Args: + tag_type(str or None): The type of tag (None, 'alias', etc) + key (str): The name of the tag + + Returns: + A Q object that for searching by this tag type and name + """ + return Q(db_tags__db_tagtype=tag_type) & Q(db_tags__db_key__iexact=key) + + class TagTypeFilter(CharFilter): """ This class lets you create different filters for tags of a specified db_tagtype. @@ -26,7 +40,7 @@ class TagTypeFilter(CharFilter): if value in EMPTY_VALUES: return qs # if they enter a value, we filter objects by having a tag of this type with the given name - return qs.filter(Q(db_tags__db_tagtype=self.tag_type) & Q(db_tags__db_key=value)) + return qs.filter(get_tag_query(self.tag_type, value)).distinct() class AliasFilter(TagTypeFilter): @@ -46,6 +60,22 @@ class BaseTypeclassFilterSet(FilterSet): """A parent class with filters for aliases and permissions""" alias = AliasFilter(lookup_expr="iexact") permission = PermissionFilter(lookup_expr="iexact") + name = CharFilter(lookup_expr="iexact", method="filter_name", field_name="db_key") + + def filter_name(self, queryset, name, value): + """ + + Args: + queryset: The queryset being filtered + name: The name of the field + value: The value passed in from GET params + + Returns: + The filtered queryset + """ + query = Q(**{f"{name}__iexact": value}) + query |= get_tag_query("alias", value) + return queryset.filter(query).distinct() class ObjectDBFilterSet(BaseTypeclassFilterSet): @@ -58,6 +88,8 @@ class ObjectDBFilterSet(BaseTypeclassFilterSet): class AccountDBFilterSet(BaseTypeclassFilterSet): """This adds filters for Account objects""" + name = CharFilter(lookup_expr="iexact", method="filter_name", field_name="username") + class Meta: model = AccountDB fields = SHARED_FIELDS + ["username", "db_is_connected", "db_is_bot"] diff --git a/evennia/web/api/serializers.py b/evennia/web/api/serializers.py index 117ca0a816..a1d265f3b7 100644 --- a/evennia/web/api/serializers.py +++ b/evennia/web/api/serializers.py @@ -19,9 +19,26 @@ from evennia.typeclasses.tags import Tag class AttributeSerializer(serializers.ModelSerializer): + value_display = serializers.SerializerMethodField(source="value") + db_value = serializers.CharField(write_only=True, required=False) + class Meta: model = Attribute - fields = ["db_key", "db_value", "db_category", "db_attrtype"] + fields = ["db_key", "db_category", "db_attrtype", "value_display", "db_value"] + + @staticmethod + def get_value_display(obj: Attribute) -> str: + """ + Gets the string display of an Attribute's value for serialization + Args: + obj: Attribute being serialized + + Returns: + The Attribute's value in string format + """ + if obj.db_strvalue: + return obj.db_strvalue + return str(obj.value) class TagSerializer(serializers.ModelSerializer): @@ -41,9 +58,9 @@ class TypeclassSerializerMixin(object): might note that the methods and fields are defined here, but they're included explicitly in each child class. What gives? It's a DRF error: serializer method fields which are inherited do not resolve correctly in child classes, and as of this current version (3.11) you must have them in the child classes explicitly - to avoid field errors. + to avoid field errors. Similarly, the child classes must contain the attribute serializer explicitly to + not have them render PK-related fields. """ - db_attributes = AttributeSerializer(many=True) shared_fields = ["id", "db_key", "db_attributes", "db_typeclass_path", "aliases", "tags", "permissions"] @@ -82,6 +99,7 @@ class TypeclassSerializerMixin(object): class ObjectDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): + db_attributes = AttributeSerializer(many=True, read_only=True) contents = serializers.SerializerMethodField() exits = serializers.SerializerMethodField() tags = serializers.SerializerMethodField() @@ -120,6 +138,7 @@ class ObjectDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): class AccountSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): """This uses the DefaultAccount object to have access to the sessions property""" + db_attributes = AttributeSerializer(many=True, read_only=True) db_key = serializers.CharField(required=False) session_ids = serializers.SerializerMethodField() tags = serializers.SerializerMethodField() @@ -144,6 +163,7 @@ class AccountSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): class ScriptDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): + db_attributes = AttributeSerializer(many=True, read_only=True) tags = serializers.SerializerMethodField() aliases = serializers.SerializerMethodField() permissions = serializers.SerializerMethodField() From d441739c9a13c2b491a9e7732c40e6e37ace91e9 Mon Sep 17 00:00:00 2001 From: TehomCD Date: Sat, 14 Mar 2020 00:36:27 -0400 Subject: [PATCH 12/15] Convert serializer to use attributes/nickhandlers --- evennia/web/api/serializers.py | 62 ++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/evennia/web/api/serializers.py b/evennia/web/api/serializers.py index a1d265f3b7..6fab237da4 100644 --- a/evennia/web/api/serializers.py +++ b/evennia/web/api/serializers.py @@ -62,9 +62,10 @@ class TypeclassSerializerMixin(object): not have them render PK-related fields. """ - shared_fields = ["id", "db_key", "db_attributes", "db_typeclass_path", "aliases", "tags", "permissions"] + shared_fields = ["id", "db_key", "attributes", "db_typeclass_path", "aliases", "tags", "permissions"] - def get_tags(self, obj): + @staticmethod + def get_tags(obj): """ Serializes tags from the object's Tagshandler Args: @@ -75,7 +76,8 @@ class TypeclassSerializerMixin(object): """ return TagSerializer(obj.tags.get(return_tagobj=True, return_list=True), many=True).data - def get_aliases(self, obj): + @staticmethod + def get_aliases(obj): """ Serializes tags from the object's Aliashandler Args: @@ -86,7 +88,8 @@ class TypeclassSerializerMixin(object): """ return TagSerializer(obj.aliases.get(return_tagobj=True, return_list=True), many=True).data - def get_permissions(self, obj): + @staticmethod + def get_permissions(obj): """ Serializes tags from the object's Permissionshandler Args: @@ -97,9 +100,34 @@ class TypeclassSerializerMixin(object): """ return TagSerializer(obj.permissions.get(return_tagobj=True, return_list=True), many=True).data + @staticmethod + def get_attributes(obj): + """ + Serializes attributes from the object's AttributeHandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of AttributeSerializer data + """ + return AttributeSerializer(obj.attributes.all(), many=True).data + + @staticmethod + def get_nicks(obj): + """ + Serializes attributes from the object's NicksHandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of AttributeSerializer data + """ + return AttributeSerializer(obj.nicks.all(), many=True).data + class ObjectDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): - db_attributes = AttributeSerializer(many=True, read_only=True) + attributes = serializers.SerializerMethodField() + nicks = serializers.SerializerMethodField() contents = serializers.SerializerMethodField() exits = serializers.SerializerMethodField() tags = serializers.SerializerMethodField() @@ -108,10 +136,11 @@ class ObjectDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): class Meta: model = DefaultObject - fields = ["db_location", "db_home", "contents", "exits"] + TypeclassSerializerMixin.shared_fields - read_only_fields = ["id", "db_attributes"] + fields = ["db_location", "db_home", "contents", "exits", "nicks"] + TypeclassSerializerMixin.shared_fields + read_only_fields = ["id"] - def get_exits(self, obj): + @staticmethod + def get_exits(obj): """ Gets exits for the object Args: @@ -123,7 +152,8 @@ class ObjectDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): exits = [ob for ob in obj.contents if ob.destination] return SimpleObjectDBSerializer(exits, many=True).data - def get_contents(self, obj): + @staticmethod + def get_contents(obj): """ Gets non-exits for the object Args: @@ -138,14 +168,16 @@ class ObjectDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): class AccountSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): """This uses the DefaultAccount object to have access to the sessions property""" - db_attributes = AttributeSerializer(many=True, read_only=True) + attributes = serializers.SerializerMethodField() + nicks = serializers.SerializerMethodField() db_key = serializers.CharField(required=False) session_ids = serializers.SerializerMethodField() tags = serializers.SerializerMethodField() aliases = serializers.SerializerMethodField() permissions = serializers.SerializerMethodField() - def get_session_ids(self, obj): + @staticmethod + def get_session_ids(obj): """ Gets a list of session IDs connected to this Account Args: @@ -158,12 +190,12 @@ class AccountSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): class Meta: model = DefaultAccount - fields = ["username", "session_ids"] + TypeclassSerializerMixin.shared_fields - read_only_fields = ["id", "db_attributes", "db_tags", "session_ids"] + fields = ["username", "session_ids", "nicks"] + TypeclassSerializerMixin.shared_fields + read_only_fields = ["id"] class ScriptDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): - db_attributes = AttributeSerializer(many=True, read_only=True) + attributes = serializers.SerializerMethodField() tags = serializers.SerializerMethodField() aliases = serializers.SerializerMethodField() permissions = serializers.SerializerMethodField() @@ -172,4 +204,4 @@ class ScriptDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): 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"] + read_only_fields = ["id"] From bf694e5d9370def6ba4195839ee5f19a223ee128 Mon Sep 17 00:00:00 2001 From: TehomCD Date: Sat, 14 Mar 2020 00:42:36 -0400 Subject: [PATCH 13/15] Convert filter_name method to staticmethod --- evennia/web/api/filters.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/web/api/filters.py b/evennia/web/api/filters.py index 93622c6b85..0f76250e9c 100644 --- a/evennia/web/api/filters.py +++ b/evennia/web/api/filters.py @@ -62,7 +62,8 @@ class BaseTypeclassFilterSet(FilterSet): permission = PermissionFilter(lookup_expr="iexact") name = CharFilter(lookup_expr="iexact", method="filter_name", field_name="db_key") - def filter_name(self, queryset, name, value): + @staticmethod + def filter_name(queryset, name, value): """ Args: From 74418a558b956cde9cd40262112ce65f746c8b03 Mon Sep 17 00:00:00 2001 From: TehomCD Date: Sat, 14 Mar 2020 01:05:53 -0400 Subject: [PATCH 14/15] Add more detailed docstring --- evennia/web/api/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/web/api/filters.py b/evennia/web/api/filters.py index 0f76250e9c..51852ef078 100644 --- a/evennia/web/api/filters.py +++ b/evennia/web/api/filters.py @@ -18,7 +18,7 @@ from evennia.scripts.models import ScriptDB def get_tag_query(tag_type: Union[str, None], key: str) -> Q: """ - + Returns a Q object for searching by tag names for typeclasses Args: tag_type(str or None): The type of tag (None, 'alias', etc) key (str): The name of the tag From 9ac2769be3335cda86e4e0a17da902b1be29734e Mon Sep 17 00:00:00 2001 From: TehomCD Date: Sat, 14 Mar 2020 01:09:04 -0400 Subject: [PATCH 15/15] Add a bit more to another docstring --- evennia/web/api/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/web/api/filters.py b/evennia/web/api/filters.py index 51852ef078..6284af08ae 100644 --- a/evennia/web/api/filters.py +++ b/evennia/web/api/filters.py @@ -65,7 +65,7 @@ class BaseTypeclassFilterSet(FilterSet): @staticmethod def filter_name(queryset, name, value): """ - + Filters a queryset by aliases or the key of the typeclass Args: queryset: The queryset being filtered name: The name of the field