mirror of
https://github.com/evennia/evennia.git
synced 2026-03-16 21:06:30 +01:00
Merge branch 'TehomCD-add-drf' into develop
This commit is contained in:
commit
2caf7c3610
13 changed files with 902 additions and 9 deletions
|
|
@ -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
|
||||
######################################################################
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
144
evennia/web/api/README.md
Normal 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
|
||||
0
evennia/web/api/__init__.py
Normal file
0
evennia/web/api/__init__.py
Normal file
104
evennia/web/api/filters.py
Normal file
104
evennia/web/api/filters.py
Normal 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"]
|
||||
85
evennia/web/api/permissions.py
Normal file
85
evennia/web/api/permissions.py
Normal 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)
|
||||
207
evennia/web/api/serializers.py
Normal file
207
evennia/web/api/serializers.py
Normal 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
145
evennia/web/api/tests.py
Normal 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
39
evennia/web/api/urls.py
Normal 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
106
evennia/web/api/views.py
Normal 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
|
||||
|
|
@ -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"))]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue