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