Merge branch 'TehomCD-add-drf' into develop

This commit is contained in:
Griatch 2020-03-21 17:46:33 +01:00
commit 2caf7c3610
13 changed files with 902 additions and 9 deletions

View file

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

View file

@ -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):

View file

@ -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):

144
evennia/web/api/README.md Normal file
View file

@ -0,0 +1,144 @@
# Evennia 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
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.
## 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
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 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 like this:
```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.
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"}
>>> 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.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 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,
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/
[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/
[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

View file

104
evennia/web/api/filters.py Normal file
View file

@ -0,0 +1,104 @@
"""
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 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
from evennia.objects.models import ObjectDB
from evennia.accounts.models import AccountDB
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
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.
"""
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(get_tag_query(self.tag_type, value)).distinct()
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 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")
@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
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):
"""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(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"]
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_interval"]

View file

@ -0,0 +1,85 @@
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.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):
"""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:
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):
"""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.
"""
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", "set_attribute"):
# access type based on set command
return self.check_locks(obj, request.user, self.update_locks)

View file

@ -0,0 +1,207 @@
"""
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.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
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_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):
class Meta:
model = Tag
fields = ["db_key", "db_category", "db_data", "db_tagtype"]
class SimpleObjectDBSerializer(serializers.ModelSerializer):
class Meta:
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. Similarly, the child classes must contain the attribute serializer explicitly to
not have them render PK-related fields.
"""
shared_fields = ["id", "db_key", "attributes", "db_typeclass_path", "aliases", "tags", "permissions"]
@staticmethod
def get_tags(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
@staticmethod
def get_aliases(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
@staticmethod
def get_permissions(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
@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):
attributes = serializers.SerializerMethodField()
nicks = serializers.SerializerMethodField()
contents = serializers.SerializerMethodField()
exits = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField()
aliases = serializers.SerializerMethodField()
permissions = serializers.SerializerMethodField()
class Meta:
model = DefaultObject
fields = ["db_location", "db_home", "contents", "exits", "nicks"] + TypeclassSerializerMixin.shared_fields
read_only_fields = ["id"]
@staticmethod
def get_exits(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
@staticmethod
def get_contents(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 AccountSerializer(TypeclassSerializerMixin, serializers.ModelSerializer):
"""This uses the DefaultAccount object to have access to the sessions property"""
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()
@staticmethod
def get_session_ids(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 = DefaultAccount
fields = ["username", "session_ids", "nicks"] + TypeclassSerializerMixin.shared_fields
read_only_fields = ["id"]
class ScriptDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer):
attributes = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField()
aliases = serializers.SerializerMethodField()
permissions = serializers.SerializerMethodField()
class Meta:
model = ScriptDB
fields = ["db_interval", "db_persistent", "db_start_delay",
"db_is_active", "db_repeats"] + TypeclassSerializerMixin.shared_fields
read_only_fields = ["id"]

145
evennia/web/api/tests.py Normal file
View file

@ -0,0 +1,145 @@
"""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
maxDiff = None
def setUp(self):
super().setUp()
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:
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", "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"},
serializers.ObjectDBSerializer(self.obj1).data),
View("character-%s" % action, self.char1, [self.char1, self.char2], serializers.ObjectDBSerializer,
{"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"},
serializers.ObjectDBSerializer(self.exit).data),
View("room-%s" % action, self.room1, [self.room1, self.room2], serializers.ObjectDBSerializer,
{"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"},
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
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)
self.assertDictEqual(response.data, view.retrieve_data)
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)
self.assertEqual(response.data[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.assertEqual(response.status_code, 200)
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 <type>-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}")
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)

39
evennia/web/api/urls.py Normal file
View file

@ -0,0 +1,39 @@
"""
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,
AccountDBViewSet,
CharacterViewSet,
ExitViewSet,
RoomViewSet,
ScriptDBViewSet
)
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")
router.register(r'exits', ExitViewSet, basename="exit")
router.register(r'rooms', RoomViewSet, basename="room")
router.register(r'scripts', ScriptDBViewSet, basename="script")
urlpatterns = router.urls

106
evennia/web/api/views.py Normal file
View file

@ -0,0 +1,106 @@
"""
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 rest_framework.response import Response
from rest_framework import status
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, AccountSerializer, ScriptDBSerializer, AttributeSerializer
from evennia.web.api.filters import ObjectDBFilterSet, AccountDBFilterSet, ScriptDBFilterSet
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,
# 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"])
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 <object type>/: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):
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)
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 = AccountSerializer
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

View file

@ -6,6 +6,8 @@
# http://diveintopython.org/regular_expressions/street_addresses.html#re.matching.2.3
#
from django.conf.urls import url
from django.conf import settings
from django.urls import path, include
from django.views.generic import RedirectView
@ -20,3 +22,6 @@ urlpatterns = [
# favicon
path("favicon.ico", RedirectView.as_view(url="/media/images/favicon.ico", permanent=False)),
]
if settings.REST_API_ENABLED:
urlpatterns += [url(r'^api/', include("evennia.web.api.urls", namespace="api"))]

View file

@ -1,9 +1,12 @@
# Evennia dependencies
# general
attrs >= 19.2.0
django >= 2.2.5, < 2.3
twisted >= 19.2.1, < 20.0.0
pytz
djangorestframework >= 3.10.3, < 3.12
django-filter >= 2.2.0, < 2.3
django-sekizai
inflect
autobahn >= 17.9.3