From 407a5642c49e11fbf97d6f4fac28c2842200b21c Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 23 May 2021 17:00:02 +0200 Subject: [PATCH] Add api customization templates --- CHANGELOG.md | 3 + docs/source/Components/Locks.md | 165 +++--------------- docs/source/Components/Permissions.md | 118 +++++++++++++ docs/source/Components/Web-API.md | 136 +++++++++++++++ docs/source/Components/Web-Admin.md | 2 +- docs/source/Components/Webserver.md | 2 + docs/source/toc.md | 2 + evennia/game_template/web/admin/urls.py | 2 +- evennia/game_template/web/api/__init__.py | 0 .../web/static/rest_framework/css/README.md | 3 + .../static/rest_framework/images/README.md | 3 + .../web/templates/rest_framework/README.md | 3 + evennia/game_template/web/urls.py | 3 +- evennia/game_template/web/webclient/urls.py | 3 +- evennia/game_template/web/website/urls.py | 3 +- evennia/locks/lockfuncs.py | 1 - evennia/web/api/README.md | 15 +- evennia/web/api/serializers.py | 2 + evennia/web/api/tests.py | 10 +- evennia/web/api/urls.py | 2 +- evennia/web/templates/website/_menu.html | 6 +- evennia/web/urls.py | 2 - evennia/web/utils/general_context.py | 9 +- evennia/web/utils/tests.py | 9 +- 24 files changed, 334 insertions(+), 170 deletions(-) create mode 100644 docs/source/Components/Permissions.md create mode 100644 docs/source/Components/Web-API.md create mode 100644 evennia/game_template/web/api/__init__.py create mode 100644 evennia/game_template/web/static/rest_framework/css/README.md create mode 100644 evennia/game_template/web/static/rest_framework/images/README.md create mode 100644 evennia/game_template/web/templates/rest_framework/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 78896c2dda..91225b0ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,9 @@ Up requirements to Django 3.2+ concept of a dynamically created `ChannelCmdSet`. - Add `Msg.db_receiver_external` field to allowe external, string-id message-receivers. - Renamed `app.css` to `website.css` for consistency. Removed old prosimii-css files. +- Remove `mygame/web/static_overrides` and -`template_overrides`, reorganize website/admin/client/api + into a more consistent structure for overriding. Expanded webpage documentation considerably. +- REST API list-view was shortened (#2401). New CSS/HTML. Add ReDoc for API autodoc page. ### Evennia 0.9.5 (2019-2020) diff --git a/docs/source/Components/Locks.md b/docs/source/Components/Locks.md index dc0fab053d..59156c7d9e 100644 --- a/docs/source/Components/Locks.md +++ b/docs/source/Components/Locks.md @@ -43,7 +43,7 @@ command: ```python if not obj.access(accessing_obj, 'delete'): accessing_obj.msg("Sorry, you may not delete that.") - return + return ``` ## Defining locks @@ -51,16 +51,16 @@ command: Defining a lock (i.e. an access restriction) in Evennia is done by adding simple strings of lock definitions to the object's `locks` property using `obj.locks.add()`. -Here are some examples of lock strings (not including the quotes): +Here are some examples of lock strings (not including the quotes): ```python delete:id(34) # only allow obj #34 to delete - edit:all() # let everyone edit + edit:all() # let everyone edit # only those who are not "very_weak" or are Admins may pick this up - get: not attr(very_weak) or perm(Admin) + get: not attr(very_weak) or perm(Admin) ``` -Formally, a lockstring has the following syntax: +Formally, a lockstring has the following syntax: ```python access_type: [NOT] lockfunc1([arg1,..]) [AND|OR] [NOT] lockfunc2([arg1,...]) [...] @@ -77,7 +77,7 @@ total result is `True`, the lock is passed. You can create several lock types one after the other by separating them with a semicolon (`;`) in the lockstring. The string below yields the same result as the previous example: - delete:id(34);edit:all();get: not attr(very_weak) or perm(Admin) + delete:id(34);edit:all();get: not attr(very_weak) or perm(Admin) ### Valid access_types @@ -92,7 +92,7 @@ the default command set) actually checks for, as in the example of `delete` abov Below are the access_types checked by the default commandset. -- [Commands](./Commands) +- [Commands](./Commands) - `cmd` - this defines who may call this command at all. - [Objects](./Objects): - `control` - who is the "owner" of the object. Can set locks, delete it etc. Defaults to the @@ -109,10 +109,10 @@ something like `call:false()`. - `get`- who may pick up the object and carry it around. - `puppet` - who may "become" this object and control it as their "character". - `attrcreate` - who may create new attributes on the object (default True) -- [Characters](./Objects#Characters): +- [Characters](./Objects#Characters): + - Same as for Objects +- [Exits](./Objects#Exits): - Same as for Objects -- [Exits](./Objects#Exits): - - Same as for Objects - `traverse` - who may pass the exit. - [Accounts](./Accounts): - `examine` - who may examine the account's properties. @@ -147,7 +147,7 @@ read a board or post to a board. You could then define locks such as: ```python obj.locks.add("read:perm(Player);post:perm(Admin)") -``` +``` This will create a 'read' access type for Characters having the `Player` permission or above and a 'post' access type for those with `Admin` permissions or above (see below how the `perm()` lock @@ -158,7 +158,7 @@ trying to read the board): ```python if not obj.access(accessing_obj, 'read'): accessing_obj.msg("Sorry, you may not read that.") - return + return ``` ### Lock functions @@ -178,15 +178,15 @@ arguments explicitly given in the lock definition will appear as extra arguments ```python # A simple example lock function. Called with e.g. `id(34)`. This is # defined in, say mygame/server/conf/lockfuncs.py - + def id(accessing_obj, accessed_obj, *args, **kwargs): if args: wanted_id = args[0] return accessing_obj.id == wanted_id - return False + return False ``` -The above could for example be used in a lock function like this: +The above could for example be used in a lock function like this: ```python # we have `obj` and `owner_object` from before @@ -202,7 +202,7 @@ We could check if the "edit" lock is passed with something like this: return ``` -In this example, everyone except the `caller` with the right `id` will get the error. +In this example, everyone except the `caller` with the right `id` will get the error. > (Using the `*` and `**` syntax causes Python to magically put all extra arguments into a list `args` and all keyword arguments into a dictionary `kwargs` respectively. If you are unfamiliar with @@ -258,123 +258,6 @@ child object to change the default. Also creation commands like `create` changes objects you create - for example it sets the `control` lock_type so as to allow you, its creator, to control and delete the object. -# Permissions - -> This section covers the underlying code use of permissions. If you just want to learn how to -practically assign permissions in-game, refer to the [Building Permissions](../Concepts/Building-Permissions) -page, which details how you use the `perm` command. - -A *permission* is simply a list of text strings stored in the handler `permissions` on `Objects` -and `Accounts`. Permissions can be used as a convenient way to structure access levels and -hierarchies. It is set by the `perm` command. Permissions are especially handled by the `perm()` and -`pperm()` lock functions listed above. - -Let's say we have a `red_key` object. We also have red chests that we want to unlock with this key. - - perm red_key = unlocks_red_chests - -This gives the `red_key` object the permission "unlocks_red_chests". Next we lock our red chests: - - lock red chest = unlock:perm(unlocks_red_chests) - -What this lock will expect is to the fed the actual key object. The `perm()` lock function will -check the permissions set on the key and only return true if the permission is the one given. - -Finally we need to actually check this lock somehow. Let's say the chest has an command `open ` -sitting on itself. Somewhere in its code the command needs to figure out which key you are using and -test if this key has the correct permission: - -```python - # self.obj is the chest - # and used_key is the key we used as argument to - # the command. The self.caller is the one trying - # to unlock the chest - if not self.obj.access(used_key, "unlock"): - self.caller.msg("The key does not fit!") - return -``` - -All new accounts are given a default set of permissions defined by -`settings.PERMISSION_ACCOUNT_DEFAULT`. - -Selected permission strings can be organized in a *permission hierarchy* by editing the tuple -`settings.PERMISSION_HIERARCHY`. Evennia's default permission hierarchy is as follows: - - Developer # like superuser but affected by locks - Admin # can administrate accounts - Builder # can edit the world - Helper # can edit help files - Player # can chat and send tells (default level) - -(Also the plural form works, so you could use `Developers` etc too). - -> There is also a `Guest` level below `Player` that is only active if `settings.GUEST_ENABLED` is -set. This is never part of `settings.PERMISSION_HIERARCHY`. - -The main use of this is that if you use the lock function `perm()` mentioned above, a lock check for -a particular permission in the hierarchy will *also* grant access to those with *higher* hierarchy -access. So if you have the permission "Admin" you will also pass a lock defined as `perm(Builder)` -or any of those levels below "Admin". - -When doing an access check from an [Object](./Objects) or Character, the `perm()` lock function will -always first use the permissions of any Account connected to that Object before checking for -permissions on the Object. In the case of hierarchical permissions (Admins, Builders etc), the -Account permission will always be used (this stops an Account from escalating their permission by -puppeting a high-level Character). If the permission looked for is not in the hierarchy, an exact -match is required, first on the Account and if not found there (or if no Account is connected), then -on the Object itself. - -Here is how you use `perm` to give an account more permissions: - - perm/account Tommy = Builders - perm/account/del Tommy = Builders # remove it again - -Note the use of the `/account` switch. It means you assign the permission to the -[Accounts](./Accounts) Tommy instead of any [Character](./Objects) that also happens to be named -"Tommy". - -Putting permissions on the *Account* guarantees that they are kept, *regardless* of which Character -they are currently puppeting. This is especially important to remember when assigning permissions -from the *hierarchy tree* - as mentioned above, an Account's permissions will overrule that of its -character. So to be sure to avoid confusion you should generally put hierarchy permissions on the -Account, not on their Characters (but see also [quelling](./Locks#Quelling)). - -Below is an example of an object without any connected account - -```python - obj1.permissions = ["Builders", "cool_guy"] - obj2.locks.add("enter:perm_above(Accounts) and perm(cool_guy)") - - obj2.access(obj1, "enter") # this returns True! -``` - -And one example of a puppet with a connected account: - -```python - account.permissions.add("Accounts") - puppet.permissions.add("Builders", "cool_guy") - obj2.locks.add("enter:perm_above(Accounts) and perm(cool_guy)") - - obj2.access(puppet, "enter") # this returns False! -``` - -## Superusers - -There is normally only one *superuser* account and that is the one first created when starting -Evennia (User #1). This is sometimes known as the "Owner" or "God" user. A superuser has more than -full access - it completely *bypasses* all locks so no checks are even run. This allows for the -superuser to always have access to everything in an emergency. But it also hides any eventual errors -you might have made in your lock definitions. So when trying out game systems you should either use -quelling (see below) or make a second Developer-level character so your locks get tested correctly. - -## Quelling - -The `quell` command can be used to enforce the `perm()` lockfunc to ignore permissions on the -Account and instead use the permissions on the Character only. This can be used e.g. by staff to -test out things with a lower permission level. Return to the normal operation with `unquell`. Note -that quelling will use the smallest of any hierarchical permission on the Account or Character, so -one cannot escalate one's Account permission by quelling to a high-permission Character. Also the -superuser can quell their powers this way, making them affectable by locks. ## More Lock definition examples @@ -384,7 +267,7 @@ You are only allowed to do *examine* on this object if you have 'excellent' eyes an Attribute `eyesight` with the value `excellent` defined on yourself) or if you have the "Builders" permission string assigned to you. - open: holds('the green key') or perm(Builder) + open: holds('the green key') or perm(Builder) This could be called by the `open` command on a "door" object. The check is passed if you are a Builder or has the right key in your inventory. @@ -453,7 +336,7 @@ object has the attribute *strength* of the right value. For this we would need t function that checks if attributes have a value greater than a given value. Luckily there is already such a one included in evennia (see `evennia/locks/lockfuncs.py`), called `attr_gt`. -So the lock string will look like this: `get:attr_gt(strength, 50)`. We put this on the box now: +So the lock string will look like this: `get:attr_gt(strength, 50)`. We put this on the box now: lock box = get:attr_gt(strength, 50) @@ -463,20 +346,20 @@ strength above 50 however and you'll pick it up no problem. Done! A very heavy b If you wanted to set this up in python code, it would look something like this: ```python - + from evennia import create_object - + # create, then set the lock box = create_object(None, key="box") box.locks.add("get:attr_gt(strength, 50)") - + # or we can assign locks in one go right away box = create_object(None, key="box", locks="get:attr_gt(strength, 50)") - + # set the attributes box.db.desc = "This is a very big and heavy box." box.db.get_err_msg = "You are not strong enough to lift this box." - + # one heavy box, ready to withstand all but the strongest... ``` @@ -492,4 +375,4 @@ when we try to hide away as much of the underlying architecture as possible. The django permissions are not completely gone however. We use it for validating passwords during login. It is also used exclusively for managing Evennia's web-based admin site, which is a graphical front-end for the database of Evennia. You edit and assign such permissions directly from the web -interface. It's stand-alone from the permissions described above. \ No newline at end of file +interface. It's stand-alone from the permissions described above. diff --git a/docs/source/Components/Permissions.md b/docs/source/Components/Permissions.md new file mode 100644 index 0000000000..5e66d672ae --- /dev/null +++ b/docs/source/Components/Permissions.md @@ -0,0 +1,118 @@ +# Permissions + +A *permission* is simply a text string stored in the handler `permissions` on `Objects` +and `Accounts`. Think of it as a specialized sort of [Tag](./Tags) - one specifically dedicated +to access checking. They are thus often tightly coupled to [Locks](./Locks). + +Permissions are used as a convenient way to structure access levels and +hierarchies. It is set by the `perm` command. Permissions are especially +handled by the `perm()` and `pperm()` [lock functions](./Locks). + +Let's say we have a `red_key` object. We also have red chests that we want to unlock with this key. + + perm red_key = unlocks_red_chests + +This gives the `red_key` object the permission "unlocks_red_chests". Next we +lock our red chests: + + lock red chest = unlock:perm(unlocks_red_chests) + +When trying to unlock the red chest with this key, the chest Typeclass could +then take the key and do an access check: + +```python +# in some typeclass file where chest is defined + +class TreasureChest(Object): + + # ... + + def open_chest(self, who, tried_key): + + if not chest.access(who, tried_key, "unlock"): + who.msg("The key does not fit!") + return + +``` + +All new accounts are given a default set of permissions defined by +`settings.PERMISSION_ACCOUNT_DEFAULT`. + +Selected permission strings can be organized in a *permission hierarchy* by editing the tuple +`settings.PERMISSION_HIERARCHY`. Evennia's default permission hierarchy is as follows: + + Developer # like superuser but affected by locks + Admin # can administrate accounts + Builder # can edit the world + Helper # can edit help files + Player # can chat and send tells (default level) + +(Also the plural form works, so you could use `Developers` etc too). + +> There is also a `Guest` level below `Player` that is only active if `settings.GUEST_ENABLED` is +set. This is never part of `settings.PERMISSION_HIERARCHY`. + +The main use of this is that if you use the lock function `perm()` mentioned above, a lock check for +a particular permission in the hierarchy will *also* grant access to those with *higher* hierarchy +access. So if you have the permission "Admin" you will also pass a lock defined as `perm(Builder)` +or any of those levels below "Admin". + +When doing an access check from an [Object](./Objects) or Character, the `perm()` lock function will +always first use the permissions of any Account connected to that Object before checking for +permissions on the Object. In the case of hierarchical permissions (Admins, Builders etc), the +Account permission will always be used (this stops an Account from escalating their permission by +puppeting a high-level Character). If the permission looked for is not in the hierarchy, an exact +match is required, first on the Account and if not found there (or if no Account is connected), then +on the Object itself. + +Here is how you use `perm` to give an account more permissions: + + perm/account Tommy = Builders + perm/account/del Tommy = Builders # remove it again + +Note the use of the `/account` switch. It means you assign the permission to the +[Accounts](./Accounts) Tommy instead of any [Character](./Objects) that also happens to be named +"Tommy". + +Putting permissions on the *Account* guarantees that they are kept, *regardless* of which Character +they are currently puppeting. This is especially important to remember when assigning permissions +from the *hierarchy tree* - as mentioned above, an Account's permissions will overrule that of its +character. So to be sure to avoid confusion you should generally put hierarchy permissions on the +Account, not on their Characters (but see also [quelling](./Locks#Quelling)). + +Below is an example of an object without any connected account + +```python + obj1.permissions = ["Builders", "cool_guy"] + obj2.locks.add("enter:perm_above(Accounts) and perm(cool_guy)") + + obj2.access(obj1, "enter") # this returns True! +``` + +And one example of a puppet with a connected account: + +```python + account.permissions.add("Accounts") + puppet.permissions.add("Builders", "cool_guy") + obj2.locks.add("enter:perm_above(Accounts) and perm(cool_guy)") + + obj2.access(puppet, "enter") # this returns False! +``` + +## Superusers + +There is normally only one *superuser* account and that is the one first created when starting +Evennia (User #1). This is sometimes known as the "Owner" or "God" user. A superuser has more than +full access - it completely *bypasses* all locks so no checks are even run. This allows for the +superuser to always have access to everything in an emergency. But it also hides any eventual errors +you might have made in your lock definitions. So when trying out game systems you should either use +quelling (see below) or make a second Developer-level character so your locks get tested correctly. + +## Quelling + +The `quell` command can be used to enforce the `perm()` lockfunc to ignore permissions on the +Account and instead use the permissions on the Character only. This can be used e.g. by staff to +test out things with a lower permission level. Return to the normal operation with `unquell`. Note +that quelling will use the smallest of any hierarchical permission on the Account or Character, so +one cannot escalate one's Account permission by quelling to a high-permission Character. Also the +superuser can quell their powers this way, making them affectable by locks. diff --git a/docs/source/Components/Web-API.md b/docs/source/Components/Web-API.md new file mode 100644 index 0000000000..ca64137265 --- /dev/null +++ b/docs/source/Components/Web-API.md @@ -0,0 +1,136 @@ +# Evennia REST API + +Evennia makes its database accessible via a REST API found on +[http://localhost:4001/api](http://localhost:4001/api) if running locally with +default setup. The API allows you to retrieve, edit and create resources from +outside the game, for example with your own custom client or game editor. + +While you can view and learn about the api in the web browser, it is really +meant to be accessed in code, by other programs. + +The API is using [Django Rest Framework][drf]. This automates the process +of setting up _views_ (Python code) to process the result of web requests. +The process of retrieving data is similar to that explained on the +[Webserver](./Webserver) page, except the views will here return [JSON][json] +data for the resource you want. You can also _send_ such JSON data +in order to update the database from the outside. + + +## Usage + +To activate the API, add this to your settings file. + + REST_API_ENABLED = True + +The main controlling setting is `REST_FRAMEWORK`, which is a dict. The keys +`DEFAULT_LIST_PERMISSION` and `DEFAULT_CREATE_PERMISSIONS` control who may +view and create new objects via the api respectively. By default, users with +['Builder'-level permission](./Permissions) or higher may access both actions. + +While the api is meant to be expanded upon, Evennia supplies several operations +out of the box. If you click the `Autodoc` button in the upper right of the `/api` +website you'll get a fancy graphical presentation of the available endpoints. + +Here is an example of calling the api in Python using the standard `requests` library. + + >>> import requests + >>> response = requests.get("https://www.mygame.com/api", auth=("MyUsername", "password123")) + >>> response.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/' + 'helpentries': 'http://www.mygame.com/api/helpentries/' } + +To list a specific type of object: + + >>> 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: + + >>> response = requests.get("https://www.mygame.com/api/accounts/?permission=developer", + auth=("MyUserName", "password123")) + >>> response.json() + { + "count": 1, + "results": [{"username": "bob",...}] + } + +Now suppose that you want to use the API to create an [Object](./Objects): + + >>> data = {"db_key": "A shiny sword"} + >>> response = requests.post("https://www.mygame.com/api/objects", + data=data, auth=("Anotherusername", "mypassword")) + >>> response.json() + {"db_key": "A shiny sword", "id": 214, "db_location": None, ...} + + +Here we made a HTTP POST request to the `/api/objects` endpoint with the `db_key` +we wanted. We got back info for the newly created object. You can now make +another request with PUT (replace everything) or PATCH (replace only what you +provide). By providing the id to the endpoint (`/api/objects/214`), +we make sure to update the right sword: + + >>> data = {"db_key": "An even SHINIER sword", "db_location": 50} + >>> response = requests.put("https://www.mygame.com/api/objects/214", + data=data, auth=("Anotherusername", "mypassword")) + >>> response.json() + {"db_key": "An even SHINIER sword", "id": 214, "db_location": 50, ...} + + +In most cases, you won't be making API requests to the backend with Python, +but with Javascript from some 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]. + +## Customizing the API + +Overall, reading up on [Django Rest Framework ViewSets](https://www.django-rest-framework.org/api-guide/viewsets) and +other parts of their documentation is required for expanding and +customizing the API. + +Check out the [Website](Website) page for help on how to override code, templates +and static files. +- API templates (for the web-display) is located in `evennia/web/api/templates/rest_framework/` (it must + be named such to allow override of the original REST framework templates). +- Static files is in `evennia/web/api/static/rest_framework/` +- The api code is located in `evennia/web/api/` - the `url.py` file here is responsible for + collecting all view-classes. + +Contrary to other web components, there is no pre-made urls.py set up for +`mygame/web/api/`. This is because the registration of models with the api is +strongly integrated with the REST api functionality. Easiest is probably to +copy over `evennia/web/api/urls.py` and modify it in place. + + +[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 diff --git a/docs/source/Components/Web-Admin.md b/docs/source/Components/Web-Admin.md index ddb2e59d1e..0d87368f1a 100644 --- a/docs/source/Components/Web-Admin.md +++ b/docs/source/Components/Web-Admin.md @@ -86,7 +86,7 @@ Only Superusers can change the `Superuser status` flag, and grant new permissions to accounts. The superuser is the only permission level that is also relevant in-game. `User Permissions` and `Groups` found on the `Account` admin page _only_ affects the admin - they have no connection to the in-game -[Permissions](Permissions) (Player, Builder, Admin etc). +[Permissions](./Permissions) (Player, Builder, Admin etc). For a staffer with `Staff status` to be able to actually do anything, the superuser must grant at least some permissions for them on their Account. This diff --git a/docs/source/Components/Webserver.md b/docs/source/Components/Webserver.md index 64fe964052..17718ab5c3 100644 --- a/docs/source/Components/Webserver.md +++ b/docs/source/Components/Webserver.md @@ -22,6 +22,8 @@ environment. It leverages the Django web framework and provides: - The [Webclient](./Webclient) page is served by the webserver, but the actual game communication (sending/receiving data) is done by the javascript client on the page opening a websocket connection directly to Evennia's Portal. +- The [Evennia REST-API](./Web-API) allows for accessing the database from outside the game + (only if `REST_API_ENABLED=True). ## Basic Webserver data flow diff --git a/docs/source/toc.md b/docs/source/toc.md index 1731b1a46c..21ddd67512 100644 --- a/docs/source/toc.md +++ b/docs/source/toc.md @@ -38,6 +38,7 @@ - [Components/Nicks](Components/Nicks) - [Components/Objects](Components/Objects) - [Components/Outputfuncs](Components/Outputfuncs) +- [Components/Permissions](Components/Permissions) - [Components/Portal And Server](Components/Portal-And-Server) - [Components/Prototypes](Components/Prototypes) - [Components/Scripts](Components/Scripts) @@ -48,6 +49,7 @@ - [Components/Tags](Components/Tags) - [Components/TickerHandler](Components/TickerHandler) - [Components/Typeclasses](Components/Typeclasses) +- [Components/Web API](Components/Web-API) - [Components/Web Admin](Components/Web-Admin) - [Components/Webclient](Components/Webclient) - [Components/Webserver](Components/Webserver) diff --git a/evennia/game_template/web/admin/urls.py b/evennia/game_template/web/admin/urls.py index 8622f5da5e..f1040fd0da 100644 --- a/evennia/game_template/web/admin/urls.py +++ b/evennia/game_template/web/admin/urls.py @@ -7,7 +7,7 @@ The main web/urls.py includes these routes for all urls starting with `admin/` """ -from django.conf.urls import path +from django.urls import path from evennia.web.admin.urls import urlpatterns as evennia_admin_urlpatterns # add patterns here diff --git a/evennia/game_template/web/api/__init__.py b/evennia/game_template/web/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/game_template/web/static/rest_framework/css/README.md b/evennia/game_template/web/static/rest_framework/css/README.md new file mode 100644 index 0000000000..39a0904068 --- /dev/null +++ b/evennia/game_template/web/static/rest_framework/css/README.md @@ -0,0 +1,3 @@ +# Evennia API static files + +Overrides for API files. diff --git a/evennia/game_template/web/static/rest_framework/images/README.md b/evennia/game_template/web/static/rest_framework/images/README.md new file mode 100644 index 0000000000..3447f3f2c6 --- /dev/null +++ b/evennia/game_template/web/static/rest_framework/images/README.md @@ -0,0 +1,3 @@ +# Static files for API + +Override images here. diff --git a/evennia/game_template/web/templates/rest_framework/README.md b/evennia/game_template/web/templates/rest_framework/README.md new file mode 100644 index 0000000000..072d97834d --- /dev/null +++ b/evennia/game_template/web/templates/rest_framework/README.md @@ -0,0 +1,3 @@ +# Templates for the Evennia API + +Override templates here. diff --git a/evennia/game_template/web/urls.py b/evennia/game_template/web/urls.py index 75fcf3675c..a4a89bba9e 100644 --- a/evennia/game_template/web/urls.py +++ b/evennia/game_template/web/urls.py @@ -12,8 +12,7 @@ should modify urls.py in those sub directories. Search the Django documentation for "URL dispatcher" for more help. """ -from django.conf.urls import path, include - +from django.urls import path, include # default evennia patterns from evennia.web.urls import urlpatterns as evennia_default_urlpatterns diff --git a/evennia/game_template/web/webclient/urls.py b/evennia/game_template/web/webclient/urls.py index 29df982406..dcac27d948 100644 --- a/evennia/game_template/web/webclient/urls.py +++ b/evennia/game_template/web/webclient/urls.py @@ -6,8 +6,7 @@ The main web/urls.py includes these routes for all urls starting with `webclient """ - -from django.conf.urls import path +from django.urls import path from evennia.web.webclient.urls import urlpatterns as evennia_webclient_urlpatterns # add patterns here diff --git a/evennia/game_template/web/website/urls.py b/evennia/game_template/web/website/urls.py index c33faa7837..236de84ade 100644 --- a/evennia/game_template/web/website/urls.py +++ b/evennia/game_template/web/website/urls.py @@ -6,8 +6,7 @@ so it can reroute to all website pages. """ - -from django.conf.urls import path +from django.urls import path from evennia.web.website.urls import urlpatterns as evennia_website_urlpatterns # add patterns here diff --git a/evennia/locks/lockfuncs.py b/evennia/locks/lockfuncs.py index 7adf710479..ac0a7bb6e1 100644 --- a/evennia/locks/lockfuncs.py +++ b/evennia/locks/lockfuncs.py @@ -168,7 +168,6 @@ def perm(accessing_obj, accessed_obj, *args, **kwargs): permission = args[0].lower() perms_object = accessing_obj.permissions.all() except (AttributeError, IndexError) as err: - print("accessing_obj err:", err) return False gtmode = kwargs.pop("_greater_than", False) diff --git a/evennia/web/api/README.md b/evennia/web/api/README.md index 95dd080fe2..df22d7a0d2 100644 --- a/evennia/web/api/README.md +++ b/evennia/web/api/README.md @@ -1,5 +1,8 @@ # Evennia API +This folder contains the code implementing the REST-API of Evennia, based on +Django Rest Framework. + ## Synopsis An API, or [Application Programming Interface][wiki-api], is a way of establishing rules @@ -18,7 +21,7 @@ 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 +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 @@ -54,7 +57,7 @@ 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 +```python >>> import requests >>> r = requests.get("https://www.mygame.com/api", auth=("user", "pw")) >>> r.json() @@ -131,16 +134,16 @@ object: >>> 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, ...} +{"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 +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 diff --git a/evennia/web/api/serializers.py b/evennia/web/api/serializers.py index d5a8166e85..6ef9719908 100644 --- a/evennia/web/api/serializers.py +++ b/evennia/web/api/serializers.py @@ -321,6 +321,7 @@ class HelpSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): "id", "db_key", "db_help_category", "db_entrytext", "db_date_created", "tags", "aliases" ] + read_only_fields = ["id"] class HelpListSerializer(TypeclassListSerializerMixin, serializers.ModelSerializer): """ @@ -332,3 +333,4 @@ class HelpListSerializer(TypeclassListSerializerMixin, serializers.ModelSerializ fields = [ "id", "db_key", "db_help_category", "db_date_created", ] + read_only_fields = ["id"] diff --git a/evennia/web/api/tests.py b/evennia/web/api/tests.py index 32c1aa14cc..8589e1ef61 100644 --- a/evennia/web/api/tests.py +++ b/evennia/web/api/tests.py @@ -39,7 +39,7 @@ class TestEvenniaRESTApi(EvenniaTest): def get_view_details(self, action): """Helper function for generating list of named tuples""" View = namedtuple( - "View", ["view_name", "obj", "list", "serializer", "create_data", "retrieve_data"] + "View", ["view_name", "obj", "list", "serializer", "list_serializer", "create_data", "retrieve_data"] ) views = [ View( @@ -47,6 +47,7 @@ class TestEvenniaRESTApi(EvenniaTest): self.obj1, [self.obj1, self.char1, self.exit, self.room1, self.room2, self.obj2, self.char2], serializers.ObjectDBSerializer, + serializers.ObjectListSerializer, {"db_key": "object-create-test-name"}, serializers.ObjectDBSerializer(self.obj1).data, ), @@ -55,6 +56,7 @@ class TestEvenniaRESTApi(EvenniaTest): self.char1, [self.char1, self.char2], serializers.ObjectDBSerializer, + serializers.ObjectListSerializer, {"db_key": "character-create-test-name"}, serializers.ObjectDBSerializer(self.char1).data, ), @@ -63,6 +65,7 @@ class TestEvenniaRESTApi(EvenniaTest): self.exit, [self.exit], serializers.ObjectDBSerializer, + serializers.ObjectListSerializer, {"db_key": "exit-create-test-name"}, serializers.ObjectDBSerializer(self.exit).data, ), @@ -71,6 +74,7 @@ class TestEvenniaRESTApi(EvenniaTest): self.room1, [self.room1, self.room2], serializers.ObjectDBSerializer, + serializers.ObjectListSerializer, {"db_key": "room-create-test-name"}, serializers.ObjectDBSerializer(self.room1).data, ), @@ -79,6 +83,7 @@ class TestEvenniaRESTApi(EvenniaTest): self.script, [self.script], serializers.ScriptDBSerializer, + serializers.ScriptListSerializer, {"db_key": "script-create-test-name"}, serializers.ScriptDBSerializer(self.script).data, ), @@ -87,6 +92,7 @@ class TestEvenniaRESTApi(EvenniaTest): self.account2, [self.account, self.account2], serializers.AccountSerializer, + serializers.AccountListSerializer, {"username": "account-create-test-name"}, serializers.AccountSerializer(self.account2).data, ), @@ -135,7 +141,7 @@ class TestEvenniaRESTApi(EvenniaTest): 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] + response.data["results"], [view.list_serializer(obj).data for obj in view.list] ) def test_create(self): diff --git a/evennia/web/api/urls.py b/evennia/web/api/urls.py index 57c5c9ddfa..d4c7414762 100644 --- a/evennia/web/api/urls.py +++ b/evennia/web/api/urls.py @@ -34,7 +34,7 @@ router.register(r"characters", views.CharacterViewSet, basename="character") router.register(r"exits", views.ExitViewSet, basename="exit") router.register(r"rooms", views.RoomViewSet, basename="room") router.register(r"scripts", views.ScriptDBViewSet, basename="script") -router.register(r"helpentries", views.HelpViewSet, basename="script") +router.register(r"helpentries", views.HelpViewSet, basename="helpentry") urlpatterns = router.urls diff --git a/evennia/web/templates/website/_menu.html b/evennia/web/templates/website/_menu.html index 32fe06d1ba..cbf8c00812 100644 --- a/evennia/web/templates/website/_menu.html +++ b/evennia/web/templates/website/_menu.html @@ -36,8 +36,10 @@ folder and edit it to add/remove links to the menu. {% endif %} {% if user.is_staff %} -
  • Admin
  • -
  • API
  • +
  • Admin
  • + {% if rest_api_enabled %} +
  • API
  • + {% endif %} {% endif %} {% endblock %} diff --git a/evennia/web/urls.py b/evennia/web/urls.py index 6953738e7f..5b732012cc 100644 --- a/evennia/web/urls.py +++ b/evennia/web/urls.py @@ -30,8 +30,6 @@ urlpatterns = [ path("webclient/", include("evennia.web.webclient.urls")), # admin path("admin/", include("evennia.web.admin.urls")), - # api - path("api/", include("evennia.web.api.urls")), # favicon path("favicon.ico", RedirectView.as_view(url="/media/images/favicon.ico", permanent=False)), ] diff --git a/evennia/web/utils/general_context.py b/evennia/web/utils/general_context.py index 8c5ed61cfc..a2a9306bf3 100644 --- a/evennia/web/utils/general_context.py +++ b/evennia/web/utils/general_context.py @@ -1,9 +1,9 @@ """ This file defines global variables that will always be available in a view -context without having to repeatedly include it. +context without having to repeatedly include it. For this to work, this file is included in the settings file, in the -TEMPLATE_CONTEXT_PROCESSORS tuple. +TEMPLATES["OPTIONS"]["context_processors"] list. """ @@ -20,6 +20,7 @@ GAME_ENTITIES = ["Objects", "Scripts", "Comms", "Help"] GAME_SETUP = ["Permissions", "Config"] CONNECTIONS = ["Irc"] WEBSITE = ["Flatpages", "News", "Sites"] +REST_API_ENABLED = False # Determine the site name and server version def set_game_name_and_slogan(): @@ -31,7 +32,7 @@ def set_game_name_and_slogan(): This function is used for unit testing the values of the globals. """ - global GAME_NAME, GAME_SLOGAN, SERVER_VERSION + global GAME_NAME, GAME_SLOGAN, SERVER_VERSION, REST_API_ENABLED try: GAME_NAME = settings.SERVERNAME.strip() except AttributeError: @@ -42,6 +43,7 @@ def set_game_name_and_slogan(): except AttributeError: GAME_SLOGAN = SERVER_VERSION + REST_API_ENABLED = settings.REST_API_ENABLED def set_webclient_settings(): """ @@ -98,4 +100,5 @@ def general_context(request): "websocket_enabled": WEBSOCKET_CLIENT_ENABLED, "websocket_port": WEBSOCKET_PORT, "websocket_url": WEBSOCKET_URL, + "rest_api_enabled": REST_API_ENABLED, } diff --git a/evennia/web/utils/tests.py b/evennia/web/utils/tests.py index 7a3194da17..4313128d41 100644 --- a/evennia/web/utils/tests.py +++ b/evennia/web/utils/tests.py @@ -9,13 +9,12 @@ class TestGeneralContext(TestCase): @patch("evennia.web.utils.general_context.GAME_NAME", "test_name") @patch("evennia.web.utils.general_context.GAME_SLOGAN", "test_game_slogan") - @patch( - "evennia.web.utils.general_context.WEBSOCKET_CLIENT_ENABLED", - "websocket_client_enabled_testvalue", - ) + @patch("evennia.web.utils.general_context.WEBSOCKET_CLIENT_ENABLED", + "websocket_client_enabled_testvalue") @patch("evennia.web.utils.general_context.WEBCLIENT_ENABLED", "webclient_enabled_testvalue") @patch("evennia.web.utils.general_context.WEBSOCKET_PORT", "websocket_client_port_testvalue") @patch("evennia.web.utils.general_context.WEBSOCKET_URL", "websocket_client_url_testvalue") + @patch("evennia.web.utils.general_context.REST_API_ENABLED", True) def test_general_context(self): request = RequestFactory().get("/") request.user = AnonymousUser() @@ -39,6 +38,7 @@ class TestGeneralContext(TestCase): "websocket_enabled": "websocket_client_enabled_testvalue", "websocket_port": "websocket_client_port_testvalue", "websocket_url": "websocket_client_url_testvalue", + "rest_api_enabled": True, }, ) @@ -48,6 +48,7 @@ class TestGeneralContext(TestCase): def test_set_game_name_and_slogan(self, mock_get_version, mock_settings): mock_get_version.return_value = "version 1" # test default/fallback values + mock_settings.REST_API_ENABLED = False general_context.set_game_name_and_slogan() self.assertEqual(general_context.GAME_NAME, "Evennia") self.assertEqual(general_context.GAME_SLOGAN, "version 1")