mirror of
https://github.com/evennia/evennia.git
synced 2026-04-02 22:17:17 +02:00
Fix merge conflicts
This commit is contained in:
commit
56ce402f97
89 changed files with 5435 additions and 818 deletions
77
CHANGELOG.md
77
CHANGELOG.md
|
|
@ -11,6 +11,77 @@ Update to Python 3
|
|||
|
||||
- Removed default `@delaccount` command, incorporating as `@account/delete` instead. Added confirmation
|
||||
question.
|
||||
- Add new `@force` command to have another object perform a command.
|
||||
- Add the Portal uptime to the `@time` command.
|
||||
- Make the `@link` command first make a local search before a global search.
|
||||
|
||||
### Web
|
||||
|
||||
Web/Django standard initiative (@strikaco)
|
||||
- Features
|
||||
- Adds a series of web-based forms and generic class-based views
|
||||
- Accounts
|
||||
- Register - Enhances registration; allows optional collection of email address
|
||||
- Form - Adds a generic Django form for creating Accounts from the web
|
||||
- Characters
|
||||
- Create - Authenticated users can create new characters from the website (requires associated form)
|
||||
- Detail - Authenticated and authorized users can view select details about characters
|
||||
- List - Authenticated and authorized users can browse a list of all characters
|
||||
- Manage - Authenticated users can edit or delete owned characters from the web
|
||||
- Form - Adds a generic Django form for creating characters from the web
|
||||
- Channels
|
||||
- Detail - Authorized users can view channel logs from the web
|
||||
- List - Authorized users can browse a list of all channels
|
||||
- Help Entries
|
||||
- Detail - Authorized users can view help entries from the web
|
||||
- List - Authorized users can browse a list of all help entries from the web
|
||||
- Navbar changes
|
||||
- Characters - Link to character list
|
||||
- Channels - Link to channel list
|
||||
- Help - Link to help entry list
|
||||
- Puppeting
|
||||
- Users can puppet their own characters within the context of the website
|
||||
- Dropdown
|
||||
- Link to create characters
|
||||
- Link to manage characters
|
||||
- Link to quick-select puppets
|
||||
- Link to password change workflow
|
||||
- Functions
|
||||
- Updates Bootstrap to v4 stable
|
||||
- Enables use of Django Messages framework to communicate with users in browser
|
||||
- Implements webclient/website `_shared_login` functionality as Django middleware
|
||||
- 'account' and 'puppet' are added to all request contexts for authenticated users
|
||||
- Adds unit tests for all web views
|
||||
- Cosmetic
|
||||
- Prettifies Django 'forgot password' workflow (requires SMTP to actually function)
|
||||
- Prettifies Django 'change password' workflow
|
||||
- Bugfixes
|
||||
- Fixes bug on login page where error messages were not being displayed
|
||||
|
||||
### Typeclasses
|
||||
|
||||
- Add new methods on all typeclasses, useful specifically for object handling from the website/admin:
|
||||
+ `web_get_admin_url()`: Returns the path to the object detail page in the Admin backend.
|
||||
+ `web_get_create_url()`: Returns the path to the typeclass' creation page on the website, if implemented.
|
||||
+ `web_get_absolute_url()`: Returns the path to the object's detail page on the website, if implemented.
|
||||
+ `web_get_update_url()`: Returns the path to the object's update page on the website, if implemented.
|
||||
+ `web_get_delete_url()`: Returns the path to the object's delete page on the website, if implemented.
|
||||
- All typeclasses have new helper class method `create`, which encompasses useful functionality
|
||||
that used to be embedded for example in the respective `@create` or `@connect` commands.
|
||||
- DefaultAccount now has new class methods implementing many things that used to be in unloggedin
|
||||
commands (these can now be customized on the class instead):
|
||||
+ `is_banned()`: Checks if a given username or IP is banned.
|
||||
+ `get_username_validators`: Return list of validators for username validation (see
|
||||
`settings.AUTH_USERNAME_VALIDATORS`)
|
||||
+ `authenticate`: Method to check given username/password.
|
||||
+ `normalize_username`: Normalizes names so (for Unicode environments) users cannot mimic existing usernames by replacing select characters with visually-similar Unicode chars.
|
||||
+ `validate_username`: Mechanism for validating a username based on predefined Django validators.
|
||||
+ `validate_password`: Mechanism for validating a password based on predefined Django validators.
|
||||
+ `set_password`: Apply password to account, using validation checks.
|
||||
|
||||
### Utils
|
||||
|
||||
- Added more unit tests.
|
||||
|
||||
### Utils
|
||||
|
||||
|
|
@ -20,6 +91,11 @@ Update to Python 3
|
|||
|
||||
## Evennia 0.8 (2018)
|
||||
|
||||
### Requirements
|
||||
|
||||
- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0
|
||||
- Add `inflect` dependency for automatic pluralization of object names.
|
||||
|
||||
### Server/Portal
|
||||
|
||||
- Removed `evennia_runner`, completely refactor `evennia_launcher.py` (the 'evennia' program)
|
||||
|
|
@ -103,7 +179,6 @@ Update to Python 3
|
|||
|
||||
### General
|
||||
|
||||
- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0
|
||||
- Start structuring the `CHANGELOG` to list features in more detail.
|
||||
- Docker image `evennia/evennia:develop` is now auto-built, tracking the develop branch.
|
||||
- Inflection and grouping of multiple objects in default room (an box, three boxes)
|
||||
|
|
|
|||
7
CONTRIBUTING.md
Normal file
7
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Contributing to Evennia
|
||||
|
||||
Evennia utilizes GitHub for issue tracking and contributions:
|
||||
|
||||
- Reporting Issues issues/bugs and making feature requests can be done [in the issue tracker](https://github.com/evennia/evennia/issues).
|
||||
- Evennia's documentation is a [wiki](https://github.com/evennia/evennia/wiki) that everyone can contribute to. Further
|
||||
instructions and details about contributing is found [here](https://github.com/evennia/evennia/wiki/Contributing).
|
||||
|
|
@ -105,7 +105,7 @@ def _create_version():
|
|||
"git rev-parse --short HEAD",
|
||||
shell=True, cwd=root, stderr=STDOUT).strip().decode()
|
||||
version = "%s (rev %s)" % (version, rev)
|
||||
except (IOError, CalledProcessError):
|
||||
except (IOError, CalledProcessError, OSError):
|
||||
# ignore if we cannot get to git
|
||||
pass
|
||||
return version
|
||||
|
|
|
|||
|
|
@ -10,19 +10,22 @@ character object, so you should customize that
|
|||
instead for most things).
|
||||
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import password_validation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.auth import authenticate, password_validation
|
||||
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
from evennia.typeclasses.models import TypeclassBase
|
||||
from evennia.accounts.manager import AccountManager
|
||||
from evennia.accounts.models import AccountDB
|
||||
from evennia.objects.models import ObjectDB
|
||||
from evennia.comms.models import ChannelDB
|
||||
from evennia.commands import cmdhandler
|
||||
from evennia.utils import logger
|
||||
from evennia.server.models import ServerConfig
|
||||
from evennia.server.throttle import Throttle
|
||||
from evennia.utils import class_from_module, create, logger
|
||||
from evennia.utils.utils import (lazy_property, to_str,
|
||||
make_iter, is_iter,
|
||||
variable_from_module)
|
||||
|
|
@ -32,6 +35,7 @@ from evennia.commands.cmdsethandler import CmdSetHandler
|
|||
|
||||
from django.utils.translation import ugettext as _
|
||||
from future.utils import with_metaclass
|
||||
from random import getrandbits
|
||||
|
||||
__all__ = ("DefaultAccount",)
|
||||
|
||||
|
|
@ -43,6 +47,9 @@ _MAX_NR_CHARACTERS = settings.MAX_NR_CHARACTERS
|
|||
_CMDSET_ACCOUNT = settings.CMDSET_ACCOUNT
|
||||
_CONNECT_CHANNEL = None
|
||||
|
||||
# Create throttles for too many account-creations and login attempts
|
||||
CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60)
|
||||
LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60)
|
||||
|
||||
class AccountSessionHandler(object):
|
||||
"""
|
||||
|
|
@ -189,6 +196,19 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
|
|||
@lazy_property
|
||||
def sessions(self):
|
||||
return AccountSessionHandler(self)
|
||||
|
||||
# Do not make this a lazy property; the web UI will not refresh it!
|
||||
@property
|
||||
def characters(self):
|
||||
# Get playable characters list
|
||||
objs = self.db._playable_characters
|
||||
|
||||
# Rebuild the list if legacy code left null values after deletion
|
||||
if None in objs:
|
||||
objs = [x for x in self.db._playable_characters if x]
|
||||
self.db._playable_characters = objs
|
||||
|
||||
return objs
|
||||
|
||||
# session-related methods
|
||||
|
||||
|
|
@ -359,6 +379,189 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
|
|||
puppet = property(__get_single_puppet)
|
||||
|
||||
# utility methods
|
||||
@classmethod
|
||||
def is_banned(cls, **kwargs):
|
||||
"""
|
||||
Checks if a given username or IP is banned.
|
||||
|
||||
Kwargs:
|
||||
ip (str, optional): IP address.
|
||||
username (str, optional): Username.
|
||||
|
||||
Returns:
|
||||
is_banned (bool): Whether either is banned or not.
|
||||
|
||||
"""
|
||||
|
||||
ip = kwargs.get('ip', '').strip()
|
||||
username = kwargs.get('username', '').lower().strip()
|
||||
|
||||
# Check IP and/or name bans
|
||||
bans = ServerConfig.objects.conf("server_bans")
|
||||
if bans and (any(tup[0] == username for tup in bans if username) or
|
||||
any(tup[2].match(ip) for tup in bans if ip and tup[2])):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_username_validators(cls, validator_config=getattr(settings, 'AUTH_USERNAME_VALIDATORS', [])):
|
||||
"""
|
||||
Retrieves and instantiates validators for usernames.
|
||||
|
||||
Args:
|
||||
validator_config (list): List of dicts comprising the battery of
|
||||
validators to apply to a username.
|
||||
|
||||
Returns:
|
||||
validators (list): List of instantiated Validator objects.
|
||||
"""
|
||||
|
||||
objs = []
|
||||
for validator in validator_config:
|
||||
try:
|
||||
klass = import_string(validator['NAME'])
|
||||
except ImportError:
|
||||
msg = "The module in NAME could not be imported: %s. Check your AUTH_USERNAME_VALIDATORS setting."
|
||||
raise ImproperlyConfigured(msg % validator['NAME'])
|
||||
objs.append(klass(**validator.get('OPTIONS', {})))
|
||||
return objs
|
||||
|
||||
@classmethod
|
||||
def authenticate(cls, username, password, ip='', **kwargs):
|
||||
"""
|
||||
Checks the given username/password against the database to see if the
|
||||
credentials are valid.
|
||||
|
||||
Note that this simply checks credentials and returns a valid reference
|
||||
to the user-- it does not log them in!
|
||||
|
||||
To finish the job:
|
||||
After calling this from a Command, associate the account with a Session:
|
||||
- session.sessionhandler.login(session, account)
|
||||
|
||||
...or after calling this from a View, associate it with an HttpRequest:
|
||||
- django.contrib.auth.login(account, request)
|
||||
|
||||
Args:
|
||||
username (str): Username of account
|
||||
password (str): Password of account
|
||||
ip (str, optional): IP address of client
|
||||
|
||||
Kwargs:
|
||||
session (Session, optional): Session requesting authentication
|
||||
|
||||
Returns:
|
||||
account (DefaultAccount, None): Account whose credentials were
|
||||
provided if not banned.
|
||||
errors (list): Error messages of any failures.
|
||||
|
||||
"""
|
||||
errors = []
|
||||
if ip: ip = str(ip)
|
||||
|
||||
# See if authentication is currently being throttled
|
||||
if ip and LOGIN_THROTTLE.check(ip):
|
||||
errors.append('Too many login failures; please try again in a few minutes.')
|
||||
|
||||
# With throttle active, do not log continued hits-- it is a
|
||||
# waste of storage and can be abused to make your logs harder to
|
||||
# read and/or fill up your disk.
|
||||
return None, errors
|
||||
|
||||
# Check IP and/or name bans
|
||||
banned = cls.is_banned(username=username, ip=ip)
|
||||
if banned:
|
||||
# this is a banned IP or name!
|
||||
errors.append("|rYou have been banned and cannot continue from here." \
|
||||
"\nIf you feel this ban is in error, please email an admin.|x")
|
||||
logger.log_sec('Authentication Denied (Banned): %s (IP: %s).' % (username, ip))
|
||||
LOGIN_THROTTLE.update(ip, 'Too many sightings of banned artifact.')
|
||||
return None, errors
|
||||
|
||||
# Authenticate and get Account object
|
||||
account = authenticate(username=username, password=password)
|
||||
if not account:
|
||||
# User-facing message
|
||||
errors.append('Username and/or password is incorrect.')
|
||||
|
||||
# Log auth failures while throttle is inactive
|
||||
logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip))
|
||||
|
||||
# Update throttle
|
||||
if ip: LOGIN_THROTTLE.update(ip, 'Too many authentication failures.')
|
||||
|
||||
# Try to call post-failure hook
|
||||
session = kwargs.get('session', None)
|
||||
if session:
|
||||
account = AccountDB.objects.get_account_from_name(username)
|
||||
if account:
|
||||
account.at_failed_login(session)
|
||||
|
||||
return None, errors
|
||||
|
||||
# Account successfully authenticated
|
||||
logger.log_sec('Authentication Success: %s (IP: %s).' % (account, ip))
|
||||
return account, errors
|
||||
|
||||
@classmethod
|
||||
def normalize_username(cls, username):
|
||||
"""
|
||||
Django: Applies NFKC Unicode normalization to usernames so that visually
|
||||
identical characters with different Unicode code points are considered
|
||||
identical.
|
||||
|
||||
(This deals with the Turkish "i" problem and similar
|
||||
annoyances. Only relevant if you go out of your way to allow Unicode
|
||||
usernames though-- Evennia accepts ASCII by default.)
|
||||
|
||||
In this case we're simply piggybacking on this feature to apply
|
||||
additional normalization per Evennia's standards.
|
||||
"""
|
||||
username = super(DefaultAccount, cls).normalize_username(username)
|
||||
|
||||
# strip excessive spaces in accountname
|
||||
username = re.sub(r"\s+", " ", username).strip()
|
||||
|
||||
return username
|
||||
|
||||
@classmethod
|
||||
def validate_username(cls, username):
|
||||
"""
|
||||
Checks the given username against the username validator associated with
|
||||
Account objects, and also checks the database to make sure it is unique.
|
||||
|
||||
Args:
|
||||
username (str): Username to validate
|
||||
|
||||
Returns:
|
||||
valid (bool): Whether or not the password passed validation
|
||||
errors (list): Error messages of any failures
|
||||
|
||||
"""
|
||||
valid = []
|
||||
errors = []
|
||||
|
||||
# Make sure we're at least using the default validator
|
||||
validators = cls.get_username_validators()
|
||||
if not validators:
|
||||
validators = [cls.username_validator]
|
||||
|
||||
# Try username against all enabled validators
|
||||
for validator in validators:
|
||||
try:
|
||||
valid.append(not validator(username))
|
||||
except ValidationError as e:
|
||||
valid.append(False)
|
||||
errors.extend(e.messages)
|
||||
|
||||
# Disqualify if any check failed
|
||||
if False in valid:
|
||||
valid = False
|
||||
else: valid = True
|
||||
|
||||
return valid, errors
|
||||
|
||||
@classmethod
|
||||
def validate_password(cls, password, account=None):
|
||||
"""
|
||||
|
|
@ -416,9 +619,137 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
|
|||
if error: raise error
|
||||
|
||||
super(DefaultAccount, self).set_password(password)
|
||||
logger.log_info("Password succesfully changed for %s." % self)
|
||||
logger.log_sec("Password successfully changed for %s." % self)
|
||||
self.at_password_change()
|
||||
|
||||
@classmethod
|
||||
def create(cls, *args, **kwargs):
|
||||
"""
|
||||
Creates an Account (or Account/Character pair for MULTISESSION_MODE<2)
|
||||
with default (or overridden) permissions and having joined them to the
|
||||
appropriate default channels.
|
||||
|
||||
Kwargs:
|
||||
username (str): Username of Account owner
|
||||
password (str): Password of Account owner
|
||||
email (str, optional): Email address of Account owner
|
||||
ip (str, optional): IP address of requesting connection
|
||||
guest (bool, optional): Whether or not this is to be a Guest account
|
||||
|
||||
permissions (str, optional): Default permissions for the Account
|
||||
typeclass (str, optional): Typeclass to use for new Account
|
||||
character_typeclass (str, optional): Typeclass to use for new char
|
||||
when applicable.
|
||||
|
||||
Returns:
|
||||
account (Account): Account if successfully created; None if not
|
||||
errors (list): List of error messages in string form
|
||||
|
||||
"""
|
||||
|
||||
account = None
|
||||
errors = []
|
||||
|
||||
username = kwargs.get('username')
|
||||
password = kwargs.get('password')
|
||||
email = kwargs.get('email', '').strip()
|
||||
guest = kwargs.get('guest', False)
|
||||
|
||||
permissions = kwargs.get('permissions', settings.PERMISSION_ACCOUNT_DEFAULT)
|
||||
typeclass = kwargs.get('typeclass', settings.BASE_ACCOUNT_TYPECLASS)
|
||||
|
||||
ip = kwargs.get('ip', '')
|
||||
if ip and CREATION_THROTTLE.check(ip):
|
||||
errors.append("You are creating too many accounts. Please log into an existing account.")
|
||||
return None, errors
|
||||
|
||||
# Normalize username
|
||||
username = cls.normalize_username(username)
|
||||
|
||||
# Validate username
|
||||
if not guest:
|
||||
valid, errs = cls.validate_username(username)
|
||||
if not valid:
|
||||
# this echoes the restrictions made by django's auth
|
||||
# module (except not allowing spaces, for convenience of
|
||||
# logging in).
|
||||
errors.extend(errs)
|
||||
return None, errors
|
||||
|
||||
# Validate password
|
||||
# Have to create a dummy Account object to check username similarity
|
||||
valid, errs = cls.validate_password(password, account=cls(username=username))
|
||||
if not valid:
|
||||
errors.extend(errs)
|
||||
return None, errors
|
||||
|
||||
# Check IP and/or name bans
|
||||
banned = cls.is_banned(username=username, ip=ip)
|
||||
if banned:
|
||||
# this is a banned IP or name!
|
||||
string = "|rYou have been banned and cannot continue from here." \
|
||||
"\nIf you feel this ban is in error, please email an admin.|x"
|
||||
errors.append(string)
|
||||
return None, errors
|
||||
|
||||
# everything's ok. Create the new account account.
|
||||
try:
|
||||
try:
|
||||
account = create.create_account(username, email, password, permissions=permissions, typeclass=typeclass)
|
||||
logger.log_sec('Account Created: %s (IP: %s).' % (account, ip))
|
||||
|
||||
except Exception as e:
|
||||
errors.append("There was an error creating the Account. If this problem persists, contact an admin.")
|
||||
logger.log_trace()
|
||||
return None, errors
|
||||
|
||||
# This needs to be set so the engine knows this account is
|
||||
# logging in for the first time. (so it knows to call the right
|
||||
# hooks during login later)
|
||||
account.db.FIRST_LOGIN = True
|
||||
|
||||
# Record IP address of creation, if available
|
||||
if ip: account.db.creator_ip = ip
|
||||
|
||||
# join the new account to the public channel
|
||||
pchannel = ChannelDB.objects.get_channel(settings.DEFAULT_CHANNELS[0]["key"])
|
||||
if not pchannel or not pchannel.connect(account):
|
||||
string = "New account '%s' could not connect to public channel!" % account.key
|
||||
errors.append(string)
|
||||
logger.log_err(string)
|
||||
|
||||
if account and settings.MULTISESSION_MODE < 2:
|
||||
# Load the appropriate Character class
|
||||
character_typeclass = kwargs.get('character_typeclass', settings.BASE_CHARACTER_TYPECLASS)
|
||||
character_home = kwargs.get('home')
|
||||
Character = class_from_module(character_typeclass)
|
||||
|
||||
# Create the character
|
||||
character, errs = Character.create(
|
||||
account.key, account, ip=ip, typeclass=character_typeclass,
|
||||
permissions=permissions, home=character_home
|
||||
)
|
||||
errors.extend(errs)
|
||||
|
||||
if character:
|
||||
# Update playable character list
|
||||
if character not in account.characters:
|
||||
account.db._playable_characters.append(character)
|
||||
|
||||
# We need to set this to have @ic auto-connect to this character
|
||||
account.db._last_puppet = character
|
||||
|
||||
except Exception:
|
||||
# We are in the middle between logged in and -not, so we have
|
||||
# to handle tracebacks ourselves at this point. If we don't,
|
||||
# we won't see any errors at all.
|
||||
errors.append("An error occurred. Please e-mail an admin if the problem persists.")
|
||||
logger.log_trace()
|
||||
|
||||
# Update the throttle to indicate a new account was created from this IP
|
||||
if ip and not guest: CREATION_THROTTLE.update(ip, 'Too many accounts being created.')
|
||||
return account, errors
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""
|
||||
Deletes the account permanently.
|
||||
|
|
@ -1067,6 +1398,78 @@ class DefaultGuest(DefaultAccount):
|
|||
their characters are deleted after disconnection.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def create(cls, **kwargs):
|
||||
"""
|
||||
Forwards request to cls.authenticate(); returns a DefaultGuest object
|
||||
if one is available for use.
|
||||
"""
|
||||
return cls.authenticate(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def authenticate(cls, **kwargs):
|
||||
"""
|
||||
Gets or creates a Guest account object.
|
||||
|
||||
Kwargs:
|
||||
ip (str, optional): IP address of requestor; used for ban checking,
|
||||
throttling and logging
|
||||
|
||||
Returns:
|
||||
account (Object): Guest account object, if available
|
||||
errors (list): List of error messages accrued during this request.
|
||||
|
||||
"""
|
||||
errors = []
|
||||
account = None
|
||||
username = None
|
||||
ip = kwargs.get('ip', '').strip()
|
||||
|
||||
# check if guests are enabled.
|
||||
if not settings.GUEST_ENABLED:
|
||||
errors.append('Guest accounts are not enabled on this server.')
|
||||
return None, errors
|
||||
|
||||
try:
|
||||
# Find an available guest name.
|
||||
for name in settings.GUEST_LIST:
|
||||
if not AccountDB.objects.filter(username__iexact=name).count():
|
||||
username = name
|
||||
break
|
||||
if not username:
|
||||
errors.append("All guest accounts are in use. Please try again later.")
|
||||
if ip: LOGIN_THROTTLE.update(ip, 'Too many requests for Guest access.')
|
||||
return None, errors
|
||||
else:
|
||||
# build a new account with the found guest username
|
||||
password = "%016x" % getrandbits(64)
|
||||
home = settings.GUEST_HOME
|
||||
permissions = settings.PERMISSION_GUEST_DEFAULT
|
||||
typeclass = settings.BASE_GUEST_TYPECLASS
|
||||
|
||||
# Call parent class creator
|
||||
account, errs = super(DefaultGuest, cls).create(
|
||||
guest=True,
|
||||
username=username,
|
||||
password=password,
|
||||
permissions=permissions,
|
||||
typeclass=typeclass,
|
||||
home=home,
|
||||
ip=ip,
|
||||
)
|
||||
errors.extend(errs)
|
||||
return account, errors
|
||||
|
||||
except Exception as e:
|
||||
# We are in the middle between logged in and -not, so we have
|
||||
# to handle tracebacks ourselves at this point. If we don't,
|
||||
# we won't see any errors at all.
|
||||
errors.append("An error occurred. Please e-mail an admin if the problem persists.")
|
||||
logger.log_trace()
|
||||
return None, errors
|
||||
|
||||
return account, errors
|
||||
|
||||
def at_post_login(self, session=None, **kwargs):
|
||||
"""
|
||||
In theory, guests only have one character regardless of which
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from mock import Mock, MagicMock
|
||||
from random import randint
|
||||
from unittest import TestCase
|
||||
|
||||
from django.test import override_settings
|
||||
from evennia.accounts.accounts import AccountSessionHandler
|
||||
from evennia.accounts.accounts import DefaultAccount
|
||||
from evennia.server.session import Session
|
||||
from evennia.accounts.accounts import DefaultAccount, DefaultGuest
|
||||
from evennia.utils.test_resources import EvenniaTest
|
||||
from evennia.utils import create
|
||||
|
||||
from django.conf import settings
|
||||
|
|
@ -58,6 +60,107 @@ class TestAccountSessionHandler(TestCase):
|
|||
"Check count method"
|
||||
self.assertEqual(self.handler.count(), len(self.handler.get()))
|
||||
|
||||
class TestDefaultGuest(EvenniaTest):
|
||||
"Check DefaultGuest class"
|
||||
|
||||
ip = '212.216.134.22'
|
||||
|
||||
def test_authenticate(self):
|
||||
# Guest account should not be permitted
|
||||
account, errors = DefaultGuest.authenticate(ip=self.ip)
|
||||
self.assertFalse(account, 'Guest account was created despite being disabled.')
|
||||
|
||||
settings.GUEST_ENABLED = True
|
||||
settings.GUEST_LIST = ['bruce_wayne']
|
||||
|
||||
# Create a guest account
|
||||
account, errors = DefaultGuest.authenticate(ip=self.ip)
|
||||
self.assertTrue(account, 'Guest account should have been created.')
|
||||
|
||||
# Create a second guest account
|
||||
account, errors = DefaultGuest.authenticate(ip=self.ip)
|
||||
self.assertFalse(account, 'Two guest accounts were created with a single entry on the guest list!')
|
||||
|
||||
settings.GUEST_ENABLED = False
|
||||
|
||||
class TestDefaultAccountAuth(EvenniaTest):
|
||||
|
||||
def setUp(self):
|
||||
super(TestDefaultAccountAuth, self).setUp()
|
||||
|
||||
self.password = "testpassword"
|
||||
self.account.delete()
|
||||
self.account = create.create_account("TestAccount%s" % randint(100000, 999999), email="test@test.com", password=self.password, typeclass=DefaultAccount)
|
||||
|
||||
def test_authentication(self):
|
||||
"Confirm Account authentication method is authenticating/denying users."
|
||||
# Valid credentials
|
||||
obj, errors = DefaultAccount.authenticate(self.account.name, self.password)
|
||||
self.assertTrue(obj, 'Account did not authenticate given valid credentials.')
|
||||
|
||||
# Invalid credentials
|
||||
obj, errors = DefaultAccount.authenticate(self.account.name, 'xyzzy')
|
||||
self.assertFalse(obj, 'Account authenticated using invalid credentials.')
|
||||
|
||||
def test_create(self):
|
||||
"Confirm Account creation is working as expected."
|
||||
# Create a normal account
|
||||
account, errors = DefaultAccount.create(username='ziggy', password='stardust11')
|
||||
self.assertTrue(account, 'New account should have been created.')
|
||||
|
||||
# Try creating a duplicate account
|
||||
account2, errors = DefaultAccount.create(username='Ziggy', password='starman11')
|
||||
self.assertFalse(account2, 'Duplicate account name should not have been allowed.')
|
||||
account.delete()
|
||||
|
||||
def test_throttle(self):
|
||||
"Confirm throttle activates on too many failures."
|
||||
for x in xrange(20):
|
||||
obj, errors = DefaultAccount.authenticate(self.account.name, 'xyzzy', ip='12.24.36.48')
|
||||
self.assertFalse(obj, 'Authentication was provided a bogus password; this should NOT have returned an account!')
|
||||
|
||||
self.assertTrue('too many login failures' in errors[-1].lower(), 'Failed logins should have been throttled.')
|
||||
|
||||
def test_username_validation(self):
|
||||
"Check username validators deny relevant usernames"
|
||||
# Should not accept Unicode by default, lest users pick names like this
|
||||
result, error = DefaultAccount.validate_username('¯\_(ツ)_/¯')
|
||||
self.assertFalse(result, "Validator allowed kanji in username.")
|
||||
|
||||
# Should not allow duplicate username
|
||||
result, error = DefaultAccount.validate_username(self.account.name)
|
||||
self.assertFalse(result, "Duplicate username should not have passed validation.")
|
||||
|
||||
# Should not allow username too short
|
||||
result, error = DefaultAccount.validate_username('xx')
|
||||
self.assertFalse(result, "2-character username passed validation.")
|
||||
|
||||
def test_password_validation(self):
|
||||
"Check password validators deny bad passwords"
|
||||
|
||||
account = create.create_account("TestAccount%s" % randint(100000, 999999),
|
||||
email="test@test.com", password="testpassword", typeclass=DefaultAccount)
|
||||
for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'):
|
||||
self.assertFalse(account.validate_password(bad, account=self.account)[0])
|
||||
|
||||
"Check validators allow sufficiently complex passwords"
|
||||
for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"):
|
||||
self.assertTrue(account.validate_password(better, account=self.account)[0])
|
||||
account.delete()
|
||||
|
||||
def test_password_change(self):
|
||||
"Check password setting and validation is working as expected"
|
||||
account = create.create_account("TestAccount%s" % randint(100000, 999999),
|
||||
email="test@test.com", password="testpassword", typeclass=DefaultAccount)
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
# Try setting some bad passwords
|
||||
for bad in ('', '#', 'TestAccount', 'password'):
|
||||
self.assertRaises(ValidationError, account.set_password, bad)
|
||||
|
||||
# Try setting a better password (test for False; returns None on success)
|
||||
self.assertFalse(account.set_password('Mxyzptlk'))
|
||||
account.delete()
|
||||
|
||||
class TestDefaultAccount(TestCase):
|
||||
"Check DefaultAccount class"
|
||||
|
|
@ -66,36 +169,6 @@ class TestDefaultAccount(TestCase):
|
|||
self.s1 = MagicMock()
|
||||
self.s1.puppet = None
|
||||
self.s1.sessid = 0
|
||||
self.s1.data_outj
|
||||
|
||||
def tearDown(self):
|
||||
if hasattr(self, "account"):
|
||||
self.account.delete()
|
||||
|
||||
def test_password_validation(self):
|
||||
"Check password validators deny bad passwords"
|
||||
|
||||
self.account = create.create_account("TestAccount%s" % randint(0, 9),
|
||||
email="test@test.com", password="testpassword", typeclass=DefaultAccount)
|
||||
for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'):
|
||||
self.assertFalse(self.account.validate_password(bad, account=self.account)[0])
|
||||
|
||||
"Check validators allow sufficiently complex passwords"
|
||||
for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"):
|
||||
self.assertTrue(self.account.validate_password(better, account=self.account)[0])
|
||||
|
||||
def test_password_change(self):
|
||||
"Check password setting and validation is working as expected"
|
||||
self.account = create.create_account("TestAccount%s" % randint(0, 9),
|
||||
email="test@test.com", password="testpassword", typeclass=DefaultAccount)
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
# Try setting some bad passwords
|
||||
for bad in ('', '#', 'TestAccount', 'password'):
|
||||
self.assertRaises(ValidationError, self.account.set_password, bad)
|
||||
|
||||
# Try setting a better password (test for False; returns None on success)
|
||||
self.assertFalse(self.account.set_password('Mxyzptlk'))
|
||||
|
||||
def test_puppet_object_no_object(self):
|
||||
"Check puppet_object method called with no object param"
|
||||
|
|
@ -199,3 +272,20 @@ class TestDefaultAccount(TestCase):
|
|||
account.puppet_object(self.s1, obj)
|
||||
self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("is already puppeted by another Account."))
|
||||
self.assertIsNone(obj.at_post_puppet.call_args)
|
||||
|
||||
|
||||
class TestAccountPuppetDeletion(EvenniaTest):
|
||||
|
||||
@override_settings(MULTISESSION_MODE=2)
|
||||
def test_puppet_deletion(self):
|
||||
# Check for existing chars
|
||||
self.assertFalse(self.account.db._playable_characters, 'Account should not have any chars by default.')
|
||||
|
||||
# Add char1 to account's playable characters
|
||||
self.account.db._playable_characters.append(self.char1)
|
||||
self.assertTrue(self.account.db._playable_characters, 'Char was not added to account.')
|
||||
|
||||
# See what happens when we delete char1.
|
||||
self.char1.delete()
|
||||
# Playable char list should be empty.
|
||||
self.assertFalse(self.account.db._playable_characters, 'Playable character list is not empty! %s' % self.account.db._playable_characters)
|
||||
|
|
|
|||
|
|
@ -163,8 +163,8 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS):
|
|||
home=default_home,
|
||||
permissions=permissions)
|
||||
# only allow creator (and developers) to puppet this char
|
||||
new_character.locks.add("puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer)" %
|
||||
(new_character.id, account.id))
|
||||
new_character.locks.add("puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer);delete:id(%i) or perm(Admin)" %
|
||||
(new_character.id, account.id, account.id))
|
||||
account.db._playable_characters.append(new_character)
|
||||
if desc:
|
||||
new_character.db.desc = desc
|
||||
|
|
@ -223,6 +223,12 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS):
|
|||
|
||||
match = match[0]
|
||||
account.ndb._char_to_delete = match
|
||||
|
||||
# Return if caller has no permission to delete this
|
||||
if not match.access(account, 'delete'):
|
||||
self.msg("You do not have permission to delete this character.")
|
||||
return
|
||||
|
||||
prompt = "|rThis will permanently destroy '%s'. This cannot be undone.|n Continue yes/[no]?"
|
||||
get_input(account, prompt % match.key, _callback)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ PERMISSION_HIERARCHY = [p.lower() for p in settings.PERMISSION_HIERARCHY]
|
|||
|
||||
# limit members for API inclusion
|
||||
__all__ = ("CmdBoot", "CmdBan", "CmdUnban",
|
||||
"CmdEmit", "CmdNewPassword", "CmdPerm", "CmdWall")
|
||||
"CmdEmit", "CmdNewPassword", "CmdPerm", "CmdWall", "CmdForce")
|
||||
|
||||
|
||||
class CmdBoot(COMMAND_DEFAULT_CLASS):
|
||||
|
|
@ -513,3 +513,33 @@ class CmdWall(COMMAND_DEFAULT_CLASS):
|
|||
message = "%s shouts \"%s\"" % (self.caller.name, self.args)
|
||||
self.msg("Announcing to all connected sessions ...")
|
||||
SESSIONS.announce_all(message)
|
||||
|
||||
|
||||
class CmdForce(COMMAND_DEFAULT_CLASS):
|
||||
"""
|
||||
forces an object to execute a command
|
||||
|
||||
Usage:
|
||||
@force <object>=<command string>
|
||||
|
||||
Example:
|
||||
@force bob=get stick
|
||||
"""
|
||||
key = "@force"
|
||||
locks = "cmd:perm(spawn) or perm(Builder)"
|
||||
help_category = "Building"
|
||||
perm_used = "edit"
|
||||
|
||||
def func(self):
|
||||
"""Implements the force command"""
|
||||
if not self.lhs or not self.rhs:
|
||||
self.caller.msg("You must provide a target and a command string to execute.")
|
||||
return
|
||||
targ = self.caller.search(self.lhs)
|
||||
if not targ:
|
||||
return
|
||||
if not targ.access(self.caller, self.perm_used):
|
||||
self.caller.msg("You don't have permission to force them to execute commands.")
|
||||
return
|
||||
targ.execute_cmd(self.rhs)
|
||||
self.caller.msg("You have forced %s to: %s" % (targ, self.rhs))
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from evennia.objects.models import ObjectDB
|
|||
from evennia.locks.lockhandler import LockException
|
||||
from evennia.commands.cmdhandler import get_and_merge_cmdsets
|
||||
from evennia.utils import create, utils, search
|
||||
from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses
|
||||
from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses, variable_from_module
|
||||
from evennia.utils.eveditor import EvEditor
|
||||
from evennia.utils.evmore import EvMore
|
||||
from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus
|
||||
|
|
@ -612,12 +612,12 @@ class CmdDesc(COMMAND_DEFAULT_CLASS):
|
|||
self.edit_handler()
|
||||
return
|
||||
|
||||
if self.rhs:
|
||||
if '=' in self.args:
|
||||
# We have an =
|
||||
obj = caller.search(self.lhs)
|
||||
if not obj:
|
||||
return
|
||||
desc = self.rhs
|
||||
desc = self.rhs or ''
|
||||
else:
|
||||
obj = caller.location or self.msg("|rYou can't describe oblivion.|n")
|
||||
if not obj:
|
||||
|
|
@ -1022,10 +1022,17 @@ class CmdLink(COMMAND_DEFAULT_CLASS):
|
|||
|
||||
object_name = self.lhs
|
||||
|
||||
# get object
|
||||
obj = caller.search(object_name, global_search=True)
|
||||
if not obj:
|
||||
return
|
||||
# try to search locally first
|
||||
results = caller.search(object_name, quiet=True)
|
||||
if len(results) > 1: # local results was a multimatch. Inform them to be more specific
|
||||
_AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit('.', 1))
|
||||
return _AT_SEARCH_RESULT(results, caller, query=object_name)
|
||||
elif len(results) == 1: # A unique local match
|
||||
obj = results[0]
|
||||
else: # No matches. Search globally
|
||||
obj = caller.search(object_name, global_search=True)
|
||||
if not obj:
|
||||
return
|
||||
|
||||
if self.rhs:
|
||||
# this means a target name was given
|
||||
|
|
@ -2854,7 +2861,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
|
|||
|
||||
key = "@spawn"
|
||||
aliases = ["olc"]
|
||||
switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc", "update")
|
||||
switch_options = ("noloc", "search", "list", "show", "examine", "save", "delete", "menu", "olc", "update", "edit")
|
||||
locks = "cmd:perm(spawn) or perm(Builder)"
|
||||
help_category = "Building"
|
||||
|
||||
|
|
@ -2905,12 +2912,13 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
|
|||
|
||||
caller = self.caller
|
||||
|
||||
if self.cmdstring == "olc" or 'menu' in self.switches or 'olc' in self.switches:
|
||||
if self.cmdstring == "olc" or 'menu' in self.switches \
|
||||
or 'olc' in self.switches or 'edit' in self.switches:
|
||||
# OLC menu mode
|
||||
prototype = None
|
||||
if self.lhs:
|
||||
key = self.lhs
|
||||
prototype = spawner.search_prototype(key=key, return_meta=True)
|
||||
prototype = protlib.search_prototype(key=key)
|
||||
if len(prototype) > 1:
|
||||
caller.msg("More than one match for {}:\n{}".format(
|
||||
key, "\n".join(proto.get('prototype_key', '') for proto in prototype)))
|
||||
|
|
@ -2918,6 +2926,10 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
|
|||
elif prototype:
|
||||
# one match
|
||||
prototype = prototype[0]
|
||||
else:
|
||||
# no match
|
||||
caller.msg("No prototype '{}' was found.".format(key))
|
||||
return
|
||||
olc_menus.start_olc(caller, session=self.session, prototype=prototype)
|
||||
return
|
||||
|
||||
|
|
@ -3034,7 +3046,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
|
|||
caller.msg("|rDeletion cancelled.|n")
|
||||
return
|
||||
try:
|
||||
success = protlib.delete_db_prototype(caller, self.args)
|
||||
success = protlib.delete_prototype(self.args)
|
||||
except protlib.PermissionError as err:
|
||||
caller.msg("|rError deleting:|R {}|n".format(err))
|
||||
caller.msg("Deletion {}.".format(
|
||||
|
|
@ -3084,7 +3096,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
|
|||
return
|
||||
# we have a prototype, check access
|
||||
prototype = prototypes[0]
|
||||
if not caller.locks.check_lockstring(caller, prototype.get('prototype_locks', ''), access_type='spawn'):
|
||||
if not caller.locks.check_lockstring(
|
||||
caller, prototype.get('prototype_locks', ''), access_type='spawn', default=True):
|
||||
caller.msg("You don't have access to use this prototype.")
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ class CharacterCmdSet(CmdSet):
|
|||
self.add(admin.CmdEmit())
|
||||
self.add(admin.CmdPerm())
|
||||
self.add(admin.CmdWall())
|
||||
self.add(admin.CmdForce())
|
||||
|
||||
# Building and world manipulation
|
||||
self.add(building.CmdTeleport())
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class CmdHelp(Command):
|
|||
if type(self).help_more:
|
||||
usemore = True
|
||||
|
||||
if self.session.protocol_key in ("websocket", "ajax/comet"):
|
||||
if self.session and self.session.protocol_key in ("websocket", "ajax/comet"):
|
||||
try:
|
||||
options = self.account.db._saved_webclient_options
|
||||
if options and options["helppopup"]:
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ class CmdReload(COMMAND_DEFAULT_CLASS):
|
|||
@reset to purge) and at_reload() hooks will be called.
|
||||
"""
|
||||
key = "@reload"
|
||||
aliases = ['@restart']
|
||||
locks = "cmd:perm(reload) or perm(Developer)"
|
||||
help_category = "System"
|
||||
|
||||
|
|
@ -710,6 +711,7 @@ class CmdTime(COMMAND_DEFAULT_CLASS):
|
|||
"""Show server time data in a table."""
|
||||
table1 = EvTable("|wServer time", "", align="l", width=78)
|
||||
table1.add_row("Current uptime", utils.time_format(gametime.uptime(), 3))
|
||||
table1.add_row("Portal uptime", utils.time_format(gametime.portal_uptime(), 3))
|
||||
table1.add_row("Total runtime", utils.time_format(gametime.runtime(), 2))
|
||||
table1.add_row("First start", datetime.datetime.fromtimestamp(gametime.server_epoch()))
|
||||
table1.add_row("Current time", datetime.datetime.now())
|
||||
|
|
|
|||
|
|
@ -243,6 +243,9 @@ class TestAdmin(CommandTest):
|
|||
def test_ban(self):
|
||||
self.call(admin.CmdBan(), "Char", "Name-Ban char was added.")
|
||||
|
||||
def test_force(self):
|
||||
self.call(admin.CmdForce(), "Char2=say test", 'Char2(#7) says, "test"|You have forced Char2 to: say test')
|
||||
|
||||
|
||||
class TestAccount(CommandTest):
|
||||
|
||||
|
|
@ -280,6 +283,32 @@ class TestAccount(CommandTest):
|
|||
def test_char_create(self):
|
||||
self.call(account.CmdCharCreate(), "Test1=Test char",
|
||||
"Created new character Test1. Use @ic Test1 to enter the game", caller=self.account)
|
||||
|
||||
def test_char_delete(self):
|
||||
# Chardelete requires user input; this test is mainly to confirm
|
||||
# whether permissions are being checked
|
||||
|
||||
# Add char to account playable characters
|
||||
self.account.db._playable_characters.append(self.char1)
|
||||
|
||||
# Try deleting as Developer
|
||||
self.call(account.CmdCharDelete(), "Char", "This will permanently destroy 'Char'. This cannot be undone. Continue yes/[no]?", caller=self.account)
|
||||
|
||||
# Downgrade permissions on account
|
||||
self.account.permissions.add('Player')
|
||||
self.account.permissions.remove('Developer')
|
||||
|
||||
# Set lock on character object to prevent deletion
|
||||
self.char1.locks.add('delete:none()')
|
||||
|
||||
# Try deleting as Player
|
||||
self.call(account.CmdCharDelete(), "Char", "You do not have permission to delete this character.", caller=self.account)
|
||||
|
||||
# Set lock on character object to allow self-delete
|
||||
self.char1.locks.add('delete:pid(%i)' % self.account.id)
|
||||
|
||||
# Try deleting as Player again
|
||||
self.call(account.CmdCharDelete(), "Char", "This will permanently destroy 'Char'. This cannot be undone. Continue yes/[no]?", caller=self.account)
|
||||
|
||||
def test_quell(self):
|
||||
self.call(account.CmdQuell(), "", "Quelling to current puppet's permissions (developer).", caller=self.account)
|
||||
|
|
@ -315,6 +344,24 @@ class TestBuilding(CommandTest):
|
|||
def test_desc(self):
|
||||
self.call(building.CmdDesc(), "Obj2=TestDesc", "The description was set on Obj2(#5).")
|
||||
|
||||
def test_empty_desc(self):
|
||||
"""
|
||||
empty desc sets desc as ''
|
||||
"""
|
||||
o2d = self.obj2.db.desc
|
||||
r1d = self.room1.db.desc
|
||||
self.call(building.CmdDesc(), "Obj2=", "The description was set on Obj2(#5).")
|
||||
assert self.obj2.db.desc == '' and self.obj2.db.desc != o2d
|
||||
assert self.room1.db.desc == r1d
|
||||
|
||||
def test_desc_default_to_room(self):
|
||||
"""no rhs changes room's desc"""
|
||||
o2d = self.obj2.db.desc
|
||||
r1d = self.room1.db.desc
|
||||
self.call(building.CmdDesc(), "Obj2", "The description was set on Room(#1).")
|
||||
assert self.obj2.db.desc == o2d
|
||||
assert self.room1.db.desc == 'Obj2' and self.room1.db.desc != r1d
|
||||
|
||||
def test_wipe(self):
|
||||
confirm = building.CmdDestroy.confirm
|
||||
building.CmdDestroy.confirm = False
|
||||
|
|
@ -334,6 +381,14 @@ class TestBuilding(CommandTest):
|
|||
self.call(building.CmdOpen(), "TestExit1=Room2", "Created new Exit 'TestExit1' from Room to Room2")
|
||||
self.call(building.CmdLink(), "TestExit1=Room", "Link created TestExit1 -> Room (one way).")
|
||||
self.call(building.CmdUnLink(), "TestExit1", "Former exit TestExit1 no longer links anywhere.")
|
||||
self.char1.location = self.room2
|
||||
self.call(building.CmdOpen(), "TestExit2=Room", "Created new Exit 'TestExit2' from Room2 to Room.")
|
||||
# ensure it matches locally first
|
||||
self.call(building.CmdLink(), "TestExit=Room2", "Link created TestExit2 -> Room2 (one way).")
|
||||
# ensure can still match globally when not a local name
|
||||
self.call(building.CmdLink(), "TestExit1=Room2", "Note: TestExit1(#8) did not have a destination set before. "
|
||||
"Make sure you linked the right thing.\n"
|
||||
"Link created TestExit1 -> Room2 (one way).")
|
||||
|
||||
def test_set_home(self):
|
||||
self.call(building.CmdSetHome(), "Obj = Room2", "Obj's home location was changed from Room")
|
||||
|
|
@ -460,6 +515,61 @@ class TestBuilding(CommandTest):
|
|||
# Test listing commands
|
||||
self.call(building.CmdSpawn(), "/list", "Key ")
|
||||
|
||||
# @spawn/edit (missing prototype)
|
||||
# brings up olc menu
|
||||
msg = self.call(
|
||||
building.CmdSpawn(),
|
||||
'/edit')
|
||||
assert 'Prototype wizard' in msg
|
||||
|
||||
# @spawn/edit with valid prototype
|
||||
# brings up olc menu loaded with prototype
|
||||
msg = self.call(
|
||||
building.CmdSpawn(),
|
||||
'/edit testball')
|
||||
assert 'Prototype wizard' in msg
|
||||
assert hasattr(self.char1.ndb._menutree, "olc_prototype")
|
||||
assert dict == type(self.char1.ndb._menutree.olc_prototype) \
|
||||
and 'prototype_key' in self.char1.ndb._menutree.olc_prototype \
|
||||
and 'key' in self.char1.ndb._menutree.olc_prototype \
|
||||
and 'testball' == self.char1.ndb._menutree.olc_prototype['prototype_key'] \
|
||||
and 'Ball' == self.char1.ndb._menutree.olc_prototype['key']
|
||||
assert 'Ball' in msg and 'testball' in msg
|
||||
|
||||
# @spawn/edit with valid prototype (synomym)
|
||||
msg = self.call(
|
||||
building.CmdSpawn(),
|
||||
'/edit BALL')
|
||||
assert 'Prototype wizard' in msg
|
||||
assert 'Ball' in msg and 'testball' in msg
|
||||
|
||||
# @spawn/edit with invalid prototype
|
||||
msg = self.call(
|
||||
building.CmdSpawn(),
|
||||
'/edit NO_EXISTS',
|
||||
"No prototype 'NO_EXISTS' was found.")
|
||||
|
||||
# @spawn/examine (missing prototype)
|
||||
# lists all prototypes that exist
|
||||
msg = self.call(
|
||||
building.CmdSpawn(),
|
||||
'/examine')
|
||||
assert 'testball' in msg and 'testprot' in msg
|
||||
|
||||
# @spawn/examine with valid prototype
|
||||
# prints the prototype
|
||||
msg = self.call(
|
||||
building.CmdSpawn(),
|
||||
'/examine BALL')
|
||||
assert 'Ball' in msg and 'testball' in msg
|
||||
|
||||
# @spawn/examine with invalid prototype
|
||||
# shows error
|
||||
self.call(
|
||||
building.CmdSpawn(),
|
||||
'/examine NO_EXISTS',
|
||||
"No prototype 'NO_EXISTS' was found.")
|
||||
|
||||
|
||||
class TestComms(CommandTest):
|
||||
|
||||
|
|
|
|||
|
|
@ -4,17 +4,11 @@ Commands that are available from the connect screen.
|
|||
import re
|
||||
import datetime
|
||||
from codecs import lookup as codecs_lookup
|
||||
from random import getrandbits
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
from evennia.accounts.models import AccountDB
|
||||
from evennia.objects.models import ObjectDB
|
||||
from evennia.server.models import ServerConfig
|
||||
from evennia.server.throttle import Throttle
|
||||
from evennia.comms.models import ChannelDB
|
||||
from evennia.server.sessionhandler import SESSIONS
|
||||
|
||||
from evennia.utils import create, logger, utils, gametime
|
||||
from evennia.utils import class_from_module, create, logger, utils, gametime
|
||||
from evennia.commands.cmdhandler import CMD_LOGINSTART
|
||||
|
||||
COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
|
||||
|
|
@ -26,11 +20,6 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate",
|
|||
MULTISESSION_MODE = settings.MULTISESSION_MODE
|
||||
CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
|
||||
|
||||
# Create throttles for too many connections, account-creations and login attempts
|
||||
CONNECTION_THROTTLE = Throttle(limit=5, timeout=1 * 60)
|
||||
CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60)
|
||||
LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60)
|
||||
|
||||
|
||||
def create_guest_account(session):
|
||||
"""
|
||||
|
|
@ -44,49 +33,20 @@ def create_guest_account(session):
|
|||
the boolean is whether guest accounts are enabled at all.
|
||||
the Account which was created from an available guest name.
|
||||
"""
|
||||
# check if guests are enabled.
|
||||
if not settings.GUEST_ENABLED:
|
||||
return False, None
|
||||
enabled = settings.GUEST_ENABLED
|
||||
address = session.address
|
||||
|
||||
# Check IP bans.
|
||||
bans = ServerConfig.objects.conf("server_bans")
|
||||
if bans and any(tup[2].match(session.address) for tup in bans if tup[2]):
|
||||
# this is a banned IP!
|
||||
string = "|rYou have been banned and cannot continue from here." \
|
||||
"\nIf you feel this ban is in error, please email an admin.|x"
|
||||
session.msg(string)
|
||||
session.sessionhandler.disconnect(session, "Good bye! Disconnecting.")
|
||||
return True, None
|
||||
# Get account class
|
||||
Guest = class_from_module(settings.BASE_GUEST_TYPECLASS)
|
||||
|
||||
try:
|
||||
# Find an available guest name.
|
||||
accountname = None
|
||||
for name in settings.GUEST_LIST:
|
||||
if not AccountDB.objects.filter(username__iexact=accountname).count():
|
||||
accountname = name
|
||||
break
|
||||
if not accountname:
|
||||
session.msg("All guest accounts are in use. Please try again later.")
|
||||
return True, None
|
||||
else:
|
||||
# build a new account with the found guest accountname
|
||||
password = "%016x" % getrandbits(64)
|
||||
home = ObjectDB.objects.get_id(settings.GUEST_HOME)
|
||||
permissions = settings.PERMISSION_GUEST_DEFAULT
|
||||
typeclass = settings.BASE_CHARACTER_TYPECLASS
|
||||
ptypeclass = settings.BASE_GUEST_TYPECLASS
|
||||
new_account = _create_account(session, accountname, password, permissions, ptypeclass)
|
||||
if new_account:
|
||||
_create_character(session, new_account, typeclass, home, permissions)
|
||||
return True, new_account
|
||||
|
||||
except Exception:
|
||||
# We are in the middle between logged in and -not, so we have
|
||||
# to handle tracebacks ourselves at this point. If we don't,
|
||||
# we won't see any errors at all.
|
||||
session.msg("An error occurred. Please e-mail an admin if the problem persists.")
|
||||
logger.log_trace()
|
||||
raise
|
||||
# Get an available guest account
|
||||
# authenticate() handles its own throttling
|
||||
account, errors = Guest.authenticate(ip=address)
|
||||
if account:
|
||||
return enabled, account
|
||||
else:
|
||||
session.msg("|R%s|n" % '\n'.join(errors))
|
||||
return enabled, None
|
||||
|
||||
|
||||
def create_normal_account(session, name, password):
|
||||
|
|
@ -101,38 +61,17 @@ def create_normal_account(session, name, password):
|
|||
Returns:
|
||||
account (Account): the account which was created from the name and password.
|
||||
"""
|
||||
# check for too many login errors too quick.
|
||||
address = session.address
|
||||
if isinstance(address, tuple):
|
||||
address = address[0]
|
||||
# Get account class
|
||||
Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
|
||||
|
||||
if LOGIN_THROTTLE.check(address):
|
||||
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
|
||||
return None
|
||||
address = session.address
|
||||
|
||||
# Match account name and check password
|
||||
account = authenticate(username=name, password=password)
|
||||
|
||||
# authenticate() handles all its own throttling
|
||||
account, errors = Account.authenticate(username=name, password=password, ip=address, session=session)
|
||||
if not account:
|
||||
# No accountname or password match
|
||||
session.msg("Incorrect login information given.")
|
||||
# this just updates the throttle
|
||||
LOGIN_THROTTLE.update(address)
|
||||
# calls account hook for a failed login if possible.
|
||||
account = AccountDB.objects.get_account_from_name(name)
|
||||
if account:
|
||||
account.at_failed_login(session)
|
||||
return None
|
||||
|
||||
# Check IP and/or name bans
|
||||
bans = ServerConfig.objects.conf("server_bans")
|
||||
if bans and (any(tup[0] == account.name.lower() for tup in bans) or
|
||||
any(tup[2].match(session.address) for tup in bans if tup[2])):
|
||||
# this is a banned IP or name!
|
||||
string = "|rYou have been banned and cannot continue from here." \
|
||||
"\nIf you feel this ban is in error, please email an admin.|x"
|
||||
session.msg(string)
|
||||
session.sessionhandler.disconnect(session, "Good bye! Disconnecting.")
|
||||
session.msg("|R%s|n" % '\n'.join(errors))
|
||||
return None
|
||||
|
||||
return account
|
||||
|
|
@ -164,15 +103,7 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
|
|||
there is no object yet before the account has logged in)
|
||||
"""
|
||||
session = self.caller
|
||||
|
||||
# check for too many login errors too quick.
|
||||
address = session.address
|
||||
if isinstance(address, tuple):
|
||||
address = address[0]
|
||||
if CONNECTION_THROTTLE.check(address):
|
||||
# timeout is 5 minutes.
|
||||
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
|
||||
return
|
||||
|
||||
args = self.args
|
||||
# extract double quote parts
|
||||
|
|
@ -180,23 +111,33 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
|
|||
if len(parts) == 1:
|
||||
# this was (hopefully) due to no double quotes being found, or a guest login
|
||||
parts = parts[0].split(None, 1)
|
||||
|
||||
# Guest login
|
||||
if len(parts) == 1 and parts[0].lower() == "guest":
|
||||
enabled, new_account = create_guest_account(session)
|
||||
if new_account:
|
||||
session.sessionhandler.login(session, new_account)
|
||||
if enabled:
|
||||
# Get Guest typeclass
|
||||
Guest = class_from_module(settings.BASE_GUEST_TYPECLASS)
|
||||
|
||||
account, errors = Guest.authenticate(ip=address)
|
||||
if account:
|
||||
session.sessionhandler.login(session, account)
|
||||
return
|
||||
else:
|
||||
session.msg("|R%s|n" % '\n'.join(errors))
|
||||
return
|
||||
|
||||
if len(parts) != 2:
|
||||
session.msg("\n\r Usage (without <>): connect <name> <password>")
|
||||
return
|
||||
|
||||
CONNECTION_THROTTLE.update(address)
|
||||
# Get account class
|
||||
Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
|
||||
|
||||
name, password = parts
|
||||
account = create_normal_account(session, name, password)
|
||||
account, errors = Account.authenticate(username=name, password=password, ip=address, session=session)
|
||||
if account:
|
||||
session.sessionhandler.login(session, account)
|
||||
else:
|
||||
session.msg("|R%s|n" % '\n'.join(errors))
|
||||
|
||||
|
||||
class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
|
||||
|
|
@ -222,14 +163,10 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
|
|||
session = self.caller
|
||||
args = self.args.strip()
|
||||
|
||||
# Rate-limit account creation.
|
||||
address = session.address
|
||||
|
||||
if isinstance(address, tuple):
|
||||
address = address[0]
|
||||
if CREATION_THROTTLE.check(address):
|
||||
session.msg("|RYou are creating too many accounts. Try again in a few minutes.|n")
|
||||
return
|
||||
# Get account class
|
||||
Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
|
||||
|
||||
# extract double quoted parts
|
||||
parts = [part.strip() for part in re.split(r"\"", args) if part.strip()]
|
||||
|
|
@ -241,77 +178,21 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
|
|||
"\nIf <name> or <password> contains spaces, enclose it in double quotes."
|
||||
session.msg(string)
|
||||
return
|
||||
accountname, password = parts
|
||||
|
||||
# sanity checks
|
||||
if not re.findall(r"^[\w. @+\-']+$", accountname) or not (0 < len(accountname) <= 30):
|
||||
# this echoes the restrictions made by django's auth
|
||||
# module (except not allowing spaces, for convenience of
|
||||
# logging in).
|
||||
string = "\n\r Accountname can max be 30 characters or fewer. Letters, spaces, digits and @/./+/-/_/' only."
|
||||
session.msg(string)
|
||||
return
|
||||
# strip excessive spaces in accountname
|
||||
accountname = re.sub(r"\s+", " ", accountname).strip()
|
||||
if AccountDB.objects.filter(username__iexact=accountname):
|
||||
# account already exists (we also ignore capitalization here)
|
||||
session.msg("Sorry, there is already an account with the name '%s'." % accountname)
|
||||
return
|
||||
# Reserve accountnames found in GUEST_LIST
|
||||
if settings.GUEST_LIST and accountname.lower() in (guest.lower() for guest in settings.GUEST_LIST):
|
||||
string = "\n\r That name is reserved. Please choose another Accountname."
|
||||
session.msg(string)
|
||||
return
|
||||
|
||||
# Validate password
|
||||
Account = utils.class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
|
||||
# Have to create a dummy Account object to check username similarity
|
||||
valid, error = Account.validate_password(password, account=Account(username=accountname))
|
||||
if error:
|
||||
errors = [e for suberror in error.messages for e in error.messages]
|
||||
string = "\n".join(errors)
|
||||
session.msg(string)
|
||||
return
|
||||
|
||||
# Check IP and/or name bans
|
||||
bans = ServerConfig.objects.conf("server_bans")
|
||||
if bans and (any(tup[0] == accountname.lower() for tup in bans) or
|
||||
|
||||
any(tup[2].match(session.address) for tup in bans if tup[2])):
|
||||
# this is a banned IP or name!
|
||||
string = "|rYou have been banned and cannot continue from here." \
|
||||
"\nIf you feel this ban is in error, please email an admin.|x"
|
||||
session.msg(string)
|
||||
session.sessionhandler.disconnect(session, "Good bye! Disconnecting.")
|
||||
return
|
||||
username, password = parts
|
||||
|
||||
# everything's ok. Create the new account account.
|
||||
try:
|
||||
permissions = settings.PERMISSION_ACCOUNT_DEFAULT
|
||||
typeclass = settings.BASE_CHARACTER_TYPECLASS
|
||||
new_account = _create_account(session, accountname, password, permissions)
|
||||
if new_account:
|
||||
if MULTISESSION_MODE < 2:
|
||||
default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME)
|
||||
_create_character(session, new_account, typeclass, default_home, permissions)
|
||||
|
||||
# Update the throttle to indicate a new account was created from this IP
|
||||
CREATION_THROTTLE.update(address)
|
||||
|
||||
# tell the caller everything went well.
|
||||
string = "A new account '%s' was created. Welcome!"
|
||||
if " " in accountname:
|
||||
string += "\n\nYou can now log in with the command 'connect \"%s\" <your password>'."
|
||||
else:
|
||||
string += "\n\nYou can now log with the command 'connect %s <your password>'."
|
||||
session.msg(string % (accountname, accountname))
|
||||
|
||||
except Exception:
|
||||
# We are in the middle between logged in and -not, so we have
|
||||
# to handle tracebacks ourselves at this point. If we don't,
|
||||
# we won't see any errors at all.
|
||||
session.msg("An error occurred. Please e-mail an admin if the problem persists.")
|
||||
logger.log_trace()
|
||||
account, errors = Account.create(username=username, password=password, ip=address, session=session)
|
||||
if account:
|
||||
# tell the caller everything went well.
|
||||
string = "A new account '%s' was created. Welcome!"
|
||||
if " " in username:
|
||||
string += "\n\nYou can now log in with the command 'connect \"%s\" <your password>'."
|
||||
else:
|
||||
string += "\n\nYou can now log with the command 'connect %s <your password>'."
|
||||
session.msg(string % (username, username))
|
||||
else:
|
||||
session.msg("|R%s|n" % '\n'.join(errors))
|
||||
|
||||
|
||||
class CmdUnconnectedQuit(COMMAND_DEFAULT_CLASS):
|
||||
|
|
@ -395,6 +276,9 @@ Next you can connect to the game: |wconnect Anna c67jHL8p|n
|
|||
You can use the |wlook|n command if you want to see the connect screen again.
|
||||
|
||||
"""
|
||||
|
||||
if settings.STAFF_CONTACT_EMAIL:
|
||||
string += 'For support, please contact: %s' % settings.STAFF_CONTACT_EMAIL
|
||||
self.caller.msg(string)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@
|
|||
Base typeclass for in-game Channels.
|
||||
|
||||
"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
|
||||
from evennia.typeclasses.models import TypeclassBase
|
||||
from evennia.comms.models import TempMsg, ChannelDB
|
||||
from evennia.comms.managers import ChannelManager
|
||||
from evennia.utils import logger
|
||||
from evennia.utils import create, logger
|
||||
from evennia.utils.utils import make_iter
|
||||
from future.utils import with_metaclass
|
||||
_CHANNEL_HANDLER = None
|
||||
|
|
@ -220,6 +224,50 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
|
|||
return self.locks.check(accessing_obj, access_type=access_type,
|
||||
default=default, no_superuser_bypass=no_superuser_bypass)
|
||||
|
||||
@classmethod
|
||||
def create(cls, key, account=None, *args, **kwargs):
|
||||
"""
|
||||
Creates a basic Channel with default parameters, unless otherwise
|
||||
specified or extended.
|
||||
|
||||
Provides a friendlier interface to the utils.create_channel() function.
|
||||
|
||||
Args:
|
||||
key (str): This must be unique.
|
||||
account (Account): Account to attribute this object to.
|
||||
|
||||
Kwargs:
|
||||
aliases (list of str): List of alternative (likely shorter) keynames.
|
||||
description (str): A description of the channel, for use in listings.
|
||||
locks (str): Lockstring.
|
||||
keep_log (bool): Log channel throughput.
|
||||
typeclass (str or class): The typeclass of the Channel (not
|
||||
often used).
|
||||
ip (str): IP address of creator (for object auditing).
|
||||
|
||||
Returns:
|
||||
channel (Channel): A newly created Channel.
|
||||
errors (list): A list of errors in string form, if any.
|
||||
|
||||
"""
|
||||
errors = []
|
||||
obj = None
|
||||
ip = kwargs.pop('ip', '')
|
||||
|
||||
try:
|
||||
kwargs['desc'] = kwargs.pop('description', '')
|
||||
obj = create.create_channel(key, *args, **kwargs)
|
||||
|
||||
# Record creator id and creation IP
|
||||
if ip: obj.db.creator_ip = ip
|
||||
if account: obj.db.creator_id = account.id
|
||||
|
||||
except Exception as exc:
|
||||
errors.append("An error occurred while creating this '%s' object." % key)
|
||||
logger.log_err(exc)
|
||||
|
||||
return obj, errors
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
Deletes channel while also cleaning up channelhandler.
|
||||
|
|
@ -578,3 +626,151 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
|
|||
|
||||
"""
|
||||
pass
|
||||
|
||||
#
|
||||
# Web/Django methods
|
||||
#
|
||||
|
||||
def web_get_admin_url(self):
|
||||
"""
|
||||
Returns the URI path for the Django Admin page for this object.
|
||||
|
||||
ex. Account#1 = '/admin/accounts/accountdb/1/change/'
|
||||
|
||||
Returns:
|
||||
path (str): URI path to Django Admin page for object.
|
||||
|
||||
"""
|
||||
content_type = ContentType.objects.get_for_model(self.__class__)
|
||||
return reverse("admin:%s_%s_change" % (content_type.app_label,
|
||||
content_type.model), args=(self.id,))
|
||||
|
||||
@classmethod
|
||||
def web_get_create_url(cls):
|
||||
"""
|
||||
Returns the URI path for a View that allows users to create new
|
||||
instances of this object.
|
||||
|
||||
ex. Chargen = '/characters/create/'
|
||||
|
||||
For this to work, the developer must have defined a named view somewhere
|
||||
in urls.py that follows the format 'modelname-action', so in this case
|
||||
a named view of 'channel-create' would be referenced by this method.
|
||||
|
||||
ex.
|
||||
url(r'channels/create/', ChannelCreateView.as_view(), name='channel-create')
|
||||
|
||||
If no View has been created and defined in urls.py, returns an
|
||||
HTML anchor.
|
||||
|
||||
This method is naive and simply returns a path. Securing access to
|
||||
the actual view and limiting who can create new objects is the
|
||||
developer's responsibility.
|
||||
|
||||
Returns:
|
||||
path (str): URI path to object creation page, if defined.
|
||||
|
||||
"""
|
||||
try:
|
||||
return reverse('%s-create' % slugify(cls._meta.verbose_name))
|
||||
except:
|
||||
return '#'
|
||||
|
||||
def web_get_detail_url(self):
|
||||
"""
|
||||
Returns the URI path for a View that allows users to view details for
|
||||
this object.
|
||||
|
||||
ex. Oscar (Character) = '/characters/oscar/1/'
|
||||
|
||||
For this to work, the developer must have defined a named view somewhere
|
||||
in urls.py that follows the format 'modelname-action', so in this case
|
||||
a named view of 'channel-detail' would be referenced by this method.
|
||||
|
||||
ex.
|
||||
url(r'channels/(?P<slug>[\w\d\-]+)/$',
|
||||
ChannelDetailView.as_view(), name='channel-detail')
|
||||
|
||||
If no View has been created and defined in urls.py, returns an
|
||||
HTML anchor.
|
||||
|
||||
This method is naive and simply returns a path. Securing access to
|
||||
the actual view and limiting who can view this object is the developer's
|
||||
responsibility.
|
||||
|
||||
Returns:
|
||||
path (str): URI path to object detail page, if defined.
|
||||
|
||||
"""
|
||||
try:
|
||||
return reverse('%s-detail' % slugify(self._meta.verbose_name),
|
||||
kwargs={'slug': slugify(self.db_key)})
|
||||
except:
|
||||
return '#'
|
||||
|
||||
|
||||
def web_get_update_url(self):
|
||||
"""
|
||||
Returns the URI path for a View that allows users to update this
|
||||
object.
|
||||
|
||||
ex. Oscar (Character) = '/characters/oscar/1/change/'
|
||||
|
||||
For this to work, the developer must have defined a named view somewhere
|
||||
in urls.py that follows the format 'modelname-action', so in this case
|
||||
a named view of 'channel-update' would be referenced by this method.
|
||||
|
||||
ex.
|
||||
url(r'channels/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$',
|
||||
ChannelUpdateView.as_view(), name='channel-update')
|
||||
|
||||
If no View has been created and defined in urls.py, returns an
|
||||
HTML anchor.
|
||||
|
||||
This method is naive and simply returns a path. Securing access to
|
||||
the actual view and limiting who can modify objects is the developer's
|
||||
responsibility.
|
||||
|
||||
Returns:
|
||||
path (str): URI path to object update page, if defined.
|
||||
|
||||
"""
|
||||
try:
|
||||
return reverse('%s-update' % slugify(self._meta.verbose_name),
|
||||
kwargs={'slug': slugify(self.db_key)})
|
||||
except:
|
||||
return '#'
|
||||
|
||||
def web_get_delete_url(self):
|
||||
"""
|
||||
Returns the URI path for a View that allows users to delete this object.
|
||||
|
||||
ex. Oscar (Character) = '/characters/oscar/1/delete/'
|
||||
|
||||
For this to work, the developer must have defined a named view somewhere
|
||||
in urls.py that follows the format 'modelname-action', so in this case
|
||||
a named view of 'channel-delete' would be referenced by this method.
|
||||
|
||||
ex.
|
||||
url(r'channels/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$',
|
||||
ChannelDeleteView.as_view(), name='channel-delete')
|
||||
|
||||
If no View has been created and defined in urls.py, returns an
|
||||
HTML anchor.
|
||||
|
||||
This method is naive and simply returns a path. Securing access to
|
||||
the actual view and limiting who can delete this object is the developer's
|
||||
responsibility.
|
||||
|
||||
Returns:
|
||||
path (str): URI path to object deletion page, if defined.
|
||||
|
||||
"""
|
||||
try:
|
||||
return reverse('%s-delete' % slugify(self._meta.verbose_name),
|
||||
kwargs={'slug': slugify(self.db_key)})
|
||||
except:
|
||||
return '#'
|
||||
|
||||
# Used by Django Sites/Admin
|
||||
get_absolute_url = web_get_detail_url
|
||||
13
evennia/comms/tests.py
Normal file
13
evennia/comms/tests.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
|
||||
from evennia.utils.test_resources import EvenniaTest
|
||||
from evennia import DefaultChannel
|
||||
|
||||
class ObjectCreationTest(EvenniaTest):
|
||||
|
||||
def test_channel_create(self):
|
||||
description = "A place to talk about coffee."
|
||||
|
||||
obj, errors = DefaultChannel.create('coffeetalk', description=description)
|
||||
self.assertTrue(obj, errors)
|
||||
self.assertFalse(errors, errors)
|
||||
self.assertEqual(description, obj.db.desc)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
|
||||
# Contrib folder
|
||||
|
||||
This folder contains 'contributions': extra snippets of code that are
|
||||
`evennia/contrib/` contains 'contributions': extra snippets of code that are
|
||||
potentially very useful for the game coder but which are considered
|
||||
too game-specific to be a part of the main Evennia game server. These
|
||||
modules are not used unless you explicitly import them. See each file
|
||||
|
|
@ -17,7 +17,7 @@ things you want from here into your game folder and change them there.
|
|||
|
||||
* Barter system (Griatch 2012) - A safe and effective barter-system
|
||||
for any game. Allows safe trading of any goods (including coin).
|
||||
* Building menu (vincent-lg 2018) - An @edit command for modifying
|
||||
* Building menu (vincent-lg 2018) - An `@edit` command for modifying
|
||||
objects using a generated menu. Customizable for different games.
|
||||
* CharGen (Griatch 2011) - A simple Character creator for OOC mode.
|
||||
Meant as a starting point for a more fleshed-out system.
|
||||
|
|
@ -60,9 +60,6 @@ things you want from here into your game folder and change them there.
|
|||
* Tree Select (FlutterSprite 2017) - A simple system for creating a
|
||||
branching EvMenu with selection options sourced from a single
|
||||
multi-line string.
|
||||
* Turnbattle (Tim Ashley Jenkins 2017) - This is a framework for a turn-based
|
||||
combat system with different levels of complexity, including versions with
|
||||
equipment and magic as well as ranged combat.
|
||||
* Wilderness (titeuf87 2017) - Make infinitely large wilderness areas
|
||||
with dynamically created locations.
|
||||
* UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax.
|
||||
|
|
@ -75,7 +72,8 @@ things you want from here into your game folder and change them there.
|
|||
objects and events using Python from in-game.
|
||||
* Turnbattle (FlutterSprite 2017) - A turn-based combat engine meant
|
||||
as a start to build from. Has attack/disengage and turn timeouts,
|
||||
and includes optional expansions for equipment and combat movement.
|
||||
and includes optional expansions for equipment and combat movement, magic
|
||||
and ranged combat.
|
||||
* Tutorial examples (Griatch 2011, 2015) - A folder of basic
|
||||
example objects, commands and scripts.
|
||||
* Tutorial world (Griatch 2011, 2015) - A folder containing the
|
||||
|
|
|
|||
|
|
@ -744,7 +744,7 @@ hole
|
|||
the remains of the castle. There is also a standing archway
|
||||
offering passage to a path along the old |wsouth|nern inner wall.
|
||||
#
|
||||
@detail portoculis;fall;fallen;grating =
|
||||
@detail portcullis;fall;fallen;grating =
|
||||
This heavy iron grating used to block off the inner part of the gate house, now it has fallen
|
||||
to the ground together with the stone archway that once help it up.
|
||||
#
|
||||
|
|
@ -786,7 +786,7 @@ archway
|
|||
The buildings make a half-circle along the main wall, here and there
|
||||
broken by falling stone and rubble. At one end (the |wnorth|nern) of
|
||||
this half-circle is the entrance to the castle, the ruined
|
||||
gatehoue. |wEast|nwards from here is some sort of open courtyard.
|
||||
gatehouse. |wEast|nwards from here is some sort of open courtyard.
|
||||
|
||||
#------------------------------------------------------------
|
||||
#
|
||||
|
|
@ -808,7 +808,7 @@ archway
|
|||
Previously one could probably continue past the obelisk and eastward
|
||||
into the castle keep itself, but that way is now completely blocked
|
||||
by fallen rubble. To the |wwest|n is the gatehouse and entrance to
|
||||
the castle, whereas |wsouth|nwards the collumns make way for a wide
|
||||
the castle, whereas |wsouth|nwards the columns make way for a wide
|
||||
open courtyard.
|
||||
#
|
||||
@set here/tutorial_info =
|
||||
|
|
|
|||
15
evennia/game_template/server/logs/README.md
Normal file
15
evennia/game_template/server/logs/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
This directory contains Evennia's log files. The existence of this README.md file is also necessary
|
||||
to correctly include the log directory in git (since log files are ignored by git and you can't
|
||||
commit an empty directory).
|
||||
|
||||
- `server.log` - log file from the game Server.
|
||||
- `portal.log` - log file from Portal proxy (internet facing)
|
||||
|
||||
Usually these logs are viewed together with `evennia -l`. They are also rotated every week so as not
|
||||
to be too big. Older log names will have a name appended by `_month_date`.
|
||||
|
||||
- `lockwarnings.log` - warnings from the lock system.
|
||||
- `http_requests.log` - this will generally be empty unless turning on debugging inside the server.
|
||||
|
||||
- `channel_<channelname>.log` - these are channel logs for the in-game channels They are also used
|
||||
by the `/history` flag in-game to get the latest message history.
|
||||
|
|
@ -11,7 +11,11 @@ game world, policy info, rules and similar.
|
|||
"""
|
||||
from builtins import object
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
|
||||
from evennia.utils.idmapper.models import SharedMemoryModel
|
||||
from evennia.help.manager import HelpEntryManager
|
||||
from evennia.typeclasses.models import Tag, TagHandler, AliasHandler
|
||||
|
|
@ -107,3 +111,158 @@ class HelpEntry(SharedMemoryModel):
|
|||
default - what to return if no lock of access_type was found
|
||||
"""
|
||||
return self.locks.check(accessing_obj, access_type=access_type, default=default)
|
||||
|
||||
#
|
||||
# Web/Django methods
|
||||
#
|
||||
|
||||
def web_get_admin_url(self):
|
||||
"""
|
||||
Returns the URI path for the Django Admin page for this object.
|
||||
|
||||
ex. Account#1 = '/admin/accounts/accountdb/1/change/'
|
||||
|
||||
Returns:
|
||||
path (str): URI path to Django Admin page for object.
|
||||
|
||||
"""
|
||||
content_type = ContentType.objects.get_for_model(self.__class__)
|
||||
return reverse("admin:%s_%s_change" % (content_type.app_label,
|
||||
content_type.model), args=(self.id,))
|
||||
|
||||
@classmethod
|
||||
def web_get_create_url(cls):
|
||||
"""
|
||||
Returns the URI path for a View that allows users to create new
|
||||
instances of this object.
|
||||
|
||||
ex. Chargen = '/characters/create/'
|
||||
|
||||
For this to work, the developer must have defined a named view somewhere
|
||||
in urls.py that follows the format 'modelname-action', so in this case
|
||||
a named view of 'character-create' would be referenced by this method.
|
||||
|
||||
ex.
|
||||
url(r'characters/create/', ChargenView.as_view(), name='character-create')
|
||||
|
||||
If no View has been created and defined in urls.py, returns an
|
||||
HTML anchor.
|
||||
|
||||
This method is naive and simply returns a path. Securing access to
|
||||
the actual view and limiting who can create new objects is the
|
||||
developer's responsibility.
|
||||
|
||||
Returns:
|
||||
path (str): URI path to object creation page, if defined.
|
||||
|
||||
"""
|
||||
try:
|
||||
return reverse('%s-create' % slugify(cls._meta.verbose_name))
|
||||
except:
|
||||
return '#'
|
||||
|
||||
def web_get_detail_url(self):
|
||||
"""
|
||||
Returns the URI path for a View that allows users to view details for
|
||||
this object.
|
||||
|
||||
ex. Oscar (Character) = '/characters/oscar/1/'
|
||||
|
||||
For this to work, the developer must have defined a named view somewhere
|
||||
in urls.py that follows the format 'modelname-action', so in this case
|
||||
a named view of 'character-detail' would be referenced by this method.
|
||||
|
||||
ex.
|
||||
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$',
|
||||
CharDetailView.as_view(), name='character-detail')
|
||||
|
||||
If no View has been created and defined in urls.py, returns an
|
||||
HTML anchor.
|
||||
|
||||
This method is naive and simply returns a path. Securing access to
|
||||
the actual view and limiting who can view this object is the developer's
|
||||
responsibility.
|
||||
|
||||
Returns:
|
||||
path (str): URI path to object detail page, if defined.
|
||||
|
||||
"""
|
||||
try:
|
||||
return reverse('%s-detail' % slugify(self._meta.verbose_name),
|
||||
kwargs={
|
||||
'category': slugify(self.db_help_category),
|
||||
'topic': slugify(self.db_key)})
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return '#'
|
||||
|
||||
|
||||
def web_get_update_url(self):
|
||||
"""
|
||||
Returns the URI path for a View that allows users to update this
|
||||
object.
|
||||
|
||||
ex. Oscar (Character) = '/characters/oscar/1/change/'
|
||||
|
||||
For this to work, the developer must have defined a named view somewhere
|
||||
in urls.py that follows the format 'modelname-action', so in this case
|
||||
a named view of 'character-update' would be referenced by this method.
|
||||
|
||||
ex.
|
||||
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$',
|
||||
CharUpdateView.as_view(), name='character-update')
|
||||
|
||||
If no View has been created and defined in urls.py, returns an
|
||||
HTML anchor.
|
||||
|
||||
This method is naive and simply returns a path. Securing access to
|
||||
the actual view and limiting who can modify objects is the developer's
|
||||
responsibility.
|
||||
|
||||
Returns:
|
||||
path (str): URI path to object update page, if defined.
|
||||
|
||||
"""
|
||||
try:
|
||||
return reverse('%s-update' % slugify(self._meta.verbose_name),
|
||||
kwargs={
|
||||
'category': slugify(self.db_help_category),
|
||||
'topic': slugify(self.db_key)})
|
||||
except:
|
||||
return '#'
|
||||
|
||||
def web_get_delete_url(self):
|
||||
"""
|
||||
Returns the URI path for a View that allows users to delete this object.
|
||||
|
||||
ex. Oscar (Character) = '/characters/oscar/1/delete/'
|
||||
|
||||
For this to work, the developer must have defined a named view somewhere
|
||||
in urls.py that follows the format 'modelname-action', so in this case
|
||||
a named view of 'character-detail' would be referenced by this method.
|
||||
|
||||
ex.
|
||||
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$',
|
||||
CharDeleteView.as_view(), name='character-delete')
|
||||
|
||||
If no View has been created and defined in urls.py, returns an
|
||||
HTML anchor.
|
||||
|
||||
This method is naive and simply returns a path. Securing access to
|
||||
the actual view and limiting who can delete this object is the developer's
|
||||
responsibility.
|
||||
|
||||
Returns:
|
||||
path (str): URI path to object deletion page, if defined.
|
||||
|
||||
"""
|
||||
try:
|
||||
return reverse('%s-delete' % slugify(self._meta.verbose_name),
|
||||
kwargs={
|
||||
'category': slugify(self.db_help_category),
|
||||
'topic': slugify(self.db_key)})
|
||||
except:
|
||||
return '#'
|
||||
|
||||
# Used by Django Sites/Admin
|
||||
get_absolute_url = web_get_detail_url
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,253 +1,346 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2016-03-02 16:22-0500\n"
|
||||
"PO-Revision-Date: 2016-03-04 11:51-0500\n"
|
||||
"Language: fr\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"X-Generator: Poedit 1.7.6\n"
|
||||
|
||||
#: commands/cmdhandler.py:485
|
||||
msgid "There were multiple matches."
|
||||
msgstr "Il y a eu plusieurs correspondances."
|
||||
|
||||
#: commands/cmdhandler.py:513
|
||||
#, python-format
|
||||
msgid "Command '%s' is not available."
|
||||
msgstr "Commande '%s' n'est pas disponible."
|
||||
|
||||
#: commands/cmdhandler.py:518
|
||||
#, python-format
|
||||
msgid " Maybe you meant %s?"
|
||||
msgstr "Voulez-vous dire %s?"
|
||||
|
||||
#: commands/cmdhandler.py:518
|
||||
msgid "or"
|
||||
msgstr "ou"
|
||||
|
||||
#: commands/cmdhandler.py:520
|
||||
msgid " Type \"help\" for help."
|
||||
msgstr "Tapez \"help\" pour de l'aide."
|
||||
|
||||
#: commands/cmdparser.py:183
|
||||
#, python-format
|
||||
msgid "Could not find '%s'."
|
||||
msgstr "Peut pas trouver '%s'."
|
||||
|
||||
#: commands/cmdsethandler.py:130
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"(Unsuccessfully tried '%s.' + '%s.%s')."
|
||||
msgstr ""
|
||||
"\n"
|
||||
"(Essayé sans succès '%s.' + '%s.%s')."
|
||||
|
||||
#: commands/cmdsethandler.py:151
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"Error loading cmdset {path}: \"{error}\""
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Erreur de chargement de cmdset {path}: \"{error}\""
|
||||
|
||||
#: commands/cmdsethandler.py:155
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"Error in loading cmdset: No cmdset class '{classname}' in {path}."
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Erreur lors du chargement de cmdset: Pas de classe cmdset '{classname}' in "
|
||||
"{path}."
|
||||
|
||||
#: commands/cmdsethandler.py:159
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"SyntaxError encountered when loading cmdset '{path}': \"{error}\"."
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Erreur de syntaxe lors du chargement de cmdset '{path}': \"{error}\"."
|
||||
|
||||
#: commands/cmdsethandler.py:163
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"Compile/Run error when loading cmdset '{path}': \"{error}\"."
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Erreur de compilation/exécution lors du chargement de cmdset '{path}': "
|
||||
"\"{error}\"."
|
||||
|
||||
#: commands/cmdsethandler.py:174
|
||||
msgid ""
|
||||
"\n"
|
||||
" (See log for details.)"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"(Voir registre pour plus de détails.)"
|
||||
|
||||
#: commands/cmdsethandler.py:247
|
||||
#, python-brace-format
|
||||
msgid "custom {mergetype} on cmdset '{cmdset}'"
|
||||
msgstr "custom {mergetype} sur cmdset '{cmdset}'"
|
||||
|
||||
#: commands/cmdsethandler.py:250
|
||||
#, python-brace-format
|
||||
msgid " <Merged {mergelist} {mergetype}, prio {prio}>: {current}"
|
||||
msgstr " <Fusionné {mergelist} {mergetype}, prio {prio}>: {current}"
|
||||
|
||||
#: commands/cmdsethandler.py:258
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
" <{key} ({mergetype}, prio {prio}, {permstring})>:\n"
|
||||
" {keylist}"
|
||||
msgstr ""
|
||||
" <{key} ({mergetype}, prio {prio}, {permstring})>:\n"
|
||||
" {keylist}"
|
||||
|
||||
#: commands/cmdsethandler.py:347
|
||||
msgid "Only CmdSets can be added to the cmdsethandler!"
|
||||
msgstr "Seulement CmdSets peuvent être ajoutés au cmdsethandler!"
|
||||
|
||||
#: comms/channelhandler.py:94
|
||||
msgid " (channel)"
|
||||
msgstr " (channel)"
|
||||
|
||||
#: locks/lockhandler.py:230
|
||||
#, python-format
|
||||
msgid "Lock: lock-function '%s' is not available."
|
||||
msgstr "Vérou: lock-function '%s' n'est pas disponible."
|
||||
|
||||
#: locks/lockhandler.py:243
|
||||
#, python-format
|
||||
msgid "Lock: definition '%s' has syntax errors."
|
||||
msgstr "Vérou: définition '%s' a des erreurs de syntaxe."
|
||||
|
||||
#: locks/lockhandler.py:247
|
||||
#, python-format
|
||||
msgid ""
|
||||
"LockHandler on %(obj)s: access type '%(access_type)s' changed from "
|
||||
"'%(source)s' to '%(goal)s' "
|
||||
msgstr ""
|
||||
"Gestionnaire de vérrou sur %(obj)s: type d'accès '%(access_type)s' a changé "
|
||||
"de '%(source)s' à '%(goal)s'"
|
||||
|
||||
#: locks/lockhandler.py:304
|
||||
#, python-format
|
||||
msgid "Lock: '%s' contains no colon (:)."
|
||||
msgstr "Verrou: '%s' contient pas de deux points (:)."
|
||||
|
||||
#: locks/lockhandler.py:308
|
||||
#, python-format
|
||||
msgid "Lock: '%s' has no access_type (left-side of colon is empty)."
|
||||
msgstr ""
|
||||
"Verrou: '%s' n'a pas de access_type (côté gauche du deux point est vide)."
|
||||
|
||||
#: locks/lockhandler.py:311
|
||||
#, python-format
|
||||
msgid "Lock: '%s' has mismatched parentheses."
|
||||
msgstr "Verrou: '%s' a des parenthèses dépareillées."
|
||||
|
||||
#: locks/lockhandler.py:314
|
||||
#, python-format
|
||||
msgid "Lock: '%s' has no valid lock functions."
|
||||
msgstr "Verrou: '%s' n'a pas de fonctions verrou valides."
|
||||
|
||||
#: objects/objects.py:528
|
||||
#, python-format
|
||||
msgid "Couldn't perform move ('%s'). Contact an admin."
|
||||
msgstr "Ne pouvait effectuer le coup ('%s'). Contactez un administrateur."
|
||||
|
||||
#: objects/objects.py:538
|
||||
msgid "The destination doesn't exist."
|
||||
msgstr "La destination n'existe pas."
|
||||
|
||||
#: objects/objects.py:651
|
||||
#, python-format
|
||||
msgid "Could not find default home '(#%d)'."
|
||||
msgstr "Ne peut trouver la maison '(#%d)' par défaut."
|
||||
|
||||
#: objects/objects.py:667
|
||||
msgid "Something went wrong! You are dumped into nowhere. Contact an admin."
|
||||
msgstr ""
|
||||
"Quelque chose a mal tourné! Vous êtes nulle part. Contactez un "
|
||||
"administrateur."
|
||||
|
||||
#: objects/objects.py:747
|
||||
#, python-format
|
||||
msgid "Your character %s has been destroyed."
|
||||
msgstr "Votre personnage %s a été détruit."
|
||||
|
||||
#: players/players.py:356
|
||||
msgid "Player being deleted."
|
||||
msgstr "Suppression de joueur."
|
||||
|
||||
#: scripts/scripthandler.py:50
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s repeats): %(desc)s"
|
||||
msgstr ""
|
||||
"\n"
|
||||
" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s répète): %(desc)s"
|
||||
|
||||
#: scripts/scripts.py:202
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Script %(key)s(#%(dbid)s) of type '%(cname)s': at_repeat() error '%(err)s'."
|
||||
msgstr ""
|
||||
"Scripte %(key)s(#%(dbid)s) de type '%(cname)s': at_repeat() erreur "
|
||||
"'%(err)s'."
|
||||
|
||||
#: server/initial_setup.py:29
|
||||
msgid ""
|
||||
"\n"
|
||||
"Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com if "
|
||||
"you need\n"
|
||||
"help, want to contribute, report issues or just join the community.\n"
|
||||
"As Player #1 you can create a demo/tutorial area with |w@batchcommand "
|
||||
"tutorial_world.build|n.\n"
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Bienvenue dans ton nouveau jeu basé sur |wEvennia|n ! Visitez http://www."
|
||||
"evennia.com si vous avez besoin\n"
|
||||
"d'aide, si vous voulez contribuer, rapporter des problèmes ou faire partie "
|
||||
"de la communauté.\n"
|
||||
"En tant que Joueur #1 vous pouvez créer une zone de démo/tutoriel avec "
|
||||
"|w@batchcommand tutorial_world.build|n.\n"
|
||||
" "
|
||||
|
||||
#: server/initial_setup.py:102
|
||||
msgid "This is User #1."
|
||||
msgstr "Utilisateur #1."
|
||||
|
||||
#: server/initial_setup.py:111
|
||||
msgid "Limbo"
|
||||
msgstr "Limbes."
|
||||
|
||||
#: server/sessionhandler.py:258
|
||||
msgid " ... Server restarted."
|
||||
msgstr " ... Serveur redémarré."
|
||||
|
||||
#: server/sessionhandler.py:408
|
||||
msgid "Logged in from elsewhere. Disconnecting."
|
||||
msgstr "Connecté d'ailleurs. Déconnexion."
|
||||
|
||||
#: server/sessionhandler.py:432
|
||||
msgid "Idle timeout exceeded, disconnecting."
|
||||
msgstr "Délai d'inactivité dépassé, déconnexion."
|
||||
# The French translation for the Evennia server.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the Evennia package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
# Maintained by: vincent-lg <vincent.legoff.srs@gmail.com>, 2018-
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-11-22 08:45+0100\n"
|
||||
"PO-Revision-Date: 2016-03-04 11:51-0500\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: fr\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
"X-Generator: Poedit 1.7.6\n"
|
||||
|
||||
#: .\accounts\accounts.py:440
|
||||
#, fuzzy
|
||||
msgid "Account being deleted."
|
||||
msgstr "Suppression du compte."
|
||||
|
||||
#: .\commands\cmdhandler.py:681
|
||||
msgid "There were multiple matches."
|
||||
msgstr "Il y a plusieurs correspondances possibles."
|
||||
|
||||
#: .\commands\cmdhandler.py:704
|
||||
#, python-format
|
||||
msgid "Command '%s' is not available."
|
||||
msgstr "La commande '%s' n'est pas disponible."
|
||||
|
||||
#: .\commands\cmdhandler.py:709
|
||||
#, python-format
|
||||
msgid " Maybe you meant %s?"
|
||||
msgstr " Vouliez-vous dire %s ?"
|
||||
|
||||
#: .\commands\cmdhandler.py:709
|
||||
msgid "or"
|
||||
msgstr "ou"
|
||||
|
||||
#: .\commands\cmdhandler.py:711
|
||||
msgid " Type \"help\" for help."
|
||||
msgstr " Tapez \"help\" pour obtenir de l'aide."
|
||||
|
||||
#: .\commands\cmdsethandler.py:89
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{traceback}\n"
|
||||
"Error loading cmdset '{path}'\n"
|
||||
"(Traceback was logged {timestamp})"
|
||||
msgstr ""
|
||||
"{traceback}\n"
|
||||
"Une erreur s'est produite lors du chargement du cmdset '{path}'\n"
|
||||
"(Référence de l'erreur : {timestamp})"
|
||||
|
||||
#: .\commands\cmdsethandler.py:94
|
||||
#, fuzzy, python-brace-format
|
||||
msgid ""
|
||||
"Error loading cmdset: No cmdset class '{classname}' in '{path}'.\n"
|
||||
"(Traceback was logged {timestamp})"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Une erreur s'est produite lors du chargement de cmdset : la classe cmdset '{classname}' est introuvable dans "
|
||||
"{path}.\n"
|
||||
"(Référence de l'erreur : {timestamp})"
|
||||
|
||||
#: .\commands\cmdsethandler.py:98
|
||||
#, fuzzy, python-brace-format
|
||||
msgid ""
|
||||
"{traceback}\n"
|
||||
"SyntaxError encountered when loading cmdset '{path}'.\n"
|
||||
"(Traceback was logged {timestamp})"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Erreur de syntaxe lors du chargement de cmdset '{path}' : \"{error}\".\n"
|
||||
"(Référence de l'erreur : {timestamp})"
|
||||
|
||||
#: .\commands\cmdsethandler.py:103
|
||||
#, fuzzy, python-brace-format
|
||||
msgid ""
|
||||
"{traceback}\n"
|
||||
"Compile/Run error when loading cmdset '{path}'.\",\n"
|
||||
"(Traceback was logged {timestamp})"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Erreur de compilation/exécution lors du chargement de cmdset '{path}' : "
|
||||
"\"{error}\".\n"
|
||||
"(Référence de l'erreur : {timestamp})"
|
||||
|
||||
#: .\commands\cmdsethandler.py:108
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"Error encountered for cmdset at path '{path}'.\n"
|
||||
"Replacing with fallback '{fallback_path}'.\n"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Une erreur a été rencontrée lors du chargement du cmdset '{path}'.\n"
|
||||
"Le cmdset '{fallback_path}' est utilisé en remplacement.\n"
|
||||
|
||||
#: .\commands\cmdsethandler.py:114
|
||||
#, python-brace-format
|
||||
msgid "Fallback path '{fallback_path}' failed to generate a cmdset."
|
||||
msgstr "Impossible de générer le cmdset de remplacement : '{fallback_path}'."
|
||||
|
||||
#: .\commands\cmdsethandler.py:182 .\commands\cmdsethandler.py:192
|
||||
#, fuzzy, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"(Unsuccessfully tried '%s')."
|
||||
msgstr ""
|
||||
"\n"
|
||||
"(Essayé sans succès '%s.' + '%s.%s')."
|
||||
|
||||
#: .\commands\cmdsethandler.py:311
|
||||
#, python-brace-format
|
||||
msgid "custom {mergetype} on cmdset '{cmdset}'"
|
||||
msgstr "custom {mergetype} sur cmdset '{cmdset}'"
|
||||
|
||||
#: .\commands\cmdsethandler.py:314
|
||||
#, python-brace-format
|
||||
msgid " <Merged {mergelist} {mergetype}, prio {prio}>: {current}"
|
||||
msgstr " <Fusionné {mergelist} {mergetype}, prio {prio}>: {current}"
|
||||
|
||||
#: .\commands\cmdsethandler.py:322
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
" <{key} ({mergetype}, prio {prio}, {permstring})>:\n"
|
||||
" {keylist}"
|
||||
msgstr ""
|
||||
" <{key} ({mergetype}, prio {prio}, {permstring})>:\n"
|
||||
" {keylist}"
|
||||
|
||||
#: .\commands\cmdsethandler.py:426
|
||||
msgid "Only CmdSets can be added to the cmdsethandler!"
|
||||
msgstr "Seuls des CmdSets peuvent être ajoutés au cmdsethandler !"
|
||||
|
||||
#: .\comms\channelhandler.py:100
|
||||
msgid "Say what?"
|
||||
msgstr "Que voulez-vous dire ?"
|
||||
|
||||
#: .\comms\channelhandler.py:105
|
||||
#, python-format
|
||||
msgid "Channel '%s' not found."
|
||||
msgstr "Le canal '%s' ne semble pas exister."
|
||||
|
||||
#: .\comms\channelhandler.py:108
|
||||
#, python-format
|
||||
msgid "You are not connected to channel '%s'."
|
||||
msgstr "Vous n'êtes pas connecté au canal '%s'."
|
||||
|
||||
#: .\comms\channelhandler.py:112
|
||||
#, python-format
|
||||
msgid "You are not permitted to send to channel '%s'."
|
||||
msgstr "Vous n'avez pas le droit de parler sur le canal '%s'."
|
||||
|
||||
#: .\comms\channelhandler.py:155
|
||||
msgid " (channel)"
|
||||
msgstr " (canal)"
|
||||
|
||||
#: .\locks\lockhandler.py:236
|
||||
#, python-format
|
||||
msgid "Lock: lock-function '%s' is not available."
|
||||
msgstr "Verrou : lock-function '%s' n'est pas disponible."
|
||||
|
||||
#: .\locks\lockhandler.py:249
|
||||
#, python-format
|
||||
msgid "Lock: definition '%s' has syntax errors."
|
||||
msgstr "Verrou : la définition '%s' a des erreurs de syntaxe."
|
||||
|
||||
#: .\locks\lockhandler.py:253
|
||||
#, python-format
|
||||
msgid ""
|
||||
"LockHandler on %(obj)s: access type '%(access_type)s' changed from "
|
||||
"'%(source)s' to '%(goal)s' "
|
||||
msgstr ""
|
||||
"Gestionnaire de verrous sur %(obj)s: type d'accès '%(access_type)s' a changé "
|
||||
"de '%(source)s' à '%(goal)s'"
|
||||
|
||||
#: .\locks\lockhandler.py:320
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Lock: '{lockdef}' contains no colon (:)."
|
||||
msgstr "Verrou : '%s' ne contient pas de deux points (:)."
|
||||
|
||||
#: .\locks\lockhandler.py:328
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Lock: '{lockdef}' has no access_type (left-side of colon is empty)."
|
||||
msgstr ""
|
||||
"Verrou : '%s' n'a pas de 'access_type' (il n'y a rien avant les deux points)."
|
||||
|
||||
#: .\locks\lockhandler.py:336
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Lock: '{lockdef}' has mismatched parentheses."
|
||||
msgstr "Verrou : '%s' a des parenthèses déséquilibrées."
|
||||
|
||||
#: .\locks\lockhandler.py:343
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Lock: '{lockdef}' has no valid lock functions."
|
||||
msgstr "Verrou : '%s' n'a pas de lock-function valide."
|
||||
|
||||
#: .\objects\objects.py:729
|
||||
#, python-format
|
||||
msgid "Couldn't perform move ('%s'). Contact an admin."
|
||||
msgstr "Impossible de se déplacer vers ('%s'). Veuillez contacter un administrateur."
|
||||
|
||||
#: .\objects\objects.py:739
|
||||
msgid "The destination doesn't exist."
|
||||
msgstr "La destination est inconnue."
|
||||
|
||||
#: .\objects\objects.py:830
|
||||
#, python-format
|
||||
msgid "Could not find default home '(#%d)'."
|
||||
msgstr "Impossible de trouver la salle de départ (default home) par défaut : '#%d'."
|
||||
|
||||
#: .\objects\objects.py:846
|
||||
msgid "Something went wrong! You are dumped into nowhere. Contact an admin."
|
||||
msgstr ""
|
||||
"Quelque chose a mal tourné ! Vous vous trouvez au milieu de nulle part. "
|
||||
"Veuillez contacter un administrateur."
|
||||
|
||||
#: .\objects\objects.py:912
|
||||
#, python-format
|
||||
msgid "Your character %s has been destroyed."
|
||||
msgstr "Votre personnage %s a été détruit."
|
||||
|
||||
#: .\scripts\scripthandler.py:53
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s repeats): %(desc)s"
|
||||
msgstr ""
|
||||
"\n"
|
||||
" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s répète) : %(desc)s"
|
||||
|
||||
#: .\scripts\scripts.py:205
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Script %(key)s(#%(dbid)s) of type '%(cname)s': at_repeat() error '%(err)s'."
|
||||
msgstr ""
|
||||
"Le script %(key)s(#%(dbid)s) de type '%(cname)s' a rencontré une erreur "
|
||||
"durant at_repeat() : '%(err)s'."
|
||||
|
||||
#: .\server\initial_setup.py:28
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"\n"
|
||||
"Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com if "
|
||||
"you need\n"
|
||||
"help, want to contribute, report issues or just join the community.\n"
|
||||
"As Account #1 you can create a demo/tutorial area with |w@batchcommand "
|
||||
"tutorial_world.build|n.\n"
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Bienvenue dans votre nouveau jeu basé sur |wEvennia|n ! Visitez le site Web\n"
|
||||
"http://www.evennia.com si vous avez besoin d'aide, pour contribuer au projet,\n"
|
||||
"afin de rapporter des bugs ou faire partie de la communauté.\n"
|
||||
"En tant que premier personnage (#1), vous pouvez créer une zone de\n"
|
||||
"démo/tutoriel en entrant la commande |w@batchcommand tutorial_world.build|n.\n"
|
||||
" "
|
||||
|
||||
#: .\server\initial_setup.py:92
|
||||
msgid "This is User #1."
|
||||
msgstr "C'est l'utilisateur #1."
|
||||
|
||||
#: .\server\initial_setup.py:105
|
||||
msgid "Limbo"
|
||||
msgstr "Limbes"
|
||||
|
||||
#: .\server\server.py:139
|
||||
#, fuzzy
|
||||
msgid "idle timeout exceeded"
|
||||
msgstr "Délai d'inactivité dépassé, déconnexion."
|
||||
|
||||
#: .\server\sessionhandler.py:386
|
||||
msgid " ... Server restarted."
|
||||
msgstr " ... Serveur redémarré."
|
||||
|
||||
#: .\server\sessionhandler.py:606
|
||||
msgid "Logged in from elsewhere. Disconnecting."
|
||||
msgstr "Connexion d'une autre session. Déconnexion de celle-ci."
|
||||
|
||||
#: .\server\sessionhandler.py:634
|
||||
msgid "Idle timeout exceeded, disconnecting."
|
||||
msgstr "Délai d'inactivité dépassé, déconnexion."
|
||||
|
||||
#: .\server\validators.py:50
|
||||
#, python-format
|
||||
msgid ""
|
||||
"%s From a terminal client, you can also use a phrase of multiple words if "
|
||||
"you enclose the password in double quotes."
|
||||
msgstr ""
|
||||
"%s Depuis votre client, vous pouvez également préciser une phrase contenant "
|
||||
"plusieurs mots séparés par un espace, dès lors que cette phrase est entourée de guillemets."
|
||||
|
||||
#: .\utils\evmenu.py:192
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Menu node '{nodename}' is either not implemented or caused an error. Make "
|
||||
"another choice."
|
||||
msgstr ""
|
||||
"Ce choix '{nodename}' n'est pas implémenté, ou bien a créé une erreur. "
|
||||
"Faies un autre choix."
|
||||
|
||||
#: .\utils\evmenu.py:194
|
||||
#, python-brace-format
|
||||
msgid "Error in menu node '{nodename}'."
|
||||
msgstr "Une erreur s'est produite dans le choix '{nodename}'."
|
||||
|
||||
#: .\utils\evmenu.py:195
|
||||
msgid "No description."
|
||||
msgstr "Description non renseignée."
|
||||
|
||||
#: .\utils\evmenu.py:196
|
||||
msgid "Commands: <menu option>, help, quit"
|
||||
msgstr "Utilisez une des commandes : <menu option>, help, quit"
|
||||
|
||||
#: .\utils\evmenu.py:197
|
||||
msgid "Commands: <menu option>, help"
|
||||
msgstr "Utilisez une des commandes : <menu option>, help"
|
||||
|
||||
#: .\utils\evmenu.py:198
|
||||
msgid "Commands: help, quit"
|
||||
msgstr "Utilisez une des commandes : help, quit"
|
||||
|
||||
#: .\utils\evmenu.py:199
|
||||
msgid "Commands: help"
|
||||
msgstr "Utilisez la commande : help"
|
||||
|
||||
#: .\utils\evmenu.py:200
|
||||
msgid "Choose an option or try 'help'."
|
||||
msgstr "Choisissez une option ou entrez la commande 'help'."
|
||||
|
||||
#: .\utils\utils.py:1866
|
||||
#, python-format
|
||||
msgid "Could not find '%s'."
|
||||
msgstr "Impossible de trouver '%s'."
|
||||
|
||||
#: .\utils\utils.py:1873
|
||||
#, python-format
|
||||
msgid "More than one match for '%s' (please narrow target):\n"
|
||||
msgstr "Plus d'une possibilité pour '%s' (veuillez préciser) :\n"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from evennia.scripts.scripthandler import ScriptHandler
|
|||
from evennia.commands import cmdset, command
|
||||
from evennia.commands.cmdsethandler import CmdSetHandler
|
||||
from evennia.commands import cmdhandler
|
||||
from evennia.utils import create
|
||||
from evennia.utils import search
|
||||
from evennia.utils import logger
|
||||
from evennia.utils import ansi
|
||||
|
|
@ -191,6 +192,10 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
|
|||
without `obj.save()` having to be called explicitly.
|
||||
|
||||
"""
|
||||
# lockstring of newly created objects, for easy overloading.
|
||||
# Will be formatted with the appropriate attributes.
|
||||
lockstring = "control:id({account_id}) or perm(Admin);delete:id({account_id}) or perm(Admin)"
|
||||
|
||||
objects = ObjectManager()
|
||||
|
||||
# on-object properties
|
||||
|
|
@ -214,7 +219,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
|
|||
@property
|
||||
def is_connected(self):
|
||||
# we get an error for objects subscribed to channels without this
|
||||
if self.account: # seems sane to pass on the account
|
||||
if self.account: # seems sane to pass on the account
|
||||
return self.account.is_connected
|
||||
else:
|
||||
return False
|
||||
|
|
@ -425,7 +430,8 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
|
|||
# only allow exact matching if searching the entire database
|
||||
# or unique #dbrefs
|
||||
exact = True
|
||||
elif candidates is None:
|
||||
else:
|
||||
# TODO: write code...if candidates is None:
|
||||
# no custom candidates given - get them automatically
|
||||
if location:
|
||||
# location(s) were given
|
||||
|
|
@ -857,6 +863,67 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
|
|||
obj.msg(_(string))
|
||||
obj.move_to(home)
|
||||
|
||||
@classmethod
|
||||
def create(cls, key, account=None, **kwargs):
|
||||
"""
|
||||
Creates a basic object with default parameters, unless otherwise
|
||||
specified or extended.
|
||||
|
||||
Provides a friendlier interface to the utils.create_object() function.
|
||||
|
||||
Args:
|
||||
key (str): Name of the new object.
|
||||
account (Account): Account to attribute this object to.
|
||||
|
||||
Kwargs:
|
||||
description (str): Brief description for this object.
|
||||
ip (str): IP address of creator (for object auditing).
|
||||
|
||||
Returns:
|
||||
object (Object): A newly created object of the given typeclass.
|
||||
errors (list): A list of errors in string form, if any.
|
||||
|
||||
"""
|
||||
errors = []
|
||||
obj = None
|
||||
|
||||
# Get IP address of creator, if available
|
||||
ip = kwargs.pop('ip', '')
|
||||
|
||||
# If no typeclass supplied, use this class
|
||||
kwargs['typeclass'] = kwargs.pop('typeclass', cls)
|
||||
|
||||
# Set the supplied key as the name of the intended object
|
||||
kwargs['key'] = key
|
||||
|
||||
# Get a supplied description, if any
|
||||
description = kwargs.pop('description', '')
|
||||
|
||||
# Create a sane lockstring if one wasn't supplied
|
||||
lockstring = kwargs.get('locks')
|
||||
if account and not lockstring:
|
||||
lockstring = cls.lockstring.format(account_id=account.id)
|
||||
kwargs['locks'] = lockstring
|
||||
|
||||
# Create object
|
||||
try:
|
||||
obj = create.create_object(**kwargs)
|
||||
|
||||
# Record creator id and creation IP
|
||||
if ip: obj.db.creator_ip = ip
|
||||
if account: obj.db.creator_id = account.id
|
||||
|
||||
# Set description if there is none, or update it if provided
|
||||
if description or not obj.db.desc:
|
||||
desc = description if description else "You see nothing special."
|
||||
obj.db.desc = desc
|
||||
|
||||
except Exception as e:
|
||||
errors.append("An error occurred while creating this '%s' object." % key)
|
||||
logger.log_err(e)
|
||||
|
||||
return obj, errors
|
||||
|
||||
def copy(self, new_key=None):
|
||||
"""
|
||||
Makes an identical copy of this object, identical except for a
|
||||
|
|
@ -912,8 +979,12 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
|
|||
# no need to disconnect, Account just jumps to OOC mode.
|
||||
# sever the connection (important!)
|
||||
if self.account:
|
||||
# Remove the object from playable characters list
|
||||
if self in self.account.db._playable_characters:
|
||||
self.account.db._playable_characters = [x for x in self.account.db._playable_characters if x != self]
|
||||
for session in self.sessions.all():
|
||||
self.account.unpuppet_object(session)
|
||||
|
||||
self.account = None
|
||||
|
||||
for script in _ScriptDB.objects.get_all_scripts_on_obj(self):
|
||||
|
|
@ -1820,6 +1891,91 @@ class DefaultCharacter(DefaultObject):
|
|||
a character avatar controlled by an account.
|
||||
|
||||
"""
|
||||
# lockstring of newly created rooms, for easy overloading.
|
||||
# Will be formatted with the appropriate attributes.
|
||||
lockstring = "puppet:id({character_id}) or pid({account_id}) or perm(Developer) or pperm(Developer);delete:id({account_id}) or perm(Admin)"
|
||||
|
||||
@classmethod
|
||||
def create(cls, key, account, **kwargs):
|
||||
"""
|
||||
Creates a basic Character with default parameters, unless otherwise
|
||||
specified or extended.
|
||||
|
||||
Provides a friendlier interface to the utils.create_character() function.
|
||||
|
||||
Args:
|
||||
key (str): Name of the new Character.
|
||||
account (obj): Account to associate this Character with. Required as
|
||||
an argument, but one can fake it out by supplying None-- it will
|
||||
change the default lockset and skip creator attribution.
|
||||
|
||||
Kwargs:
|
||||
description (str): Brief description for this object.
|
||||
ip (str): IP address of creator (for object auditing).
|
||||
|
||||
Returns:
|
||||
character (Object): A newly created Character of the given typeclass.
|
||||
errors (list): A list of errors in string form, if any.
|
||||
|
||||
"""
|
||||
errors = []
|
||||
obj = None
|
||||
|
||||
# Get IP address of creator, if available
|
||||
ip = kwargs.pop('ip', '')
|
||||
|
||||
# If no typeclass supplied, use this class
|
||||
kwargs['typeclass'] = kwargs.pop('typeclass', cls)
|
||||
|
||||
# Set the supplied key as the name of the intended object
|
||||
kwargs['key'] = key
|
||||
|
||||
# Get home for character
|
||||
kwargs['home'] = ObjectDB.objects.get_id(kwargs.get('home', settings.DEFAULT_HOME))
|
||||
|
||||
# Get permissions
|
||||
kwargs['permissions'] = kwargs.get('permissions', settings.PERMISSION_ACCOUNT_DEFAULT)
|
||||
|
||||
# Get description if provided
|
||||
description = kwargs.pop('description', '')
|
||||
|
||||
# Get locks if provided
|
||||
locks = kwargs.pop('locks', '')
|
||||
|
||||
try:
|
||||
# Check to make sure account does not have too many chars
|
||||
if len(account.characters) >= settings.MAX_NR_CHARACTERS:
|
||||
errors.append("There are too many characters associated with this account.")
|
||||
return obj, errors
|
||||
|
||||
# Create the Character
|
||||
obj = create.create_object(**kwargs)
|
||||
|
||||
# Record creator id and creation IP
|
||||
if ip: obj.db.creator_ip = ip
|
||||
if account:
|
||||
obj.db.creator_id = account.id
|
||||
if obj not in account.characters:
|
||||
account.db._playable_characters.append(obj)
|
||||
|
||||
# Add locks
|
||||
if not locks and account:
|
||||
# Allow only the character itself and the creator account to puppet this character (and Developers).
|
||||
locks = cls.lockstring.format(**{'character_id': obj.id, 'account_id': account.id})
|
||||
elif not locks and not account:
|
||||
locks = cls.lockstring.format(**{'character_id': obj.id, 'account_id': -1})
|
||||
|
||||
obj.locks.add(locks)
|
||||
|
||||
# If no description is set, set a default description
|
||||
if description or not obj.db.desc:
|
||||
obj.db.desc = description if description else "This is a character."
|
||||
|
||||
except Exception as e:
|
||||
errors.append("An error occurred while creating this '%s' object." % key)
|
||||
logger.log_err(e)
|
||||
|
||||
return obj, errors
|
||||
|
||||
def basetype_setup(self):
|
||||
"""
|
||||
|
|
@ -1937,6 +2093,72 @@ class DefaultRoom(DefaultObject):
|
|||
This is the base room object. It's just like any Object except its
|
||||
location is always `None`.
|
||||
"""
|
||||
# lockstring of newly created rooms, for easy overloading.
|
||||
# Will be formatted with the {id} of the creating object.
|
||||
lockstring = "control:id({id}) or perm(Admin); " \
|
||||
"delete:id({id}) or perm(Admin); " \
|
||||
"edit:id({id}) or perm(Admin)"
|
||||
|
||||
@classmethod
|
||||
def create(cls, key, account, **kwargs):
|
||||
"""
|
||||
Creates a basic Room with default parameters, unless otherwise
|
||||
specified or extended.
|
||||
|
||||
Provides a friendlier interface to the utils.create_object() function.
|
||||
|
||||
Args:
|
||||
key (str): Name of the new Room.
|
||||
account (obj): Account to associate this Room with.
|
||||
|
||||
Kwargs:
|
||||
description (str): Brief description for this object.
|
||||
ip (str): IP address of creator (for object auditing).
|
||||
|
||||
Returns:
|
||||
room (Object): A newly created Room of the given typeclass.
|
||||
errors (list): A list of errors in string form, if any.
|
||||
|
||||
"""
|
||||
errors = []
|
||||
obj = None
|
||||
|
||||
# Get IP address of creator, if available
|
||||
ip = kwargs.pop('ip', '')
|
||||
|
||||
# If no typeclass supplied, use this class
|
||||
kwargs['typeclass'] = kwargs.pop('typeclass', cls)
|
||||
|
||||
# Set the supplied key as the name of the intended object
|
||||
kwargs['key'] = key
|
||||
|
||||
# Get who to send errors to
|
||||
kwargs['report_to'] = kwargs.pop('report_to', account)
|
||||
|
||||
# Get description, if provided
|
||||
description = kwargs.pop('description', '')
|
||||
|
||||
try:
|
||||
# Create the Room
|
||||
obj = create.create_object(**kwargs)
|
||||
|
||||
# Set appropriate locks
|
||||
lockstring = kwargs.get('locks', cls.lockstring.format(id=account.id))
|
||||
obj.locks.add(lockstring)
|
||||
|
||||
# Record creator id and creation IP
|
||||
if ip: obj.db.creator_ip = ip
|
||||
if account: obj.db.creator_id = account.id
|
||||
|
||||
# If no description is set, set a default description
|
||||
if description or not obj.db.desc:
|
||||
obj.db.desc = description if description else "This is a room."
|
||||
|
||||
except Exception as e:
|
||||
errors.append("An error occurred while creating this '%s' object." % key)
|
||||
logger.log_err(e)
|
||||
|
||||
return obj, errors
|
||||
|
||||
def basetype_setup(self):
|
||||
"""
|
||||
|
|
@ -2015,6 +2237,13 @@ class DefaultExit(DefaultObject):
|
|||
|
||||
exit_command = ExitCommand
|
||||
priority = 101
|
||||
|
||||
# lockstring of newly created exits, for easy overloading.
|
||||
# Will be formatted with the {id} of the creating object.
|
||||
lockstring = "control:id({id}) or perm(Admin); " \
|
||||
"delete:id({id}) or perm(Admin); " \
|
||||
"edit:id({id}) or perm(Admin)"
|
||||
|
||||
# Helper classes and methods to implement the Exit. These need not
|
||||
# be overloaded unless one want to change the foundation for how
|
||||
# Exits work. See the end of the class for hook methods to overload.
|
||||
|
|
@ -2053,6 +2282,73 @@ class DefaultExit(DefaultObject):
|
|||
|
||||
# Command hooks
|
||||
|
||||
@classmethod
|
||||
def create(cls, key, account, source, dest, **kwargs):
|
||||
"""
|
||||
Creates a basic Exit with default parameters, unless otherwise
|
||||
specified or extended.
|
||||
|
||||
Provides a friendlier interface to the utils.create_object() function.
|
||||
|
||||
Args:
|
||||
key (str): Name of the new Exit, as it should appear from the
|
||||
source room.
|
||||
account (obj): Account to associate this Exit with.
|
||||
source (Room): The room to create this exit in.
|
||||
dest (Room): The room to which this exit should go.
|
||||
|
||||
Kwargs:
|
||||
description (str): Brief description for this object.
|
||||
ip (str): IP address of creator (for object auditing).
|
||||
|
||||
Returns:
|
||||
exit (Object): A newly created Room of the given typeclass.
|
||||
errors (list): A list of errors in string form, if any.
|
||||
|
||||
"""
|
||||
errors = []
|
||||
obj = None
|
||||
|
||||
# Get IP address of creator, if available
|
||||
ip = kwargs.pop('ip', '')
|
||||
|
||||
# If no typeclass supplied, use this class
|
||||
kwargs['typeclass'] = kwargs.pop('typeclass', cls)
|
||||
|
||||
# Set the supplied key as the name of the intended object
|
||||
kwargs['key'] = key
|
||||
|
||||
# Get who to send errors to
|
||||
kwargs['report_to'] = kwargs.pop('report_to', account)
|
||||
|
||||
# Set to/from rooms
|
||||
kwargs['location'] = source
|
||||
kwargs['destination'] = dest
|
||||
|
||||
description = kwargs.pop('description', '')
|
||||
|
||||
try:
|
||||
# Create the Exit
|
||||
obj = create.create_object(**kwargs)
|
||||
|
||||
# Set appropriate locks
|
||||
lockstring = kwargs.get('locks', cls.lockstring.format(id=account.id))
|
||||
obj.locks.add(lockstring)
|
||||
|
||||
# Record creator id and creation IP
|
||||
if ip: obj.db.creator_ip = ip
|
||||
if account: obj.db.creator_id = account.id
|
||||
|
||||
# If no description is set, set a default description
|
||||
if description or not obj.db.desc:
|
||||
obj.db.desc = description if description else "This is an exit."
|
||||
|
||||
except Exception as e:
|
||||
errors.append("An error occurred while creating this '%s' object." % key)
|
||||
logger.log_err(e)
|
||||
|
||||
return obj, errors
|
||||
|
||||
def basetype_setup(self):
|
||||
"""
|
||||
Setup exit-security
|
||||
|
|
|
|||
47
evennia/objects/tests.py
Normal file
47
evennia/objects/tests.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from evennia.utils.test_resources import EvenniaTest
|
||||
from evennia import DefaultObject, DefaultCharacter, DefaultRoom, DefaultExit
|
||||
|
||||
|
||||
class DefaultObjectTest(EvenniaTest):
|
||||
|
||||
ip = '212.216.139.14'
|
||||
|
||||
def test_object_create(self):
|
||||
description = 'A home for a grouch.'
|
||||
obj, errors = DefaultObject.create('trashcan', self.account, description=description, ip=self.ip)
|
||||
self.assertTrue(obj, errors)
|
||||
self.assertFalse(errors, errors)
|
||||
self.assertEqual(description, obj.db.desc)
|
||||
self.assertEqual(obj.db.creator_ip, self.ip)
|
||||
|
||||
def test_character_create(self):
|
||||
description = 'A furry green monster, reeking of garbage.'
|
||||
obj, errors = DefaultCharacter.create('oscar', self.account, description=description, ip=self.ip)
|
||||
self.assertTrue(obj, errors)
|
||||
self.assertFalse(errors, errors)
|
||||
self.assertEqual(description, obj.db.desc)
|
||||
self.assertEqual(obj.db.creator_ip, self.ip)
|
||||
|
||||
def test_room_create(self):
|
||||
description = 'A dimly-lit alley behind the local Chinese restaurant.'
|
||||
obj, errors = DefaultRoom.create('alley', self.account, description=description, ip=self.ip)
|
||||
self.assertTrue(obj, errors)
|
||||
self.assertFalse(errors, errors)
|
||||
self.assertEqual(description, obj.db.desc)
|
||||
self.assertEqual(obj.db.creator_ip, self.ip)
|
||||
|
||||
def test_exit_create(self):
|
||||
description = 'The steaming depths of the dumpster, ripe with refuse in various states of decomposition.'
|
||||
obj, errors = DefaultExit.create('in', self.account, self.room1, self.room2, description=description, ip=self.ip)
|
||||
self.assertTrue(obj, errors)
|
||||
self.assertFalse(errors, errors)
|
||||
self.assertEqual(description, obj.db.desc)
|
||||
self.assertEqual(obj.db.creator_ip, self.ip)
|
||||
|
||||
def test_urls(self):
|
||||
"Make sure objects are returning URLs"
|
||||
self.assertTrue(self.char1.get_absolute_url())
|
||||
self.assertTrue('admin' in self.char1.web_get_admin_url())
|
||||
|
||||
self.assertTrue(self.room1.get_absolute_url())
|
||||
self.assertTrue('admin' in self.room1.web_get_admin_url())
|
||||
|
|
@ -562,6 +562,7 @@ def node_index(caller):
|
|||
|
||||
text = """
|
||||
|c --- Prototype wizard --- |n
|
||||
%s
|
||||
|
||||
A |cprototype|n is a 'template' for |wspawning|n an in-game entity. A field of the prototype
|
||||
can either be hard-coded, left empty or scripted using |w$protfuncs|n - for example to
|
||||
|
|
@ -599,6 +600,17 @@ def node_index(caller):
|
|||
{pfuncs}
|
||||
""".format(pfuncs=_format_protfuncs())
|
||||
|
||||
# If a prototype is being edited, show its key and
|
||||
# prototype_key under the title
|
||||
loaded_prototype = ''
|
||||
if 'prototype_key' in prototype \
|
||||
or 'key' in prototype:
|
||||
loaded_prototype = ' --- Editing: |y{}({})|n --- '.format(
|
||||
prototype.get('key', ''),
|
||||
prototype.get('prototype_key', '')
|
||||
)
|
||||
text = text % (loaded_prototype)
|
||||
|
||||
text = (text, helptxt)
|
||||
|
||||
options = []
|
||||
|
|
|
|||
|
|
@ -37,12 +37,15 @@ prototype key (this value must be possible to serialize in an Attribute).
|
|||
|
||||
from ast import literal_eval
|
||||
from random import randint as base_randint, random as base_random, choice as base_choice
|
||||
import re
|
||||
|
||||
from evennia.utils import search
|
||||
from evennia.utils.utils import justify as base_justify, is_iter, to_str
|
||||
|
||||
_PROTLIB = None
|
||||
|
||||
_RE_DBREF = re.compile(r"\#[0-9]+")
|
||||
|
||||
|
||||
# default protfuncs
|
||||
|
||||
|
|
@ -325,3 +328,14 @@ def objlist(*args, **kwargs):
|
|||
|
||||
"""
|
||||
return ["#{}".format(obj.id) for obj in _obj_search(return_list=True, *args, **kwargs)]
|
||||
|
||||
|
||||
def dbref(*args, **kwargs):
|
||||
"""
|
||||
Usage $dbref(<#dbref>)
|
||||
Returns one Object searched globally by #dbref. Error if #dbref is invalid.
|
||||
"""
|
||||
if not args or len(args) < 1 or _RE_DBREF.match(args[0]) is None:
|
||||
raise ValueError('$dbref requires a valid #dbref argument.')
|
||||
|
||||
return obj(args[0])
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos
|
|||
|
||||
"""
|
||||
|
||||
import re
|
||||
import hashlib
|
||||
import time
|
||||
from ast import literal_eval
|
||||
|
|
@ -33,8 +32,6 @@ _PROTOTYPE_TAG_CATEGORY = "from_prototype"
|
|||
_PROTOTYPE_TAG_META_CATEGORY = "db_prototype"
|
||||
PROT_FUNCS = {}
|
||||
|
||||
_RE_DBREF = re.compile(r"(?<!\$obj\()(#[0-9]+)")
|
||||
|
||||
|
||||
class PermissionError(RuntimeError):
|
||||
pass
|
||||
|
|
@ -258,7 +255,7 @@ def delete_prototype(prototype_key, caller=None):
|
|||
stored_prototype = stored_prototype[0]
|
||||
if caller:
|
||||
if not stored_prototype.access(caller, 'edit'):
|
||||
raise PermissionError("{} does not have permission to "
|
||||
raise PermissionError("{} needs explicit 'edit' permissions to "
|
||||
"delete prototype {}.".format(caller, prototype_key))
|
||||
stored_prototype.delete()
|
||||
return True
|
||||
|
|
@ -374,14 +371,14 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed
|
|||
display_tuples = []
|
||||
for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')):
|
||||
lock_use = caller.locks.check_lockstring(
|
||||
caller, prototype.get('prototype_locks', ''), access_type='spawn')
|
||||
caller, prototype.get('prototype_locks', ''), access_type='spawn', default=True)
|
||||
if not show_non_use and not lock_use:
|
||||
continue
|
||||
if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES:
|
||||
lock_edit = False
|
||||
else:
|
||||
lock_edit = caller.locks.check_lockstring(
|
||||
caller, prototype.get('prototype_locks', ''), access_type='edit')
|
||||
caller, prototype.get('prototype_locks', ''), access_type='edit', default=True)
|
||||
if not show_non_edit and not lock_edit:
|
||||
continue
|
||||
ptags = []
|
||||
|
|
@ -576,9 +573,6 @@ def protfunc_parser(value, available_functions=None, testing=False, stacktrace=F
|
|||
|
||||
available_functions = PROT_FUNCS if available_functions is None else available_functions
|
||||
|
||||
# insert $obj(#dbref) for #dbref
|
||||
value = _RE_DBREF.sub("$obj(\\1)", value)
|
||||
|
||||
result = inlinefuncs.parse_inlinefunc(
|
||||
value, available_funcs=available_functions,
|
||||
stacktrace=stacktrace, testing=testing, **kwargs)
|
||||
|
|
@ -713,7 +707,8 @@ def check_permission(prototype_key, action, default=True):
|
|||
lockstring = prototype.get("prototype_locks")
|
||||
|
||||
if lockstring:
|
||||
return check_lockstring(None, lockstring, default=default, access_type=action)
|
||||
return check_lockstring(None, lockstring,
|
||||
default=default, access_type=action)
|
||||
return default
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from evennia.utils.test_resources import EvenniaTest
|
|||
from evennia.utils.tests.test_evmenu import TestEvMenu
|
||||
from evennia.prototypes import spawner, prototypes as protlib
|
||||
from evennia.prototypes import menus as olc_menus
|
||||
from evennia.prototypes import protfuncs as protofuncs
|
||||
|
||||
from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY
|
||||
|
||||
|
|
@ -312,11 +313,83 @@ class TestProtFuncs(EvenniaTest):
|
|||
self.assertEqual(protlib.protfunc_parser(
|
||||
"$eval({'test': '1', 2:3, 3: $toint(3.5)})"), {'test': '1', 2: 3, 3: 3})
|
||||
|
||||
self.assertEqual(protlib.protfunc_parser("$obj(#1)", session=self.session), '#1')
|
||||
self.assertEqual(protlib.protfunc_parser("#1", session=self.session), '#1')
|
||||
self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6')
|
||||
self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6')
|
||||
self.assertEqual(protlib.protfunc_parser("$objlist(#1)", session=self.session), ['#1'])
|
||||
|
||||
# no object search
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("obj(#1)", session=self.session), 'obj(#1)')
|
||||
mocked__obj_search.assert_not_called()
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("dbref(#1)", session=self.session), 'dbref(#1)')
|
||||
mocked__obj_search.assert_not_called()
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("stone(#12345)", session=self.session), 'stone(#12345)')
|
||||
mocked__obj_search.assert_not_called()
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("#1", session=self.session), '#1')
|
||||
mocked__obj_search.assert_not_called()
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("#12345", session=self.session), '#12345')
|
||||
mocked__obj_search.assert_not_called()
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("nothing(#1)", session=self.session), 'nothing(#1)')
|
||||
mocked__obj_search.assert_not_called()
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("(#12345)", session=self.session), '(#12345)')
|
||||
mocked__obj_search.assert_not_called()
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("obj(Char)", session=self.session), 'obj(Char)')
|
||||
mocked__obj_search.assert_not_called()
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("objlist(#1)", session=self.session), 'objlist(#1)')
|
||||
mocked__obj_search.assert_not_called()
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("dbref(Char)", session=self.session), 'dbref(Char)')
|
||||
mocked__obj_search.assert_not_called()
|
||||
|
||||
|
||||
# obj search happens
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("$objlist(#1)", session=self.session), ['#1'])
|
||||
mocked__obj_search.assert_called_once()
|
||||
assert ('#1',) == mocked__obj_search.call_args[0]
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("$obj(#1)", session=self.session), '#1')
|
||||
mocked__obj_search.assert_called_once()
|
||||
assert ('#1',) == mocked__obj_search.call_args[0]
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("$dbref(#1)", session=self.session), '#1')
|
||||
mocked__obj_search.assert_called_once()
|
||||
assert ('#1',) == mocked__obj_search.call_args[0]
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6')
|
||||
mocked__obj_search.assert_called_once()
|
||||
assert ('Char',) == mocked__obj_search.call_args[0]
|
||||
|
||||
|
||||
# bad invocation
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertEqual(protlib.protfunc_parser("$badfunc(#1)", session=self.session), '<UNKNOWN>')
|
||||
mocked__obj_search.assert_not_called()
|
||||
|
||||
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
|
||||
self.assertRaises(ValueError, protlib.protfunc_parser, "$dbref(Char)")
|
||||
mocked__obj_search.assert_not_called()
|
||||
|
||||
|
||||
self.assertEqual(protlib.value_to_obj(
|
||||
protlib.protfunc_parser("#6", session=self.session)), self.char1)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from django.utils.translation import ugettext as _
|
|||
from evennia.typeclasses.models import TypeclassBase
|
||||
from evennia.scripts.models import ScriptDB
|
||||
from evennia.scripts.manager import ScriptManager
|
||||
from evennia.utils import logger
|
||||
from evennia.utils import create, logger
|
||||
from future.utils import with_metaclass
|
||||
|
||||
__all__ = ["DefaultScript", "DoNothing", "Store"]
|
||||
|
|
@ -324,6 +324,32 @@ class DefaultScript(ScriptBase):
|
|||
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def create(cls, key, **kwargs):
|
||||
"""
|
||||
Provides a passthrough interface to the utils.create_script() function.
|
||||
|
||||
Args:
|
||||
key (str): Name of the new object.
|
||||
|
||||
Returns:
|
||||
object (Object): A newly created object of the given typeclass.
|
||||
errors (list): A list of errors in string form, if any.
|
||||
|
||||
"""
|
||||
errors = []
|
||||
obj = None
|
||||
|
||||
kwargs['key'] = key
|
||||
|
||||
try:
|
||||
obj = create.create_script(**kwargs)
|
||||
except Exception as e:
|
||||
errors.append("The script '%s' encountered errors and could not be created." % key)
|
||||
logger.log_err(e)
|
||||
|
||||
return obj, errors
|
||||
|
||||
def at_script_creation(self):
|
||||
"""
|
||||
Only called once, when script is first created.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
# this is an optimized version only available in later Django versions
|
||||
from unittest import TestCase
|
||||
from evennia import DefaultScript
|
||||
from evennia.scripts.models import ScriptDB, ObjectDoesNotExist
|
||||
from evennia.utils.create import create_script
|
||||
from evennia.utils.test_resources import EvenniaTest
|
||||
from evennia.scripts.scripts import DoNothing
|
||||
|
||||
|
||||
class TestScript(EvenniaTest):
|
||||
|
||||
def test_create(self):
|
||||
"Check the script can be created via the convenience method."
|
||||
obj, errors = DefaultScript.create('useless-machine')
|
||||
self.assertTrue(obj, errors)
|
||||
self.assertFalse(errors, errors)
|
||||
|
||||
class TestScriptDB(TestCase):
|
||||
"Check the singleton/static ScriptDB object works correctly"
|
||||
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol):
|
|||
server_restart_mode = kwargs.get("server_restart_mode", "shutdown")
|
||||
self.factory.server.run_init_hooks(server_restart_mode)
|
||||
server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata"))
|
||||
server_sessionhandler.portal_start_time = kwargs.get("portal_start_time")
|
||||
|
||||
elif operation == amp.SRELOAD: # server reload
|
||||
# shut down in reload mode
|
||||
|
|
|
|||
|
|
@ -92,7 +92,10 @@ TWISTED_MIN = '18.0.0'
|
|||
DJANGO_MIN = '1.11'
|
||||
DJANGO_REC = '1.11'
|
||||
|
||||
sys.path[1] = EVENNIA_ROOT
|
||||
try:
|
||||
sys.path[1] = EVENNIA_ROOT
|
||||
except IndexError:
|
||||
sys.path.append(EVENNIA_ROOT)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
#
|
||||
|
|
@ -216,6 +219,19 @@ RECREATED_SETTINGS = \
|
|||
their accounts with their old passwords.
|
||||
"""
|
||||
|
||||
ERROR_INITMISSING = \
|
||||
"""
|
||||
ERROR: 'evennia --initmissing' must be called from the root of
|
||||
your game directory, since it tries to create any missing files
|
||||
in the server/ subfolder.
|
||||
"""
|
||||
|
||||
RECREATED_MISSING = \
|
||||
"""
|
||||
(Re)created any missing directories or files. Evennia should
|
||||
be ready to run now!
|
||||
"""
|
||||
|
||||
ERROR_DATABASE = \
|
||||
"""
|
||||
ERROR: Your database does not seem to be set up correctly.
|
||||
|
|
@ -255,7 +271,7 @@ INFO_WINDOWS_BATFILE = \
|
|||
twistd.bat to point to the actual location of the Twisted
|
||||
executable (usually called twistd.py) on your machine.
|
||||
|
||||
This procedure is only done once. Run evennia.py again when you
|
||||
This procedure is only done once. Run `evennia` again when you
|
||||
are ready to start the server.
|
||||
"""
|
||||
|
||||
|
|
@ -319,7 +335,7 @@ MENU = \
|
|||
| 7) Kill Server only (send kill signal to process) |
|
||||
| 8) Kill Portal + Server |
|
||||
+--- Information -----------------------------------------------+
|
||||
| 9) Tail log files (quickly see errors) |
|
||||
| 9) Tail log files (quickly see errors - Ctrl-C to exit) |
|
||||
| 10) Status |
|
||||
| 11) Port info |
|
||||
+--- Testing ---------------------------------------------------+
|
||||
|
|
@ -1207,7 +1223,7 @@ def evennia_version():
|
|||
"git rev-parse --short HEAD",
|
||||
shell=True, cwd=EVENNIA_ROOT, stderr=STDOUT).strip().decode()
|
||||
version = "%s (rev %s)" % (version, rev)
|
||||
except (IOError, CalledProcessError):
|
||||
except (IOError, CalledProcessError, OSError):
|
||||
# move on if git is not answering
|
||||
pass
|
||||
return version
|
||||
|
|
@ -1337,7 +1353,10 @@ def create_settings_file(init=True, secret_settings=False):
|
|||
else:
|
||||
print("Reset the settings file.")
|
||||
|
||||
default_settings_path = os.path.join(EVENNIA_TEMPLATE, "server", "conf", "settings.py")
|
||||
if secret_settings:
|
||||
default_settings_path = os.path.join(EVENNIA_TEMPLATE, "server", "conf", "secret_settings.py")
|
||||
else:
|
||||
default_settings_path = os.path.join(EVENNIA_TEMPLATE, "server", "conf", "settings.py")
|
||||
shutil.copy(default_settings_path, settings_path)
|
||||
|
||||
with open(settings_path, 'r') as f:
|
||||
|
|
@ -1631,7 +1650,7 @@ def error_check_python_modules():
|
|||
#
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def init_game_directory(path, check_db=True):
|
||||
def init_game_directory(path, check_db=True, need_gamedir=True):
|
||||
"""
|
||||
Try to analyze the given path to find settings.py - this defines
|
||||
the game directory and also sets PYTHONPATH as well as the django
|
||||
|
|
@ -1640,15 +1659,17 @@ def init_game_directory(path, check_db=True):
|
|||
Args:
|
||||
path (str): Path to new game directory, including its name.
|
||||
check_db (bool, optional): Check if the databae exists.
|
||||
need_gamedir (bool, optional): set to False if Evennia doesn't require to be run in a valid game directory.
|
||||
|
||||
"""
|
||||
# set the GAMEDIR path
|
||||
set_gamedir(path)
|
||||
if need_gamedir:
|
||||
set_gamedir(path)
|
||||
|
||||
# Add gamedir to python path
|
||||
sys.path.insert(0, GAMEDIR)
|
||||
|
||||
if TEST_MODE:
|
||||
if TEST_MODE or not need_gamedir:
|
||||
if ENFORCED_SETTING:
|
||||
print(NOTE_TEST_CUSTOM.format(settings_dotpath=SETTINGS_DOTPATH))
|
||||
os.environ['DJANGO_SETTINGS_MODULE'] = SETTINGS_DOTPATH
|
||||
|
|
@ -1675,6 +1696,10 @@ def init_game_directory(path, check_db=True):
|
|||
if check_db:
|
||||
check_database()
|
||||
|
||||
# if we don't have to check the game directory, return right away
|
||||
if not need_gamedir:
|
||||
return
|
||||
|
||||
# set up the Evennia executables and log file locations
|
||||
global AMP_PORT, AMP_HOST, AMP_INTERFACE
|
||||
global SERVER_PY_FILE, PORTAL_PY_FILE
|
||||
|
|
@ -1920,6 +1945,10 @@ def main():
|
|||
'--initsettings', action='store_true', dest="initsettings",
|
||||
default=False,
|
||||
help="create a new, empty settings file as\n gamedir/server/conf/settings.py")
|
||||
parser.add_argument(
|
||||
'--initmissing', action='store_true', dest="initmissing",
|
||||
default=False,
|
||||
help="checks for missing secret_settings or server logs\n directory, and adds them if needed")
|
||||
parser.add_argument(
|
||||
'--profiler', action='store_true', dest='profiler', default=False,
|
||||
help="start given server component under the Python profiler")
|
||||
|
|
@ -1993,6 +2022,21 @@ def main():
|
|||
print(ERROR_INITSETTINGS)
|
||||
sys.exit()
|
||||
|
||||
if args.initmissing:
|
||||
try:
|
||||
log_path = os.path.join(SERVERDIR, "logs")
|
||||
if not os.path.exists(log_path):
|
||||
os.makedirs(log_path)
|
||||
|
||||
settings_path = os.path.join(CONFDIR, "secret_settings.py")
|
||||
if not os.path.exists(settings_path):
|
||||
create_settings_file(init=False, secret_settings=True)
|
||||
|
||||
print(RECREATED_MISSING)
|
||||
except IOError:
|
||||
print(ERROR_INITMISSING)
|
||||
sys.exit()
|
||||
|
||||
if args.tail_log:
|
||||
# set up for tailing the log files
|
||||
global NO_REACTOR_STOP
|
||||
|
|
@ -2061,6 +2105,10 @@ def main():
|
|||
elif option != "noop":
|
||||
# pass-through to django manager
|
||||
check_db = False
|
||||
need_gamedir = True
|
||||
# some commands don't require the presence of a game directory to work
|
||||
if option in ('makemessages', 'compilemessages'):
|
||||
need_gamedir = False
|
||||
|
||||
# handle special django commands
|
||||
if option in ('runserver', 'testserver'):
|
||||
|
|
@ -2073,7 +2121,7 @@ def main():
|
|||
global TEST_MODE
|
||||
TEST_MODE = True
|
||||
|
||||
init_game_directory(CURRENT_DIR, check_db=check_db)
|
||||
init_game_directory(CURRENT_DIR, check_db=check_db, need_gamedir=need_gamedir)
|
||||
|
||||
# pass on to the manager
|
||||
args = [option]
|
||||
|
|
@ -2089,6 +2137,11 @@ def main():
|
|||
kwargs[arg.lstrip("--")] = value
|
||||
else:
|
||||
args.append(arg)
|
||||
|
||||
# makemessages needs a special syntax to not conflict with the -l option
|
||||
if len(args) > 1 and args[0] == "makemessages":
|
||||
args.insert(1, "-l")
|
||||
|
||||
try:
|
||||
django.core.management.call_command(*args, **kwargs)
|
||||
except django.core.management.base.CommandError as exc:
|
||||
|
|
|
|||
|
|
@ -440,7 +440,8 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol):
|
|||
self.send_AdminPortal2Server(amp.DUMMYSESSION,
|
||||
amp.PSYNC,
|
||||
server_restart_mode=server_restart_mode,
|
||||
sessiondata=sessdata)
|
||||
sessiondata=sessdata,
|
||||
portal_start_time=self.factory.portal.start_time)
|
||||
self.factory.portal.sessions.at_server_connection()
|
||||
|
||||
if self.factory.server_connection:
|
||||
|
|
|
|||
|
|
@ -421,7 +421,7 @@ class IRCBotFactory(protocol.ReconnectingClientFactory):
|
|||
|
||||
def clientConnectionLost(self, connector, reason):
|
||||
"""
|
||||
Called when Client looses connection.
|
||||
Called when Client loses connection.
|
||||
|
||||
Args:
|
||||
connector (Connection): Represents the connection.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from builtins import object
|
|||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
|
||||
from os.path import dirname, abspath
|
||||
from twisted.application import internet, service
|
||||
|
|
@ -114,6 +115,8 @@ class Portal(object):
|
|||
self.server_restart_mode = "shutdown"
|
||||
self.server_info_dict = {}
|
||||
|
||||
self.start_time = time.time()
|
||||
|
||||
# in non-interactive portal mode, this gets overwritten by
|
||||
# cmdline sent by the evennia launcher
|
||||
self.server_twistd_cmd = self._get_backup_server_twistd_cmd()
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
|
|||
|
||||
from evennia.utils.utils import delay
|
||||
# timeout the handshakes in case the client doesn't reply at all
|
||||
delay(2, callback=self.handshake_done, timeout=True)
|
||||
self._handshake_delay = delay(2, callback=self.handshake_done, timeout=True)
|
||||
|
||||
# TCP/IP keepalive watches for dead links
|
||||
self.transport.setTcpKeepAlive(1)
|
||||
|
|
|
|||
|
|
@ -8,9 +8,24 @@ try:
|
|||
except ImportError:
|
||||
import unittest
|
||||
|
||||
from mock import Mock
|
||||
import string
|
||||
from evennia.server.portal import irc
|
||||
|
||||
from twisted.conch.telnet import IAC, WILL, DONT, SB, SE, NAWS, DO
|
||||
from twisted.test import proto_helpers
|
||||
from twisted.trial.unittest import TestCase as TwistedTestCase
|
||||
|
||||
from .telnet import TelnetServerFactory, TelnetProtocol
|
||||
from .portal import PORTAL_SESSIONS
|
||||
from .suppress_ga import SUPPRESS_GA
|
||||
from .naws import DEFAULT_HEIGHT, DEFAULT_WIDTH
|
||||
from .ttype import TTYPE, IS
|
||||
from .mccp import MCCP
|
||||
from .mssp import MSSP
|
||||
from .mxp import MXP
|
||||
from .telnet_oob import MSDP, MSDP_VAL, MSDP_VAR
|
||||
|
||||
|
||||
class TestIRC(TestCase):
|
||||
|
||||
|
|
@ -73,3 +88,64 @@ class TestIRC(TestCase):
|
|||
s = r'|wthis|Xis|gis|Ma|C|complex|*string'
|
||||
|
||||
self.assertEqual(irc.parse_irc_to_ansi(irc.parse_ansi_to_irc(s)), s)
|
||||
|
||||
|
||||
class TestTelnet(TwistedTestCase):
|
||||
def setUp(self):
|
||||
super(TestTelnet, self).setUp()
|
||||
factory = TelnetServerFactory()
|
||||
factory.protocol = TelnetProtocol
|
||||
factory.sessionhandler = PORTAL_SESSIONS
|
||||
factory.sessionhandler.portal = Mock()
|
||||
self.proto = factory.buildProtocol(("localhost", 0))
|
||||
self.transport = proto_helpers.StringTransport()
|
||||
self.addCleanup(factory.sessionhandler.disconnect_all)
|
||||
|
||||
def test_mudlet_ttype(self):
|
||||
self.transport.client = ["localhost"]
|
||||
self.transport.setTcpKeepAlive = Mock()
|
||||
d = self.proto.makeConnection(self.transport)
|
||||
# test suppress_ga
|
||||
self.assertTrue(self.proto.protocol_flags["NOGOAHEAD"])
|
||||
self.proto.dataReceived(IAC + DONT + SUPPRESS_GA)
|
||||
self.assertFalse(self.proto.protocol_flags["NOGOAHEAD"])
|
||||
self.assertEqual(self.proto.handshakes, 7)
|
||||
# test naws
|
||||
self.assertEqual(self.proto.protocol_flags['SCREENWIDTH'], {0: DEFAULT_WIDTH})
|
||||
self.assertEqual(self.proto.protocol_flags['SCREENHEIGHT'], {0: DEFAULT_HEIGHT})
|
||||
self.proto.dataReceived(IAC + WILL + NAWS)
|
||||
self.proto.dataReceived([IAC, SB, NAWS, '', 'x', '', 'd', IAC, SE])
|
||||
self.assertEqual(self.proto.protocol_flags['SCREENWIDTH'][0], 120)
|
||||
self.assertEqual(self.proto.protocol_flags['SCREENHEIGHT'][0], 100)
|
||||
self.assertEqual(self.proto.handshakes, 6)
|
||||
# test ttype
|
||||
self.assertTrue(self.proto.protocol_flags["FORCEDENDLINE"])
|
||||
self.assertFalse(self.proto.protocol_flags["TTYPE"])
|
||||
self.assertTrue(self.proto.protocol_flags["ANSI"])
|
||||
self.proto.dataReceived(IAC + WILL + TTYPE)
|
||||
self.proto.dataReceived([IAC, SB, TTYPE, IS, "MUDLET", IAC, SE])
|
||||
self.assertTrue(self.proto.protocol_flags["XTERM256"])
|
||||
self.assertEqual(self.proto.protocol_flags["CLIENTNAME"], "MUDLET")
|
||||
self.proto.dataReceived([IAC, SB, TTYPE, IS, "XTERM", IAC, SE])
|
||||
self.proto.dataReceived([IAC, SB, TTYPE, IS, "MTTS 137", IAC, SE])
|
||||
self.assertEqual(self.proto.handshakes, 5)
|
||||
# test mccp
|
||||
self.proto.dataReceived(IAC + DONT + MCCP)
|
||||
self.assertFalse(self.proto.protocol_flags['MCCP'])
|
||||
self.assertEqual(self.proto.handshakes, 4)
|
||||
# test mssp
|
||||
self.proto.dataReceived(IAC + DONT + MSSP)
|
||||
self.assertEqual(self.proto.handshakes, 3)
|
||||
# test oob
|
||||
self.proto.dataReceived(IAC + DO + MSDP)
|
||||
self.proto.dataReceived([IAC, SB, MSDP, MSDP_VAR, "LIST", MSDP_VAL, "COMMANDS", IAC, SE])
|
||||
self.assertTrue(self.proto.protocol_flags['OOB'])
|
||||
self.assertEqual(self.proto.handshakes, 2)
|
||||
# test mxp
|
||||
self.proto.dataReceived(IAC + DONT + MXP)
|
||||
self.assertFalse(self.proto.protocol_flags['MXP'])
|
||||
self.assertEqual(self.proto.handshakes, 1)
|
||||
# clean up to prevent Unclean reactor
|
||||
self.proto.nop_keep_alive.stop()
|
||||
self.proto._handshake_delay.cancel()
|
||||
return d
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ import time
|
|||
# TODO!
|
||||
#sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
|
||||
#os.environ['DJANGO_SETTINGS_MODULE'] = 'game.settings'
|
||||
import ev
|
||||
from evennia.utils.idmapper import base as _idmapper
|
||||
import evennia
|
||||
from evennia.utils.idmapper import models as _idmapper
|
||||
|
||||
LOGFILE = "logs/memoryusage.log"
|
||||
INTERVAL = 30 # log every 30 seconds
|
||||
|
||||
|
||||
class Memplot(ev.Script):
|
||||
class Memplot(evennia.DefaultScript):
|
||||
"""
|
||||
Describes a memory plotting action.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from django.test import TestCase
|
||||
from mock import Mock
|
||||
from mock import Mock, patch, mock_open
|
||||
from .dummyrunner_settings import (c_creates_button, c_creates_obj, c_digs, c_examines, c_help, c_idles, c_login,
|
||||
c_login_nodig, c_logout, c_looks, c_moves, c_moves_n, c_moves_s, c_socialize)
|
||||
import memplot
|
||||
|
||||
|
||||
class TestDummyrunnerSettings(TestCase):
|
||||
|
|
@ -91,3 +92,21 @@ class TestDummyrunnerSettings(TestCase):
|
|||
|
||||
def test_c_move_s(self):
|
||||
self.assertEqual(c_moves_s(self.client), "south")
|
||||
|
||||
|
||||
class TestMemPlot(TestCase):
|
||||
@patch.object(memplot, "_idmapper")
|
||||
@patch.object(memplot, "os")
|
||||
@patch.object(memplot, "open", new_callable=mock_open, create=True)
|
||||
@patch.object(memplot, "time")
|
||||
def test_memplot(self, mock_time, mocked_open, mocked_os, mocked_idmapper):
|
||||
from evennia.utils.create import create_script
|
||||
mocked_idmapper.cache_size.return_value = (9, 5000)
|
||||
mock_time.time = Mock(return_value=6000.0)
|
||||
script = create_script(memplot.Memplot)
|
||||
script.db.starttime = 0.0
|
||||
mocked_os.popen.read.return_value = 5000.0
|
||||
script.at_repeat()
|
||||
handle = mocked_open()
|
||||
handle.write.assert_called_with('100.0, 0.001, 0.001, 9\n')
|
||||
script.stop()
|
||||
|
|
|
|||
|
|
@ -284,6 +284,8 @@ class ServerSessionHandler(SessionHandler):
|
|||
super().__init__(*args, **kwargs)
|
||||
self.server = None # set at server initialization
|
||||
self.server_data = {"servername": _SERVERNAME}
|
||||
# will be set on psync
|
||||
self.portal_start_time = 0.0
|
||||
|
||||
def _run_cmd_login(self, session):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,25 +1,27 @@
|
|||
from collections import defaultdict, deque
|
||||
from evennia.utils import logger
|
||||
import time
|
||||
|
||||
|
||||
class Throttle(object):
|
||||
"""
|
||||
Keeps a running count of failed actions per IP address.
|
||||
|
||||
Keeps a running count of failed actions per IP address.
|
||||
|
||||
Available methods indicate whether or not the number of failures exceeds a
|
||||
particular threshold.
|
||||
|
||||
|
||||
This version of the throttle is usable by both the terminal server as well
|
||||
as the web server, imposes limits on memory consumption by using deques
|
||||
with length limits instead of open-ended lists, and removes sparse keys when
|
||||
no recent failures have been recorded.
|
||||
"""
|
||||
|
||||
|
||||
error_msg = 'Too many failed attempts; you must wait a few minutes before trying again.'
|
||||
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Allows setting of throttle parameters.
|
||||
|
||||
|
||||
Kwargs:
|
||||
limit (int): Max number of failures before imposing limiter
|
||||
timeout (int): number of timeout seconds after
|
||||
|
|
@ -31,55 +33,67 @@ class Throttle(object):
|
|||
self.storage = defaultdict(deque)
|
||||
self.cache_size = self.limit = kwargs.get('limit', 5)
|
||||
self.timeout = kwargs.get('timeout', 5 * 60)
|
||||
|
||||
|
||||
def get(self, ip=None):
|
||||
"""
|
||||
Convenience function that returns the storage table, or part of.
|
||||
|
||||
|
||||
Args:
|
||||
ip (str, optional): IP address of requestor
|
||||
|
||||
|
||||
Returns:
|
||||
storage (dict): When no IP is provided, returns a dict of all
|
||||
current IPs being tracked and the timestamps of their recent
|
||||
storage (dict): When no IP is provided, returns a dict of all
|
||||
current IPs being tracked and the timestamps of their recent
|
||||
failures.
|
||||
timestamps (deque): When an IP is provided, returns a deque of
|
||||
timestamps (deque): When an IP is provided, returns a deque of
|
||||
timestamps of recent failures only for that IP.
|
||||
|
||||
|
||||
"""
|
||||
if ip: return self.storage.get(ip, deque(maxlen=self.cache_size))
|
||||
else: return self.storage
|
||||
|
||||
def update(self, ip):
|
||||
|
||||
def update(self, ip, failmsg='Exceeded threshold.'):
|
||||
"""
|
||||
Store the time of the latest failure/
|
||||
|
||||
Store the time of the latest failure.
|
||||
|
||||
Args:
|
||||
ip (str): IP address of requestor
|
||||
|
||||
failmsg (str, optional): Message to display in logs upon activation
|
||||
of throttle.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
|
||||
"""
|
||||
# Get current status
|
||||
previously_throttled = self.check(ip)
|
||||
|
||||
# Enforce length limits
|
||||
if not self.storage[ip].maxlen:
|
||||
self.storage[ip] = deque(maxlen=self.cache_size)
|
||||
|
||||
|
||||
self.storage[ip].append(time.time())
|
||||
|
||||
|
||||
# See if this update caused a change in status
|
||||
currently_throttled = self.check(ip)
|
||||
|
||||
# If this makes it engage, log a single activation event
|
||||
if (not previously_throttled and currently_throttled):
|
||||
logger.log_sec('Throttle Activated: %s (IP: %s, %i hits in %i seconds.)' % (failmsg, ip, self.limit, self.timeout))
|
||||
|
||||
def check(self, ip):
|
||||
"""
|
||||
This will check the session's address against the
|
||||
storage dictionary to check they haven't spammed too many
|
||||
storage dictionary to check they haven't spammed too many
|
||||
fails recently.
|
||||
|
||||
|
||||
Args:
|
||||
ip (str): IP address of requestor
|
||||
|
||||
|
||||
Returns:
|
||||
throttled (bool): True if throttling is active,
|
||||
False otherwise.
|
||||
|
||||
|
||||
"""
|
||||
now = time.time()
|
||||
ip = str(ip)
|
||||
|
|
@ -97,5 +111,3 @@ class Throttle(object):
|
|||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -1,34 +1,73 @@
|
|||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext as _
|
||||
from evennia.accounts.models import AccountDB
|
||||
import re
|
||||
|
||||
|
||||
class EvenniaUsernameAvailabilityValidator:
|
||||
"""
|
||||
Checks to make sure a given username is not taken or otherwise reserved.
|
||||
"""
|
||||
|
||||
def __call__(self, username):
|
||||
"""
|
||||
Validates a username to make sure it is not in use or reserved.
|
||||
|
||||
Args:
|
||||
username (str): Username to validate
|
||||
|
||||
Returns:
|
||||
None (None): None if password successfully validated,
|
||||
raises ValidationError otherwise.
|
||||
|
||||
"""
|
||||
|
||||
# Check guest list
|
||||
if (settings.GUEST_LIST and username.lower() in (guest.lower() for guest in settings.GUEST_LIST)):
|
||||
raise ValidationError(
|
||||
_('Sorry, that username is reserved.'),
|
||||
code='evennia_username_reserved',
|
||||
)
|
||||
|
||||
# Check database
|
||||
exists = AccountDB.objects.filter(username__iexact=username).exists()
|
||||
if exists:
|
||||
raise ValidationError(
|
||||
_('Sorry, that username is already taken.'),
|
||||
code='evennia_username_taken',
|
||||
)
|
||||
|
||||
|
||||
class EvenniaPasswordValidator:
|
||||
|
||||
def __init__(self, regex=r"^[\w. @+\-',]+$", policy="Password should contain a mix of letters, spaces, digits and @/./+/-/_/'/, only."):
|
||||
|
||||
def __init__(self, regex=r"^[\w. @+\-',]+$",
|
||||
policy="Password should contain a mix of letters, "
|
||||
"spaces, digits and @/./+/-/_/'/, only."):
|
||||
"""
|
||||
Constructs a standard Django password validator.
|
||||
|
||||
|
||||
Args:
|
||||
regex (str): Regex pattern of valid characters to allow.
|
||||
policy (str): Brief explanation of what the defined regex permits.
|
||||
|
||||
|
||||
"""
|
||||
self.regex = regex
|
||||
self.policy = policy
|
||||
|
||||
|
||||
def validate(self, password, user=None):
|
||||
"""
|
||||
Validates a password string to make sure it meets predefined Evennia
|
||||
acceptable character policy.
|
||||
|
||||
|
||||
Args:
|
||||
password (str): Password to validate
|
||||
user (None): Unused argument but required by Django
|
||||
|
||||
|
||||
Returns:
|
||||
None (None): None if password successfully validated,
|
||||
raises ValidationError otherwise.
|
||||
|
||||
|
||||
"""
|
||||
# Check complexity
|
||||
if not re.findall(self.regex, password):
|
||||
|
|
@ -41,11 +80,12 @@ class EvenniaPasswordValidator:
|
|||
"""
|
||||
Returns a user-facing explanation of the password policy defined
|
||||
by this validator.
|
||||
|
||||
|
||||
Returns:
|
||||
text (str): Explanation of password policy.
|
||||
|
||||
|
||||
"""
|
||||
return _(
|
||||
"%s From a terminal client, you can also use a phrase of multiple words if you enclose the password in double quotes." % self.policy
|
||||
)
|
||||
"%s From a terminal client, you can also use a phrase of multiple words if "
|
||||
"you enclose the password in double quotes." % self.policy
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ always be sure of what you have changed and what is default behaviour.
|
|||
|
||||
"""
|
||||
from builtins import range
|
||||
from django.contrib.messages import constants as messages
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
import os
|
||||
|
|
@ -116,7 +117,7 @@ AMP_INTERFACE = '127.0.0.1'
|
|||
EVENNIA_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
# Path to the game directory (containing the server/conf/settings.py file)
|
||||
# This is dynamically created- there is generally no need to change this!
|
||||
if sys.argv[1] == 'test' if len(sys.argv) > 1 else False:
|
||||
if EVENNIA_DIR.lower() == os.getcwd().lower() or (sys.argv[1] == 'test' if len(sys.argv) > 1 else False):
|
||||
# unittesting mode
|
||||
GAME_DIR = os.getcwd()
|
||||
else:
|
||||
|
|
@ -139,7 +140,7 @@ HTTP_LOG_FILE = os.path.join(LOG_DIR, 'http_requests.log')
|
|||
LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, 'lockwarnings.log')
|
||||
# Rotate log files when server and/or portal stops. This will keep log
|
||||
# file sizes down. Turn off to get ever growing log files and never
|
||||
# loose log info.
|
||||
# lose log info.
|
||||
CYCLE_LOGFILES = True
|
||||
# Number of lines to append to rotating channel logs when they rotate
|
||||
CHANNEL_LOG_NUM_TAIL_LINES = 20
|
||||
|
|
@ -222,7 +223,8 @@ COMMAND_RATE_WARNING = "You entered commands too fast. Wait a moment and try aga
|
|||
# 0 or less.
|
||||
MAX_CHAR_LIMIT = 6000
|
||||
# The warning to echo back to users if they enter a very large string
|
||||
MAX_CHAR_LIMIT_WARNING = "You entered a string that was too long. Please break it up into multiple parts."
|
||||
MAX_CHAR_LIMIT_WARNING = ("You entered a string that was too long. "
|
||||
"Please break it up into multiple parts.")
|
||||
# If this is true, errors and tracebacks from the engine will be
|
||||
# echoed as text in-game as well as to the log. This can speed up
|
||||
# debugging. OBS: Showing full tracebacks to regular users could be a
|
||||
|
|
@ -410,12 +412,14 @@ CMDSET_CHARACTER = "commands.default_cmdsets.CharacterCmdSet"
|
|||
CMDSET_ACCOUNT = "commands.default_cmdsets.AccountCmdSet"
|
||||
# Location to search for cmdsets if full path not given
|
||||
CMDSET_PATHS = ["commands", "evennia", "contribs"]
|
||||
# Fallbacks for cmdset paths that fail to load. Note that if you change the path for your default cmdsets,
|
||||
# you will also need to copy CMDSET_FALLBACKS after your change in your settings file for it to detect the change.
|
||||
CMDSET_FALLBACKS = {CMDSET_CHARACTER: 'evennia.commands.default.cmdset_character.CharacterCmdSet',
|
||||
CMDSET_ACCOUNT: 'evennia.commands.default.cmdset_account.AccountCmdSet',
|
||||
CMDSET_SESSION: 'evennia.commands.default.cmdset_session.SessionCmdSet',
|
||||
CMDSET_UNLOGGEDIN: 'evennia.commands.default.cmdset_unloggedin.UnloggedinCmdSet'}
|
||||
# Fallbacks for cmdset paths that fail to load. Note that if you change the path for your
|
||||
# default cmdsets, you will also need to copy CMDSET_FALLBACKS after your change in your
|
||||
# settings file for it to detect the change.
|
||||
CMDSET_FALLBACKS = {
|
||||
CMDSET_CHARACTER: 'evennia.commands.default.cmdset_character.CharacterCmdSet',
|
||||
CMDSET_ACCOUNT: 'evennia.commands.default.cmdset_account.AccountCmdSet',
|
||||
CMDSET_SESSION: 'evennia.commands.default.cmdset_session.SessionCmdSet',
|
||||
CMDSET_UNLOGGEDIN: 'evennia.commands.default.cmdset_unloggedin.UnloggedinCmdSet'}
|
||||
# Parent class for all default commands. Changing this class will
|
||||
# modify all default commands, so do so carefully.
|
||||
COMMAND_DEFAULT_CLASS = "evennia.commands.default.muxcommand.MuxCommand"
|
||||
|
|
@ -666,6 +670,9 @@ DEBUG = False
|
|||
ADMINS = () # 'Your Name', 'your_email@domain.com'),)
|
||||
# These guys get broken link notifications when SEND_BROKEN_LINK_EMAILS is True.
|
||||
MANAGERS = ADMINS
|
||||
# This is a public point of contact for players or the public to contact
|
||||
# a staff member or administrator of the site. It is publicly posted.
|
||||
STAFF_CONTACT_EMAIL = None
|
||||
# Absolute path to the directory that holds file uploads from web apps.
|
||||
# Example: "/home/media/media.lawrence.com"
|
||||
MEDIA_ROOT = os.path.join(GAME_DIR, "web", "media")
|
||||
|
|
@ -752,6 +759,7 @@ TEMPLATES = [{
|
|||
'django.contrib.auth.context_processors.auth',
|
||||
'django.template.context_processors.media',
|
||||
'django.template.context_processors.debug',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'sekizai.context_processors.sekizai',
|
||||
'evennia.web.utils.general_context.general_context'],
|
||||
# While true, show "pretty" error messages for template syntax errors.
|
||||
|
|
@ -762,14 +770,15 @@ TEMPLATES = [{
|
|||
# MiddleWare are semi-transparent extensions to Django's functionality.
|
||||
# see http://www.djangoproject.com/documentation/middleware/ for a more detailed
|
||||
# explanation.
|
||||
MIDDLEWARE_CLASSES = (
|
||||
MIDDLEWARE = (
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware', # 1.4?
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.admindocs.middleware.XViewMiddleware',
|
||||
'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware',)
|
||||
'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware',
|
||||
'evennia.web.utils.middleware.SharedLoginMiddleware',)
|
||||
|
||||
######################################################################
|
||||
# Evennia components
|
||||
|
|
@ -786,6 +795,7 @@ INSTALLED_APPS = (
|
|||
'django.contrib.flatpages',
|
||||
'django.contrib.sites',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.messages',
|
||||
'sekizai',
|
||||
'evennia.utils.idmapper',
|
||||
'evennia.server',
|
||||
|
|
@ -811,9 +821,24 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||
{'NAME': 'evennia.server.validators.EvenniaPasswordValidator'}]
|
||||
|
||||
# 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': 'evennia.server.validators.EvenniaUsernameAvailabilityValidator'}]
|
||||
|
||||
# Use a custom test runner that just tests Evennia-specific apps.
|
||||
TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner'
|
||||
|
||||
# Messages and Bootstrap don't classify events the same way; this setting maps
|
||||
# messages.error() to Bootstrap 'danger' classes.
|
||||
MESSAGE_TAGS = {
|
||||
messages.ERROR: 'danger',
|
||||
}
|
||||
|
||||
######################################################################
|
||||
# Django extensions
|
||||
######################################################################
|
||||
|
|
@ -821,7 +846,7 @@ TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner'
|
|||
# Django extesions are useful third-party tools that are not
|
||||
# always included in the default django distro.
|
||||
try:
|
||||
import django_extensions
|
||||
import django_extensions # noqa
|
||||
INSTALLED_APPS = INSTALLED_APPS + ('django_extensions',)
|
||||
except ImportError:
|
||||
# Django extensions are not installed in all distros.
|
||||
|
|
|
|||
|
|
@ -31,9 +31,12 @@ from django.db.models import signals
|
|||
|
||||
from django.db.models.base import ModelBase
|
||||
from django.db import models
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import smart_str
|
||||
from django.utils.text import slugify
|
||||
|
||||
from evennia.typeclasses.attributes import Attribute, AttributeHandler, NAttributeHandler
|
||||
from evennia.typeclasses.tags import Tag, TagHandler, AliasHandler, PermissionHandler
|
||||
|
|
@ -733,3 +736,182 @@ class TypedObject(SharedMemoryModel):
|
|||
|
||||
"""
|
||||
pass
|
||||
|
||||
#
|
||||
# Web/Django methods
|
||||
#
|
||||
|
||||
def web_get_admin_url(self):
|
||||
"""
|
||||
Returns the URI path for the Django Admin page for this object.
|
||||
|
||||
ex. Account#1 = '/admin/accounts/accountdb/1/change/'
|
||||
|
||||
Returns:
|
||||
path (str): URI path to Django Admin page for object.
|
||||
|
||||
"""
|
||||
content_type = ContentType.objects.get_for_model(self.__class__)
|
||||
return reverse("admin:%s_%s_change" % (content_type.app_label,
|
||||
content_type.model), args=(self.id,))
|
||||
|
||||
@classmethod
|
||||
def web_get_create_url(cls):
|
||||
"""
|
||||
Returns the URI path for a View that allows users to create new
|
||||
instances of this object.
|
||||
|
||||
ex. Chargen = '/characters/create/'
|
||||
|
||||
For this to work, the developer must have defined a named view somewhere
|
||||
in urls.py that follows the format 'modelname-action', so in this case
|
||||
a named view of 'character-create' would be referenced by this method.
|
||||
|
||||
ex.
|
||||
url(r'characters/create/', ChargenView.as_view(), name='character-create')
|
||||
|
||||
If no View has been created and defined in urls.py, returns an
|
||||
HTML anchor.
|
||||
|
||||
This method is naive and simply returns a path. Securing access to
|
||||
the actual view and limiting who can create new objects is the
|
||||
developer's responsibility.
|
||||
|
||||
Returns:
|
||||
path (str): URI path to object creation page, if defined.
|
||||
|
||||
"""
|
||||
try:
|
||||
return reverse('%s-create' % slugify(cls._meta.verbose_name))
|
||||
except:
|
||||
return '#'
|
||||
|
||||
def web_get_detail_url(self):
|
||||
"""
|
||||
Returns the URI path for a View that allows users to view details for
|
||||
this object.
|
||||
|
||||
ex. Oscar (Character) = '/characters/oscar/1/'
|
||||
|
||||
For this to work, the developer must have defined a named view somewhere
|
||||
in urls.py that follows the format 'modelname-action', so in this case
|
||||
a named view of 'character-detail' would be referenced by this method.
|
||||
|
||||
ex.
|
||||
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$',
|
||||
CharDetailView.as_view(), name='character-detail')
|
||||
|
||||
If no View has been created and defined in urls.py, returns an
|
||||
HTML anchor.
|
||||
|
||||
This method is naive and simply returns a path. Securing access to
|
||||
the actual view and limiting who can view this object is the developer's
|
||||
responsibility.
|
||||
|
||||
Returns:
|
||||
path (str): URI path to object detail page, if defined.
|
||||
|
||||
"""
|
||||
try:
|
||||
return reverse('%s-detail' % slugify(self._meta.verbose_name),
|
||||
kwargs={'pk': self.pk, 'slug': slugify(self.name)})
|
||||
except:
|
||||
return '#'
|
||||
|
||||
def web_get_puppet_url(self):
|
||||
"""
|
||||
Returns the URI path for a View that allows users to puppet a specific
|
||||
object.
|
||||
|
||||
ex. Oscar (Character) = '/characters/oscar/1/puppet/'
|
||||
|
||||
For this to work, the developer must have defined a named view somewhere
|
||||
in urls.py that follows the format 'modelname-action', so in this case
|
||||
a named view of 'character-puppet' would be referenced by this method.
|
||||
|
||||
ex.
|
||||
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/puppet/$',
|
||||
CharPuppetView.as_view(), name='character-puppet')
|
||||
|
||||
If no View has been created and defined in urls.py, returns an
|
||||
HTML anchor.
|
||||
|
||||
This method is naive and simply returns a path. Securing access to
|
||||
the actual view and limiting who can view this object is the developer's
|
||||
responsibility.
|
||||
|
||||
Returns:
|
||||
path (str): URI path to object puppet page, if defined.
|
||||
|
||||
"""
|
||||
try:
|
||||
return reverse('%s-puppet' % slugify(self._meta.verbose_name),
|
||||
kwargs={'pk': self.pk, 'slug': slugify(self.name)})
|
||||
except:
|
||||
return '#'
|
||||
|
||||
def web_get_update_url(self):
|
||||
"""
|
||||
Returns the URI path for a View that allows users to update this
|
||||
object.
|
||||
|
||||
ex. Oscar (Character) = '/characters/oscar/1/change/'
|
||||
|
||||
For this to work, the developer must have defined a named view somewhere
|
||||
in urls.py that follows the format 'modelname-action', so in this case
|
||||
a named view of 'character-update' would be referenced by this method.
|
||||
|
||||
ex.
|
||||
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$',
|
||||
CharUpdateView.as_view(), name='character-update')
|
||||
|
||||
If no View has been created and defined in urls.py, returns an
|
||||
HTML anchor.
|
||||
|
||||
This method is naive and simply returns a path. Securing access to
|
||||
the actual view and limiting who can modify objects is the developer's
|
||||
responsibility.
|
||||
|
||||
Returns:
|
||||
path (str): URI path to object update page, if defined.
|
||||
|
||||
"""
|
||||
try:
|
||||
return reverse('%s-update' % slugify(self._meta.verbose_name),
|
||||
kwargs={'pk': self.pk, 'slug': slugify(self.name)})
|
||||
except:
|
||||
return '#'
|
||||
|
||||
def web_get_delete_url(self):
|
||||
"""
|
||||
Returns the URI path for a View that allows users to delete this object.
|
||||
|
||||
ex. Oscar (Character) = '/characters/oscar/1/delete/'
|
||||
|
||||
For this to work, the developer must have defined a named view somewhere
|
||||
in urls.py that follows the format 'modelname-action', so in this case
|
||||
a named view of 'character-detail' would be referenced by this method.
|
||||
|
||||
ex.
|
||||
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$',
|
||||
CharDeleteView.as_view(), name='character-delete')
|
||||
|
||||
If no View has been created and defined in urls.py, returns an
|
||||
HTML anchor.
|
||||
|
||||
This method is naive and simply returns a path. Securing access to
|
||||
the actual view and limiting who can delete this object is the developer's
|
||||
responsibility.
|
||||
|
||||
Returns:
|
||||
path (str): URI path to object deletion page, if defined.
|
||||
|
||||
"""
|
||||
try:
|
||||
return reverse('%s-delete' % slugify(self._meta.verbose_name),
|
||||
kwargs={'pk': self.pk, 'slug': slugify(self.name)})
|
||||
except:
|
||||
return '#'
|
||||
|
||||
# Used by Django Sites/Admin
|
||||
get_absolute_url = web_get_detail_url
|
||||
|
|
|
|||
|
|
@ -229,6 +229,12 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None,
|
|||
# at_first_save hook on the typeclass, where the _createdict
|
||||
# can be used.
|
||||
new_script.save()
|
||||
|
||||
if not new_script.id:
|
||||
# this happens in the case of having a repeating script with `repeats=1` and
|
||||
# `start_delay=False` - the script will run once and immediately stop before save is over.
|
||||
return None
|
||||
|
||||
return new_script
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,17 @@ def uptime():
|
|||
return time.time() - SERVER_START_TIME
|
||||
|
||||
|
||||
def portal_uptime():
|
||||
"""
|
||||
Get the current uptime of the portal.
|
||||
|
||||
Returns:
|
||||
time (float): The uptime of the portal.
|
||||
"""
|
||||
from evennia.server.sessionhandler import SESSIONS
|
||||
return time.time() - SESSIONS.portal_start_time
|
||||
|
||||
|
||||
def game_epoch():
|
||||
"""
|
||||
Get the game epoch.
|
||||
|
|
|
|||
|
|
@ -398,6 +398,11 @@ class SharedMemoryModel(with_metaclass(SharedMemoryModelBase, Model)):
|
|||
super().save(*args, **kwargs)
|
||||
callFromThread(_save_callback, self, *args, **kwargs)
|
||||
|
||||
if not self.pk:
|
||||
# this can happen if some of the startup methods immediately
|
||||
# delete the object (an example are Scripts that start and die immediately)
|
||||
return
|
||||
|
||||
# update field-update hooks and eventual OOB watchers
|
||||
new = False
|
||||
if "update_fields" in kwargs and kwargs["update_fields"]:
|
||||
|
|
@ -422,6 +427,7 @@ class SharedMemoryModel(with_metaclass(SharedMemoryModelBase, Model)):
|
|||
# fieldtracker = "_oob_at_%s_postsave" % fieldname
|
||||
# if hasattr(self, fieldtracker):
|
||||
# _GA(self, fieldtracker)(fieldname)
|
||||
pass
|
||||
|
||||
|
||||
class WeakSharedMemoryModelBase(SharedMemoryModelBase):
|
||||
|
|
|
|||
79
evennia/utils/tests/test_create_functions.py
Normal file
79
evennia/utils/tests/test_create_functions.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""
|
||||
Tests of create functions
|
||||
|
||||
"""
|
||||
|
||||
from evennia.utils.test_resources import EvenniaTest
|
||||
from evennia.scripts.scripts import DefaultScript
|
||||
from evennia.utils import create
|
||||
|
||||
|
||||
class TestCreateScript(EvenniaTest):
|
||||
|
||||
def test_create_script(self):
|
||||
class TestScriptA(DefaultScript):
|
||||
def at_script_creation(self):
|
||||
self.key = 'test_script'
|
||||
self.interval = 10
|
||||
self.persistent = False
|
||||
|
||||
script = create.create_script(TestScriptA, key='test_script')
|
||||
assert script is not None
|
||||
assert script.interval == 10
|
||||
assert script.key == 'test_script'
|
||||
script.stop()
|
||||
|
||||
def test_create_script_w_repeats_equal_1(self):
|
||||
class TestScriptB(DefaultScript):
|
||||
def at_script_creation(self):
|
||||
self.key = 'test_script'
|
||||
self.interval = 10
|
||||
self.repeats = 1
|
||||
self.persistent = False
|
||||
|
||||
# script is already stopped (interval=1, start_delay=False)
|
||||
script = create.create_script(TestScriptB, key='test_script')
|
||||
assert script is None
|
||||
|
||||
def test_create_script_w_repeats_equal_1_persisted(self):
|
||||
class TestScriptB1(DefaultScript):
|
||||
def at_script_creation(self):
|
||||
self.key = 'test_script'
|
||||
self.interval = 10
|
||||
self.repeats = 1
|
||||
self.persistent = True
|
||||
|
||||
# script is already stopped (interval=1, start_delay=False)
|
||||
script = create.create_script(TestScriptB1, key='test_script')
|
||||
assert script is None
|
||||
|
||||
def test_create_script_w_repeats_equal_2(self):
|
||||
class TestScriptC(DefaultScript):
|
||||
def at_script_creation(self):
|
||||
self.key = 'test_script'
|
||||
self.interval = 10
|
||||
self.repeats = 2
|
||||
self.persistent = False
|
||||
|
||||
script = create.create_script(TestScriptC, key='test_script')
|
||||
assert script is not None
|
||||
assert script.interval == 10
|
||||
assert script.repeats == 2
|
||||
assert script.key == 'test_script'
|
||||
script.stop()
|
||||
|
||||
def test_create_script_w_repeats_equal_1_and_delayed(self):
|
||||
class TestScriptD(DefaultScript):
|
||||
def at_script_creation(self):
|
||||
self.key = 'test_script'
|
||||
self.interval = 10
|
||||
self.start_delay = True
|
||||
self.repeats = 1
|
||||
self.persistent = False
|
||||
|
||||
script = create.create_script(TestScriptD, key='test_script')
|
||||
assert script is not None
|
||||
assert script.interval == 10
|
||||
assert script.repeats == 1
|
||||
assert script.key == 'test_script'
|
||||
script.stop()
|
||||
|
|
@ -67,7 +67,17 @@ def general_context(request):
|
|||
Returns common Evennia-related context stuff, which
|
||||
is automatically added to context of all views.
|
||||
"""
|
||||
account = None
|
||||
if request.user.is_authenticated(): account = request.user
|
||||
|
||||
puppet = None
|
||||
if account and request.session.get('puppet'):
|
||||
pk = int(request.session.get('puppet'))
|
||||
puppet = next((x for x in account.characters if x.pk == pk), None)
|
||||
|
||||
return {
|
||||
'account': account,
|
||||
'puppet': puppet,
|
||||
'game_name': GAME_NAME,
|
||||
'game_slogan': GAME_SLOGAN,
|
||||
'evennia_userapps': ACCOUNT_RELATED,
|
||||
|
|
|
|||
61
evennia/web/utils/middleware.py
Normal file
61
evennia/web/utils/middleware.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from django.contrib.auth import authenticate, login
|
||||
from evennia.accounts.models import AccountDB
|
||||
from evennia.utils import logger
|
||||
|
||||
class SharedLoginMiddleware(object):
|
||||
"""
|
||||
Handle the shared login between website and webclient.
|
||||
|
||||
"""
|
||||
def __init__(self, get_response):
|
||||
# One-time configuration and initialization.
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# Code to be executed for each request before
|
||||
# the view (and later middleware) are called.
|
||||
|
||||
# Synchronize credentials between webclient and website
|
||||
# Must be performed *before* rendering the view (issue #1723)
|
||||
self.make_shared_login(request)
|
||||
|
||||
# Process view
|
||||
response = self.get_response(request)
|
||||
|
||||
# Code to be executed for each request/response after
|
||||
# the view is called.
|
||||
|
||||
# Return processed view
|
||||
return response
|
||||
|
||||
@classmethod
|
||||
def make_shared_login(cls, request):
|
||||
csession = request.session
|
||||
account = request.user
|
||||
website_uid = csession.get("website_authenticated_uid", None)
|
||||
webclient_uid = csession.get("webclient_authenticated_uid", None)
|
||||
|
||||
if not csession.session_key:
|
||||
# this is necessary to build the sessid key
|
||||
csession.save()
|
||||
|
||||
if account.is_authenticated():
|
||||
# Logged into website
|
||||
if not website_uid:
|
||||
# fresh website login (just from login page)
|
||||
csession["website_authenticated_uid"] = account.id
|
||||
if webclient_uid is None:
|
||||
# auto-login web client
|
||||
csession["webclient_authenticated_uid"] = account.id
|
||||
|
||||
elif webclient_uid:
|
||||
# Not logged into website, but logged into webclient
|
||||
if not website_uid:
|
||||
csession["website_authenticated_uid"] = account.id
|
||||
account = AccountDB.objects.get(id=webclient_uid)
|
||||
try:
|
||||
# calls our custom authenticate, in web/utils/backend.py
|
||||
authenticate(autologin=account)
|
||||
login(request, account)
|
||||
except AttributeError:
|
||||
logger.log_trace()
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
from mock import Mock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test import RequestFactory, TestCase
|
||||
from mock import MagicMock, patch
|
||||
from . import general_context
|
||||
|
||||
|
||||
class TestGeneralContext(TestCase):
|
||||
maxDiff = None
|
||||
|
||||
|
|
@ -15,8 +13,18 @@ class TestGeneralContext(TestCase):
|
|||
@patch('evennia.web.utils.general_context.WEBSOCKET_PORT', "websocket_client_port_testvalue")
|
||||
@patch('evennia.web.utils.general_context.WEBSOCKET_URL', "websocket_client_url_testvalue")
|
||||
def test_general_context(self):
|
||||
request = Mock()
|
||||
self.assertEqual(general_context.general_context(request), {
|
||||
request = RequestFactory().get('/')
|
||||
request.user = AnonymousUser()
|
||||
request.session = {
|
||||
'account': None,
|
||||
'puppet': None,
|
||||
}
|
||||
|
||||
response = general_context.general_context(request)
|
||||
|
||||
self.assertEqual(response, {
|
||||
'account': None,
|
||||
'puppet': None,
|
||||
'game_name': "test_name",
|
||||
'game_slogan': "test_game_slogan",
|
||||
'evennia_userapps': ['Accounts'],
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ An "emitter" object must have a function
|
|||
return;
|
||||
}
|
||||
this.connection.connect();
|
||||
log('Evenna reconnecting.')
|
||||
log('Evennia reconnecting.')
|
||||
},
|
||||
|
||||
// Returns true if the connection is open.
|
||||
|
|
|
|||
|
|
@ -69,8 +69,12 @@ let history_plugin = (function () {
|
|||
}
|
||||
|
||||
if (history_entry !== null) {
|
||||
// Doing a history navigation; replace the text in the input.
|
||||
inputfield.val(history_entry);
|
||||
// Performing a history navigation
|
||||
// replace the text in the input and move the cursor to the end of the new value
|
||||
inputfield.val('');
|
||||
inputfield.blur().focus().val(history_entry);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -30,32 +30,31 @@ plugin_handler.add('hotbuttons', (function () {
|
|||
// Add Buttons
|
||||
var addButtonsUI = function () {
|
||||
var buttons = $( [
|
||||
'<div id="buttons" class="split split-vertical">',
|
||||
' <div id="buttonsform" class="wrapper">',
|
||||
' <div id="buttonscontrol" class="input-group">',
|
||||
' <button class="btn" id="assign_button0" type="button" value="button0">unassigned</button>',
|
||||
' <button class="btn" id="assign_button1" type="button" value="button1">unassigned</button>',
|
||||
' <button class="btn" id="assign_button2" type="button" value="button2">unassigned</button>',
|
||||
' <button class="btn" id="assign_button3" type="button" value="button3">unassigned</button>',
|
||||
' <button class="btn" id="assign_button4" type="button" value="button4">unassigned</button>',
|
||||
' <button class="btn" id="assign_button5" type="button" value="button5">unassigned</button>',
|
||||
' <button class="btn" id="assign_button6" type="button" value="button6">unassigned</button>',
|
||||
' <button class="btn" id="assign_button7" type="button" value="button7">unassigned</button>',
|
||||
' <button class="btn" id="assign_button8" type="button" value="button8">unassigned</button>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
'</div>',
|
||||
].join("\n") );
|
||||
'<div id="buttons" class="split split-vertical">',
|
||||
' <div id="buttonsform">',
|
||||
' <div id="buttonscontrol" class="input-group">',
|
||||
' <button class="btn" id="assign_button0" type="button" value="button0">unassigned</button>',
|
||||
' <button class="btn" id="assign_button1" type="button" value="button1">unassigned</button>',
|
||||
' <button class="btn" id="assign_button2" type="button" value="button2">unassigned</button>',
|
||||
' <button class="btn" id="assign_button3" type="button" value="button3">unassigned</button>',
|
||||
' <button class="btn" id="assign_button4" type="button" value="button4">unassigned</button>',
|
||||
' <button class="btn" id="assign_button5" type="button" value="button5">unassigned</button>',
|
||||
' <button class="btn" id="assign_button6" type="button" value="button6">unassigned</button>',
|
||||
' <button class="btn" id="assign_button7" type="button" value="button7">unassigned</button>',
|
||||
' <button class="btn" id="assign_button8" type="button" value="button8">unassigned</button>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
'</div>',
|
||||
].join("\n") );
|
||||
|
||||
// Add buttons in front of the existing #inputform
|
||||
buttons.insertBefore('#inputform');
|
||||
$('#inputform').addClass('split split-vertical');
|
||||
$('#input').prev().replaceWith(buttons);
|
||||
|
||||
Split(['#buttons','#inputform'], {
|
||||
Split(['#main','#buttons','#input'], {
|
||||
sizes: [85,5,10],
|
||||
direction: 'vertical',
|
||||
sizes: [50,50],
|
||||
gutterSize: 4,
|
||||
minSize: 150,
|
||||
minSize: [150,20,50],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,10 +77,12 @@ let options_plugin = (function () {
|
|||
if (code === 27) { // Escape key
|
||||
if ($('#helpdialog').is(':visible')) {
|
||||
plugins['popups'].closePopup("#helpdialog");
|
||||
} else {
|
||||
plugins['popups'].closePopup("#optionsdialog");
|
||||
return true;
|
||||
}
|
||||
if ($('#optionsdialog').is(':visible')) {
|
||||
plugins['popups'].closePopup("#optionsdialog");
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -129,6 +131,21 @@ let options_plugin = (function () {
|
|||
plugins['popups'].closePopup("#helpdialog");
|
||||
}
|
||||
|
||||
//
|
||||
// Make sure to close any dialogs on connection lost
|
||||
var onText = function (args, kwargs) {
|
||||
// is helppopup set? and if so, does this Text have type 'help'?
|
||||
if ('helppopup' in options && options['helppopup'] ) {
|
||||
if (kwargs && ('type' in kwargs) && (kwargs['type'] == 'help') ) {
|
||||
$('#helpdialogcontent').append('<div>'+ args + '</div>');
|
||||
plugins['popups'].togglePopup("#helpdialog");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
//
|
||||
// Register and init plugin
|
||||
var init = function () {
|
||||
|
|
@ -155,6 +172,7 @@ let options_plugin = (function () {
|
|||
onGotOptions: onGotOptions,
|
||||
onPrompt: onPrompt,
|
||||
onConnectionClose: onConnectionClose,
|
||||
onText: onText,
|
||||
}
|
||||
})()
|
||||
plugin_handler.add('options', options_plugin);
|
||||
|
|
|
|||
|
|
@ -183,12 +183,12 @@ let splithandler_plugin = (function () {
|
|||
var dialog = $("#splitdialogcontent");
|
||||
dialog.empty();
|
||||
|
||||
var selection = '<select name="pane">';
|
||||
var selection = '<select name="pane">';
|
||||
for ( var pane in split_panes ) {
|
||||
selection = selection + '<option value="' + pane + '">' + pane + '</option>';
|
||||
selection = selection + '<option value="' + pane + '">' + pane + '</option>';
|
||||
}
|
||||
selection = "Pane to split: " + selection + "</select> ";
|
||||
dialog.append(selection);
|
||||
selection = "Pane to split: " + selection + "</select> ";
|
||||
dialog.append(selection);
|
||||
|
||||
dialog.append('<input type="radio" name="direction" value="vertical" checked>top/bottom </>');
|
||||
dialog.append('<input type="radio" name="direction" value="horizontal">side-by-side <hr />');
|
||||
|
|
@ -203,7 +203,7 @@ let splithandler_plugin = (function () {
|
|||
dialog.append('<input type="radio" name="flow2" value="replace">replace </>');
|
||||
dialog.append('<input type="radio" name="flow2" value="append">append <hr />');
|
||||
|
||||
dialog.append('<div id="splitclose" class="btn btn-large btn-outline-primary float-right">Split</div>');
|
||||
dialog.append('<div id="splitclose" class="btn btn-large btn-outline-primary float-right">Split</div>');
|
||||
|
||||
$("#splitclose").bind("click", onSplitDialogClose);
|
||||
|
||||
|
|
@ -251,21 +251,21 @@ let splithandler_plugin = (function () {
|
|||
var dialog = $("#panedialogcontent");
|
||||
dialog.empty();
|
||||
|
||||
var selection = '<select name="assign-pane">';
|
||||
var selection = '<select name="assign-pane">';
|
||||
for ( var pane in split_panes ) {
|
||||
selection = selection + '<option value="' + pane + '">' + pane + '</option>';
|
||||
selection = selection + '<option value="' + pane + '">' + pane + '</option>';
|
||||
}
|
||||
selection = "Assign to pane: " + selection + "</select> <hr />";
|
||||
dialog.append(selection);
|
||||
selection = "Assign to pane: " + selection + "</select> <hr />";
|
||||
dialog.append(selection);
|
||||
|
||||
var multiple = '<select multiple name="assign-type">';
|
||||
var multiple = '<select multiple name="assign-type">';
|
||||
for ( var type in known_types ) {
|
||||
multiple = multiple + '<option value="' + known_types[type] + '">' + known_types[type] + '</option>';
|
||||
multiple = multiple + '<option value="' + known_types[type] + '">' + known_types[type] + '</option>';
|
||||
}
|
||||
multiple = "Content types: " + multiple + "</select> <hr />";
|
||||
dialog.append(multiple);
|
||||
multiple = "Content types: " + multiple + "</select> <hr />";
|
||||
dialog.append(multiple);
|
||||
|
||||
dialog.append('<div id="paneclose" class="btn btn-large btn-outline-primary float-right">Assign</div>');
|
||||
dialog.append('<div id="paneclose" class="btn btn-large btn-outline-primary float-right">Assign</div>');
|
||||
|
||||
$("#paneclose").bind("click", onPaneControlDialogClose);
|
||||
|
||||
|
|
@ -276,9 +276,9 @@ let splithandler_plugin = (function () {
|
|||
// Close "Pane Controls" dialog
|
||||
var onPaneControlDialogClose = function () {
|
||||
var pane = $("select[name=assign-pane]").val();
|
||||
var types = $("select[name=assign-type]").val();
|
||||
var types = $("select[name=assign-type]").val();
|
||||
|
||||
// var types = new Array;
|
||||
// var types = new Array;
|
||||
// $('#splitdialogcontent input[type=checkbox]:checked').each(function() {
|
||||
// types.push( $(this).attr('value') );
|
||||
// });
|
||||
|
|
@ -287,24 +287,24 @@ let splithandler_plugin = (function () {
|
|||
|
||||
plugins['popups'].closePopup("#panedialog");
|
||||
}
|
||||
|
||||
//
|
||||
// helper function sending text to a pane
|
||||
var txtToPane = function (panekey, txt) {
|
||||
var pane = split_panes[panekey];
|
||||
var text_div = $('#' + panekey + '-sub');
|
||||
var pane = split_panes[panekey];
|
||||
var text_div = $('#' + panekey + '-sub');
|
||||
|
||||
if ( pane['update_method'] == 'replace' ) {
|
||||
text_div.html(txt)
|
||||
} else if ( pane['update_method'] == 'append' ) {
|
||||
text_div.append(txt);
|
||||
var scrollHeight = text_div.parent().prop("scrollHeight");
|
||||
text_div.parent().animate({ scrollTop: scrollHeight }, 0);
|
||||
} else { // line feed
|
||||
text_div.append("<div class='out'>" + txt + "</div>");
|
||||
var scrollHeight = text_div.parent().prop("scrollHeight");
|
||||
text_div.parent().animate({ scrollTop: scrollHeight }, 0);
|
||||
}
|
||||
|
||||
if ( pane['update_method'] == 'replace' ) {
|
||||
text_div.html(txt)
|
||||
} else if ( pane['update_method'] == 'append' ) {
|
||||
text_div.append(txt);
|
||||
var scrollHeight = text_div.parent().prop("scrollHeight");
|
||||
text_div.parent().animate({ scrollTop: scrollHeight }, 0);
|
||||
} else { // line feed
|
||||
text_div.append("<div class='out'>" + txt + "</div>");
|
||||
var scrollHeight = text_div.parent().prop("scrollHeight");
|
||||
text_div.parent().animate({ scrollTop: scrollHeight }, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -316,53 +316,76 @@ let splithandler_plugin = (function () {
|
|||
//
|
||||
// Accept plugin onText events
|
||||
var onText = function (args, kwargs) {
|
||||
|
||||
// If the message is not itself tagged, we'll assume it
|
||||
// should go into any panes with 'all' or 'rest' set
|
||||
// If the message is not itself tagged, we'll assume it
|
||||
// should go into any panes with 'all' or 'rest' set
|
||||
var msgtype = "rest";
|
||||
|
||||
if ( kwargs && 'type' in kwargs ) {
|
||||
msgtype = kwargs['type'];
|
||||
msgtype = kwargs['type'];
|
||||
if ( ! known_types.includes(msgtype) ) {
|
||||
// this is a new output type that can be mapped to panes
|
||||
console.log('detected new output type: ' + msgtype)
|
||||
known_types.push(msgtype);
|
||||
}
|
||||
}
|
||||
var target_panes = [];
|
||||
var rest_panes = [];
|
||||
|
||||
for (var key in split_panes) {
|
||||
var pane = split_panes[key];
|
||||
// is this message type mapped to this pane (or does the pane has an 'all' type)?
|
||||
if (pane['types'].length > 0) {
|
||||
if (pane['types'].includes(msgtype) || pane['types'].includes('all')) {
|
||||
target_panes.push(key);
|
||||
} else if (pane['types'].includes('rest')) {
|
||||
// store rest-panes in case we have no explicit to send to
|
||||
rest_panes.push(key);
|
||||
}
|
||||
} else {
|
||||
// unassigned panes are assumed to be rest-panes too
|
||||
rest_panes.push(key);
|
||||
}
|
||||
}
|
||||
var ntargets = target_panes.length;
|
||||
var nrests = rest_panes.length;
|
||||
if (ntargets > 0) {
|
||||
// we have explicit target panes to send to
|
||||
for (var i=0; i<ntargets; i++) {
|
||||
txtToPane(target_panes[i], args[0]);
|
||||
}
|
||||
return true;
|
||||
} else if (nrests > 0) {
|
||||
// no targets, send remainder to rest-panes/unassigned
|
||||
for (var i=0; i<nrests; i++) {
|
||||
txtToPane(rest_panes[i], args[0]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// unhandled message
|
||||
}
|
||||
var target_panes = [];
|
||||
var rest_panes = [];
|
||||
|
||||
for (var key in split_panes) {
|
||||
var pane = split_panes[key];
|
||||
// is this message type mapped to this pane (or does the pane has an 'all' type)?
|
||||
if (pane['types'].length > 0) {
|
||||
if (pane['types'].includes(msgtype) || pane['types'].includes('all')) {
|
||||
target_panes.push(key);
|
||||
} else if (pane['types'].includes('rest')) {
|
||||
// store rest-panes in case we have no explicit to send to
|
||||
rest_panes.push(key);
|
||||
}
|
||||
} else {
|
||||
// unassigned panes are assumed to be rest-panes too
|
||||
rest_panes.push(key);
|
||||
}
|
||||
}
|
||||
var ntargets = target_panes.length;
|
||||
var nrests = rest_panes.length;
|
||||
if (ntargets > 0) {
|
||||
// we have explicit target panes to send to
|
||||
for (var i=0; i<ntargets; i++) {
|
||||
txtToPane(target_panes[i], args[0]);
|
||||
}
|
||||
return true;
|
||||
} else if (nrests > 0) {
|
||||
// no targets, send remainder to rest-panes/unassigned
|
||||
for (var i=0; i<nrests; i++) {
|
||||
txtToPane(rest_panes[i], args[0]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// unhandled message
|
||||
return false;
|
||||
}
|
||||
|
||||
//
|
||||
// onKeydown check for 'ESC' key.
|
||||
var onKeydown = function (event) {
|
||||
var code = event.which;
|
||||
|
||||
if (code === 27) { // Escape key
|
||||
if ($('#splitdialog').is(':visible')) {
|
||||
plugins['popups'].closePopup("#splitdialog");
|
||||
return true;
|
||||
}
|
||||
if ($('#panedialog').is(':visible')) {
|
||||
plugins['popups'].closePopup("#panedialog");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// capture all keys while one of our "modal" dialogs is open
|
||||
if ($('#splitdialogcontent').is(':visible') || $('#panedialogcontent').is(':visible')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -409,6 +432,7 @@ let splithandler_plugin = (function () {
|
|||
dynamic_split: dynamic_split,
|
||||
undo_split: undo_split,
|
||||
set_pane_types: set_pane_types,
|
||||
onKeydown: onKeydown,
|
||||
}
|
||||
})()
|
||||
plugin_handler.add('splithandler', splithandler_plugin);
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ JQuery available.
|
|||
<script src={% static "webclient/js/evennia.js" %} language="javascript" type="text/javascript" charset="utf-8"/></script>
|
||||
|
||||
<!-- set up splits before loading the GUI -->
|
||||
<script src="https://unpkg.com/split.js/split.min.js"></script>
|
||||
<script src="https://unpkg.com/split.js@1.5.9/dist/split.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/mustache.js/2.3.0/mustache.min.js"></script>
|
||||
|
||||
<!-- Load gui library -->
|
||||
|
|
@ -73,10 +73,10 @@ JQuery available.
|
|||
<script src={% static "webclient/js/plugins/popups.js" %} language="javascript" type="text/javascript"></script>
|
||||
<script src={% static "webclient/js/plugins/options.js" %} language="javascript" type="text/javascript"></script>
|
||||
<script src={% static "webclient/js/plugins/history.js" %} language="javascript" type="text/javascript"></script>
|
||||
<script src={% static "webclient/js/plugins/splithandler.js" %} language="javascript" type="text/javascript"></script>
|
||||
<script src={% static "webclient/js/plugins/default_in.js" %} language="javascript" type="text/javascript"></script>
|
||||
<script src={% static "webclient/js/plugins/oob.js" %} language="javascript" type="text/javascript"></script>
|
||||
<script src={% static "webclient/js/plugins/notifications.js" %} language="javascript" type="text/javascript"></script>
|
||||
<script src={% static "webclient/js/plugins/splithandler.js" %} language="javascript" type="text/javascript"></script>
|
||||
<script src={% static "webclient/js/plugins/default_out.js" %} language="javascript" type="text/javascript"></script>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ page and serve it eventual static content.
|
|||
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
from django.shortcuts import render
|
||||
from django.contrib.auth import login, authenticate
|
||||
|
||||
|
|
@ -12,51 +14,16 @@ from evennia.accounts.models import AccountDB
|
|||
from evennia.utils import logger
|
||||
|
||||
|
||||
def _shared_login(request):
|
||||
"""
|
||||
Handle the shared login between website and webclient.
|
||||
|
||||
"""
|
||||
csession = request.session
|
||||
account = request.user
|
||||
# these can have 3 values:
|
||||
# None - previously unused (auto-login)
|
||||
# False - actively logged out (don't auto-login)
|
||||
# <uid> - logged in User/Account id
|
||||
website_uid = csession.get("website_authenticated_uid", None)
|
||||
webclient_uid = csession.get("webclient_authenticated_uid", None)
|
||||
|
||||
# check if user has authenticated to website
|
||||
if not csession.session_key:
|
||||
# this is necessary to build the sessid key
|
||||
csession.save()
|
||||
|
||||
if webclient_uid:
|
||||
# The webclient has previously registered a login to this browser_session
|
||||
if not account.is_authenticated() and not website_uid:
|
||||
try:
|
||||
account = AccountDB.objects.get(id=webclient_uid)
|
||||
except AccountDB.DoesNotExist:
|
||||
# this can happen e.g. for guest accounts or deletions
|
||||
csession["website_authenticated_uid"] = False
|
||||
csession["webclient_authenticated_uid"] = False
|
||||
return
|
||||
try:
|
||||
# calls our custom authenticate in web/utils/backends.py
|
||||
account = authenticate(autologin=account)
|
||||
login(request, account)
|
||||
csession["website_authenticated_uid"] = webclient_uid
|
||||
except AttributeError:
|
||||
logger.log_trace()
|
||||
|
||||
|
||||
def webclient(request):
|
||||
"""
|
||||
Webclient page template loading.
|
||||
|
||||
"""
|
||||
# handle webclient-website shared login
|
||||
_shared_login(request)
|
||||
# auto-login is now handled by evennia.web.utils.middleware
|
||||
|
||||
# check if webclient should be enabled
|
||||
if not settings.WEBCLIENT_ENABLED:
|
||||
raise Http404
|
||||
|
||||
# make sure to store the browser session's hash so the webclient can get to it!
|
||||
pagevars = {'browser_sessid': request.session.session_key}
|
||||
|
|
|
|||
157
evennia/web/website/forms.py
Normal file
157
evennia/web/website/forms.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.forms import UserCreationForm, UsernameField
|
||||
from django.forms import ModelForm
|
||||
from django.utils.html import escape
|
||||
from evennia.utils import class_from_module
|
||||
|
||||
class EvenniaForm(forms.Form):
|
||||
"""
|
||||
This is a stock Django form, but modified so that all values provided
|
||||
through it are escaped (sanitized). Validation is performed by the fields
|
||||
you define in the form.
|
||||
|
||||
This has little to do with Evennia itself and is more general web security-
|
||||
related.
|
||||
|
||||
https://www.owasp.org/index.php/Input_Validation_Cheat_Sheet#Goals_of_Input_Validation
|
||||
|
||||
"""
|
||||
def clean(self):
|
||||
"""
|
||||
Django hook. Performed on form submission.
|
||||
|
||||
Returns:
|
||||
cleaned (dict): Dictionary of key:value pairs submitted on the form.
|
||||
|
||||
"""
|
||||
# Call parent function
|
||||
cleaned = super(EvenniaForm, self).clean()
|
||||
|
||||
# Escape all values provided by user
|
||||
cleaned = {k:escape(v) for k,v in cleaned.items()}
|
||||
return cleaned
|
||||
|
||||
class AccountForm(UserCreationForm):
|
||||
"""
|
||||
This is a generic Django form tailored to the Account model.
|
||||
|
||||
In this incarnation it does not allow getting/setting of attributes, only
|
||||
core User model fields (username, email, password).
|
||||
|
||||
"""
|
||||
class Meta:
|
||||
"""
|
||||
This is a Django construct that provides additional configuration to
|
||||
the form.
|
||||
|
||||
"""
|
||||
# The model/typeclass this form creates
|
||||
model = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
|
||||
|
||||
# The fields to display on the form, in the given order
|
||||
fields = ("username", "email")
|
||||
|
||||
# Any overrides of field classes
|
||||
field_classes = {'username': UsernameField}
|
||||
|
||||
# Username is collected as part of the core UserCreationForm, so we just need
|
||||
# to add a field to (optionally) capture email.
|
||||
email = forms.EmailField(help_text="A valid email address. Optional; used for password resets.", required=False)
|
||||
|
||||
class ObjectForm(EvenniaForm, ModelForm):
|
||||
"""
|
||||
This is a Django form for generic Evennia Objects that allows modification
|
||||
of attributes when called from a descendent of ObjectUpdate or ObjectCreate
|
||||
views.
|
||||
|
||||
It defines no fields by default; you have to do that by extending this class
|
||||
and defining what fields you want to be recorded. See the CharacterForm for
|
||||
a simple example of how to do this.
|
||||
|
||||
"""
|
||||
class Meta:
|
||||
"""
|
||||
This is a Django construct that provides additional configuration to
|
||||
the form.
|
||||
|
||||
"""
|
||||
# The model/typeclass this form creates
|
||||
model = class_from_module(settings.BASE_OBJECT_TYPECLASS)
|
||||
|
||||
# The fields to display on the form, in the given order
|
||||
fields = ("db_key",)
|
||||
|
||||
# This lets us rename ugly db-specific keys to something more human
|
||||
labels = {
|
||||
'db_key': 'Name',
|
||||
}
|
||||
|
||||
class CharacterForm(ObjectForm):
|
||||
"""
|
||||
This is a Django form for Evennia Character objects.
|
||||
|
||||
Since Evennia characters only have one attribute by default, this form only
|
||||
defines a field for that single attribute. The names of fields you define should
|
||||
correspond to their names as stored in the dbhandler; you can display
|
||||
'prettier' versions of the fieldname on the form using the 'label' kwarg.
|
||||
|
||||
The basic field types are CharFields and IntegerFields, which let you enter
|
||||
text and numbers respectively. IntegerFields have some neat validation tricks
|
||||
they can do, like mandating values fall within a certain range.
|
||||
|
||||
For example, a complete "age" field (which stores its value to
|
||||
`character.db.age` might look like:
|
||||
|
||||
age = forms.IntegerField(
|
||||
label="Your Age",
|
||||
min_value=18, max_value=9000,
|
||||
help_text="Years since your birth.")
|
||||
|
||||
Default input fields are generic single-line text boxes. You can control what
|
||||
sort of input field users will see by specifying a "widget." An example of
|
||||
this is used for the 'desc' field to show a Textarea box instead of a Textbox.
|
||||
|
||||
For help in building out your form, please see:
|
||||
https://docs.djangoproject.com/en/1.11/topics/forms/#building-a-form-in-django
|
||||
|
||||
For more information on fields and their capabilities, see:
|
||||
https://docs.djangoproject.com/en/1.11/ref/forms/fields/
|
||||
|
||||
For more on widgets, see:
|
||||
https://docs.djangoproject.com/en/1.11/ref/forms/widgets/
|
||||
|
||||
"""
|
||||
class Meta:
|
||||
"""
|
||||
This is a Django construct that provides additional configuration to
|
||||
the form.
|
||||
|
||||
"""
|
||||
# Get the correct object model
|
||||
model = class_from_module(settings.BASE_CHARACTER_TYPECLASS)
|
||||
|
||||
# Allow entry of the 'key' field
|
||||
fields = ("db_key",)
|
||||
|
||||
# Rename 'key' to something more intelligible
|
||||
labels = {
|
||||
'db_key': 'Name',
|
||||
}
|
||||
|
||||
# Fields pertaining to configurable attributes on the Character object.
|
||||
desc = forms.CharField(label='Description', max_length=2048, required=False,
|
||||
widget=forms.Textarea(attrs={'rows': 3}),
|
||||
help_text="A brief description of your character.")
|
||||
|
||||
class CharacterUpdateForm(CharacterForm):
|
||||
"""
|
||||
This is a Django form for updating Evennia Character objects.
|
||||
|
||||
By default it is the same as the CharacterForm, but if there are circumstances
|
||||
in which you don't want to let players edit all the same attributes they had
|
||||
access to during creation, you can redefine this form with those fields you do
|
||||
wish to allow.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
|
@ -23,35 +23,67 @@ folder and edit it to add/remove links to the menu.
|
|||
<ul class="navbar-nav">
|
||||
{% block nabvar_left %}
|
||||
<li>
|
||||
<a class="nav-link" href="/">Home</a>
|
||||
<a class="nav-link" href="{% url 'index' %}">Home</a>
|
||||
</li>
|
||||
<!-- evennia documentation -->
|
||||
<li>
|
||||
<a class="nav-link" href="https://github.com/evennia/evennia/wiki/Evennia-Introduction/">About</a>
|
||||
</li>
|
||||
<li><a class="nav-link" href="https://github.com/evennia/evennia/wiki">Documentation</a></li>
|
||||
<li><a class="nav-link" href="{% url 'admin:index' %}">Admin Interface</a></li>
|
||||
<!-- end evennia documentation -->
|
||||
|
||||
<!-- game views -->
|
||||
<li><a class="nav-link" href="{% url 'characters' %}">Characters</a></li>
|
||||
<li><a class="nav-link" href="{% url 'channels' %}">Channels</a></li>
|
||||
<li><a class="nav-link" href="{% url 'help' %}">Help</a></li>
|
||||
<!-- end game views -->
|
||||
|
||||
{% if webclient_enabled %}
|
||||
<li><a class="nav-link" href="{% url 'webclient:index' %}">Play Online</a></li>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_staff %}
|
||||
<li><a class="nav-link" href="{% url 'admin:index' %}">Admin</a></li>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</ul>
|
||||
<ul class="nav navbar-nav ml-auto w-120 justify-content-end">
|
||||
{% block navbar_right %}
|
||||
{% endblock %}
|
||||
|
||||
{% block navbar_user %}
|
||||
{% if user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link">Logged in as {{user.username}}</a>
|
||||
{% if account %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" data-toggle="dropdown" href="#" id="user_options" aria-expanded="false">
|
||||
{% if puppet %}
|
||||
Welcome, {{ puppet }}! <span class="text-muted">({{ account.username }})</span> <span class="caret"></span>
|
||||
{% else %}
|
||||
Logged in as {{ account.username }} <span class="caret"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="dropdown-menu" aria-labelledby="user_options">
|
||||
<a class="dropdown-item" href="{% url 'character-create' %}">Create Character</a>
|
||||
<a class="dropdown-item" href="{% url 'character-manage' %}">Manage Characters</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
{% for character in account.characters|slice:"10" %}
|
||||
<a class="dropdown-item" href="{{ character.web_get_puppet_url }}?next={{ request.path }}">{{ character }}</a>
|
||||
{% empty %}
|
||||
<a class="dropdown-item" href="#">No characters found!</a>
|
||||
{% endfor %}
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="{% url 'password_change' %}">Change Password</a>
|
||||
<a class="dropdown-item" href="{% url 'logout' %}">Log Out</a>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<a class="nav-link" href="{% url 'logout' %}">Log Out</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<a class="nav-link" href="{% url 'login' %}">Log In</a>
|
||||
<a class="nav-link" href="{% url 'login' %}?next={{ request.path }}">Log In</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="nav-link" href="{% url 'to_be_implemented' %}">Register</a>
|
||||
<a class="nav-link" href="{% url 'register' %}">Register</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
<link rel="icon" type="image/x-icon" href="/static/website/images/evennia_logo.png" />
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
|
||||
|
||||
<!-- Base CSS -->
|
||||
<link rel="stylesheet" type="text/css" href="{% static "website/css/app.css" %}">
|
||||
|
|
@ -29,6 +29,8 @@
|
|||
<title>{{game_name}} - {% if flatpage %}{{flatpage.title}}{% else %}{% block titleblock %}{{page_title}}{% endblock %}{% endif %}</title>
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}
|
||||
|
||||
<div id="top"><a href="#main-content" class="sr-only sr-only-focusable">Skip to main content.</a></div>
|
||||
{% include "website/_menu.html" %}
|
||||
<div class="container main-content mt-4" id="main-copy">
|
||||
|
|
@ -40,8 +42,12 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
<div class="{% if sidebar %}col-8{% else %}col{% endif %}">
|
||||
{% include 'website/messages.html' %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
|
||||
{% include 'website/pagination.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -53,10 +59,12 @@
|
|||
</div>
|
||||
{% endblock %}
|
||||
</footer>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
<!-- jQuery first, then Tether, then Bootstrap JS. -->
|
||||
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.11.0/umd/popper.min.js" integrity="sha384-b/U6ypiBEHpOf/4+1nzFpr53nxSS+GLCkfwBdFNTxtclqqenISfwAzpKaMNFNmj4" crossorigin="anonymous"></script>
|
||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/js/bootstrap.min.js" integrity="sha384-h0AbiXch4ZDo7tp9hKZ4TsHbi047NrKGLO3SEJAg45jXxnGIfYzk4Si90RDIqNm1" crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
|
||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
94
evennia/web/website/templates/website/channel_detail.html
Normal file
94
evennia/web/website/templates/website/channel_detail.html
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
{{ view.page_title }} ({{ object }})
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">{{ view.page_title }} ({{ object }})</h1>
|
||||
<hr />
|
||||
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'channels' %}">Channels</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ object.db_key|title }}</li>
|
||||
</ol>
|
||||
<hr />
|
||||
|
||||
<div class="row">
|
||||
|
||||
<!-- left column -->
|
||||
<div class="col-lg-9 col-sm-12">
|
||||
|
||||
<!-- heading -->
|
||||
<div class="card border-light">
|
||||
<div class="card-body">
|
||||
{% if object.db.desc and object.db.desc != None %}{{ object.db.desc }}{% else %}No description provided.{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<!-- end heading -->
|
||||
|
||||
{% if object_list %}
|
||||
<pre>{% for log in object_list %}
|
||||
<a id="{{ log.key }}"></a>{{ log.timestamp }}: {{ log.message }}{% endfor %}</pre>
|
||||
{% else %}
|
||||
<pre>No recent log entries have been recorded for this channel.</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- end left column -->
|
||||
|
||||
<!-- right column -->
|
||||
<div class="col-lg-3 col-sm-12">
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<!-- admin button -->
|
||||
<a class="btn btn-info btn-block mb-3" href="{{ object.web_get_admin_url }}">Admin</a>
|
||||
<!-- end admin button -->
|
||||
<hr />
|
||||
{% endif %}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<dl>
|
||||
{% for attribute, value in attribute_list.items %}
|
||||
<dt>{{ attribute }}</dt>
|
||||
<dd>{{ value }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
<dl>
|
||||
<dt>Subscriptions</dt>
|
||||
<dd>{{ object.subscriptions.all|length }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if object_filters %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Date Index</div>
|
||||
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for filter in object_filters %}
|
||||
<a href="#{{ filter }}" class="list-group-item">{{ filter }}</a>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<!-- end right column -->
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
86
evennia/web/website/templates/website/channel_list.html
Normal file
86
evennia/web/website/templates/website/channel_list.html
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
{{ view.page_title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">{{ view.page_title }}</h1>
|
||||
<hr />
|
||||
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'channels' %}">Channels</a></li>
|
||||
</ol>
|
||||
<hr />
|
||||
|
||||
<div class="row">
|
||||
|
||||
<!-- left column -->
|
||||
<div class="col-lg-9 col-sm-12">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th scope="col">Channel</th>
|
||||
<th scope="col">Description</th>
|
||||
<th scope="col">Subscriptions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for object in object_list %}
|
||||
<tr>
|
||||
<td><a href="{{ object.web_get_detail_url }}">{{ object.name }}</a></td>
|
||||
<td>{{ object.db.desc }}</td>
|
||||
<td>{{ object.subscriptions.all|length }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3">No channels were found!</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- end left column -->
|
||||
|
||||
<!-- right column -->
|
||||
<div class="col-lg-3 col-sm-12">
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<!-- admin button -->
|
||||
<a class="btn btn-info btn-block mb-3" href="/admin/comms/channeldb/">Admin</a>
|
||||
<!-- end admin button -->
|
||||
<hr />
|
||||
{% endif %}
|
||||
|
||||
{% if most_popular %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Most Popular</div>
|
||||
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for top in most_popular %}
|
||||
<a href="{{ top.web_get_detail_url }}" class="list-group-item">{{ top }} <span class="badge badge-light float-right">{{ top.subscriptions.all|length }}</span></a>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<!-- end right column -->
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
51
evennia/web/website/templates/website/character_form.html
Normal file
51
evennia/web/website/templates/website/character_form.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
{{ view.page_title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% load addclass %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">{{ view.page_title }}</h1>
|
||||
<hr />
|
||||
|
||||
{% if form.errors %}
|
||||
{% for field in form %}
|
||||
{% for error in field.errors %}
|
||||
<div class="alert alert-danger" role="alert">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="?">
|
||||
{% csrf_token %}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="form-field my-3">
|
||||
{{ field.label_tag }}
|
||||
{{ field | addclass:"form-control" }}
|
||||
{% if field.help_text %}
|
||||
<small class="form-text text-muted">{{ field.help_text|safe }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<hr />
|
||||
<div class="form-group">
|
||||
<input class="form-control btn btn-outline-secondary" type="submit" value="Submit" />
|
||||
<input type="hidden" name="next" value="{{ next }}" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
27
evennia/web/website/templates/website/character_list.html
Normal file
27
evennia/web/website/templates/website/character_list.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
{{ view.page_title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">{{ view.page_title }}</h1>
|
||||
<hr />
|
||||
|
||||
<ul>
|
||||
{% for object in object_list %}
|
||||
<li><a href="{{ object.web_get_detail_url }}">{{ object }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
{{ view.page_title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">{{ view.page_title }}</h1>
|
||||
<hr />
|
||||
|
||||
{% for object in object_list %}
|
||||
<div class="media mb-4">
|
||||
<a href="{{ object.web_get_detail_url }}"><img class="d-flex mr-3" src="http://placehold.jp/50x50.png" alt="" /></a>
|
||||
<div class="media-body">
|
||||
<p class="float-right ml-2">{{ object.db_date_created }}
|
||||
<br /><a href="{{ object.web_get_delete_url }}">Delete</a>
|
||||
<br /><a href="{{ object.web_get_update_url }}">Edit</a></p>
|
||||
<h5 class="mt-0"><a href="{{ object.web_get_detail_url }}">{{ object }}</a> {% if object.subtitle %}<small class="text-muted" style="white-space:nowrap;">{{ object.subtitle }}</small>{% endif %}</h5>
|
||||
<p>{{ object.db.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-block">
|
||||
<div class="card-block p-4">
|
||||
<h1 class="card-title">Admin</h1>
|
||||
<p class="card-text">
|
||||
Welcome to the Evennia Admin Page. Here, you can edit many facets of accounts, characters, and other parts of the game.
|
||||
|
|
|
|||
51
evennia/web/website/templates/website/generic_form.html
Normal file
51
evennia/web/website/templates/website/generic_form.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
Form
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="container main-content mt-4" id="main-copy">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 offset-lg-3 col-sm-12">
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Form</h1>
|
||||
<hr />
|
||||
|
||||
{% if form.errors %}
|
||||
{% for field in form %}
|
||||
{% for error in field.errors %}
|
||||
<div class="alert alert-danger" role="alert">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="?">
|
||||
{% csrf_token %}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="form-field my-3">
|
||||
{{ field.label_tag }}
|
||||
{{ field | addclass:"form-control" }}
|
||||
{% if field.help_text %}
|
||||
<small class="form-text text-muted">{{ field.help_text|safe }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<hr />
|
||||
<div class="form-group">
|
||||
<input class="form-control btn btn-outline-secondary" type="submit" value="Submit" />
|
||||
<input type="hidden" name="next" value="{{ next }}" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
86
evennia/web/website/templates/website/help_detail.html
Normal file
86
evennia/web/website/templates/website/help_detail.html
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
{{ view.page_title }} ({{ object|title }})
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
|
||||
<!-- main content -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">{{ view.page_title }} ({{ object|title }})</h1>
|
||||
<hr />
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'help' %}">Compendium</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'help' %}#{{ object.db_help_category }}">{{ object.db_help_category|title }}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ object.db_key|title }}</li>
|
||||
</ol>
|
||||
<hr />
|
||||
|
||||
<div class="row">
|
||||
<!-- left column -->
|
||||
<div class="col-lg-9 col-sm-12">
|
||||
<p>{{ entry_text }}</p>
|
||||
|
||||
{% if topic_previous or topic_next %}
|
||||
<hr />
|
||||
<!-- navigation -->
|
||||
<nav aria-label="Topic Navigation">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if topic_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ topic_previous.web_get_detail_url }}">Previous ({{ topic_previous|title }})</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if topic_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ topic_next.web_get_detail_url }}">Next ({{ topic_next|title }})</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
<!-- end navigation -->
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<!-- end left column -->
|
||||
|
||||
<!-- right column (sidebar) -->
|
||||
<div class="col-lg-3 col-sm-12">
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<!-- admin button -->
|
||||
<a class="btn btn-info btn-block mb-3" href="{{ object.web_get_admin_url }}">Admin</a>
|
||||
<!-- end admin button -->
|
||||
<hr />
|
||||
{% endif %}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">{{ object.db_help_category|title }}</div>
|
||||
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for topic in topic_list %}
|
||||
<a href="{{ topic.web_get_detail_url }}" class="list-group-item {% if topic == object %}active disabled{% endif %}">{{ topic|title }}</a>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- end right column -->
|
||||
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- end main content -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
109
evennia/web/website/templates/website/help_list.html
Normal file
109
evennia/web/website/templates/website/help_list.html
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
{{ view.page_title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">{{ view.page_title }}</h1>
|
||||
<hr />
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'help' %}">Compendium</a></li>
|
||||
</ol>
|
||||
<hr />
|
||||
<div class="row">
|
||||
{% regroup object_list by help_category as category_list %}
|
||||
|
||||
{% if category_list %}
|
||||
<!-- left column -->
|
||||
<div class="col-lg-9 col-sm-12">
|
||||
|
||||
<!-- intro -->
|
||||
<div class="card border-light">
|
||||
<div class="card-body">
|
||||
<p>This section of the site is a guide to understanding the mechanics behind {{ game_name }}.</p>
|
||||
<p>It is organized first by category, then by topic. The box to the right will let you skip to particular categories.</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<!-- end intro -->
|
||||
|
||||
<!-- index list -->
|
||||
<div class="mx-3">
|
||||
{% for help_category in category_list %}
|
||||
<h5><a id="{{ help_category.grouper }}"></a>{{ help_category.grouper|title }}</h5>
|
||||
<ul>
|
||||
{% for object in help_category.list %}
|
||||
<li><a href="{{ object.web_get_detail_url }}">{{ object|title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
<!-- end index list -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- end left column -->
|
||||
|
||||
<!-- right column (index) -->
|
||||
<div class="col-lg-3 col-sm-12">
|
||||
{% if user.is_staff %}
|
||||
<!-- admin button -->
|
||||
<a class="btn btn-info btn-block mb-3" href="/admin/help/helpentry/">Admin</a>
|
||||
<!-- end admin button -->
|
||||
<hr />
|
||||
{% endif %}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Category Index</div>
|
||||
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for category in category_list %}
|
||||
<a href="#{{ category.grouper }}" class="list-group-item">{{ category.grouper|title }}</a>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- end right column -->
|
||||
{% else %}
|
||||
{% if user.is_staff %}
|
||||
<div class="col-lg-12 col-sm-12">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h4 class="alert-heading">Hey, staff member {{ user.get_username }}!</h4>
|
||||
<hr />
|
||||
<p><strong>Your Help section is currently blank!</strong></p>
|
||||
<p>You're missing out on an opportunity to attract visitors (and potentially new players) to {{ game_name }}!</p>
|
||||
<p>Use Evennia's <a href="https://github.com/evennia/evennia/wiki/Help-System#database-help-entries" class="alert-link" target="_blank">Help System</a> to tell the world about the universe you've created, its lore and legends, its people and creatures, and their customs and conflicts!</p>
|
||||
<p>You don't even need coding skills-- writing Help Entries is no more complicated than writing an email or blog post. Once you publish your first entry, these ugly boxes go away and this page will turn into an index of everything you've written about {{ game_name }}.</p>
|
||||
<p>The documentation you write is eventually picked up by search engines, so the more you write about how {{ game_name }} works, the larger your web presence will be-- and the more traffic you'll attract.
|
||||
<p>Everything you write can be viewed either on this site or within the game itself, using the in-game help commands.</p>
|
||||
<hr>
|
||||
<p class="mb-0"><a href="/admin/help/helpentry/add/" class="alert-link">Click here</a> to start writing about {{ game_name }}!</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-lg-12 col-sm-12">
|
||||
<div class="alert alert-secondary" role="alert">
|
||||
<h4 class="alert-heading">Under Construction!</h4>
|
||||
<p>Thanks for your interest, but we're still working on developing and documenting the {{ game_name }} universe!</p>
|
||||
<p>Check back later for more information as we publish it.</p>
|
||||
<hr>
|
||||
<p class="mb-0"><a href="{% url 'index' %}" class="alert-link">Click here</a> to go back to the main page.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
<div class="row">
|
||||
<div class="col-12 col-md-4 mb-3">
|
||||
<div class="card">
|
||||
<h4 class="card-header">Accounts</h4>
|
||||
<h4 class="card-header text-center">Accounts</h4>
|
||||
|
||||
<div class="card-body">
|
||||
<p>
|
||||
|
|
|
|||
9
evennia/web/website/templates/website/messages.html
Normal file
9
evennia/web/website/templates/website/messages.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{% if messages %}
|
||||
<!-- messages -->
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<!-- end messages -->
|
||||
{% endif %}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
{{ view.page_title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="container main-content mt-4" id="main-copy">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 offset-lg-3 col-sm-12">
|
||||
<div class="card mt-3 border border-danger">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">{{ view.page_title }}</h1>
|
||||
<hr />
|
||||
<form method="post" action="?">
|
||||
{% csrf_token %}
|
||||
<p>Are you sure you want to delete "{{ object }}"?</p>
|
||||
|
||||
<hr />
|
||||
<div class="form-group">
|
||||
<input class="form-control btn btn-outline-danger" type="submit" value="yes" />
|
||||
<input type="hidden" name="next" value="{{ next }}" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
42
evennia/web/website/templates/website/object_detail.html
Normal file
42
evennia/web/website/templates/website/object_detail.html
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
{{ view.page_title }} ({{ object }})
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">{{ view.page_title }}</h1>
|
||||
<hr />
|
||||
|
||||
<div class="row">
|
||||
|
||||
<!-- left/avatar column -->
|
||||
<div class="col-lg-3 col-sm-12">
|
||||
<img class="d-flex mr-3" src="http://placehold.jp/250x250.png" alt="Image of {{ object }}">
|
||||
</div>
|
||||
<!-- end left/avatar column -->
|
||||
|
||||
<!-- right/content column -->
|
||||
<div class="col-lg-9 col-sm-12">
|
||||
<dl>
|
||||
{% for attribute, value in attribute_list.items %}
|
||||
<dt>{{ attribute }}</dt>
|
||||
<dd>{{ value }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
<!-- end right/content column -->
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
27
evennia/web/website/templates/website/object_list.html
Normal file
27
evennia/web/website/templates/website/object_list.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
{{ view.page_title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">{{ view.page_title }}</h1>
|
||||
<hr />
|
||||
|
||||
<ul>
|
||||
{% for object in object_list %}
|
||||
<li><a href="{{ object.web_get_detail_url }}">{{ object }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
32
evennia/web/website/templates/website/pagination.html
Normal file
32
evennia/web/website/templates/website/pagination.html
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{% if page_obj %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<!-- Pagination -->
|
||||
<ul class="pagination justify-content-center mb-0">
|
||||
<li class="page-item {% if page_obj.has_previous %}{% else %}disabled{% endif %}">
|
||||
<a class="page-link" href="{% if page_obj.has_previous %}?{% if q %}q={{ q }}&{% endif %}page={{ page_obj.previous_page_number }}{% endif %}" aria-label="Previous">
|
||||
<span aria-hidden="true">«</span>
|
||||
<span class="sr-only">Previous</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% for l in page_obj.paginator.page_range %}
|
||||
{% if l <= page_obj.number|add:5 and l >= page_obj.number|add:-5 %}
|
||||
<li class="page-item {% if forloop.counter == page_obj.number %}active{% endif %}"><a class="page-link" href="?{% if q %}q={{ q }}&{% endif %}page={{ forloop.counter }}">{{ forloop.counter }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<li class="page-item {% if page_obj.has_next %}{% else %}disabled{% endif %}">
|
||||
<a class="page-link" href="{% if page_obj.has_next %}?{% if q %}q={{ q }}&{% endif %}page={{ page_obj.next_page_number }}{% endif %}" aria-label="Next">
|
||||
<span aria-hidden="true">»</span>
|
||||
<span class="sr-only">Next</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -4,44 +4,56 @@
|
|||
Login
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% block body %}
|
||||
|
||||
{% load addclass %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Login</h1>
|
||||
<hr />
|
||||
{% if user.is_authenticated %}
|
||||
<p>You are already logged in!</p>
|
||||
{% else %}
|
||||
{% if form.has_errors %}
|
||||
<p>Your username and password didn't match. Please try again.</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action=".">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_username">Username:</label>
|
||||
{{ form.username | addclass:"form-control" }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_password">Password:</label>
|
||||
{{ form.password | addclass:"form-control" }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<input class="form-control" type="submit" value="Login" />
|
||||
<input type="hidden" name="next" value="{{ next }}" />
|
||||
</div>
|
||||
</form>
|
||||
<div class="container main-content mt-4" id="main-copy">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 offset-lg-3 col-sm-12">
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Login</h1>
|
||||
<hr />
|
||||
{% include 'website/messages.html' %}
|
||||
{% if user.is_authenticated %}
|
||||
<div class="alert alert-info" role="alert">You are already logged in!</div>
|
||||
{% else %}
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger" role="alert">Your username and password are incorrect. Please try again.</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if not user.is_authenticated %}
|
||||
<form method="post" action=".">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_username">Username:</label>
|
||||
{{ form.username | addclass:"form-control" }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_password">Password:</label>
|
||||
{{ form.password | addclass:"form-control" }}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col-lg-6 col-sm-12 text-center small"><a href="{% url 'password_reset' %}">Forgot Password?</a></div>
|
||||
<div class="col-lg-6 col-sm-12 text-center small"><a href="{% url 'register' %}">Create Account</a></div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<div class="form-group">
|
||||
<input class="form-control btn btn-outline-secondary" type="submit" value="Login" />
|
||||
<input type="hidden" name="next" value="{{ next }}" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
Password Changed
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="container main-content mt-4" id="main-copy">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 offset-lg-3 col-sm-12">
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Password Changed</h1>
|
||||
<hr />
|
||||
|
||||
<p>Your password was changed.</p>
|
||||
|
||||
<p>Click <a href="{% url 'index' %}">here</a> to return to the index.</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
Password Change
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="row">
|
||||
<div class="col-lg-6 offset-lg-3 col-sm-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Password Change</h1>
|
||||
<hr />
|
||||
|
||||
{% if form.errors %}
|
||||
{% for field in form %}
|
||||
{% for error in field.errors %}
|
||||
<div class="alert alert-danger" role="alert">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="?">
|
||||
{% csrf_token %}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="form-field my-3">
|
||||
{{ field.label_tag }}
|
||||
{{ field | addclass:"form-control" }}
|
||||
{% if field.help_text %}
|
||||
<small class="form-text text-muted">{{ field.help_text|safe }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<hr />
|
||||
<div class="form-group">
|
||||
<input class="form-control btn btn-outline-secondary" type="submit" value="Submit" />
|
||||
<input type="hidden" name="next" value="{{ next }}" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
Forgot Password - Reset Successful
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="container main-content mt-4" id="main-copy">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 offset-lg-3 col-sm-12">
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Password Reset</h1>
|
||||
<hr />
|
||||
{% if user.is_authenticated %}
|
||||
<div class="alert alert-info" role="alert">You are already logged in!</div>
|
||||
{% else %}
|
||||
|
||||
<p>Your password has been successfully reset!</p>
|
||||
|
||||
<p>You may now log in using it <a href="{% url 'login' %}">here.</a></p>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
Forgot Password - Reset
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="container main-content mt-4" id="main-copy">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 offset-lg-3 col-sm-12">
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Reset Password</h1>
|
||||
<hr />
|
||||
{% if not validlink %}
|
||||
<div class="alert alert-danger" role="alert">The password reset link has expired. Please request another to proceed.</div>
|
||||
{% else %}
|
||||
|
||||
{% if form.errors %}
|
||||
{% for field in form %}
|
||||
{% for error in field.errors %}
|
||||
<div class="alert alert-danger" role="alert">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action=".">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_username">Enter new password:</label>
|
||||
{{ form.new_password1 | addclass:"form-control" }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_username">Confirm password:</label>
|
||||
{{ form.new_password2 | addclass:"form-control" }}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<div class="form-group">
|
||||
<input class="form-control btn btn-outline-secondary" type="submit" value="Login" />
|
||||
<input type="hidden" name="next" value="{{ next }}" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
Forgot Password - Reset Link Sent
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="container main-content mt-4" id="main-copy">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 offset-lg-3 col-sm-12">
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Reset Sent</h1>
|
||||
<hr />
|
||||
{% if user.is_authenticated %}
|
||||
<div class="alert alert-info" role="alert">You are already logged in!</div>
|
||||
{% else %}
|
||||
|
||||
<p>Instructions for resetting your password will be emailed to the
|
||||
address you provided, if that address matches the one we have on file
|
||||
for your account. You should receive them shortly.</p>
|
||||
|
||||
<p>Please allow up to to a few hours for the email to transmit, and be
|
||||
sure to check your spam folder if it doesn't show up in a timely manner.</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p><a href="{% url 'index' %}">Click here</a> to return to the main page.</p>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{% autoescape off %}
|
||||
To initiate the password reset process for your {{ user.get_username }} {{ site_name }} account,
|
||||
click the link below:
|
||||
|
||||
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
|
||||
|
||||
If clicking the link above doesn't work, please copy and paste the URL in a new browser
|
||||
window instead.
|
||||
|
||||
If you did not request a password reset, please disregard this notice. Whoever requested it
|
||||
cannot follow through on resetting your password without access to this message.
|
||||
|
||||
Sincerely,
|
||||
{{ site_name }} Management.
|
||||
{% endautoescape %}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
Forgot Password
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="container main-content mt-4" id="main-copy">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 offset-lg-3 col-sm-12">
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Forgot Password</h1>
|
||||
<hr />
|
||||
{% if user.is_authenticated %}
|
||||
<div class="alert alert-info" role="alert">You are already logged in!</div>
|
||||
{% else %}
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger" role="alert">The email address provided is incorrect.</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if not user.is_authenticated %}
|
||||
<form method="post" action=".">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_username">Email address:</label>
|
||||
{{ form.email | addclass:"form-control" }}
|
||||
<small id="emailHelp" class="form-text text-muted">The email address you provided at registration. If you left it blank, your password cannot be reset through this form.</small>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<div class="form-group">
|
||||
<input class="form-control btn btn-outline-secondary" type="submit" value="Submit" />
|
||||
<input type="hidden" name="next" value="{{ next }}" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block titleblock %}
|
||||
Register
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
{% load addclass %}
|
||||
<div class="container main-content mt-4" id="main-copy">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 offset-lg-3 col-sm-12">
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Register</h1>
|
||||
<hr />
|
||||
{% if user.is_authenticated %}
|
||||
<div class="alert alert-info" role="alert">You are already registered!</div>
|
||||
{% else %}
|
||||
{% if form.errors %}
|
||||
{% for field in form %}
|
||||
{% for error in field.errors %}
|
||||
<div class="alert alert-danger" role="alert">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if not user.is_authenticated %}
|
||||
<form method="post" action="?">
|
||||
{% csrf_token %}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="form-field my-3">
|
||||
{{ field.label_tag }}
|
||||
{{ field | addclass:"form-control" }}
|
||||
{% if field.help_text %}
|
||||
<small class="form-text text-muted">{{ field.help_text|safe }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<hr />
|
||||
<div class="form-group">
|
||||
<input class="form-control btn btn-outline-secondary" type="submit" value="Register" />
|
||||
<input type="hidden" name="next" value="{{ next }}" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
281
evennia/web/website/tests.py
Normal file
281
evennia/web/website/tests.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
from django.conf import settings
|
||||
from django.utils.text import slugify
|
||||
from django.test import Client, override_settings
|
||||
from django.urls import reverse
|
||||
from evennia.utils import class_from_module
|
||||
from evennia.utils.test_resources import EvenniaTest
|
||||
|
||||
class EvenniaWebTest(EvenniaTest):
|
||||
|
||||
# Use the same classes the views are expecting
|
||||
account_typeclass = settings.BASE_ACCOUNT_TYPECLASS
|
||||
object_typeclass = settings.BASE_OBJECT_TYPECLASS
|
||||
character_typeclass = settings.BASE_CHARACTER_TYPECLASS
|
||||
exit_typeclass = settings.BASE_EXIT_TYPECLASS
|
||||
room_typeclass = settings.BASE_ROOM_TYPECLASS
|
||||
script_typeclass = settings.BASE_SCRIPT_TYPECLASS
|
||||
channel_typeclass = settings.BASE_CHANNEL_TYPECLASS
|
||||
|
||||
# Default named url
|
||||
url_name = 'index'
|
||||
|
||||
# Response to expect for unauthenticated requests
|
||||
unauthenticated_response = 200
|
||||
|
||||
# Response to expect for authenticated requests
|
||||
authenticated_response = 200
|
||||
|
||||
def setUp(self):
|
||||
super(EvenniaWebTest, self).setUp()
|
||||
|
||||
# Add chars to account rosters
|
||||
self.account.db._playable_characters = [self.char1]
|
||||
self.account2.db._playable_characters = [self.char2]
|
||||
|
||||
for account in (self.account, self.account2):
|
||||
# Demote accounts to Player permissions
|
||||
account.permissions.add('Player')
|
||||
account.permissions.remove('Developer')
|
||||
|
||||
# Grant permissions to chars
|
||||
for char in account.db._playable_characters:
|
||||
char.locks.add('edit:id(%s) or perm(Admin)' % account.pk)
|
||||
char.locks.add('delete:id(%s) or perm(Admin)' % account.pk)
|
||||
char.locks.add('view:all()')
|
||||
|
||||
def test_valid_chars(self):
|
||||
"Make sure account has playable characters"
|
||||
self.assertTrue(self.char1 in self.account.db._playable_characters)
|
||||
self.assertTrue(self.char2 in self.account2.db._playable_characters)
|
||||
|
||||
def get_kwargs(self):
|
||||
return {}
|
||||
|
||||
def test_get(self):
|
||||
# Try accessing page while not logged in
|
||||
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()))
|
||||
self.assertEqual(response.status_code, self.unauthenticated_response)
|
||||
|
||||
def login(self):
|
||||
return self.client.login(username='TestAccount', password='testpassword')
|
||||
|
||||
def test_get_authenticated(self):
|
||||
logged_in = self.login()
|
||||
self.assertTrue(logged_in, 'Account failed to log in!')
|
||||
|
||||
# Try accessing page while logged in
|
||||
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True)
|
||||
|
||||
self.assertEqual(response.status_code, self.authenticated_response)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
class AdminTest(EvenniaWebTest):
|
||||
url_name = 'django_admin'
|
||||
unauthenticated_response = 302
|
||||
|
||||
class IndexTest(EvenniaWebTest):
|
||||
url_name = 'index'
|
||||
|
||||
class RegisterTest(EvenniaWebTest):
|
||||
url_name = 'register'
|
||||
|
||||
class LoginTest(EvenniaWebTest):
|
||||
url_name = 'login'
|
||||
|
||||
class LogoutTest(EvenniaWebTest):
|
||||
url_name = 'logout'
|
||||
|
||||
class PasswordResetTest(EvenniaWebTest):
|
||||
url_name = 'password_change'
|
||||
unauthenticated_response = 302
|
||||
|
||||
class WebclientTest(EvenniaWebTest):
|
||||
url_name = 'webclient:index'
|
||||
|
||||
@override_settings(WEBCLIENT_ENABLED=True)
|
||||
def test_get(self):
|
||||
self.authenticated_response = 200
|
||||
self.unauthenticated_response = 200
|
||||
super(WebclientTest, self).test_get()
|
||||
|
||||
@override_settings(WEBCLIENT_ENABLED=False)
|
||||
def test_get_disabled(self):
|
||||
self.authenticated_response = 404
|
||||
self.unauthenticated_response = 404
|
||||
super(WebclientTest, self).test_get()
|
||||
|
||||
class ChannelListTest(EvenniaWebTest):
|
||||
url_name = 'channels'
|
||||
|
||||
class ChannelDetailTest(EvenniaWebTest):
|
||||
url_name = 'channel-detail'
|
||||
|
||||
def setUp(self):
|
||||
super(ChannelDetailTest, self).setUp()
|
||||
|
||||
klass = class_from_module(self.channel_typeclass)
|
||||
|
||||
# Create a channel
|
||||
klass.create('demo')
|
||||
|
||||
def get_kwargs(self):
|
||||
return {
|
||||
'slug': slugify('demo')
|
||||
}
|
||||
|
||||
class CharacterCreateView(EvenniaWebTest):
|
||||
url_name = 'character-create'
|
||||
unauthenticated_response = 302
|
||||
|
||||
@override_settings(MULTISESSION_MODE=0)
|
||||
def test_valid_access_multisession_0(self):
|
||||
"Account1 with no characters should be able to create a new one"
|
||||
self.account.db._playable_characters = []
|
||||
|
||||
# Login account
|
||||
self.login()
|
||||
|
||||
# Post data for a new character
|
||||
data = {
|
||||
'db_key': 'gannon',
|
||||
'desc': 'Some dude.'
|
||||
}
|
||||
|
||||
response = self.client.post(reverse(self.url_name), data=data, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Make sure the character was actually created
|
||||
self.assertTrue(len(self.account.db._playable_characters) == 1, 'Account only has the following characters attributed to it: %s' % self.account.db._playable_characters)
|
||||
|
||||
@override_settings(MULTISESSION_MODE=2)
|
||||
@override_settings(MAX_NR_CHARACTERS=10)
|
||||
def test_valid_access_multisession_2(self):
|
||||
"Account1 should be able to create a new character"
|
||||
# Login account
|
||||
self.login()
|
||||
|
||||
# Post data for a new character
|
||||
data = {
|
||||
'db_key': 'gannon',
|
||||
'desc': 'Some dude.'
|
||||
}
|
||||
|
||||
response = self.client.post(reverse(self.url_name), data=data, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Make sure the character was actually created
|
||||
self.assertTrue(len(self.account.db._playable_characters) > 1, 'Account only has the following characters attributed to it: %s' % self.account.db._playable_characters)
|
||||
|
||||
class CharacterPuppetView(EvenniaWebTest):
|
||||
url_name = 'character-puppet'
|
||||
unauthenticated_response = 302
|
||||
|
||||
def get_kwargs(self):
|
||||
return {
|
||||
'pk': self.char1.pk,
|
||||
'slug': slugify(self.char1.name)
|
||||
}
|
||||
|
||||
def test_invalid_access(self):
|
||||
"Account1 should not be able to puppet Account2:Char2"
|
||||
# Login account
|
||||
self.login()
|
||||
|
||||
# Try to access puppet page for char2
|
||||
kwargs = {
|
||||
'pk': self.char2.pk,
|
||||
'slug': slugify(self.char2.name)
|
||||
}
|
||||
response = self.client.get(reverse(self.url_name, kwargs=kwargs), follow=True)
|
||||
self.assertTrue(response.status_code >= 400, "Invalid access should return a 4xx code-- either obj not found or permission denied! (Returned %s)" % response.status_code)
|
||||
|
||||
class CharacterListView(EvenniaWebTest):
|
||||
url_name = 'characters'
|
||||
unauthenticated_response = 302
|
||||
|
||||
class CharacterManageView(EvenniaWebTest):
|
||||
url_name = 'character-manage'
|
||||
unauthenticated_response = 302
|
||||
|
||||
class CharacterUpdateView(EvenniaWebTest):
|
||||
url_name = 'character-update'
|
||||
unauthenticated_response = 302
|
||||
|
||||
def get_kwargs(self):
|
||||
return {
|
||||
'pk': self.char1.pk,
|
||||
'slug': slugify(self.char1.name)
|
||||
}
|
||||
|
||||
def test_valid_access(self):
|
||||
"Account1 should be able to update Account1:Char1"
|
||||
# Login account
|
||||
self.login()
|
||||
|
||||
# Try to access update page for char1
|
||||
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Try to update char1 desc
|
||||
data = {'db_key': self.char1.db_key, 'desc': "Just a regular type of dude."}
|
||||
response = self.client.post(reverse(self.url_name, kwargs=self.get_kwargs()), data=data, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Make sure the change was made successfully
|
||||
self.assertEqual(self.char1.db.desc, data['desc'])
|
||||
|
||||
def test_invalid_access(self):
|
||||
"Account1 should not be able to update Account2:Char2"
|
||||
# Login account
|
||||
self.login()
|
||||
|
||||
# Try to access update page for char2
|
||||
kwargs = {
|
||||
'pk': self.char2.pk,
|
||||
'slug': slugify(self.char2.name)
|
||||
}
|
||||
response = self.client.get(reverse(self.url_name, kwargs=kwargs), follow=True)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
class CharacterDeleteView(EvenniaWebTest):
|
||||
url_name = 'character-delete'
|
||||
unauthenticated_response = 302
|
||||
|
||||
def get_kwargs(self):
|
||||
return {
|
||||
'pk': self.char1.pk,
|
||||
'slug': slugify(self.char1.name)
|
||||
}
|
||||
|
||||
def test_valid_access(self):
|
||||
"Account1 should be able to delete Account1:Char1"
|
||||
# Login account
|
||||
self.login()
|
||||
|
||||
# Try to access delete page for char1
|
||||
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Proceed with deleting it
|
||||
data = {'value': 'yes'}
|
||||
response = self.client.post(reverse(self.url_name, kwargs=self.get_kwargs()), data=data, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Make sure it deleted
|
||||
self.assertFalse(self.char1 in self.account.db._playable_characters, 'Char1 is still in Account playable characters list.')
|
||||
|
||||
def test_invalid_access(self):
|
||||
"Account1 should not be able to delete Account2:Char2"
|
||||
# Login account
|
||||
self.login()
|
||||
|
||||
# Try to access delete page for char2
|
||||
kwargs = {
|
||||
'pk': self.char2.pk,
|
||||
'slug': slugify(self.char2.name)
|
||||
}
|
||||
response = self.client.get(reverse(self.url_name, kwargs=kwargs), follow=True)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
|
@ -9,12 +9,30 @@ from django import views as django_views
|
|||
from evennia.web.website import views as website_views
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^$', website_views.page_index, name="index"),
|
||||
url(r'^$', website_views.EvenniaIndexView.as_view(), name="index"),
|
||||
url(r'^tbi/', website_views.to_be_implemented, name='to_be_implemented'),
|
||||
|
||||
# User Authentication (makes login/logout url names available)
|
||||
url(r'^authenticate/', include('django.contrib.auth.urls')),
|
||||
|
||||
url(r'^auth/register', website_views.AccountCreateView.as_view(), name="register"),
|
||||
url(r'^auth/', include('django.contrib.auth.urls')),
|
||||
|
||||
# Help Topics
|
||||
url(r'^help/$', website_views.HelpListView.as_view(), name="help"),
|
||||
url(r'^help/(?P<category>[\w\d\-]+)/(?P<topic>[\w\d\-]+)/$', website_views.HelpDetailView.as_view(), name="help-entry-detail"),
|
||||
|
||||
# Channels
|
||||
url(r'^channels/$', website_views.ChannelListView.as_view(), name="channels"),
|
||||
url(r'^channels/(?P<slug>[\w\d\-]+)/$', website_views.ChannelDetailView.as_view(), name="channel-detail"),
|
||||
|
||||
# Character management
|
||||
url(r'^characters/$', website_views.CharacterListView.as_view(), name="characters"),
|
||||
url(r'^characters/create/$', website_views.CharacterCreateView.as_view(), name="character-create"),
|
||||
url(r'^characters/manage/$', website_views.CharacterManageView.as_view(), name="character-manage"),
|
||||
url(r'^characters/detail/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', website_views.CharacterDetailView.as_view(), name="character-detail"),
|
||||
url(r'^characters/puppet/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', website_views.CharacterPuppetView.as_view(), name="character-puppet"),
|
||||
url(r'^characters/update/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', website_views.CharacterUpdateView.as_view(), name="character-update"),
|
||||
url(r'^characters/delete/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', website_views.CharacterDeleteView.as_view(), name="character-delete"),
|
||||
|
||||
# Django original admin page. Make this URL is always available, whether
|
||||
# we've chosen to use Evennia's custom admin or not.
|
||||
url(r'django_admin/', website_views.admin_wrapper, name="django_admin"),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,7 @@ pypiwin32
|
|||
# general
|
||||
django > 1.11, < 2.0
|
||||
twisted >= 18.0.0, < 19.0.0
|
||||
pillow == 2.9.0
|
||||
pillow == 5.2.0
|
||||
pytz
|
||||
future >= 0.15.2
|
||||
django-sekizai
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue