From 70c5e9608ebad38905fe3c4a2fef09fee673c207 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 1 Oct 2018 18:29:21 +0200 Subject: [PATCH 01/79] Run collectstatic. Fix input autofocus in webclient --- evennia/web/webclient/static/webclient/js/plugins/default_in.js | 1 + 1 file changed, 1 insertion(+) diff --git a/evennia/web/webclient/static/webclient/js/plugins/default_in.js b/evennia/web/webclient/static/webclient/js/plugins/default_in.js index 28bfc9f315..02fd401706 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/default_in.js +++ b/evennia/web/webclient/static/webclient/js/plugins/default_in.js @@ -8,6 +8,7 @@ let defaultin_plugin = (function () { // // handle the default key triggering onSend() var onKeydown = function (event) { + $("#inputfield").focus(); if ( (event.which === 13) && (!event.shiftKey) ) { // Enter Key without shift var inputfield = $("#inputfield"); var outtext = inputfield.val(); From f407a90f45ff33638659e74472d311e4ac208482 Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 1 Oct 2018 20:12:24 +0000 Subject: [PATCH 02/79] Extends normalize_username() function to strip excessive spaces. --- evennia/accounts/accounts.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 2c33e5c1f8..07fb8f318c 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -10,7 +10,7 @@ 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 @@ -359,6 +359,27 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): puppet = property(__get_single_puppet) # utility methods + @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_password(cls, password, account=None): """ From e990176a02f01f4bb6a9fa5d15f98fbbf711eada Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 1 Oct 2018 21:24:33 +0000 Subject: [PATCH 03/79] Adds username normalization/validation and authentication methods to Account class. --- evennia/accounts/accounts.py | 110 ++++++++++++++++++++++++++++++++++- evennia/accounts/tests.py | 29 +++++++++ evennia/server/validators.py | 35 +++++++++++ evennia/settings_default.py | 22 +++++++ 4 files changed, 194 insertions(+), 2 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 07fb8f318c..8da9a7ea9b 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -13,9 +13,10 @@ 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 @@ -359,6 +360,74 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): puppet = property(__get_single_puppet) # utility methods + @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=None): + """ + 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 + + 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) + + # 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.') + + # System log message + logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip)) + + 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): """ @@ -379,6 +448,43 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): 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.append(x) for x in 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): diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 2855dd0ca2..f1a9f16c28 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + from mock import Mock from random import randint from unittest import TestCase @@ -59,6 +61,33 @@ class TestDefaultAccount(TestCase): self.s1 = Session() self.s1.puppet = None self.s1.sessid = 0 + + self.password = "testpassword" + 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_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" diff --git a/evennia/server/validators.py b/evennia/server/validators.py index b10f990a8a..bccbde6b51 100644 --- a/evennia/server/validators.py +++ b/evennia/server/validators.py @@ -1,7 +1,42 @@ +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."): diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 9efbb6314b..04c928ac99 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -809,6 +809,28 @@ AUTH_PASSWORD_VALIDATORS = [ {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, {'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' From 2fe3f40a5c9fb1ee066c8fd80804b0f8e806a635 Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 1 Oct 2018 23:58:12 +0000 Subject: [PATCH 04/79] Moves LOGIN and CREATION throttles from Command module to Account module. --- evennia/accounts/accounts.py | 20 ++++++++++++++++++-- evennia/commands/default/unloggedin.py | 10 ++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 8da9a7ea9b..1ce0f4d9f9 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -23,6 +23,7 @@ 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.server.throttle import Throttle from evennia.utils import logger from evennia.utils.utils import (lazy_property, to_str, make_iter, to_unicode, is_iter, @@ -44,6 +45,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): """ @@ -413,15 +417,27 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): 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 fill up your disk. + 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.') - # System log message + # 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) + return None, errors # Account successfully authenticated @@ -543,7 +559,7 @@ 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() def delete(self, *args, **kwargs): diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 8ae700e2c1..d77d7ee2b9 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -7,12 +7,13 @@ import datetime from random import getrandbits from django.conf import settings from django.contrib.auth import authenticate +from evennia.accounts.accounts import CREATION_THROTTLE, LOGIN_THROTTLE 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.models import ServerConfig from evennia.server.sessionhandler import SESSIONS +from evennia.server.throttle import Throttle from evennia.utils import create, logger, utils, gametime from evennia.commands.cmdhandler import CMD_LOGINSTART @@ -26,11 +27,8 @@ __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 +# Create throttles for too many connections 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): """ From 16648d47d1d80e7950dc3f14879d765d1fbc5613 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 2 Oct 2018 00:05:07 +0000 Subject: [PATCH 05/79] Adds logging of throttle activation and customizable message upon update. --- evennia/accounts/accounts.py | 2 +- evennia/server/throttle.py | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 1ce0f4d9f9..a6a29fab72 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -436,7 +436,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip)) # Update throttle - if ip: LOGIN_THROTTLE.update(ip) + if ip: LOGIN_THROTTLE.update(ip, 'Too many authentication failures') return None, errors diff --git a/evennia/server/throttle.py b/evennia/server/throttle.py index 56c88c63f2..944b7f880e 100644 --- a/evennia/server/throttle.py +++ b/evennia/server/throttle.py @@ -1,4 +1,5 @@ from collections import defaultdict, deque +from evennia.utils import logger import time class Throttle(object): @@ -50,22 +51,34 @@ class Throttle(object): 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): """ From 21d66ab625ac4e91cbf3d3306dd7b585ebef2f38 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 2 Oct 2018 20:23:23 +0000 Subject: [PATCH 06/79] Moves account creation logic from Commands module to Account class. --- evennia/accounts/accounts.py | 269 ++++++++++++++++++++++++- evennia/accounts/tests.py | 37 +++- evennia/commands/default/unloggedin.py | 222 +++++--------------- evennia/server/validators.py | 2 +- 4 files changed, 354 insertions(+), 176 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index a6a29fab72..6bd812b6fa 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -23,8 +23,9 @@ 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.server.models import ServerConfig from evennia.server.throttle import Throttle -from evennia.utils import logger +from evennia.utils import create, logger from evennia.utils.utils import (lazy_property, to_str, make_iter, to_unicode, is_iter, variable_from_module) @@ -34,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",) @@ -364,6 +366,31 @@ 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', [])): """ @@ -386,9 +413,85 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): raise ImproperlyConfigured(msg % validator['NAME']) objs.append(klass(**validator.get('OPTIONS', {}))) return objs + + @classmethod + def authenticate_guest(cls, **kwargs): + """ + Gets or creates a Guest account object. + + Kwargs: + ip (str, optional): IP address of requestor; used for ban checking, + throttling and logging + + """ + 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 + + # 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 if IP banned + if ip and cls.is_banned(ip=ip): + 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).' % ('guest', ip)) + LOGIN_THROTTLE.update(ip, 'Too many sightings of banned IP.') + 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 = ObjectDB.objects.get_id(settings.GUEST_HOME) + permissions = settings.PERMISSION_GUEST_DEFAULT + character_typeclass = settings.BASE_CHARACTER_TYPECLASS + account_typeclass = settings.BASE_GUEST_TYPECLASS + account, errs = cls.create( + guest=True, + username=username, + password=password, + permissions=permissions, + account_typeclass=account_typeclass, + character_typeclass=character_typeclass, + ip=ip, + ) + errors.extend(errs) + return account, errors + + 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() + return None, errors + + return account, errors @classmethod - def authenticate(cls, username, password, ip=None): + def authenticate(cls, username, password, ip='', **kwargs): """ Checks the given username/password against the database to see if the credentials are valid. @@ -408,6 +511,9 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): 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. @@ -423,7 +529,17 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): # 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 fill up your disk. + # 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 @@ -436,7 +552,14 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip)) # Update throttle - if ip: LOGIN_THROTTLE.update(ip, 'Too many authentication failures') + 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 @@ -493,7 +616,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): valid.append(not validator(username)) except ValidationError as e: valid.append(False) - [errors.append(x) for x in e.messages] + errors.extend(e.messages) # Disqualify if any check failed if False in valid: @@ -561,6 +684,142 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): super(DefaultAccount, self).set_password(password) 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 MM<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 + 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) + account_typeclass = kwargs.get('account_typeclass', settings.BASE_ACCOUNT_TYPECLASS) + character_typeclass = kwargs.get('character_typeclass', settings.BASE_CHARACTER_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=account_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: + if settings.MULTISESSION_MODE < 2: + default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME) + + try: + character = create.create_object(character_typeclass, key=account.key, home=default_home, permissions=permissions) + + # set playable character list + account.db._playable_characters.append(character) + + # allow only the character itself and the account to puppet this character (and Developers). + character.locks.add("puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer)" % + (character.id, account.id)) + + # If no description is set, set a default description + if not character.db.desc: + character.db.desc = "This is a character." + # We need to set this to have @ic auto-connect to this character + account.db._last_puppet = character + + # Record creator id and creation IP + if ip: character.db.creator_ip = ip + character.db.creator_id = account.id + + except Exception as e: + errors.append("There was an error creating a Character. If this problem persists, contact an admin.") + logger.log_trace() + + 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): """ diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index f1a9f16c28..72f13bbb3a 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -75,6 +75,41 @@ class TestDefaultAccount(TestCase): 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 + account, errors = DefaultAccount.create(username='Ziggy', password='starman11') + self.assertFalse(account, 'Duplicate account name should not have been allowed.') + + # Guest account should not be permitted + account, errors = DefaultAccount.authenticate_guest() + 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 = DefaultAccount.authenticate_guest() + self.assertTrue(account, 'Guest account should have been created.') + + # Create a second guest account + account, errors = DefaultAccount.authenticate_guest() + self.assertFalse(account, 'Two guest accounts were created despite a single entry on the guest list!') + + settings.GUEST_ENABLED = False + + 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 @@ -92,7 +127,7 @@ class TestDefaultAccount(TestCase): def test_password_validation(self): "Check password validators deny bad passwords" - self.account = create.create_account("TestAccount%s" % randint(0, 9), + self.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(self.account.validate_password(bad, account=self.account)[0]) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index d77d7ee2b9..7cf8b40c88 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -4,10 +4,9 @@ Commands that are available from the connect screen. import re import time import datetime -from random import getrandbits + from django.conf import settings from django.contrib.auth import authenticate -from evennia.accounts.accounts import CREATION_THROTTLE, LOGIN_THROTTLE from evennia.accounts.models import AccountDB from evennia.objects.models import ObjectDB from evennia.comms.models import ChannelDB @@ -15,7 +14,7 @@ from evennia.server.models import ServerConfig from evennia.server.sessionhandler import SESSIONS from evennia.server.throttle import Throttle -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) @@ -27,9 +26,6 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate", MULTISESSION_MODE = settings.MULTISESSION_MODE CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE -# Create throttles for too many connections -CONNECTION_THROTTLE = Throttle(limit=5, timeout=1 * 60) - def create_guest_account(session): """ Creates a guest account/character for this session, if one is available. @@ -42,50 +38,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 - - # 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 - - 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 - + enabled = settings.GUEST_ENABLED + address = session.address + + # Get account class + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + + # Get an available guest account + # authenticate_guest() handles its own throttling + account, errors = Account.authenticate_guest(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): """ @@ -99,38 +65,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. + # Get account class + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + address = session.address - if isinstance(address, tuple): - address = address[0] - - if LOGIN_THROTTLE.check(address): - session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n") - return None - + # 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 @@ -162,15 +107,10 @@ 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 + + # Get account class + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) args = self.args # extract double quote parts @@ -178,23 +118,27 @@ 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: + account, errors = Account.authenticate_guest(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 ") return - CONNECTION_THROTTLE.update(address) 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): @@ -220,14 +164,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()] @@ -239,77 +179,21 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): "\nIf or 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\" '." - else: - string += "\n\nYou can now log with the command 'connect %s '." - 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\" '." + else: + string += "\n\nYou can now log with the command 'connect %s '." + session.msg(string % (username, username)) + else: + session.msg("|R%s|n" % '\n'.join(errors)) class CmdUnconnectedQuit(COMMAND_DEFAULT_CLASS): diff --git a/evennia/server/validators.py b/evennia/server/validators.py index bccbde6b51..fdadeda6e3 100644 --- a/evennia/server/validators.py +++ b/evennia/server/validators.py @@ -23,7 +23,7 @@ class EvenniaUsernameAvailabilityValidator: """ # Check guest list - if settings.GUEST_LIST and username.lower() in (guest.lower() for guest in settings.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', From a19d2e10a5fed5677bc57a2d6c63bb0669065dd6 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 3 Oct 2018 20:47:27 +0000 Subject: [PATCH 07/79] Adds logging to create/puppet/update/delete commands. --- evennia/commands/default/account.py | 7 ++++++- evennia/commands/default/admin.py | 15 +++++++++++++-- evennia/commands/default/comms.py | 4 +++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index b50f55a8e0..7eda54e75c 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -23,7 +23,7 @@ from builtins import range import time from django.conf import settings from evennia.server.sessionhandler import SESSIONS -from evennia.utils import utils, create, search, evtable +from evennia.utils import utils, create, logger, search, evtable COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -171,6 +171,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): new_character.db.desc = "This is a character." self.msg("Created new character %s. Use |w@ic %s|n to enter the game as this character." % (new_character.key, new_character.key)) + logger.log_sec('Character Created: %s (Caller: %s, IP: %s).' % (new_character, account, self.session.address)) class CmdCharDelete(COMMAND_DEFAULT_CLASS): @@ -214,6 +215,7 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS): caller.db._playable_characters = [pc for pc in caller.db._playable_characters if pc != delobj] delobj.delete() self.msg("Character '%s' was permanently deleted." % key) + logger.log_sec('Character Deleted: %s (Caller: %s, IP: %s).' % (key, account, self.session.address)) else: self.msg("Deletion was aborted.") del caller.ndb._char_to_delete @@ -279,8 +281,10 @@ class CmdIC(COMMAND_DEFAULT_CLASS): try: account.puppet_object(session, new_character) account.db._last_puppet = new_character + logger.log_sec('Puppet Success: (Caller: %s, Target: %s, IP: %s).' % (account, new_character, self.session.address)) except RuntimeError as exc: self.msg("|rYou cannot become |C%s|n: %s" % (new_character.name, exc)) + logger.log_sec('Puppet Failed: %s (Caller: %s, Target: %s, IP: %s).' % (exc, account, new_character, self.session.address)) # note that this is inheriting from MuxAccountLookCommand, @@ -641,6 +645,7 @@ class CmdPassword(COMMAND_DEFAULT_CLASS): account.set_password(newpass) account.save() self.msg("Password changed.") + logger.log_sec('Password Changed: %s (Caller: %s, IP: %s).' % (account, account, self.session.address)) class CmdQuit(COMMAND_DEFAULT_CLASS): diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py index fc90277127..917612c882 100644 --- a/evennia/commands/default/admin.py +++ b/evennia/commands/default/admin.py @@ -9,7 +9,7 @@ import re from django.conf import settings from evennia.server.sessionhandler import SESSIONS from evennia.server.models import ServerConfig -from evennia.utils import evtable, search, class_from_module +from evennia.utils import evtable, logger, search, class_from_module COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -95,6 +95,9 @@ class CmdBoot(COMMAND_DEFAULT_CLASS): for session in boot_list: session.msg(feedback) session.account.disconnect_session_from_account(session) + + if pobj and boot_list: + logger.log_sec('Booted: %s (Reason: %s, Caller: %s, IP: %s).' % (pobj, reason, caller, self.session.address)) # regex matching IP addresses with wildcards, eg. 233.122.4.* @@ -203,6 +206,7 @@ class CmdBan(COMMAND_DEFAULT_CLASS): banlist.append(bantup) ServerConfig.objects.conf('server_bans', banlist) self.caller.msg("%s-Ban |w%s|n was added." % (typ, ban)) + logger.log_sec('Banned %s: %s (Caller: %s, IP: %s).' % (typ, ban.strip(), self.caller, self.session.address)) class CmdUnban(COMMAND_DEFAULT_CLASS): @@ -246,8 +250,10 @@ class CmdUnban(COMMAND_DEFAULT_CLASS): ban = banlist[num - 1] del banlist[num - 1] ServerConfig.objects.conf('server_bans', banlist) + value = " ".join([s for s in ban[:2]]) self.caller.msg("Cleared ban %s: %s" % - (num, " ".join([s for s in ban[:2]]))) + (num, value)) + logger.log_sec('Unbanned: %s (Caller: %s, IP: %s).' % (value.strip(), self.caller, self.session.address)) class CmdDelAccount(COMMAND_DEFAULT_CLASS): @@ -317,6 +323,7 @@ class CmdDelAccount(COMMAND_DEFAULT_CLASS): if reason: string += " Reason given:\n '%s'" % reason account.msg(string) + logger.log_sec('Account Deleted: %s (Reason: %s, Caller: %s, IP: %s).' % (account, reason, caller, self.session.address)) account.delete() self.msg("Account %s was successfully deleted." % uname) @@ -445,6 +452,7 @@ class CmdNewPassword(COMMAND_DEFAULT_CLASS): if account.character != caller: account.msg("%s has changed your password to '%s'." % (caller.name, newpass)) + logger.log_sec('Password Changed: %s (Caller: %s, IP: %s).' % (account, caller, self.session.address)) class CmdPerm(COMMAND_DEFAULT_CLASS): @@ -526,6 +534,7 @@ class CmdPerm(COMMAND_DEFAULT_CLASS): else: caller_result.append("\nPermission %s removed from %s (if they existed)." % (perm, obj.name)) target_result.append("\n%s revokes the permission(s) %s from you." % (caller.name, perm)) + logger.log_sec('Permissions Deleted: %s, %s (Caller: %s, IP: %s).' % (perm, obj, caller, self.session.address)) else: # add a new permission permissions = obj.permissions.all() @@ -547,6 +556,8 @@ class CmdPerm(COMMAND_DEFAULT_CLASS): caller_result.append("\nPermission '%s' given to %s (%s)." % (perm, obj.name, plystring)) target_result.append("\n%s gives you (%s, %s) the permission '%s'." % (caller.name, obj.name, plystring, perm)) + logger.log_sec('Permissions Added: %s, %s (Caller: %s, IP: %s).' % (obj, perm, caller, self.session.address)) + caller.msg("".join(caller_result).strip()) if target_result: obj.msg("".join(target_result).strip()) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index d9fe0b0d20..90ef0a0086 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -14,7 +14,7 @@ from evennia.accounts.models import AccountDB from evennia.accounts import bots from evennia.comms.channelhandler import CHANNELHANDLER from evennia.locks.lockhandler import LockException -from evennia.utils import create, utils, evtable +from evennia.utils import create, logger, utils, evtable from evennia.utils.utils import make_iter, class_from_module COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -368,6 +368,7 @@ class CmdCdestroy(COMMAND_DEFAULT_CLASS): channel.delete() CHANNELHANDLER.update() self.msg("Channel '%s' was destroyed." % channel_key) + logger.log_sec('Channel Deleted: %s (Caller: %s, IP: %s).' % (channel_key, caller, self.session.address)) class CmdCBoot(COMMAND_DEFAULT_CLASS): @@ -433,6 +434,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS): # disconnect account channel.disconnect(account) CHANNELHANDLER.update() + logger.log_sec('Channel Boot: %s (Channel: %s, Reason: %s, Caller: %s, IP: %s).' % (account, channel, reason, caller, self.session.address)) class CmdCemit(COMMAND_DEFAULT_CLASS): From 52fb3674dc51be83000e3e6a7cf6972b0904917e Mon Sep 17 00:00:00 2001 From: Brenden Tuck Date: Thu, 4 Oct 2018 19:59:30 -0400 Subject: [PATCH 08/79] Fix #1668 - up arrow key regression --- .../static/webclient/js/plugins/history.js | 30 ++++--------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/evennia/web/webclient/static/webclient/js/plugins/history.js b/evennia/web/webclient/static/webclient/js/plugins/history.js index 1bef6031cd..c33dbcabf9 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/history.js +++ b/evennia/web/webclient/static/webclient/js/plugins/history.js @@ -43,14 +43,6 @@ let history_plugin = (function () { history_pos = 0; } - // - // Go to the last history line - var end = function () { - // move to the end of the history stack - history_pos = 0; - return history[history.length -1]; - } - // // Add input to the scratch line var scratch = function (input) { @@ -69,28 +61,17 @@ let history_plugin = (function () { var history_entry = null; var inputfield = $("#inputfield"); - if (inputfield[0].selectionStart == inputfield.val().length) { - // Only process up/down arrow if cursor is at the end of the line. - if (code === 38) { // Arrow up - history_entry = back(); - } - else if (code === 40) { // Arrow down - history_entry = fwd(); - } + if (code === 38) { // Arrow up + history_entry = back(); + } + else if (code === 40) { // Arrow down + history_entry = fwd(); } if (history_entry !== null) { // Doing a history navigation; replace the text in the input. inputfield.val(history_entry); } - else { - // Save the current contents of the input to the history scratch area. - setTimeout(function () { - // Need to wait until after the key-up to capture the value. - scratch(inputfield.val()); - end(); - }, 0); - } return false; } @@ -99,6 +80,7 @@ let history_plugin = (function () { // Listen for onSend lines to add to history var onSend = function (line) { add(line); + return null; // we are not returning an altered input line } // From 1c3791417c8ced2e74572b651acdc64115d31fcc Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 5 Oct 2018 19:01:38 +0000 Subject: [PATCH 09/79] Fixes incorrect LOGIN_URL and LOGOUT_URL by means of reverse_lazy call. --- evennia/settings_default.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 9efbb6314b..9d6ad9e686 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -13,6 +13,7 @@ always be sure of what you have changed and what is default behaviour. """ from builtins import range +from django.urls import reverse_lazy import os import sys @@ -697,9 +698,9 @@ ROOT_URLCONF = 'web.urls' # Where users are redirected after logging in via contrib.auth.login. LOGIN_REDIRECT_URL = '/' # Where to redirect users when using the @login_required decorator. -LOGIN_URL = '/accounts/login' +LOGIN_URL = reverse_lazy('login') # Where to redirect users who wish to logout. -LOGOUT_URL = '/accounts/login' +LOGOUT_URL = reverse_lazy('logout') # URL that handles the media served from MEDIA_ROOT. # Example: "http://media.lawrence.com" MEDIA_URL = '/media/' From 95577487a7e47587327ad7aa051143b801e3fccd Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 5 Oct 2018 22:02:20 +0000 Subject: [PATCH 10/79] Adds get_absolute_url() and get_admin_url() methods to DefaultObject, DefaultCharacter, Account and DefaultRoom objects. --- evennia/accounts/accounts.py | 21 +++++++++++++++++ evennia/accounts/tests.py | 13 +++++++++++ evennia/objects/objects.py | 44 ++++++++++++++++++++++++++++++++++++ evennia/objects/tests.py | 11 +++++++++ 4 files changed, 89 insertions(+) create mode 100644 evennia/objects/tests.py diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 2c33e5c1f8..f90575edd2 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -14,7 +14,9 @@ instead for most things). import time from django.conf import settings from django.contrib.auth import password_validation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.urls import reverse_lazy from django.utils import timezone from evennia.typeclasses.models import TypeclassBase from evennia.accounts.manager import AccountManager @@ -189,6 +191,25 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): @lazy_property def sessions(self): return AccountSessionHandler(self) + + def get_absolute_url(self): + """ + Returns the canonical URL for an Account. + + To callers, this method should appear to return a string that can be + used to refer to the object over HTTP. + + https://docs.djangoproject.com/en/2.1/ref/models/instances/#get-absolute-url + """ + try: return reverse_lazy('account-detail', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: return '#' + + def get_admin_url(self): + """ + Returns a link to this object's entry within the Django Admin panel. + """ + content_type = ContentType.objects.get_for_model(self.__class__) + return reverse_lazy("admin:%s_%s_change" % (content_type.app_label, content_type.model), args=(self.id,)) # session-related methods diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 2855dd0ca2..4d941782ad 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -59,6 +59,19 @@ class TestDefaultAccount(TestCase): self.s1 = Session() self.s1.puppet = None self.s1.sessid = 0 + + def test_absolute_url(self): + "Get URL for account detail page on website" + self.account = create.create_account("TestAccount%s" % randint(100000, 999999), + email="test@test.com", password="testpassword", typeclass=DefaultAccount) + self.assertTrue(self.account.get_absolute_url()) + + def test_admin_url(self): + "Get object's URL for access via Admin pane" + self.account = create.create_account("TestAccount%s" % randint(100000, 999999), + email="test@test.com", password="testpassword", typeclass=DefaultAccount) + self.assertTrue(self.account.get_admin_url()) + self.assertTrue(self.account.get_admin_url() != '#') def test_password_validation(self): "Check password validators deny bad passwords" diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 27d8147999..167cd60612 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -12,6 +12,9 @@ from future.utils import with_metaclass from collections import defaultdict from django.conf import settings +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.typeclasses.attributes import NickHandler @@ -324,6 +327,25 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): # look at 'an egg'. self.aliases.add(singular, category="plural_key") return singular, plural + + def get_absolute_url(self): + """ + Returns the canonical URL for an object. + + To callers, this method should appear to return a string that can be + used to refer to the object over HTTP. + + https://docs.djangoproject.com/en/2.1/ref/models/instances/#get-absolute-url + """ + try: return reverse('object-detail', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: return '#' + + def get_admin_url(self): + """ + Returns a link to this object's entry within the Django Admin panel. + """ + 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,)) def search(self, searchdata, global_search=False, @@ -1821,6 +1843,17 @@ class DefaultCharacter(DefaultObject): a character avatar controlled by an account. """ + def get_absolute_url(self): + """ + Returns the canonical URL for a Character. + + To callers, this method should appear to return a string that can be + used to refer to the object over HTTP. + + https://docs.djangoproject.com/en/2.1/ref/models/instances/#get-absolute-url + """ + try: return reverse('character-detail', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: return super(DefaultCharacter, self).get_absolute_url() def basetype_setup(self): """ @@ -1938,6 +1971,17 @@ class DefaultRoom(DefaultObject): This is the base room object. It's just like any Object except its location is always `None`. """ + def get_absolute_url(self): + """ + Returns the canonical URL for a Room. + + To callers, this method should appear to return a string that can be + used to refer to the object over HTTP. + + https://docs.djangoproject.com/en/2.1/ref/models/instances/#get-absolute-url + """ + try: return reverse('location-detail', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: return super(DefaultRoom, self).get_absolute_url() def basetype_setup(self): """ diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py new file mode 100644 index 0000000000..f317f2c39c --- /dev/null +++ b/evennia/objects/tests.py @@ -0,0 +1,11 @@ +from evennia.utils.test_resources import EvenniaTest + +class DefaultObjectTest(EvenniaTest): + + def test_urls(self): + "Make sure objects are returning URLs" + self.assertTrue(self.char1.get_absolute_url()) + self.assertTrue('admin' in self.char1.get_admin_url()) + + self.assertTrue(self.room1.get_absolute_url()) + self.assertTrue('admin' in self.room1.get_admin_url()) \ No newline at end of file From e37b324229a543994b3bb111c6dc1b96a26041c3 Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 5 Oct 2018 22:30:12 +0000 Subject: [PATCH 11/79] Adds dynamic get_*_url() fields to DefaultObject and children and static get_*_url() fields to DefaultAccount. --- evennia/accounts/accounts.py | 16 +++++++++++ evennia/objects/objects.py | 56 +++++++++++++++++++----------------- 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index f90575edd2..bd1fb4f573 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -204,6 +204,22 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): try: return reverse_lazy('account-detail', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) except: return '#' + def get_delete_url(self): + """ + Returns the canonical URL to the page that allows deleting an object. + + """ + try: return reverse('account-delete', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: return '#' + + def get_update_url(self): + """ + Returns the canonical URL to the page that allows updating an object. + + """ + try: return reverse('account-update', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: return '#' + def get_admin_url(self): """ Returns a link to this object's entry within the Django Admin panel. diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 167cd60612..71ccb28f77 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -7,6 +7,7 @@ entities. """ import time import inflect +import re from builtins import object from future.utils import with_metaclass from collections import defaultdict @@ -328,21 +329,48 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): self.aliases.add(singular, category="plural_key") return singular, plural + def get_url_prefix(self): + """ + Derives the object name from the class name. + + i.e. 'DefaultAccount' = 'default-account', 'Character' = 'character' + """ + klass = self.__class__.__name__ + terms = [x.lower() for x in re.split('([A-Z][a-z]+)', klass) if x] + return slugify(' '.join(terms)) + def get_absolute_url(self): """ - Returns the canonical URL for an object. + Returns the canonical URL to view an object. To callers, this method should appear to return a string that can be used to refer to the object over HTTP. https://docs.djangoproject.com/en/2.1/ref/models/instances/#get-absolute-url """ - try: return reverse('object-detail', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + try: return reverse('%s-detail' % self.get_url_prefix(), kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: return '#' + + def get_delete_url(self): + """ + Returns the canonical URL to the page that allows deleting an object. + + """ + try: return reverse('%s-delete' % self.get_url_prefix(), kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: return '#' + + def get_update_url(self): + """ + Returns the canonical URL to the page that allows updating an object. + + """ + try: return reverse('%s-update' % self.get_url_prefix(), kwargs={'pk': self.pk, 'slug': slugify(self.name)}) except: return '#' def get_admin_url(self): """ Returns a link to this object's entry within the Django Admin panel. + """ 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,)) @@ -1843,18 +1871,6 @@ class DefaultCharacter(DefaultObject): a character avatar controlled by an account. """ - def get_absolute_url(self): - """ - Returns the canonical URL for a Character. - - To callers, this method should appear to return a string that can be - used to refer to the object over HTTP. - - https://docs.djangoproject.com/en/2.1/ref/models/instances/#get-absolute-url - """ - try: return reverse('character-detail', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) - except: return super(DefaultCharacter, self).get_absolute_url() - def basetype_setup(self): """ Setup character-specific security. @@ -1971,18 +1987,6 @@ class DefaultRoom(DefaultObject): This is the base room object. It's just like any Object except its location is always `None`. """ - def get_absolute_url(self): - """ - Returns the canonical URL for a Room. - - To callers, this method should appear to return a string that can be - used to refer to the object over HTTP. - - https://docs.djangoproject.com/en/2.1/ref/models/instances/#get-absolute-url - """ - try: return reverse('location-detail', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) - except: return super(DefaultRoom, self).get_absolute_url() - def basetype_setup(self): """ Simple room setup setting locks to make sure the room From d3c6359b09fbea9583068a17ee1f0570445b7d9d Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 5 Oct 2018 22:34:34 +0000 Subject: [PATCH 12/79] Makes lazy reversals not lazy; prevents errors from manifesting out of scope. --- evennia/accounts/accounts.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index bd1fb4f573..6631911dc8 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -16,7 +16,7 @@ from django.conf import settings from django.contrib.auth import password_validation from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError -from django.urls import reverse_lazy +from django.urls import reverse from django.utils import timezone from evennia.typeclasses.models import TypeclassBase from evennia.accounts.manager import AccountManager @@ -201,7 +201,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): https://docs.djangoproject.com/en/2.1/ref/models/instances/#get-absolute-url """ - try: return reverse_lazy('account-detail', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + try: return reverse('account-detail', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) except: return '#' def get_delete_url(self): @@ -225,7 +225,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): Returns a link to this object's entry within the Django Admin panel. """ content_type = ContentType.objects.get_for_model(self.__class__) - return reverse_lazy("admin:%s_%s_change" % (content_type.app_label, content_type.model), args=(self.id,)) + return reverse("admin:%s_%s_change" % (content_type.app_label, content_type.model), args=(self.id,)) # session-related methods From a5b4f4139798b8c46d6c18085a88f8a9af836bc2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 6 Oct 2018 14:21:24 +0200 Subject: [PATCH 13/79] [fix] Correct missing caller arg in security message --- evennia/commands/default/admin.py | 10 +++++----- evennia/commands/default/comms.py | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py index 917612c882..3bb4e7e512 100644 --- a/evennia/commands/default/admin.py +++ b/evennia/commands/default/admin.py @@ -95,7 +95,7 @@ class CmdBoot(COMMAND_DEFAULT_CLASS): for session in boot_list: session.msg(feedback) session.account.disconnect_session_from_account(session) - + if pobj and boot_list: logger.log_sec('Booted: %s (Reason: %s, Caller: %s, IP: %s).' % (pobj, reason, caller, self.session.address)) @@ -435,9 +435,9 @@ class CmdNewPassword(COMMAND_DEFAULT_CLASS): account = caller.search_account(self.lhs) if not account: return - + newpass = self.rhs - + # Validate password validated, error = account.validate_password(newpass) if not validated: @@ -445,7 +445,7 @@ class CmdNewPassword(COMMAND_DEFAULT_CLASS): string = "\n".join(errors) caller.msg(string) return - + account.set_password(newpass) account.save() self.msg("%s - new password set to '%s'." % (account.name, newpass)) @@ -557,7 +557,7 @@ class CmdPerm(COMMAND_DEFAULT_CLASS): target_result.append("\n%s gives you (%s, %s) the permission '%s'." % (caller.name, obj.name, plystring, perm)) logger.log_sec('Permissions Added: %s, %s (Caller: %s, IP: %s).' % (obj, perm, caller, self.session.address)) - + caller.msg("".join(caller_result).strip()) if target_result: obj.msg("".join(target_result).strip()) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index 90ef0a0086..7abde75492 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -434,7 +434,8 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS): # disconnect account channel.disconnect(account) CHANNELHANDLER.update() - logger.log_sec('Channel Boot: %s (Channel: %s, Reason: %s, Caller: %s, IP: %s).' % (account, channel, reason, caller, self.session.address)) + logger.log_sec('Channel Boot: %s (Channel: %s, Reason: %s, Caller: %s, IP: %s).' % ( + account, channel, reason, self.caller, self.session.address)) class CmdCemit(COMMAND_DEFAULT_CLASS): From 7133492630936604c53ed6089da4fdbdc9a4030b Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 6 Oct 2018 19:00:54 +0200 Subject: [PATCH 14/79] Be more lenient with spawning old, more free-form prototypes --- evennia/commands/default/building.py | 3 +- evennia/prototypes/prototypes.py | 51 ++++++++++++++++++++++------ evennia/prototypes/spawner.py | 3 ++ 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index bd4fb5e188..f0ae108f00 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2889,7 +2889,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): "use the 'exec' prototype key.") return None try: - protlib.validate_prototype(prototype) + # we homogenize first, to be more lenient + protlib.validate_prototype(protlib.homogenize_prototype(prototype)) except RuntimeError as err: self.caller.msg(str(err)) return diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 67eccaafd0..a03cbc519f 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -6,6 +6,8 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos """ import re +import hashlib +import time from ast import literal_eval from django.conf import settings from evennia.scripts.scripts import DefaultScript @@ -13,7 +15,7 @@ from evennia.objects.models import ObjectDB from evennia.utils.create import create_script from evennia.utils.utils import ( all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module, - get_all_typeclasses, to_str, dbref, justify) + get_all_typeclasses, to_str, dbref, justify, class_from_module) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger from evennia.utils import inlinefuncs, dbserialize @@ -47,8 +49,8 @@ class ValidationError(RuntimeError): def homogenize_prototype(prototype, custom_keys=None): """ - Homogenize the more free-form prototype (where undefined keys are non-category attributes) - into the stricter form using `attrs` required by the system. + Homogenize the more free-form prototype supported pre Evennia 0.7 into the stricter form. + Args: prototype (dict): Prototype. @@ -56,18 +58,45 @@ def homogenize_prototype(prototype, custom_keys=None): the default reserved keys. Returns: - homogenized (dict): Prototype where all non-identified keys grouped as attributes. + homogenized (dict): Prototype where all non-identified keys grouped as attributes and other + homogenizations like adding missing prototype_keys and setting a default typeclass. + """ reserved = _PROTOTYPE_RESERVED_KEYS + (custom_keys or ()) + attrs = list(prototype.get('attrs', [])) # break reference + tags = make_iter(prototype.get('tags', [])) + homogenized_tags = [] + homogenized = {} for key, val in prototype.items(): if key in reserved: - homogenized[key] = val + if key == 'tags': + for tag in tags: + if not is_iter(tag): + homogenized_tags.append((tag, None, None)) + else: + homogenized_tags.append(tag) + else: + homogenized[key] = val else: + # unassigned keys -> attrs attrs.append((key, val, None, '')) if attrs: homogenized['attrs'] = attrs + if homogenized_tags: + homogenized['tags'] = homogenized_tags + + # add required missing parts that had defaults before + + if "prototype_key" not in prototype: + # assign a random hash as key + homogenized["prototype_key"] = "prototype-{}".format( + hashlib.md5(str(time.time())).hexdigest()[:7]) + + if "typeclass" not in prototype: + homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS + return homogenized @@ -432,11 +461,13 @@ def validate_prototype(prototype, protkey=None, protparents=None, _flags['warnings'].append("Prototype {} can only be used as a mixin since it lacks " "a typeclass or a prototype_parent.".format(protkey)) - if (strict and typeclass and typeclass not - in get_all_typeclasses("evennia.objects.models.ObjectDB")): - _flags['errors'].append( - "Prototype {} is based on typeclass {}, which could not be imported!".format( - protkey, typeclass)) + if strict and typeclass: + try: + class_from_module(typeclass) + except ImportError as err: + _flags['errors'].append( + "{}: Prototype {} is based on typeclass {}, which could not be imported!".format( + err, protkey, typeclass)) # recursively traverese prototype_parent chain diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 7d876cf580..1ca7229bea 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -659,6 +659,9 @@ def spawn(*prototypes, **kwargs): # get available protparents protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} + if not kwargs.get("only_validate"): + prototypes = [protlib.homogenize_prototype(prot) for prot in prototypes] + # overload module's protparents with specifically given protparents # we allow prototype_key to be the key of the protparent dict, to allow for module-level # prototype imports. We need to insert prototype_key in this case From b5c6a483ac70ccb25d0d046fc1602f8f89eeb273 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 6 Oct 2018 19:05:57 +0200 Subject: [PATCH 15/79] Fix bug in spawning with attributes --- evennia/prototypes/spawner.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 1ca7229bea..b22770aa94 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -733,8 +733,9 @@ def spawn(*prototypes, **kwargs): val = make_iter(prot.pop("attrs", [])) attributes = [] for (attrname, value, category, locks) in val: - attributes.append((attrname, init_spawn_value(val), category, locks)) + attributes.append((attrname, init_spawn_value(value), category, locks)) + print("attributes to spawn: IN: {}, OUT: {}".format(val, attributes)) simple_attributes = [] for key, value in ((key, value) for key, value in prot.items() if not (key.startswith("ndb_"))): From 8b1ab0bc8594513732fa5be0bd0431f1ad43c1ad Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 6 Oct 2018 19:06:44 +0200 Subject: [PATCH 16/79] Remove debug info --- evennia/prototypes/spawner.py | 1 - 1 file changed, 1 deletion(-) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index b22770aa94..ce08944139 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -735,7 +735,6 @@ def spawn(*prototypes, **kwargs): for (attrname, value, category, locks) in val: attributes.append((attrname, init_spawn_value(value), category, locks)) - print("attributes to spawn: IN: {}, OUT: {}".format(val, attributes)) simple_attributes = [] for key, value in ((key, value) for key, value in prot.items() if not (key.startswith("ndb_"))): From 550a25820d55095fa6e9179955fa53f2f28d2fb7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 6 Oct 2018 21:00:31 +0200 Subject: [PATCH 17/79] Fix unittests. Implement #1675. --- evennia/prototypes/prototypes.py | 2 +- evennia/prototypes/tests.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index a03cbc519f..550f1b2e7b 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -94,7 +94,7 @@ def homogenize_prototype(prototype, custom_keys=None): homogenized["prototype_key"] = "prototype-{}".format( hashlib.md5(str(time.time())).hexdigest()[:7]) - if "typeclass" not in prototype: + if "typeclass" not in prototype and "prototype_parent" not in prototype: homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS return homogenized diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 1ad1d9ac47..3cf2e38c7b 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -384,8 +384,9 @@ class TestPrototypeStorage(EvenniaTest): prot3 = protlib.create_prototype(**self.prot3) # partial match - self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3]) - self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3]) + with mock.patch("evennia.prototypes.prototypes._MODULE_PROTOTYPES", {}): + self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3]) + self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3]) self.assertTrue(str(unicode(protlib.list_prototypes(self.char1)))) From 77cf8075d9c03a39049b7ec3455451e098df848d Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Oct 2018 10:50:35 +0200 Subject: [PATCH 18/79] Clarify prototype_key replacement in modules; address #1676. --- evennia/prototypes/prototypes.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 550f1b2e7b..eac53a6504 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -105,9 +105,11 @@ def homogenize_prototype(prototype, custom_keys=None): for mod in settings.PROTOTYPE_MODULES: # to remove a default prototype, override it with an empty dict. # internally we store as (key, desc, locks, tags, prototype_dict) - prots = [(prototype_key.lower(), homogenize_prototype(prot)) - for prototype_key, prot in all_from_module(mod).items() - if prot and isinstance(prot, dict)] + prots = [] + for variable_name, prot in all_from_module(mod).items(): + if "prototype_key" not in prot: + prot['prototype_key'] = variable_name.lower() + prots.append((prot['prototype_key'], homogenize_prototype(prot))) # assign module path to each prototype_key for easy reference _MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots}) # make sure the prototype contains all meta info From 68ff0ac9d63d23766abb19259454d721d95d5ed1 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Oct 2018 10:50:35 +0200 Subject: [PATCH 19/79] Clarify prototype_key replacement in modules; address #1676. --- evennia/prototypes/prototypes.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 550f1b2e7b..eac53a6504 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -105,9 +105,11 @@ def homogenize_prototype(prototype, custom_keys=None): for mod in settings.PROTOTYPE_MODULES: # to remove a default prototype, override it with an empty dict. # internally we store as (key, desc, locks, tags, prototype_dict) - prots = [(prototype_key.lower(), homogenize_prototype(prot)) - for prototype_key, prot in all_from_module(mod).items() - if prot and isinstance(prot, dict)] + prots = [] + for variable_name, prot in all_from_module(mod).items(): + if "prototype_key" not in prot: + prot['prototype_key'] = variable_name.lower() + prots.append((prot['prototype_key'], homogenize_prototype(prot))) # assign module path to each prototype_key for easy reference _MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots}) # make sure the prototype contains all meta info From fe14dfddef2cba06fc0eefe9067beb4708c152c9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Oct 2018 12:31:43 +0200 Subject: [PATCH 20/79] Fix bug in unittest that would cause occational name collision --- evennia/accounts/tests.py | 18 +++++++++++++++--- evennia/prototypes/spawner.py | 7 ++++--- evennia/typeclasses/attributes.py | 2 +- evennia/typeclasses/tags.py | 5 ++++- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 2855dd0ca2..1175051d72 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -14,9 +14,15 @@ class TestAccountSessionHandler(TestCase): "Check AccountSessionHandler class" def setUp(self): - self.account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) + self.account = create.create_account( + "TestAccount%s" % randint(0, 999999), email="test@test.com", + password="testpassword", typeclass=DefaultAccount) self.handler = AccountSessionHandler(self.account) + def tearDown(self): + if hasattr(self, 'account'): + self.account.delete() + def test_get(self): "Check get method" self.assertEqual(self.handler.get(), []) @@ -60,6 +66,10 @@ class TestDefaultAccount(TestCase): self.s1.puppet = None self.s1.sessid = 0 + def tearDown(self): + if hasattr(self, "account"): + self.account.delete() + def test_password_validation(self): "Check password validators deny bad passwords" @@ -71,7 +81,6 @@ class TestDefaultAccount(TestCase): "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]) - self.account.delete() def test_password_change(self): "Check password setting and validation is working as expected" @@ -109,7 +118,9 @@ class TestDefaultAccount(TestCase): import evennia.server.sessionhandler - account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) + account = create.create_account( + "TestAccount%s" % randint(0, 999999), email="test@test.com", + password="testpassword", typeclass=DefaultAccount) self.s1.uid = account.uid evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1 @@ -171,6 +182,7 @@ class TestDefaultAccount(TestCase): import evennia.server.sessionhandler account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) + self.account = account self.s1.uid = account.uid evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1 diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index ce08944139..ac6ad854b1 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -259,11 +259,11 @@ def prototype_from_object(obj): if aliases: prot['aliases'] = aliases tags = [(tag.db_key, tag.db_category, tag.db_data) - for tag in obj.tags.get(return_tagobj=True, return_list=True) if tag] + for tag in obj.tags.all(return_objs=True)] if tags: prot['tags'] = tags attrs = [(attr.key, attr.value, attr.category, ';'.join(attr.locks.all())) - for attr in obj.attributes.get(return_obj=True, return_list=True) if attr] + for attr in obj.attributes.all()] if attrs: prot['attrs'] = attrs @@ -660,6 +660,7 @@ def spawn(*prototypes, **kwargs): protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} if not kwargs.get("only_validate"): + # homogenization to be more lenient about prototype format when entering the prototype manually prototypes = [protlib.homogenize_prototype(prot) for prot in prototypes] # overload module's protparents with specifically given protparents @@ -714,7 +715,7 @@ def spawn(*prototypes, **kwargs): val = prot.pop("tags", []) tags = [] - for (tag, category, data) in tags: + for (tag, category, data) in val: tags.append((init_spawn_value(val, str), category, data)) prototype_key = prototype.get('prototype_key', None) diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 863628172a..1dc1902494 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -668,7 +668,7 @@ class AttributeHandler(object): def all(self, accessing_obj=None, default_access=True): """ - Return all Attribute objects on this object. + Return all Attribute objects on this object, regardless of category. Args: accessing_obj (object, optional): Check the `attrread` diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index 488dce0f85..ea675366fd 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -345,13 +345,14 @@ class TagHandler(object): self._catcache = {} self._cache_complete = False - def all(self, return_key_and_category=False): + def all(self, return_key_and_category=False, return_objs=False): """ Get all tags in this handler, regardless of category. Args: return_key_and_category (bool, optional): Return a list of tuples `[(key, category), ...]`. + return_objs (bool, optional): Return tag objects. Returns: tags (list): A list of tag keys `[tagkey, tagkey, ...]` or @@ -365,6 +366,8 @@ class TagHandler(object): if return_key_and_category: # return tuple (key, category) return [(to_str(tag.db_key), to_str(tag.db_category)) for tag in tags] + elif return_objs: + return tags else: return [to_str(tag.db_key) for tag in tags] From fdc4550e19902a6b7d162de170344a2da6f146f5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Oct 2018 14:31:36 +0200 Subject: [PATCH 21/79] Cleanup of account tests with more mocking --- evennia/accounts/tests.py | 29 +++++++++++++++-------------- evennia/prototypes/spawner.py | 1 + 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 1175051d72..78ee87f37d 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -1,7 +1,8 @@ -from mock import Mock +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 @@ -30,24 +31,24 @@ class TestAccountSessionHandler(TestCase): import evennia.server.sessionhandler - s1 = Session() + s1 = MagicMock() s1.logged_in = True s1.uid = self.account.uid evennia.server.sessionhandler.SESSIONS[s1.uid] = s1 - s2 = Session() + s2 = MagicMock() s2.logged_in = True s2.uid = self.account.uid + 1 evennia.server.sessionhandler.SESSIONS[s2.uid] = s2 - s3 = Session() + s3 = MagicMock() s3.logged_in = False s3.uid = self.account.uid + 2 evennia.server.sessionhandler.SESSIONS[s3.uid] = s3 - self.assertEqual(self.handler.get(), [s1]) - self.assertEqual(self.handler.get(self.account.uid), [s1]) - self.assertEqual(self.handler.get(self.account.uid + 1), []) + self.assertEqual([s.uid for s in self.handler.get()], [s1.uid]) + self.assertEqual([s.uid for s in [self.handler.get(self.account.uid)]], [s1.uid]) + self.assertEqual([s.uid for s in self.handler.get(self.account.uid + 1)], []) def test_all(self): "Check all method" @@ -62,9 +63,10 @@ class TestDefaultAccount(TestCase): "Check DefaultAccount class" def setUp(self): - self.s1 = Session() + self.s1 = MagicMock() self.s1.puppet = None self.s1.sessid = 0 + self.s1.data_outj def tearDown(self): if hasattr(self, "account"): @@ -142,10 +144,7 @@ class TestDefaultAccount(TestCase): self.s1.uid = account.uid evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1 - self.s1.puppet = None - self.s1.logged_in = True - self.s1.data_out = Mock(return_value=None) - + self.s1.data_out = MagicMock() obj = Mock() obj.access = Mock(return_value=False) @@ -154,6 +153,7 @@ class TestDefaultAccount(TestCase): self.assertTrue(self.s1.data_out.call_args[1]['text'].startswith("You don't have permission to puppet")) self.assertIsNone(obj.at_post_puppet.call_args) + @override_settings(MULTISESSION_MODE=0) def test_puppet_object_joining_other_session(self): "Check puppet_object method called, joining other session" @@ -165,15 +165,16 @@ class TestDefaultAccount(TestCase): self.s1.puppet = None self.s1.logged_in = True - self.s1.data_out = Mock(return_value=None) + self.s1.data_out = MagicMock() obj = Mock() obj.access = Mock(return_value=True) obj.account = account + obj.sessions.all = MagicMock(return_value=[self.s1]) account.puppet_object(self.s1, obj) # works because django.conf.settings.MULTISESSION_MODE is not in (1, 3) - self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("from another of your sessions.")) + self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("from another of your sessions.|n")) self.assertTrue(obj.at_post_puppet.call_args[1] == {}) def test_puppet_object_already_puppeted(self): diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index ac6ad854b1..03bc6d2024 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -258,6 +258,7 @@ def prototype_from_object(obj): aliases = obj.aliases.get(return_list=True) if aliases: prot['aliases'] = aliases + from evennia import set_trace;set_trace() tags = [(tag.db_key, tag.db_category, tag.db_data) for tag in obj.tags.all(return_objs=True)] if tags: From a2e8b27ee4905d993623e0ff8e79b06a334e258f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Oct 2018 18:29:25 +0200 Subject: [PATCH 22/79] Update docker file to better handle starting without an existing game folder --- Dockerfile | 12 ++++++++++-- bin/unix/evennia-docker-start.sh | 16 ++++++++++++---- evennia/prototypes/spawner.py | 1 - 3 files changed, 22 insertions(+), 7 deletions(-) mode change 100644 => 100755 bin/unix/evennia-docker-start.sh diff --git a/Dockerfile b/Dockerfile index 381c83f925..961d3ad8ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ # Usage: # cd to a folder where you want your game data to be (or where it already is). # -# docker run -it -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia +# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia # # (If your OS does not support $PWD, replace it with the full path to your current # folder). @@ -15,6 +15,14 @@ # You will end up in a shell where the `evennia` command is available. From here you # can install and run the game normally. Use Ctrl-D to exit the evennia docker container. # +# You can also start evennia directly by passing arguments to the folder: +# +# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia evennia start -l +# +# This will start Evennia running as the core process of the container. Note that you *must* use -l +# or one of the foreground modes (like evennia ipstart) since otherwise the container will immediately +# die since no foreground process keeps it up. +# # The evennia/evennia base image is found on DockerHub and can also be used # as a base for creating your own custom containerized Evennia game. For more # info, see https://github.com/evennia/evennia/wiki/Running%20Evennia%20in%20Docker . @@ -58,7 +66,7 @@ WORKDIR /usr/src/game ENV PS1 "evennia|docker \w $ " # startup a shell when we start the container -ENTRYPOINT bash -c "source /usr/src/evennia/bin/unix/evennia-docker-start.sh" +ENTRYPOINT ["/usr/src/evennia/bin/unix/evennia-docker-start.sh"] # expose the telnet, webserver and websocket client ports EXPOSE 4000 4001 4005 diff --git a/bin/unix/evennia-docker-start.sh b/bin/unix/evennia-docker-start.sh old mode 100644 new mode 100755 index d1333aaef6..5c87052da9 --- a/bin/unix/evennia-docker-start.sh +++ b/bin/unix/evennia-docker-start.sh @@ -1,10 +1,18 @@ -#! /bin/bash +#! /bin/sh # called by the Dockerfile to start the server in docker mode # remove leftover .pid files (such as from when dropping the container) rm /usr/src/game/server/*.pid >& /dev/null || true -# start evennia server; log to server.log but also output to stdout so it can -# be viewed with docker-compose logs -exec 3>&1; evennia start -l +PS1="evennia|docker \w $ " + +cmd="$@" +output="Docker starting with argument '$cmd' ..." +if test -z $cmd; then + cmd="bash" + output="No argument given, starting shell ..." +fi + +echo $output +exec 3>&1; $cmd diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 03bc6d2024..ac6ad854b1 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -258,7 +258,6 @@ def prototype_from_object(obj): aliases = obj.aliases.get(return_list=True) if aliases: prot['aliases'] = aliases - from evennia import set_trace;set_trace() tags = [(tag.db_key, tag.db_category, tag.db_data) for tag in obj.tags.all(return_objs=True)] if tags: From 14eea024bbfdc3c2e95962aa058c22d6e58fc234 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Oct 2018 18:45:28 +0200 Subject: [PATCH 23/79] Correct tag handling in prototype; fix unittests --- evennia/prototypes/tests.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 3cf2e38c7b..411bd45c27 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -134,6 +134,7 @@ class TestUtils(EvenniaTest): 'prototype_key': Something, 'prototype_locks': 'spawn:all();edit:all()', 'prototype_tags': [], + 'tags': [(u'footag', u'foocategory', None)], 'typeclass': 'evennia.objects.objects.DefaultObject'}) self.assertEqual(old_prot, @@ -182,6 +183,7 @@ class TestUtils(EvenniaTest): 'typeclass': ('evennia.objects.objects.DefaultObject', 'evennia.objects.objects.DefaultObject', 'KEEP'), 'aliases': {'foo': ('foo', None, 'REMOVE')}, + 'tags': {u'footag': ((u'footag', u'foocategory', None), None, 'REMOVE')}, 'prototype_desc': ('Built from Obj', 'New version of prototype', 'UPDATE'), 'permissions': {"Builder": (None, 'Builder', 'ADD')} @@ -200,6 +202,7 @@ class TestUtils(EvenniaTest): 'prototype_key': 'UPDATE', 'prototype_locks': 'KEEP', 'prototype_tags': 'KEEP', + 'tags': 'REMOVE', 'typeclass': 'KEEP'} ) From 40eb691cd4dcdfe86d3e00c6208002401556da71 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Oct 2018 21:17:40 +0200 Subject: [PATCH 24/79] Create hash password when creating irc bot. --- evennia/commands/default/comms.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index d9fe0b0d20..04fcceba46 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -7,6 +7,8 @@ make sure to homogenize self.caller to always be the account object for easy handling. """ +import hashlib +import time from past.builtins import cmp from django.conf import settings from evennia.comms.models import ChannelDB, Msg @@ -918,8 +920,9 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS): self.msg("Account '%s' already exists and is not a bot." % botname) return else: + password = hashlib.md5(str(time.time())).hexdigest()[:11] try: - bot = create.create_account(botname, None, None, typeclass=botclass) + bot = create.create_account(botname, None, password, typeclass=botclass) except Exception as err: self.msg("|rError, could not create the bot:|n '%s'." % err) return From c153a1d7e4c93513d9c072a8f3b08bf1229d854d Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 8 Oct 2018 18:20:35 +0200 Subject: [PATCH 25/79] Resolve bug when trying to examine self when unprivileged --- evennia/accounts/accounts.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 2c33e5c1f8..3eab69a3ce 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -1001,7 +1001,10 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): if target and not is_iter(target): # single target - just show it - return target.return_appearance(self) + if hasattr(target, "return_appearance"): + return target.return_appearance(self) + else: + return "{} has no in-game appearance.".format(target) else: # list of targets - make list to disconnect from db characters = list(tar for tar in target if tar) if target else [] From 3b75780b40604564e846a9baedb4354800425b99 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 8 Oct 2018 18:50:33 +0200 Subject: [PATCH 26/79] Make tutorial_world roots give clearer errors. Allow home/quit from dark room. Resolves #1584. --- evennia/contrib/tutorial_world/objects.py | 10 +++++----- evennia/contrib/tutorial_world/rooms.py | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py index f83462ad6b..331b6b1a21 100644 --- a/evennia/contrib/tutorial_world/objects.py +++ b/evennia/contrib/tutorial_world/objects.py @@ -475,14 +475,14 @@ class CmdShiftRoot(Command): root_pos["blue"] -= 1 self.caller.msg("The root with blue flowers gets in the way and is pushed to the left.") else: - self.caller.msg("You cannot move the root in that direction.") + self.caller.msg("The root hangs straight down - you can only move it left or right.") elif color == "blue": if direction == "left": root_pos[color] = max(-1, root_pos[color] - 1) self.caller.msg("You shift the root with small blue flowers to the left.") if root_pos[color] != 0 and root_pos[color] == root_pos["red"]: root_pos["red"] += 1 - self.caller.msg("The reddish root is to big to fit as well, so that one falls away to the left.") + self.caller.msg("The reddish root is too big to fit as well, so that one falls away to the left.") elif direction == "right": root_pos[color] = min(1, root_pos[color] + 1) self.caller.msg("You shove the root adorned with small blue flowers to the right.") @@ -490,7 +490,7 @@ class CmdShiftRoot(Command): root_pos["red"] -= 1 self.caller.msg("The thick reddish root gets in the way and is pushed back to the left.") else: - self.caller.msg("You cannot move the root in that direction.") + self.caller.msg("The root hangs straight down - you can only move it left or right.") # now the horizontal roots (yellow/green). They can be moved up/down elif color == "yellow": @@ -507,7 +507,7 @@ class CmdShiftRoot(Command): root_pos["green"] -= 1 self.caller.msg("The weedy green root is shifted upwards to make room.") else: - self.caller.msg("You cannot move the root in that direction.") + self.caller.msg("The root hangs across the wall - you can only move it up or down.") elif color == "green": if direction == "up": root_pos[color] = max(-1, root_pos[color] - 1) @@ -522,7 +522,7 @@ class CmdShiftRoot(Command): root_pos["yellow"] -= 1 self.caller.msg("The root with yellow flowers gets in the way and is pushed upwards.") else: - self.caller.msg("You cannot move the root in that direction.") + self.caller.msg("The root hangs across the wall - you can only move it up or down.") # we have moved the root. Store new position self.obj.db.root_pos = root_pos diff --git a/evennia/contrib/tutorial_world/rooms.py b/evennia/contrib/tutorial_world/rooms.py index 780f774af7..58e13a1356 100644 --- a/evennia/contrib/tutorial_world/rooms.py +++ b/evennia/contrib/tutorial_world/rooms.py @@ -747,9 +747,16 @@ class CmdLookDark(Command): """ caller = self.caller - if random.random() < 0.75: + # count how many searches we've done + nr_searches = caller.ndb.dark_searches + if nr_searches is None: + nr_searches = 0 + caller.ndb.dark_searches = nr_searches + + if nr_searches < 4 and random.random() < 0.90: # we don't find anything caller.msg(random.choice(DARK_MESSAGES)) + caller.ndb.dark_searches += 1 else: # we could have found something! if any(obj for obj in caller.contents if utils.inherits_from(obj, LightSource)): @@ -791,7 +798,8 @@ class CmdDarkNoMatch(Command): def func(self): """Implements the command.""" - self.caller.msg("Until you find some light, there's not much you can do. Try feeling around.") + self.caller.msg("Until you find some light, there's not much you can do. " + "Try feeling around, maybe you'll find something helpful!") class DarkCmdSet(CmdSet): @@ -814,7 +822,9 @@ class DarkCmdSet(CmdSet): self.add(CmdLookDark()) self.add(CmdDarkHelp()) self.add(CmdDarkNoMatch()) - self.add(default_cmds.CmdSay) + self.add(default_cmds.CmdSay()) + self.add(default_cmds.CmdQuit()) + self.add(default_cmds.CmdHome()) class DarkRoom(TutorialRoom): From 52c84b44b58a0af578814e48313d3f3a687e5d2f Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 8 Oct 2018 19:03:15 +0200 Subject: [PATCH 27/79] Handle prototype modules with non-dicts as global variables --- evennia/prototypes/prototypes.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index eac53a6504..fc8edb55ab 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -107,9 +107,10 @@ for mod in settings.PROTOTYPE_MODULES: # internally we store as (key, desc, locks, tags, prototype_dict) prots = [] for variable_name, prot in all_from_module(mod).items(): - if "prototype_key" not in prot: - prot['prototype_key'] = variable_name.lower() - prots.append((prot['prototype_key'], homogenize_prototype(prot))) + if isinstance(prot, dict): + if "prototype_key" not in prot: + prot['prototype_key'] = variable_name.lower() + prots.append((prot['prototype_key'], homogenize_prototype(prot))) # assign module path to each prototype_key for easy reference _MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots}) # make sure the prototype contains all meta info From 19d9883343a4b4059977f7817d9d5d40ab4176a0 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 9 Oct 2018 20:28:47 +0000 Subject: [PATCH 28/79] Updates docstring to expand MM acronym for clarity. --- evennia/accounts/accounts.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 6bd812b6fa..e7f838ecbd 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -688,9 +688,9 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): @classmethod def create(cls, *args, **kwargs): """ - Creates an Account (or Account/Character pair for MM<2) with default - (or overridden) permissions and having joined them to the appropriate - default channels. + 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 From d27482f4fc951cb5ac3d62f53981a6df0364aab6 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 9 Oct 2018 20:29:12 +0000 Subject: [PATCH 29/79] Implements create() method migrated from CmdCreate on DefaultObject. --- evennia/objects/objects.py | 69 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 27d8147999..6525e2f39a 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -857,6 +857,75 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): string = "This place should not exist ... contact an admin." obj.msg(_(string)) obj.move_to(home) + + @classmethod + def create(cls, key, **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. + + Kwargs: + caller (Object): Intended owner (read: 'caller') of object. + description (str): Brief description of this object. + home (Object or str): Obj or #dbref to use as the object's + home location. + permissions (list): A list of permission strings or tuples (permstring, category). + locks (str): one or more lockstrings, separated by semicolons. + aliases (list): A list of alternative keys or tuples (aliasstring, category). + tags (list): List of tag keys or tuples (tagkey, category) or (tagkey, category, data). + destination (Object or str): Obj or #dbref to use as an Exit's + target. + report_to (Object): The object to return error messages to. + nohome (bool): This allows the creation of objects without a + default home location; only used when creating the default + location itself or during unittests. + attributes (list): Tuples on the form (key, value) or (key, value, category), + (key, value, lockstring) or (key, value, lockstring, default_access). + to set as Attributes on the new object. + nattributes (list): Non-persistent tuples on the form (key, value). Note that + adding this rarely makes sense since this data will not survive a reload. + typeclass (class or str): Class or python path to a typeclass. + + Returns: + object (Object): A newly created object of the given typeclass. + errors (list): A list of errors in string form, if any. + + """ + errors = [] + caller = kwargs.get('caller') + + # 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 + + # Create a sane lockstring if one wasn't supplied + lockstring = kwargs.get('locks') + if caller and not lockstring: + lock_template = "control:id({id}) or perm(Admin);delete:id({id}) or perm(Admin)" + lockstring = lock_template.format(id=caller.id) + kwargs['locks'] = lockstring + + # Create object + try: + obj = create.create_object(**kwargs) + except Exception as e: + errors.append("Object '%s' could not be created." % key) + logger.log_trace() + return None, errors + + # Set description if there is none, or update it if provided + if kwargs.get('description') or not obj.db.desc: + desc = kwargs.get('description', "You see nothing special.") + obj.db.desc = desc + + return obj, errors def copy(self, new_key=None): """ From 0dcb4e8af20b5c1ae28eef4f1dfed8dfab1519b1 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 9 Oct 2018 22:20:06 +0000 Subject: [PATCH 30/79] Implements create() methods on DefaultObject, DefaultCharacter, DefaultRoom and DefaultExit. --- evennia/objects/objects.py | 268 ++++++++++++++++++++++++++++++++----- 1 file changed, 234 insertions(+), 34 deletions(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 6525e2f39a..7d9e344936 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -191,6 +191,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 @@ -859,7 +863,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): obj.move_to(home) @classmethod - def create(cls, key, **kwargs): + def create(cls, key, account=None, **kwargs): """ Creates a basic object with default parameters, unless otherwise specified or extended. @@ -868,28 +872,11 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): Args: key (str): Name of the new object. + account (Account): Account to attribute this object to Kwargs: - caller (Object): Intended owner (read: 'caller') of object. - description (str): Brief description of this object. - home (Object or str): Obj or #dbref to use as the object's - home location. - permissions (list): A list of permission strings or tuples (permstring, category). - locks (str): one or more lockstrings, separated by semicolons. - aliases (list): A list of alternative keys or tuples (aliasstring, category). - tags (list): List of tag keys or tuples (tagkey, category) or (tagkey, category, data). - destination (Object or str): Obj or #dbref to use as an Exit's - target. - report_to (Object): The object to return error messages to. - nohome (bool): This allows the creation of objects without a - default home location; only used when creating the default - location itself or during unittests. - attributes (list): Tuples on the form (key, value) or (key, value, category), - (key, value, lockstring) or (key, value, lockstring, default_access). - to set as Attributes on the new object. - nattributes (list): Non-persistent tuples on the form (key, value). Note that - adding this rarely makes sense since this data will not survive a reload. - typeclass (class or str): Class or python path to a typeclass. + 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. @@ -897,7 +884,10 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): """ errors = [] - caller = kwargs.get('caller') + 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) @@ -907,24 +897,27 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): # Create a sane lockstring if one wasn't supplied lockstring = kwargs.get('locks') - if caller and not lockstring: - lock_template = "control:id({id}) or perm(Admin);delete:id({id}) or perm(Admin)" - lockstring = lock_template.format(id=caller.id) + if account and not lockstring: + lockstring = cls.lockstring.format(account_id=account.id) kwargs['locks'] = lockstring # Create object try: obj = create.create_object(**kwargs) - except Exception as e: - errors.append("Object '%s' could not be created." % key) - logger.log_trace() - return None, errors - - # Set description if there is none, or update it if provided - if kwargs.get('description') or not obj.db.desc: - desc = kwargs.get('description', "You see nothing special.") - obj.db.desc = desc + # Record creator id and creation IP + if ip: obj.db.creator_ip = ip + if caller: obj.db.creator_id = account.id + + # Set description if there is none, or update it if provided + if kwargs.get('description') or not obj.db.desc: + desc = kwargs.get('description', "You see nothing special.") + obj.db.desc = desc + + except Exception as e: + errors.append("There was an error creating the Object '%s'. If this problem persists, contact an admin." % key) + logger.log_trace() + return obj, errors def copy(self, new_key=None): @@ -1890,7 +1883,79 @@ 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)" + + @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) + + try: + # 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 + + # Add locks + locks = kwargs.pop('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 kwargs.get('description') or not obj.db.desc: + obj.db.desc = kwargs.get('description', "This is a character.") + + except Exception as e: + errors.append("There was an error creating a Character. If this problem persists, contact an admin.") + logger.log_trace() + + return obj, errors + def basetype_setup(self): """ Setup character-specific security. @@ -2007,6 +2072,69 @@ 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) + + 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 kwargs.get('description') or not obj.db.desc: + obj.db.desc = kwargs.get('description', "This is a room.") + + except Exception as e: + errors.append("There was an error creating a Room. If this problem persists, contact an admin.") + logger.log_trace() + + return obj, errors def basetype_setup(self): """ @@ -2085,6 +2213,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. @@ -2122,6 +2257,71 @@ class DefaultExit(DefaultObject): return exit_cmdset # Command hooks + + @classmethod + def create(cls, key, source, dest, account, **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. + source (Room): The room to create this exit in. + dest (Room): The room to which this exit should go. + account (obj): Account to associate this Exit with. + + 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 + + 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 kwargs.get('description') or not obj.db.desc: + obj.db.desc = kwargs.get('description', "This is an exit.") + + except Exception as e: + errors.append("There was an error creating an Exit. If this problem persists, contact an admin.") + logger.log_trace() + + return obj, errors def basetype_setup(self): """ From 8ea87f4727f0dda6872820beddeed80c27c039bf Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 9 Oct 2018 23:04:01 +0000 Subject: [PATCH 31/79] Bugfixes. --- evennia/objects/objects.py | 63 ++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 7d9e344936..83ec5fd2ff 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -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 @@ -429,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 @@ -872,7 +874,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): Args: key (str): Name of the new object. - account (Account): Account to attribute this object to + account (Account): Account to attribute this object to. Kwargs: description (str): Brief description for this object. @@ -887,7 +889,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): obj = None # Get IP address of creator, if available - ip = kwargs.pop('ip') + ip = kwargs.pop('ip', '') # If no typeclass supplied, use this class kwargs['typeclass'] = kwargs.pop('typeclass', cls) @@ -895,6 +897,9 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): # 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: @@ -907,16 +912,15 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): # Record creator id and creation IP if ip: obj.db.creator_ip = ip - if caller: obj.db.creator_id = account.id + if account: obj.db.creator_id = account.id # Set description if there is none, or update it if provided - if kwargs.get('description') or not obj.db.desc: - desc = kwargs.get('description', "You see nothing special.") + 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("There was an error creating the Object '%s'. If this problem persists, contact an admin." % key) - logger.log_trace() + errors.append(str(e)) return obj, errors @@ -1914,7 +1918,7 @@ class DefaultCharacter(DefaultObject): obj = None # Get IP address of creator, if available - ip = kwargs.pop('ip') + ip = kwargs.pop('ip', '') # If no typeclass supplied, use this class kwargs['typeclass'] = kwargs.pop('typeclass', cls) @@ -1928,6 +1932,12 @@ class DefaultCharacter(DefaultObject): # 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: # Create the Character obj = create.create_object(**kwargs) @@ -1937,7 +1947,6 @@ class DefaultCharacter(DefaultObject): if account: obj.db.creator_id = account.id # Add locks - locks = kwargs.pop('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}) @@ -1947,12 +1956,11 @@ class DefaultCharacter(DefaultObject): obj.locks.add(locks) # If no description is set, set a default description - if kwargs.get('description') or not obj.db.desc: - obj.db.desc = kwargs.get('description', "This is a character.") + if description or not obj.db.desc: + obj.db.desc = description if description else "This is a character." except Exception as e: - errors.append("There was an error creating a Character. If this problem persists, contact an admin.") - logger.log_trace() + errors.append(str(e)) return obj, errors @@ -2103,7 +2111,7 @@ class DefaultRoom(DefaultObject): obj = None # Get IP address of creator, if available - ip = kwargs.pop('ip') + ip = kwargs.pop('ip', '') # If no typeclass supplied, use this class kwargs['typeclass'] = kwargs.pop('typeclass', cls) @@ -2114,6 +2122,9 @@ class DefaultRoom(DefaultObject): # 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) @@ -2127,12 +2138,11 @@ class DefaultRoom(DefaultObject): if account: obj.db.creator_id = account.id # If no description is set, set a default description - if kwargs.get('description') or not obj.db.desc: - obj.db.desc = kwargs.get('description', "This is a room.") + if description or not obj.db.desc: + obj.db.desc = description if description else "This is a room." except Exception as e: - errors.append("There was an error creating a Room. If this problem persists, contact an admin.") - logger.log_trace() + errors.append(str(e)) return obj, errors @@ -2259,7 +2269,7 @@ class DefaultExit(DefaultObject): # Command hooks @classmethod - def create(cls, key, source, dest, account, **kwargs): + def create(cls, key, account, source, dest, **kwargs): """ Creates a basic Exit with default parameters, unless otherwise specified or extended. @@ -2269,9 +2279,9 @@ class DefaultExit(DefaultObject): 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. - account (obj): Account to associate this Exit with. Kwargs: description (str): Brief description for this object. @@ -2286,7 +2296,7 @@ class DefaultExit(DefaultObject): obj = None # Get IP address of creator, if available - ip = kwargs.pop('ip') + ip = kwargs.pop('ip', '') # If no typeclass supplied, use this class kwargs['typeclass'] = kwargs.pop('typeclass', cls) @@ -2301,6 +2311,8 @@ class DefaultExit(DefaultObject): kwargs['location'] = source kwargs['destination'] = dest + description = kwargs.pop('description', '') + try: # Create the Exit obj = create.create_object(**kwargs) @@ -2314,12 +2326,11 @@ class DefaultExit(DefaultObject): if account: obj.db.creator_id = account.id # If no description is set, set a default description - if kwargs.get('description') or not obj.db.desc: - obj.db.desc = kwargs.get('description', "This is an exit.") + if description or not obj.db.desc: + obj.db.desc = description if description else "This is an exit." except Exception as e: - errors.append("There was an error creating an Exit. If this problem persists, contact an admin.") - logger.log_trace() + errors.append(str(e)) return obj, errors From 3935f42cd84c365fc2e4eb2283c28429c4ee4137 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 9 Oct 2018 23:04:22 +0000 Subject: [PATCH 32/79] Adds unit tests for create() methods. --- evennia/objects/tests.py | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 evennia/objects/tests.py diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py new file mode 100644 index 0000000000..144ac5a678 --- /dev/null +++ b/evennia/objects/tests.py @@ -0,0 +1,41 @@ + +from evennia.utils.test_resources import EvenniaTest +from evennia import DefaultObject, DefaultCharacter, DefaultRoom, DefaultExit + +class ObjectCreationTest(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('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_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) + + \ No newline at end of file From 18bcc4ffa6d4d13010d12a81f1a2073cf8a8e9eb Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 9 Oct 2018 23:21:39 +0000 Subject: [PATCH 33/79] Modifies Account.create() to use Character.create() on lesser multisession modes. --- evennia/accounts/accounts.py | 39 ++++++++++++------------------------ 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index e7f838ecbd..4825891b9f 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -25,7 +25,7 @@ from evennia.comms.models import ChannelDB from evennia.commands import cmdhandler from evennia.server.models import ServerConfig from evennia.server.throttle import Throttle -from evennia.utils import create, logger +from evennia.utils import class_from_module, create, logger from evennia.utils.utils import (lazy_property, to_str, make_iter, to_unicode, is_iter, variable_from_module) @@ -782,33 +782,20 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): errors.append(string) logger.log_err(string) - if account: - if settings.MULTISESSION_MODE < 2: - default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME) - - try: - character = create.create_object(character_typeclass, key=account.key, home=default_home, permissions=permissions) - - # set playable character list - account.db._playable_characters.append(character) + if account and settings.MULTISESSION_MODE < 2: + # Load the appropriate Character class + Character = class_from_module(settings.BASE_CHARACTER_TYPECLASS) - # allow only the character itself and the account to puppet this character (and Developers). - character.locks.add("puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer)" % - (character.id, account.id)) + # Create the character + character, errs = Character.create(account.key, account, ip=ip) + errors.extend(errs) - # If no description is set, set a default description - if not character.db.desc: - character.db.desc = "This is a character." - # We need to set this to have @ic auto-connect to this character - account.db._last_puppet = character - - # Record creator id and creation IP - if ip: character.db.creator_ip = ip - character.db.creator_id = account.id - - except Exception as e: - errors.append("There was an error creating a Character. If this problem persists, contact an admin.") - logger.log_trace() + if character: + # Update playable character list + 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 From f943b3e1799cdefa79c7cfcb45924e44a601b351 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 9 Oct 2018 23:41:04 +0000 Subject: [PATCH 34/79] Adds create() method to DefaultScript and unit test for DefaultScript.create(). --- evennia/scripts/scripts.py | 28 +++++++++++++++++++++++++++- evennia/scripts/tests.py | 10 ++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index 8bff161cf5..ba6f7edff2 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -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"] @@ -323,6 +323,32 @@ class DefaultScript(ScriptBase): or describe a state that changes under certain conditions. """ + + @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): """ diff --git a/evennia/scripts/tests.py b/evennia/scripts/tests.py index dbd36ae956..f5120d0da5 100644 --- a/evennia/scripts/tests.py +++ b/evennia/scripts/tests.py @@ -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" From d78cd562795d8c4e8fcbe5f77440b49fe29e339f Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 10 Oct 2018 00:41:27 +0000 Subject: [PATCH 35/79] Implements create() and authenticate() on DefaultGuest object; migrates DefaultAccount.authenticate_guest(). --- evennia/accounts/accounts.py | 164 +++++++++++++++++------------------ evennia/accounts/tests.py | 46 +++++----- 2 files changed, 108 insertions(+), 102 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 4825891b9f..845db6794e 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -413,82 +413,6 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): raise ImproperlyConfigured(msg % validator['NAME']) objs.append(klass(**validator.get('OPTIONS', {}))) return objs - - @classmethod - def authenticate_guest(cls, **kwargs): - """ - Gets or creates a Guest account object. - - Kwargs: - ip (str, optional): IP address of requestor; used for ban checking, - throttling and logging - - """ - 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 - - # 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 if IP banned - if ip and cls.is_banned(ip=ip): - 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).' % ('guest', ip)) - LOGIN_THROTTLE.update(ip, 'Too many sightings of banned IP.') - 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 = ObjectDB.objects.get_id(settings.GUEST_HOME) - permissions = settings.PERMISSION_GUEST_DEFAULT - character_typeclass = settings.BASE_CHARACTER_TYPECLASS - account_typeclass = settings.BASE_GUEST_TYPECLASS - account, errs = cls.create( - guest=True, - username=username, - password=password, - permissions=permissions, - account_typeclass=account_typeclass, - character_typeclass=character_typeclass, - ip=ip, - ) - errors.extend(errs) - return account, errors - - 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() - return None, errors - - return account, errors @classmethod def authenticate(cls, username, password, ip='', **kwargs): @@ -700,7 +624,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): guest (bool, optional): Whether or not this is to be a Guest account permissions (str, optional): Default permissions for the Account - account_typeclass (str, optional): Typeclass to use for new Account + typeclass (str, optional): Typeclass to use for new Account character_typeclass (str, optional): Typeclass to use for new char when applicable. @@ -719,8 +643,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): guest = kwargs.get('guest', False) permissions = kwargs.get('permissions', settings.PERMISSION_ACCOUNT_DEFAULT) - account_typeclass = kwargs.get('account_typeclass', settings.BASE_ACCOUNT_TYPECLASS) - character_typeclass = kwargs.get('character_typeclass', settings.BASE_CHARACTER_TYPECLASS) + typeclass = kwargs.get('typeclass', settings.BASE_ACCOUNT_TYPECLASS) ip = kwargs.get('ip', '') if ip and CREATION_THROTTLE.check(ip): @@ -759,7 +682,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): # everything's ok. Create the new account account. try: try: - account = create.create_account(username, email, password, permissions=permissions, typeclass=account_typeclass) + 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: @@ -784,10 +707,15 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): if account and settings.MULTISESSION_MODE < 2: # Load the appropriate Character class - Character = class_from_module(settings.BASE_CHARACTER_TYPECLASS) + 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) + character, errs = Character.create( + account.key, account, ip=ip, typeclass=character_typeclass, + permissions=permissions, home=character_home + ) errors.extend(errs) if character: @@ -1453,6 +1381,78 @@ class DefaultGuest(DefaultAccount): This class is used for guest logins. Unlike Accounts, Guests and 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): """ diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index f68d344e37..e78cf9ffd1 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -6,8 +6,9 @@ from unittest import TestCase from django.test import override_settings from evennia.accounts.accounts import AccountSessionHandler -from evennia.accounts.accounts import DefaultAccount +from evennia.accounts.accounts import DefaultAccount, DefaultGuest from evennia.server.session import Session +from evennia.utils.test_resources import EvenniaTest from evennia.utils import create from django.conf import settings @@ -59,9 +60,31 @@ class TestAccountSessionHandler(TestCase): def test_count(self): "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 TestDefaultAccount(TestCase): +class TestDefaultAccount(EvenniaTest): "Check DefaultAccount class" def setUp(self): @@ -92,23 +115,6 @@ class TestDefaultAccount(TestCase): account, errors = DefaultAccount.create(username='Ziggy', password='starman11') self.assertFalse(account, 'Duplicate account name should not have been allowed.') - # Guest account should not be permitted - account, errors = DefaultAccount.authenticate_guest() - 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 = DefaultAccount.authenticate_guest() - self.assertTrue(account, 'Guest account should have been created.') - - # Create a second guest account - account, errors = DefaultAccount.authenticate_guest() - self.assertFalse(account, 'Two guest accounts were created despite a single entry on the guest list!') - - settings.GUEST_ENABLED = False - def test_throttle(self): "Confirm throttle activates on too many failures." for x in xrange(20): From ed26a522c818da83384c6ae2edea5f13e3c73f3c Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 10 Oct 2018 00:48:54 +0000 Subject: [PATCH 36/79] Changes method used for guest authentication. --- evennia/commands/default/unloggedin.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 7cf8b40c88..dc0f1ed476 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -109,9 +109,6 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS): session = self.caller address = session.address - # Get account class - Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) - args = self.args # extract double quote parts parts = [part.strip() for part in re.split(r"\"", args) if part.strip()] @@ -121,7 +118,10 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS): # Guest login if len(parts) == 1 and parts[0].lower() == "guest": - account, errors = Account.authenticate_guest(ip=address) + # 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 @@ -133,6 +133,9 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS): session.msg("\n\r Usage (without <>): connect ") return + # Get account class + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + name, password = parts account, errors = Account.authenticate(username=name, password=password, ip=address, session=session) if account: From c7f041d6d061e1a38f63bf37ee3b7f63dfc3f4d9 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 10 Oct 2018 00:57:39 +0000 Subject: [PATCH 37/79] Corrects additional incorrect guest authentication method. --- evennia/commands/default/unloggedin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index dc0f1ed476..7ff2c9a29a 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -42,11 +42,11 @@ def create_guest_account(session): address = session.address # Get account class - Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + Guest = class_from_module(settings.BASE_GUEST_TYPECLASS) # Get an available guest account - # authenticate_guest() handles its own throttling - account, errors = Account.authenticate_guest(ip=address) + # authenticate() handles its own throttling + account, errors = Guest.authenticate(ip=address) if account: return enabled, account else: From 13a3fff2703c81e8e0f161b652799d99e2ac2aed Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 10 Oct 2018 01:15:19 +0000 Subject: [PATCH 38/79] Redirects system errors from user-facing return to error log. --- evennia/objects/objects.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 83ec5fd2ff..1b5cca6f64 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -920,7 +920,8 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): obj.db.desc = desc except Exception as e: - errors.append(str(e)) + errors.append("An error occurred while creating this '%s' object." % key) + logger.log_err(e) return obj, errors @@ -1960,7 +1961,8 @@ class DefaultCharacter(DefaultObject): obj.db.desc = description if description else "This is a character." except Exception as e: - errors.append(str(e)) + errors.append("An error occurred while creating this '%s' object." % key) + logger.log_err(e) return obj, errors @@ -2142,7 +2144,8 @@ class DefaultRoom(DefaultObject): obj.db.desc = description if description else "This is a room." except Exception as e: - errors.append(str(e)) + errors.append("An error occurred while creating this '%s' object." % key) + logger.log_err(e) return obj, errors @@ -2330,7 +2333,8 @@ class DefaultExit(DefaultObject): obj.db.desc = description if description else "This is an exit." except Exception as e: - errors.append(str(e)) + errors.append("An error occurred while creating this '%s' object." % key) + logger.log_err(e) return obj, errors From 5f9047b161e2d5ec25263ba6d42dc33ab060662e Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 10 Oct 2018 23:26:20 +0200 Subject: [PATCH 39/79] Make Session.execute_cmd consistent with Account/Object by accepting the `session` keyword --- evennia/server/serversession.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index b7f74cef5d..c5de7cf5be 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -407,7 +407,7 @@ class ServerSession(Session): else: self.data_out(**kwargs) - def execute_cmd(self, raw_string, **kwargs): + def execute_cmd(self, raw_string, session=None, **kwargs): """ Do something as this object. This method is normally never called directly, instead incoming command instructions are @@ -417,6 +417,9 @@ class ServerSession(Session): Args: raw_string (string): Raw command input + session (Session): This is here to make API consistent with + Account/Object.execute_cmd. If given, data is passed to + that Session, otherwise use self. Kwargs: Other keyword arguments will be added to the found command object instace as variables before it executes. This is @@ -426,7 +429,7 @@ class ServerSession(Session): """ # inject instruction into input stream kwargs["text"] = ((raw_string,), {}) - self.sessionhandler.data_in(self, **kwargs) + self.sessionhandler.data_in(session or self, **kwargs) def __eq__(self, other): """Handle session comparisons""" From 965e97329493bc7ac56f982ae072351d0b4b11f6 Mon Sep 17 00:00:00 2001 From: Will Hutcheson Date: Fri, 12 Oct 2018 16:37:27 -0500 Subject: [PATCH 40/79] Move delaccount functionality to @accounts/delete Implement #1477 --- evennia/commands/default/admin.py | 76 +--------------------- evennia/commands/default/cmdset_account.py | 1 - evennia/commands/default/system.py | 52 ++++++++++++++- 3 files changed, 51 insertions(+), 78 deletions(-) diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py index 3bb4e7e512..08890dfcdc 100644 --- a/evennia/commands/default/admin.py +++ b/evennia/commands/default/admin.py @@ -16,7 +16,7 @@ COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) PERMISSION_HIERARCHY = [p.lower() for p in settings.PERMISSION_HIERARCHY] # limit members for API inclusion -__all__ = ("CmdBoot", "CmdBan", "CmdUnban", "CmdDelAccount", +__all__ = ("CmdBoot", "CmdBan", "CmdUnban", "CmdEmit", "CmdNewPassword", "CmdPerm", "CmdWall") @@ -133,7 +133,7 @@ class CmdBan(COMMAND_DEFAULT_CLASS): reason to be able to later remember why the ban was put in place. It is often preferable to ban an account from the server than to - delete an account with @delaccount. If banned by name, that account + delete an account with @accounts/delete. If banned by name, that account account can no longer be logged into. IP (Internet Protocol) address banning allows blocking all access @@ -256,78 +256,6 @@ class CmdUnban(COMMAND_DEFAULT_CLASS): logger.log_sec('Unbanned: %s (Caller: %s, IP: %s).' % (value.strip(), self.caller, self.session.address)) -class CmdDelAccount(COMMAND_DEFAULT_CLASS): - """ - delete an account from the server - - Usage: - @delaccount[/switch] [: reason] - - Switch: - delobj - also delete the account's currently - assigned in-game object. - - Completely deletes a user from the server database, - making their nick and e-mail again available. - """ - - key = "@delaccount" - switch_options = ("delobj",) - locks = "cmd:perm(delaccount) or perm(Developer)" - help_category = "Admin" - - def func(self): - """Implements the command.""" - - caller = self.caller - args = self.args - - if hasattr(caller, 'account'): - caller = caller.account - - if not args: - self.msg("Usage: @delaccount [: reason]") - return - - reason = "" - if ':' in args: - args, reason = [arg.strip() for arg in args.split(':', 1)] - - # We use account_search since we want to be sure to find also accounts - # that lack characters. - accounts = search.account_search(args) - - if not accounts: - self.msg('Could not find an account by that name.') - return - - if len(accounts) > 1: - string = "There were multiple matches:\n" - string += "\n".join(" %s %s" % (account.id, account.key) for account in accounts) - self.msg(string) - return - - # one single match - - account = accounts.first() - - if not account.access(caller, 'delete'): - string = "You don't have the permissions to delete that account." - self.msg(string) - return - - uname = account.username - # boot the account then delete - self.msg("Informing and disconnecting account ...") - string = "\nYour account '%s' is being *permanently* deleted.\n" % uname - if reason: - string += " Reason given:\n '%s'" % reason - account.msg(string) - logger.log_sec('Account Deleted: %s (Reason: %s, Caller: %s, IP: %s).' % (account, reason, caller, self.session.address)) - account.delete() - self.msg("Account %s was successfully deleted." % uname) - - class CmdEmit(COMMAND_DEFAULT_CLASS): """ admin command for emitting message to multiple objects diff --git a/evennia/commands/default/cmdset_account.py b/evennia/commands/default/cmdset_account.py index d7b887c017..8173e461c5 100644 --- a/evennia/commands/default/cmdset_account.py +++ b/evennia/commands/default/cmdset_account.py @@ -55,7 +55,6 @@ class AccountCmdSet(CmdSet): self.add(system.CmdPy()) # Admin commands - self.add(admin.CmdDelAccount()) self.add(admin.CmdNewPassword()) # Comm commands diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index c454de9aff..070420f4c3 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -18,7 +18,7 @@ from evennia.server.sessionhandler import SESSIONS from evennia.scripts.models import ScriptDB from evennia.objects.models import ObjectDB from evennia.accounts.models import AccountDB -from evennia.utils import logger, utils, gametime, create +from evennia.utils import logger, utils, gametime, create, search from evennia.utils.eveditor import EvEditor from evennia.utils.evtable import EvTable from evennia.utils.utils import crop, class_from_module @@ -460,17 +460,22 @@ class CmdObjects(COMMAND_DEFAULT_CLASS): class CmdAccounts(COMMAND_DEFAULT_CLASS): """ - list all registered accounts + Manage registered accounts Usage: @accounts [nr] + @accounts/delete [: reason] - Lists statistics about the Accounts registered with the game. + Switches: + delete - delete an account from the server + + By default, lists statistics about the Accounts registered with the game. It will list the amount of latest registered accounts If not given, defaults to 10. """ key = "@accounts" aliases = ["@listaccounts"] + switch_options = ("delete",) locks = "cmd:perm(listaccounts) or perm(Admin)" help_category = "System" @@ -478,6 +483,47 @@ class CmdAccounts(COMMAND_DEFAULT_CLASS): """List the accounts""" caller = self.caller + args = self.args + + if "delete" in self.switches: + account = getattr(caller, "account") + if not account or not account.check_permstring("Developer"): + caller.msg("You are not allowed to delete accounts.") + return + if not args: + caller.msg("Usage: @accounts/delete [: reason]") + return + reason = "" + if ":" in args: + args, reason = [arg.strip() for arg in args.split(":", 1)] + # We use account_search since we want to be sure to find also accounts + # that lack characters. + accounts = search.account_search(args) + if not accounts: + self.msg("Could not find an account by that name.") + return + if len(accounts) > 1: + string = "There were multiple matches:\n" + string += "\n".join(" %s %s" % (account.id, account.key) for account in accounts) + self.msg(string) + return + account = accounts.first() + if not account.access(caller, "delete"): + self.msg("You don't have the permissions to delete that account.") + return + username = account.username + # Boot the account then delete it. + self.msg("Informing and disconnecting account ...") + string = "\nYour account '%s' is being *permanently* deleted.\n" % username + if reason: + string += " Reason given:\n '%s'" % reason + account.msg(string) + logger.log_sec("Account Deleted: %s (Reason: %s, Caller: %s, IP: %s)." % (account, reason, caller, self.session.address)) + account.delete() + self.msg("Account %s was successfully deleted." % username) + return + + # No switches, default to displaying a list of accounts. if self.args and self.args.isdigit(): nlim = int(self.args) else: From 3bd34087ce8f7479486fb2c7344d9fbd61166e61 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 13 Oct 2018 11:26:31 +0200 Subject: [PATCH 41/79] Add confirmation question to new accounts/delete switch --- evennia/commands/default/building.py | 7 +++---- evennia/commands/default/system.py | 13 +++++++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index f0ae108f00..19ab0f7508 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -737,12 +737,11 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS): confirm += ", ".join(["#{}".format(obj.id) for obj in objs]) confirm += " [yes]/no?" if self.default_confirm == 'yes' else " yes/[no]" answer = "" - while answer.strip().lower() not in ("y", "yes", "n", "no"): - answer = yield(confirm) - answer = self.default_confirm if answer == '' else answer + answer = yield(confirm) + answer = self.default_confirm if answer == '' else answer if answer.strip().lower() in ("n", "no"): - caller.msg("Cancelled: no object was destroyed.") + caller.msg("Canceled: no object was destroyed.") delete = False if delete: diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index 070420f4c3..3a14a296b4 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -474,8 +474,8 @@ class CmdAccounts(COMMAND_DEFAULT_CLASS): If not given, defaults to 10. """ key = "@accounts" - aliases = ["@listaccounts"] - switch_options = ("delete",) + aliases = ["@account", "@listaccounts"] + switch_options = ("delete", ) locks = "cmd:perm(listaccounts) or perm(Admin)" help_category = "System" @@ -512,6 +512,15 @@ class CmdAccounts(COMMAND_DEFAULT_CLASS): self.msg("You don't have the permissions to delete that account.") return username = account.username + # ask for confirmation + confirm = ("It is often better to block access to an account rather than to delete it. " + "|yAre you sure you want to permanently delete " + "account '|n{}|y'|n yes/[no]?".format(username)) + answer = yield(confirm) + if answer.lower() not in ('y', 'yes'): + caller.msg("Canceled deletion.") + return + # Boot the account then delete it. self.msg("Informing and disconnecting account ...") string = "\nYour account '%s' is being *permanently* deleted.\n" % username From bedebdd5247a706907c4c44794cbc991825cc13d Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 13 Oct 2018 11:28:41 +0200 Subject: [PATCH 42/79] Update changelog. --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index afe5ad1ba7..f70d8b7504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Evennia 0.9 (2018-2019) + +### Commands + +- Removed default `@delaccount` command, incorporating as `@account/delete` instead. Added confirmation + question. + + ## Evennia 0.8 (2018) ### Server/Portal From 3fbd74b33203ca9fa9b8e7816c64becaf3b83474 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 13 Oct 2018 16:59:07 +0200 Subject: [PATCH 43/79] Fix (again) of tag batch creation --- evennia/prototypes/spawner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index ac6ad854b1..d1c099fb57 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -716,7 +716,7 @@ def spawn(*prototypes, **kwargs): val = prot.pop("tags", []) tags = [] for (tag, category, data) in val: - tags.append((init_spawn_value(val, str), category, data)) + tags.append((init_spawn_value(tag, str), category, data)) prototype_key = prototype.get('prototype_key', None) if prototype_key: From 9f8c1a4f644e5a73f479386334f30696ee6c8496 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 16 Oct 2018 10:13:05 +0200 Subject: [PATCH 44/79] Add requirement changes to 0.8 changelog, for clarity --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afe5ad1ba7..13c450939f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Evennia 0.8 (2018) +### Requirements + +- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0 +- Add `autobahn` dependency for Websocket support, removing very old embedded txWS library (from a + time before websocket specification was still not fixed). +- Add `inflect` dependency for automatic pluralization of object names. + ### Server/Portal - Removed `evennia_runner`, completely refactor `evennia_launcher.py` (the 'evennia' program) @@ -85,7 +92,6 @@ ### 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) From fe969111ce274aab16d6baf2edd0adebc359e8be Mon Sep 17 00:00:00 2001 From: Tehom Date: Tue, 16 Oct 2018 19:51:43 -0400 Subject: [PATCH 45/79] Add stub for testing Telnet --- evennia/server/portal/tests.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/evennia/server/portal/tests.py b/evennia/server/portal/tests.py index be400144c6..b09f71ae03 100644 --- a/evennia/server/portal/tests.py +++ b/evennia/server/portal/tests.py @@ -11,6 +11,11 @@ except ImportError: import string from evennia.server.portal import irc +from twisted.test import proto_helpers +from twisted.trial.unittest import TestCase as TwistedTestCase + +from .telnet import TelnetServerFactory + class TestIRC(TestCase): @@ -73,3 +78,15 @@ 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() + self.proto = factory.buildProtocol(("localhost", 0)) + self.transport = proto_helpers.StringTransport() + + def test_connect(self): + self.proto.makeConnection(self.transport) + # TODO: Add rest of stuff for testing connection From 6b96e84fd03039f0512ec947ae29841409c96618 Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Tue, 16 Oct 2018 19:31:10 -0500 Subject: [PATCH 46/79] Tests for @desc obj= --- evennia/commands/default/tests.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index a3be98984a..6010b008da 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -263,6 +263,20 @@ 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): + 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 == '' + assert self.room1.db.desc == r1d + + def test_desc_default_to_room(self): + 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' + def test_wipe(self): confirm = building.CmdDestroy.confirm building.CmdDestroy.confirm = False @@ -446,4 +460,4 @@ class TestUnconnectedCommand(CommandTest): settings.SERVERNAME, datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), SESSIONS.account_count(), utils.get_evennia_version()) - self.call(unloggedin.CmdUnconnectedInfo(), "", expected) \ No newline at end of file + self.call(unloggedin.CmdUnconnectedInfo(), "", expected) From 40a37e501f1ffb94f448e886a13220abf402caa0 Mon Sep 17 00:00:00 2001 From: Tehom Date: Tue, 16 Oct 2018 20:33:12 -0400 Subject: [PATCH 47/79] Add some cleanup steps to prevent unclean reactor --- evennia/server/portal/telnet.py | 2 +- evennia/server/portal/tests.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index 955ea5e918..83e2fa03a2 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -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) diff --git a/evennia/server/portal/tests.py b/evennia/server/portal/tests.py index b09f71ae03..53a732f121 100644 --- a/evennia/server/portal/tests.py +++ b/evennia/server/portal/tests.py @@ -8,13 +8,15 @@ try: except ImportError: import unittest +from mock import Mock import string from evennia.server.portal import irc from twisted.test import proto_helpers from twisted.trial.unittest import TestCase as TwistedTestCase -from .telnet import TelnetServerFactory +from .telnet import TelnetServerFactory, TelnetProtocol +from .portal import PORTAL_SESSIONS class TestIRC(TestCase): @@ -84,9 +86,19 @@ 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_connect(self): - self.proto.makeConnection(self.transport) + self.transport.client = ["localhost"] + self.transport.setTcpKeepAlive = Mock() + d = self.proto.makeConnection(self.transport) # TODO: Add rest of stuff for testing connection + # clean up to prevent Unclean reactor + self.proto.nop_keep_alive.stop() + self.proto._handshake_delay.cancel() + return d From dc44dc0176acdb6404f116a798393fae9134f85f Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Tue, 16 Oct 2018 19:49:19 -0500 Subject: [PATCH 48/79] In @desc command, validate rhs based on = sign present in orig args. Default MUX parsing assigns None to rhs if there is nothing on the right of the = sign. --- evennia/commands/default/building.py | 4 ++-- evennia/commands/default/tests.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 0afeea8fe5..aaafea30ac 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -589,12 +589,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: diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 6010b008da..6e5877608c 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -264,6 +264,9 @@ class TestBuilding(CommandTest): 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).") @@ -271,6 +274,7 @@ class TestBuilding(CommandTest): 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).") From e08efc68dc637235592aa25ca2fd4b2951544baa Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Tue, 16 Oct 2018 20:09:17 -0500 Subject: [PATCH 49/79] Harden assertions --- evennia/commands/default/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 6e5877608c..1b97a59f9f 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -270,7 +270,7 @@ class TestBuilding(CommandTest): 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 == '' + assert self.obj2.db.desc == '' and self.obj2.db.desc != o2d assert self.room1.db.desc == r1d def test_desc_default_to_room(self): @@ -279,7 +279,7 @@ class TestBuilding(CommandTest): 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' + assert self.room1.db.desc == 'Obj2' and self.room1.db.desc != r1d def test_wipe(self): confirm = building.CmdDestroy.confirm From b4383592016e532ebedb315616353712988bd257 Mon Sep 17 00:00:00 2001 From: Tehom Date: Wed, 17 Oct 2018 01:50:57 -0400 Subject: [PATCH 50/79] Add test of NOGOAHEAD --- evennia/server/portal/tests.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/evennia/server/portal/tests.py b/evennia/server/portal/tests.py index 53a732f121..4142ca297e 100644 --- a/evennia/server/portal/tests.py +++ b/evennia/server/portal/tests.py @@ -12,11 +12,13 @@ from mock import Mock import string from evennia.server.portal import irc +from twisted.conch.telnet import IAC, NOP, LINEMODE, GA, WILL, WONT, ECHO, NULL, DONT 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 class TestIRC(TestCase): @@ -98,6 +100,10 @@ class TestTelnet(TwistedTestCase): self.transport.setTcpKeepAlive = Mock() d = self.proto.makeConnection(self.transport) # TODO: Add rest of stuff for testing connection + 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) # clean up to prevent Unclean reactor self.proto.nop_keep_alive.stop() self.proto._handshake_delay.cancel() From ef0e0e0b4c6e2827f8832512632c580280c1af28 Mon Sep 17 00:00:00 2001 From: Tehom Date: Wed, 17 Oct 2018 12:49:48 -0400 Subject: [PATCH 51/79] Add various simple tests for different handshakes --- evennia/server/portal/tests.py | 49 +++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/evennia/server/portal/tests.py b/evennia/server/portal/tests.py index 4142ca297e..791e5172a4 100644 --- a/evennia/server/portal/tests.py +++ b/evennia/server/portal/tests.py @@ -12,13 +12,19 @@ from mock import Mock import string from evennia.server.portal import irc -from twisted.conch.telnet import IAC, NOP, LINEMODE, GA, WILL, WONT, ECHO, NULL, DONT +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): @@ -95,15 +101,50 @@ class TestTelnet(TwistedTestCase): self.transport = proto_helpers.StringTransport() self.addCleanup(factory.sessionhandler.disconnect_all) - def test_connect(self): + def test_mudlet_ttype(self): self.transport.client = ["localhost"] self.transport.setTcpKeepAlive = Mock() d = self.proto.makeConnection(self.transport) - # TODO: Add rest of stuff for testing connection + # test suppress_ga self.assertTrue(self.proto.protocol_flags["NOGOAHEAD"]) - self.proto.dataReceived(IAC + DONT+ SUPPRESS_GA) + 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() From 904969efb92064e6b2640519479e955dfd2740f4 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 17 Oct 2018 20:25:28 +0000 Subject: [PATCH 52/79] Compacts options dict for username validators list. --- evennia/settings_default.py | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index d6da94fb2f..7926ed276c 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -813,25 +813,12 @@ AUTH_PASSWORD_VALIDATORS = [ # Username validation plugins AUTH_USERNAME_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.validators.ASCIIUsernameValidator', - }, - { - 'NAME': 'django.core.validators.MinLengthValidator', - 'OPTIONS': { - 'limit_value': 3, - } - }, - { - 'NAME': 'django.core.validators.MaxLengthValidator', - 'OPTIONS': { - 'limit_value': 30, - } - }, - { - 'NAME': 'evennia.server.validators.EvenniaUsernameAvailabilityValidator', - }, -] + {'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' From 06fe77a6ef16a3e425dd826e352333410c271b86 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 17 Oct 2018 20:26:51 +0000 Subject: [PATCH 53/79] Adds create() method to DefaultChannel object, and unit test. --- evennia/comms/comms.py | 38 +++++++++++++++++++++++++++++++++++++- evennia/comms/tests.py | 13 +++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 evennia/comms/tests.py diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index a7d74ae0e4..adb90c66ac 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -5,7 +5,7 @@ Base typeclass for in-game Channels. 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 +220,42 @@ 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, *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. + + 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). + + Returns: + channel (Channel): A newly created Channel. + errors (list): A list of errors in string form, if any. + + """ + errors = [] + obj = None + + try: + kwargs['desc'] = kwargs.pop('description', '') + obj = create.create_channel(key, *args, **kwargs) + 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. diff --git a/evennia/comms/tests.py b/evennia/comms/tests.py new file mode 100644 index 0000000000..d38e34544b --- /dev/null +++ b/evennia/comms/tests.py @@ -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) \ No newline at end of file From c9ddef316f9ecf5ab536579888dc01be4a66c05a Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 17 Oct 2018 21:37:49 +0000 Subject: [PATCH 54/79] Adds object auditing options to DefaultChannel. --- evennia/comms/comms.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index adb90c66ac..90d1b04ad5 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -221,7 +221,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): default=default, no_superuser_bypass=no_superuser_bypass) @classmethod - def create(cls, key, *args, **kwargs): + def create(cls, key, account=None, *args, **kwargs): """ Creates a basic Channel with default parameters, unless otherwise specified or extended. @@ -230,6 +230,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): 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. @@ -238,6 +239,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): 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. @@ -246,10 +248,16 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): """ 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) From e1b4e6f7bc208328da423509a758c976579f9323 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 17 Oct 2018 22:52:06 +0000 Subject: [PATCH 55/79] Renames all URL methods to web_*, moves to TypedObject model. --- evennia/accounts/accounts.py | 37 --------- evennia/accounts/tests.py | 6 +- evennia/objects/objects.py | 46 ----------- evennia/typeclasses/models.py | 145 ++++++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 86 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 6631911dc8..2c33e5c1f8 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -14,9 +14,7 @@ instead for most things). import time from django.conf import settings from django.contrib.auth import password_validation -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError -from django.urls import reverse from django.utils import timezone from evennia.typeclasses.models import TypeclassBase from evennia.accounts.manager import AccountManager @@ -191,41 +189,6 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): @lazy_property def sessions(self): return AccountSessionHandler(self) - - def get_absolute_url(self): - """ - Returns the canonical URL for an Account. - - To callers, this method should appear to return a string that can be - used to refer to the object over HTTP. - - https://docs.djangoproject.com/en/2.1/ref/models/instances/#get-absolute-url - """ - try: return reverse('account-detail', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) - except: return '#' - - def get_delete_url(self): - """ - Returns the canonical URL to the page that allows deleting an object. - - """ - try: return reverse('account-delete', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) - except: return '#' - - def get_update_url(self): - """ - Returns the canonical URL to the page that allows updating an object. - - """ - try: return reverse('account-update', kwargs={'pk': self.pk, 'slug': slugify(self.name)}) - except: return '#' - - def get_admin_url(self): - """ - Returns a link to this object's entry within the Django Admin panel. - """ - 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,)) # session-related methods diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 4d941782ad..fbf976d644 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -64,14 +64,14 @@ class TestDefaultAccount(TestCase): "Get URL for account detail page on website" self.account = create.create_account("TestAccount%s" % randint(100000, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) - self.assertTrue(self.account.get_absolute_url()) + self.assertTrue(self.account.web_detail_url()) def test_admin_url(self): "Get object's URL for access via Admin pane" self.account = create.create_account("TestAccount%s" % randint(100000, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) - self.assertTrue(self.account.get_admin_url()) - self.assertTrue(self.account.get_admin_url() != '#') + self.assertTrue(self.account.web_admin_url()) + self.assertTrue(self.account.web_admin_url() != '#') def test_password_validation(self): "Check password validators deny bad passwords" diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 71ccb28f77..027e8ff47f 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -329,52 +329,6 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): self.aliases.add(singular, category="plural_key") return singular, plural - def get_url_prefix(self): - """ - Derives the object name from the class name. - - i.e. 'DefaultAccount' = 'default-account', 'Character' = 'character' - """ - klass = self.__class__.__name__ - terms = [x.lower() for x in re.split('([A-Z][a-z]+)', klass) if x] - return slugify(' '.join(terms)) - - def get_absolute_url(self): - """ - Returns the canonical URL to view an object. - - To callers, this method should appear to return a string that can be - used to refer to the object over HTTP. - - https://docs.djangoproject.com/en/2.1/ref/models/instances/#get-absolute-url - """ - try: return reverse('%s-detail' % self.get_url_prefix(), kwargs={'pk': self.pk, 'slug': slugify(self.name)}) - except: return '#' - - def get_delete_url(self): - """ - Returns the canonical URL to the page that allows deleting an object. - - """ - try: return reverse('%s-delete' % self.get_url_prefix(), kwargs={'pk': self.pk, 'slug': slugify(self.name)}) - except: return '#' - - def get_update_url(self): - """ - Returns the canonical URL to the page that allows updating an object. - - """ - try: return reverse('%s-update' % self.get_url_prefix(), kwargs={'pk': self.pk, 'slug': slugify(self.name)}) - except: return '#' - - def get_admin_url(self): - """ - Returns a link to this object's entry within the Django Admin panel. - - """ - 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,)) - def search(self, searchdata, global_search=False, use_nicks=True, diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index c156aa2ac6..098606b53c 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -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,145 @@ class TypedObject(SharedMemoryModel): """ pass + + # + # Web/Django methods + # + + def web_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_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' % cls._meta.verbose_name.lower()) + except: return '#' + + def web_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[\w\d\-]+)/(?P[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' % self._meta.verbose_name.lower(), kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: return '#' + + def web_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[\w\d\-]+)/(?P[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' % self._meta.verbose_name.lower(), kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: return '#' + + def web_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[\w\d\-]+)/(?P[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' % self._meta.verbose_name.lower(), kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: return '#' + + def get_absolute_url(self): + """ + Django construct; used by Django Sites framework and within the Admin + panel for reverse linking to the object detail page. + + https://docs.djangoproject.com/en/2.1/ref/models/instances/#get-absolute-url + + Returns: + path (str): URI path to object detail page, if defined. + + """ + return self.web_detail_url() \ No newline at end of file From 788120706228d9c62d08cab1fe037bae6dc9fd3c Mon Sep 17 00:00:00 2001 From: Tehom Date: Wed, 17 Oct 2018 22:43:33 -0400 Subject: [PATCH 56/79] Add tests for memplot --- evennia/server/profiling/memplot.py | 6 +++--- evennia/server/profiling/tests.py | 21 ++++++++++++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/evennia/server/profiling/memplot.py b/evennia/server/profiling/memplot.py index e8d7e76b12..c6a227a370 100644 --- a/evennia/server/profiling/memplot.py +++ b/evennia/server/profiling/memplot.py @@ -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. diff --git a/evennia/server/profiling/tests.py b/evennia/server/profiling/tests.py index b3e9fba8d5..cca4e0d99b 100644 --- a/evennia/server/profiling/tests.py +++ b/evennia/server/profiling/tests.py @@ -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() From 7dadc86693d3b94f3743b1fea9d4dd99a5f885d4 Mon Sep 17 00:00:00 2001 From: Tehom Date: Wed, 17 Oct 2018 23:41:19 -0400 Subject: [PATCH 57/79] Implement local-first search for @link command --- evennia/commands/default/building.py | 17 ++++++++++++----- evennia/commands/default/tests.py | 8 ++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 19ab0f7508..ff8d56753b 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -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 @@ -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 diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 19277c168a..78e77e2b88 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -334,6 +334,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") From 040cc2aa9f33c3fc2e84fa382a362ab90ba2cfdd Mon Sep 17 00:00:00 2001 From: Tehom Date: Thu, 18 Oct 2018 04:24:03 -0400 Subject: [PATCH 58/79] Add portal uptime to @time command. --- evennia/commands/default/system.py | 1 + evennia/server/amp_client.py | 1 + evennia/server/portal/amp_server.py | 3 ++- evennia/server/portal/portal.py | 3 +++ evennia/server/sessionhandler.py | 2 ++ evennia/utils/gametime.py | 11 +++++++++++ 6 files changed, 20 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index 3a14a296b4..a6cc24263d 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -710,6 +710,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()) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index a4300adf4d..816ecf2705 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -221,6 +221,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 diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index c07b5c121d..cdcd4a1552 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -428,7 +428,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: diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 91b3efc7bc..bb2a7b9f07 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -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() diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index 8e439b42dd..906ad1ec54 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -280,6 +280,8 @@ class ServerSessionHandler(SessionHandler): super(ServerSessionHandler, self).__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): """ diff --git a/evennia/utils/gametime.py b/evennia/utils/gametime.py index 3736128819..48910c4bfd 100644 --- a/evennia/utils/gametime.py +++ b/evennia/utils/gametime.py @@ -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. From b083a445457fd285c0c2c71e6bda3d494dbcae33 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 18 Oct 2018 17:15:54 +0200 Subject: [PATCH 59/79] Some clarification in the launcher --- CHANGELOG.md | 2 -- evennia/server/evennia_launcher.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c450939f..5b56c29d85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,6 @@ ### Requirements - Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0 -- Add `autobahn` dependency for Websocket support, removing very old embedded txWS library (from a - time before websocket specification was still not fixed). - Add `inflect` dependency for automatic pluralization of object names. ### Server/Portal diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 779a1e4aa2..ef6bf61055 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -325,7 +325,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 ---------------------------------------------------+ From 5c3bdd1b4a6bace2b4305e542f6e07fa14f9b71a Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 18 Oct 2018 21:30:02 +0000 Subject: [PATCH 60/79] Fixes object delete method so that deleted characters are removed from owner's playable character list. --- evennia/accounts/tests.py | 18 ++++++++++++++++++ evennia/objects/objects.py | 4 ++++ 2 files changed, 22 insertions(+) diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 78ee87f37d..1285891d32 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -7,6 +7,7 @@ from evennia.accounts.accounts import AccountSessionHandler from evennia.accounts.accounts import DefaultAccount from evennia.server.session import Session from evennia.utils import create +from evennia.utils.test_resources import EvenniaTest from django.conf import settings @@ -199,3 +200,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) \ No newline at end of file diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 27d8147999..3d0b9e02b7 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -913,8 +913,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): From 4946b0f65d56da15f13604f29921c0daebb6efc7 Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Thu, 18 Oct 2018 17:52:25 -0500 Subject: [PATCH 61/79] Tests for @spawn/edit and @spawn/examine --- evennia/commands/default/tests.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index a3be98984a..cbae47866d 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -372,6 +372,17 @@ class TestBuilding(CommandTest): self.call(building.CmdSpawn(), \ "'NO_EXIST'", "No prototype named 'NO_EXIST'") + # @span/edit + self.call( + building.CmdSpawn(), + '/edit', + 'spawn: Extra switch "/edit" ignored') + + # @span/examine + self.call( + '/examine', + building.CmdSpawn(), 'spawn: Extra switch "/examine" ignored') + class TestComms(CommandTest): @@ -446,4 +457,4 @@ class TestUnconnectedCommand(CommandTest): settings.SERVERNAME, datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), SESSIONS.account_count(), utils.get_evennia_version()) - self.call(unloggedin.CmdUnconnectedInfo(), "", expected) \ No newline at end of file + self.call(unloggedin.CmdUnconnectedInfo(), "", expected) From d4f76b3d2e0033c96515552ab02f1843321b9f6c Mon Sep 17 00:00:00 2001 From: Tehom Date: Thu, 18 Oct 2018 23:12:22 -0400 Subject: [PATCH 62/79] Add a simple 'force' command to force objects to execute commands. --- evennia/commands/default/admin.py | 32 +++++++++++++++++++- evennia/commands/default/cmdset_character.py | 1 + evennia/commands/default/tests.py | 3 ++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py index 08890dfcdc..c260abaa2e 100644 --- a/evennia/commands/default/admin.py +++ b/evennia/commands/default/admin.py @@ -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 = + + 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)) diff --git a/evennia/commands/default/cmdset_character.py b/evennia/commands/default/cmdset_character.py index cfc8a30ca4..438996f536 100644 --- a/evennia/commands/default/cmdset_character.py +++ b/evennia/commands/default/cmdset_character.py @@ -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()) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 19277c168a..b0f5211c6f 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -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): From ca81d35e1c5f5ff84c2b7a6c9d82651e8ca7f96c Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Fri, 19 Oct 2018 18:44:45 -0500 Subject: [PATCH 63/79] Baseline tests: assert current behavior (disregarding requirements) --- evennia/commands/default/tests.py | 36 +++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 691e1e63cb..be47a06aac 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -460,16 +460,44 @@ class TestBuilding(CommandTest): # Test listing commands self.call(building.CmdSpawn(), "/list", "Key ") - # @span/edit + # @span/edit (missing prototype) self.call( building.CmdSpawn(), '/edit', - 'spawn: Extra switch "/edit" ignored') + '@spawn: Extra switch "/edit" ignored.|Usage: @spawn or {key: value, ...}\n (2 existing prototypes. Use /list to inspect)') + # assert 'Prototype wizard' in msg - # @span/examine + # @spawn/edit with valid prototype + # with self.assertRaises(AttributeError): self.call( + building.CmdSpawn(), + '/edit BALL', + '@spawn: Extra switch "/edit" ignored.|Spawned Ball(#13).') + + # @spawn/edit with invalid prototype + #`with self.assertRaises(AttributeError): + self.call( + building.CmdSpawn(), + '/edit NO_EXISTS', + '@spawn: Extra switch "/edit" ignored.|No prototype named \'NO_EXISTS\'.') + + # @spawn/examine (missing prototype) + self.call( + building.CmdSpawn(), '/examine', - building.CmdSpawn(), 'spawn: Extra switch "/examine" ignored') + '@spawn: Extra switch "/examine" ignored.|Usage: @spawn or {key: value, ...}\n (2 existing prototypes. Use /list to inspect)') + + # @spawn/examine with valid prototype + self.call( + building.CmdSpawn(), + '/examine BALL', + '@spawn: Extra switch "/examine" ignored.|Spawned Ball(#14).') + + # @spawn/examine with invalid prototype + self.call( + building.CmdSpawn(), + '/examine NO_EXISTS', + '@spawn: Extra switch "/examine" ignored.|No prototype named \'NO_EXISTS\'.') class TestComms(CommandTest): From d3ddeda7c9e13fc1e79adf47966c18016188fbe7 Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Fri, 19 Oct 2018 19:01:27 -0500 Subject: [PATCH 64/79] @spawn/edit is equivalent to @spawn/olc and @olc --- evennia/commands/default/building.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index f0ae108f00..ad613609ba 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2856,7 +2856,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", "save", "delete", "menu", "olc", "update", "edit") locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" @@ -2907,7 +2907,8 @@ 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: From ff6deb90c1bd2cc0087d2012b72364db6a827789 Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Fri, 19 Oct 2018 19:16:02 -0500 Subject: [PATCH 65/79] Modify tests so they pass. Some new tests throw exceptions --- evennia/commands/default/tests.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index be47a06aac..0007a84e07 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -461,25 +461,25 @@ class TestBuilding(CommandTest): self.call(building.CmdSpawn(), "/list", "Key ") # @span/edit (missing prototype) - self.call( + msg = self.call( building.CmdSpawn(), - '/edit', - '@spawn: Extra switch "/edit" ignored.|Usage: @spawn or {key: value, ...}\n (2 existing prototypes. Use /list to inspect)') - # assert 'Prototype wizard' in msg + '/edit') + # '@spawn: Extra switch "/edit" ignored.|Usage: @spawn or {key: value, ...}\n (2 existing prototypes. Use /list to inspect)') + assert 'Prototype wizard' in msg # @spawn/edit with valid prototype - # with self.assertRaises(AttributeError): - self.call( - building.CmdSpawn(), - '/edit BALL', - '@spawn: Extra switch "/edit" ignored.|Spawned Ball(#13).') + with self.assertRaises(AttributeError): + self.call( + building.CmdSpawn(), + '/edit BALL', + '@spawn: Extra switch "/edit" ignored.|Spawned Ball(#13).') # @spawn/edit with invalid prototype - #`with self.assertRaises(AttributeError): - self.call( - building.CmdSpawn(), - '/edit NO_EXISTS', - '@spawn: Extra switch "/edit" ignored.|No prototype named \'NO_EXISTS\'.') + with self.assertRaises(AttributeError): + self.call( + building.CmdSpawn(), + '/edit NO_EXISTS', + '@spawn: Extra switch "/edit" ignored.|No prototype named \'NO_EXISTS\'.') # @spawn/examine (missing prototype) self.call( @@ -491,7 +491,8 @@ class TestBuilding(CommandTest): self.call( building.CmdSpawn(), '/examine BALL', - '@spawn: Extra switch "/examine" ignored.|Spawned Ball(#14).') + # '@spawn: Extra switch "/examine" ignored.|Spawned Ball(#14).') + '@spawn: Extra switch "/examine" ignored.|Spawned Ball(#13).') # @spawn/examine with invalid prototype self.call( From 931290a7a0a8710afd8128b7962996efb2c21b12 Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Sat, 20 Oct 2018 20:11:41 -0500 Subject: [PATCH 66/79] Handle '@spawn/edit ' (no prototype key given) brings OLC menu. Handle '@spawn/edit testball' brings OLC menu. Handle '@spawn/edit NO_EXISTS' that returns error --- evennia/commands/default/building.py | 6 +++++- evennia/commands/default/tests.py | 27 +++++++++++++++------------ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index ed6f6d273f..65fedaf5c2 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2913,7 +2913,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): 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))) @@ -2921,6 +2921,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 diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 59fdc7db69..1cbb81b090 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -479,25 +479,28 @@ class TestBuilding(CommandTest): self.call(building.CmdSpawn(), "/list", "Key ") # @span/edit (missing prototype) + # brings up olc menu msg = self.call( building.CmdSpawn(), '/edit') - # '@spawn: Extra switch "/edit" ignored.|Usage: @spawn or {key: value, ...}\n (2 existing prototypes. Use /list to inspect)') - assert 'Prototype wizard' in msg + assert msg.startswith('______________________________________________________________________________\n\n --- Prototype wizard --- \n\n') # @spawn/edit with valid prototype - with self.assertRaises(AttributeError): - self.call( - building.CmdSpawn(), - '/edit BALL', - '@spawn: Extra switch "/edit" ignored.|Spawned Ball(#13).') + self.call( + building.CmdSpawn(), + '/edit testball') + # TODO: OLC menu comes up but it gives no + # indication of testball prototype being + # edited ... Is this correct? + # On top of OCL being shown, msg is preceded + # by Room(#1)... + assert 'Prototype wizard' in msg # @spawn/edit with invalid prototype - with self.assertRaises(AttributeError): - self.call( - building.CmdSpawn(), - '/edit NO_EXISTS', - '@spawn: Extra switch "/edit" ignored.|No prototype named \'NO_EXISTS\'.') + msg = self.call( + building.CmdSpawn(), + '/edit NO_EXISTS', + "No prototype 'NO_EXISTS' was found.") # @spawn/examine (missing prototype) self.call( From 9f26d4b728d1cc98e71f3207a429bdb5245c5f94 Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Sat, 20 Oct 2018 20:12:55 -0500 Subject: [PATCH 67/79] code cleanup --- evennia/commands/default/tests.py | 1 - 1 file changed, 1 deletion(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 1cbb81b090..d8657a7113 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -512,7 +512,6 @@ class TestBuilding(CommandTest): self.call( building.CmdSpawn(), '/examine BALL', - # '@spawn: Extra switch "/examine" ignored.|Spawned Ball(#14).') '@spawn: Extra switch "/examine" ignored.|Spawned Ball(#13).') # @spawn/examine with invalid prototype From f70fa467f5d0845ef0cf9e413413d06eb70f23b4 Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Sat, 20 Oct 2018 20:22:11 -0500 Subject: [PATCH 68/79] Test @spawn/edit BALL (synonym) --- evennia/commands/default/tests.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index d8657a7113..8dce5ccc46 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -486,7 +486,7 @@ class TestBuilding(CommandTest): assert msg.startswith('______________________________________________________________________________\n\n --- Prototype wizard --- \n\n') # @spawn/edit with valid prototype - self.call( + msg = self.call( building.CmdSpawn(), '/edit testball') # TODO: OLC menu comes up but it gives no @@ -496,6 +496,12 @@ class TestBuilding(CommandTest): # by Room(#1)... assert 'Prototype wizard' in msg + # @spawn/edit with valid prototype (synomym) + msg = self.call( + building.CmdSpawn(), + '/edit BALL') + assert 'Prototype wizard' in msg + # @spawn/edit with invalid prototype msg = self.call( building.CmdSpawn(), From b5e87409de137d9c6fe1f0f4877d7ecc7c9e72aa Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 21 Oct 2018 15:45:17 +0200 Subject: [PATCH 69/79] Update devel changelog with latest mergers --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d00135dbf3..8a31aecb97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ - 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. + +### Utils + +- Added more unit tests. ## Evennia 0.8 (2018) From 1ccada6e7128f94e17ea1f6088ee1f18da02d76d Mon Sep 17 00:00:00 2001 From: Brenden Tuck Date: Sun, 21 Oct 2018 10:03:54 -0400 Subject: [PATCH 70/79] Fix #1686 cursor placement issues --- .../web/webclient/static/webclient/js/plugins/history.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/evennia/web/webclient/static/webclient/js/plugins/history.js b/evennia/web/webclient/static/webclient/js/plugins/history.js index c33dbcabf9..60b1a2b163 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/history.js +++ b/evennia/web/webclient/static/webclient/js/plugins/history.js @@ -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; From 1cbbdf5410e82309669edfa9eaf42cc3f07ecbcd Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Sun, 21 Oct 2018 13:43:36 -0500 Subject: [PATCH 71/79] Harden test '@spawn/edit ' by asserting that olc_menu has been loaded with valid-prototype.UX Enhancement to OLC menu. Underneath the title, display 'Editing: key(prototype_key)' of the prototype being edited. If none, show blank line --- evennia/commands/default/tests.py | 20 +++++++++++++------- evennia/prototypes/menus.py | 12 ++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 8dce5ccc46..f285cc9614 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -478,29 +478,33 @@ class TestBuilding(CommandTest): # Test listing commands self.call(building.CmdSpawn(), "/list", "Key ") - # @span/edit (missing prototype) + # @spawn/edit (missing prototype) # brings up olc menu msg = self.call( building.CmdSpawn(), '/edit') - assert msg.startswith('______________________________________________________________________________\n\n --- Prototype wizard --- \n\n') + assert 'Prototype wizard' in msg # @spawn/edit with valid prototype + # brings up olc menu loaded with prototype msg = self.call( building.CmdSpawn(), '/edit testball') - # TODO: OLC menu comes up but it gives no - # indication of testball prototype being - # edited ... Is this correct? - # On top of OCL being shown, msg is preceded - # by Room(#1)... 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( @@ -518,6 +522,8 @@ class TestBuilding(CommandTest): self.call( building.CmdSpawn(), '/examine BALL', + # FIXME: should this print the existing prototype + # instead of spawning it? '@spawn: Extra switch "/examine" ignored.|Spawned Ball(#13).') # @spawn/examine with invalid prototype diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 9605ea1a8f..c10f32429f 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -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 = [] From e956bcf3be4c9a6cae51b01c4f04e4e9f8dbe31b Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Sun, 21 Oct 2018 14:15:34 -0500 Subject: [PATCH 72/79] Add 'examine' as valid switch alias for 'show' with its corresponding tests --- evennia/commands/default/building.py | 2 +- evennia/commands/default/tests.py | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 65fedaf5c2..503a9c15e9 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2856,7 +2856,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): key = "@spawn" aliases = ["olc"] - switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc", "update", "edit") + switch_options = ("noloc", "search", "list", "show", "examine", "save", "delete", "menu", "olc", "update", "edit") locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index f285cc9614..7b2c78951b 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -513,24 +513,25 @@ class TestBuilding(CommandTest): "No prototype 'NO_EXISTS' was found.") # @spawn/examine (missing prototype) - self.call( + # lists all prototypes that exist + msg = self.call( building.CmdSpawn(), - '/examine', - '@spawn: Extra switch "/examine" ignored.|Usage: @spawn or {key: value, ...}\n (2 existing prototypes. Use /list to inspect)') + '/examine') + assert 'testball' in msg and 'testprot' in msg # @spawn/examine with valid prototype - self.call( + # prints the prototype + msg = self.call( building.CmdSpawn(), - '/examine BALL', - # FIXME: should this print the existing prototype - # instead of spawning it? - '@spawn: Extra switch "/examine" ignored.|Spawned Ball(#13).') + '/examine BALL') + assert 'Ball' in msg and 'testball' in msg # @spawn/examine with invalid prototype + # shows error self.call( building.CmdSpawn(), '/examine NO_EXISTS', - '@spawn: Extra switch "/examine" ignored.|No prototype named \'NO_EXISTS\'.') + "No prototype 'NO_EXISTS' was found.") class TestComms(CommandTest): From 84f1cad6a250411c3ff8707870fbb13a78517780 Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 22 Oct 2018 20:33:56 +0000 Subject: [PATCH 73/79] Renames methods to web_get_* and fixes tests. --- evennia/accounts/tests.py | 6 +++--- evennia/objects/tests.py | 4 ++-- evennia/typeclasses/models.py | 24 +++++++----------------- 3 files changed, 12 insertions(+), 22 deletions(-) diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index fbf976d644..be07a30cbd 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -64,14 +64,14 @@ class TestDefaultAccount(TestCase): "Get URL for account detail page on website" self.account = create.create_account("TestAccount%s" % randint(100000, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) - self.assertTrue(self.account.web_detail_url()) + self.assertTrue(self.account.web_get_detail_url()) def test_admin_url(self): "Get object's URL for access via Admin pane" self.account = create.create_account("TestAccount%s" % randint(100000, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) - self.assertTrue(self.account.web_admin_url()) - self.assertTrue(self.account.web_admin_url() != '#') + self.assertTrue(self.account.web_get_admin_url()) + self.assertTrue(self.account.web_get_admin_url() != '#') def test_password_validation(self): "Check password validators deny bad passwords" diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index f317f2c39c..824007f287 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -5,7 +5,7 @@ class DefaultObjectTest(EvenniaTest): def test_urls(self): "Make sure objects are returning URLs" self.assertTrue(self.char1.get_absolute_url()) - self.assertTrue('admin' in self.char1.get_admin_url()) + self.assertTrue('admin' in self.char1.web_get_admin_url()) self.assertTrue(self.room1.get_absolute_url()) - self.assertTrue('admin' in self.room1.get_admin_url()) \ No newline at end of file + self.assertTrue('admin' in self.room1.web_get_admin_url()) \ No newline at end of file diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 098606b53c..d549efb8ff 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -741,7 +741,7 @@ class TypedObject(SharedMemoryModel): # Web/Django methods # - def web_admin_url(self): + def web_get_admin_url(self): """ Returns the URI path for the Django Admin page for this object. @@ -755,7 +755,7 @@ class TypedObject(SharedMemoryModel): return reverse("admin:%s_%s_change" % (content_type.app_label, content_type.model), args=(self.id,)) @classmethod - def web_create_url(cls): + def web_get_create_url(cls): """ Returns the URI path for a View that allows users to create new instances of this object. @@ -783,7 +783,7 @@ class TypedObject(SharedMemoryModel): try: return reverse('%s-create' % cls._meta.verbose_name.lower()) except: return '#' - def web_detail_url(self): + def web_get_detail_url(self): """ Returns the URI path for a View that allows users to view details for this object. @@ -811,7 +811,7 @@ class TypedObject(SharedMemoryModel): try: return reverse('%s-detail' % self._meta.verbose_name.lower(), kwargs={'pk': self.pk, 'slug': slugify(self.name)}) except: return '#' - def web_update_url(self): + def web_get_update_url(self): """ Returns the URI path for a View that allows users to update this object. @@ -839,7 +839,7 @@ class TypedObject(SharedMemoryModel): try: return reverse('%s-update' % self._meta.verbose_name.lower(), kwargs={'pk': self.pk, 'slug': slugify(self.name)}) except: return '#' - def web_delete_url(self): + def web_get_delete_url(self): """ Returns the URI path for a View that allows users to delete this object. @@ -866,15 +866,5 @@ class TypedObject(SharedMemoryModel): try: return reverse('%s-delete' % self._meta.verbose_name.lower(), kwargs={'pk': self.pk, 'slug': slugify(self.name)}) except: return '#' - def get_absolute_url(self): - """ - Django construct; used by Django Sites framework and within the Admin - panel for reverse linking to the object detail page. - - https://docs.djangoproject.com/en/2.1/ref/models/instances/#get-absolute-url - - Returns: - path (str): URI path to object detail page, if defined. - - """ - return self.web_detail_url() \ No newline at end of file + # Used by Django Sites/Admin + get_absolute_url = web_get_detail_url \ No newline at end of file From e1431dca9471754dfed7b71b7da4fe416a76bc2b Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 22 Oct 2018 23:32:12 +0200 Subject: [PATCH 74/79] Made some pep8 fixes --- evennia/accounts/tests.py | 14 ++- evennia/objects/objects.py | 10 +- evennia/objects/tests.py | 7 +- evennia/typeclasses/models.py | 185 ++++++++++++++++++---------------- 4 files changed, 113 insertions(+), 103 deletions(-) diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index d60db0fce5..084e8a6a66 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -5,12 +5,9 @@ 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.utils import create from evennia.utils.test_resources import EvenniaTest -from django.conf import settings - class TestAccountSessionHandler(TestCase): "Check AccountSessionHandler class" @@ -73,7 +70,7 @@ class TestDefaultAccount(TestCase): self.account = create.create_account("TestAccount%s" % randint(100000, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) self.assertTrue(self.account.web_get_detail_url()) - + def test_admin_url(self): "Get object's URL for access via Admin pane" self.account = create.create_account("TestAccount%s" % randint(100000, 999999), @@ -211,17 +208,18 @@ class TestDefaultAccount(TestCase): 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) \ No newline at end of file + self.assertFalse(self.account.db._playable_characters, + 'Playable character list is not empty! %s' % self.account.db._playable_characters) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 7e1a13722e..919da40a7c 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -7,15 +7,11 @@ entities. """ import time import inflect -import re from builtins import object from future.utils import with_metaclass from collections import defaultdict from django.conf import settings -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.typeclasses.attributes import NickHandler @@ -218,7 +214,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 @@ -328,7 +324,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): # look at 'an egg'. self.aliases.add(singular, category="plural_key") return singular, plural - + def search(self, searchdata, global_search=False, use_nicks=True, @@ -922,7 +918,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): 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): diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index 824007f287..2c014cbe77 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -1,11 +1,12 @@ from evennia.utils.test_resources import EvenniaTest + class DefaultObjectTest(EvenniaTest): - + 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()) \ No newline at end of file + self.assertTrue('admin' in self.room1.web_get_admin_url()) diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index d549efb8ff..61dddc0c80 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -740,131 +740,146 @@ class TypedObject(SharedMemoryModel): # # 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,)) - + 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 + 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' % cls._meta.verbose_name.lower()) - except: return '#' - + try: + return reverse('%s-create' % cls._meta.verbose_name.lower()) + except: + return '#' + def web_get_detail_url(self): """ - Returns the URI path for a View that allows users to view details for + 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[\w\d\-]+)/(?P[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' % self._meta.verbose_name.lower(), 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[\w\d\-]+)/(?P[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' % self._meta.verbose_name.lower(), 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[\w\d\-]+)/(?P[0-9]+)/delete/$', CharDeleteView.as_view(), name='character-delete') - + url(r'characters/(?P[\w\d\-]+)/(?P[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 delete this object is the developer's + 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' % self._meta.verbose_name.lower(), + 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[\w\d\-]+)/(?P[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' % self._meta.verbose_name.lower(), + 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[\w\d\-]+)/(?P[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' % self._meta.verbose_name.lower(), kwargs={'pk': self.pk, 'slug': slugify(self.name)}) - except: return '#' - + try: + return reverse('%s-delete' % self._meta.verbose_name.lower(), + kwargs={'pk': self.pk, 'slug': slugify(self.name)}) + except: + return '#' + # Used by Django Sites/Admin - get_absolute_url = web_get_detail_url \ No newline at end of file + get_absolute_url = web_get_detail_url From 8e1c7fadce1e4f5924f9119fe06aae114185c15b Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 22 Oct 2018 21:57:38 +0000 Subject: [PATCH 75/79] Fixes failing tests. --- evennia/accounts/tests.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index a6f9d5bcbf..2f2d6cc7eb 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -84,16 +84,14 @@ class TestDefaultGuest(EvenniaTest): self.assertFalse(account, 'Two guest accounts were created with a single entry on the guest list!') settings.GUEST_ENABLED = False - -class TestDefaultAccount(EvenniaTest): - "Check DefaultAccount class" - + +class TestDefaultAccountAuth(EvenniaTest): + def setUp(self): - self.s1 = MagicMock() - self.s1.puppet = None - self.s1.sessid = 0 + 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): @@ -138,10 +136,6 @@ class TestDefaultAccount(EvenniaTest): result, error = DefaultAccount.validate_username('xx') self.assertFalse(result, "2-character username passed validation.") - def tearDown(self): - if hasattr(self, "account"): - self.account.delete() - def test_password_validation(self): "Check password validators deny bad passwords" @@ -167,6 +161,14 @@ class TestDefaultAccount(EvenniaTest): # Try setting a better password (test for False; returns None on success) self.assertFalse(self.account.set_password('Mxyzptlk')) +class TestDefaultAccount(TestCase): + "Check DefaultAccount class" + + def setUp(self): + self.s1 = MagicMock() + self.s1.puppet = None + self.s1.sessid = 0 + def test_puppet_object_no_object(self): "Check puppet_object method called with no object param" From 70a21265df6706a663a9bd65d2721e55c43ca11d Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 22 Oct 2018 22:12:54 +0000 Subject: [PATCH 76/79] Fixes failed unit tests. --- evennia/accounts/tests.py | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 81ac25c2a3..2cfd9350cb 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -6,12 +6,14 @@ from unittest import TestCase from django.test import override_settings from evennia.accounts.accounts import AccountSessionHandler - from evennia.accounts.accounts import DefaultAccount, DefaultGuest from evennia.server.session import Session +from evennia.utils.test_resources import EvenniaTest from evennia.utils import create from evennia.utils.test_resources import EvenniaTest +from django.conf import settings + class TestAccountSessionHandler(TestCase): "Check AccountSessionHandler class" @@ -134,43 +136,30 @@ class TestDefaultAccountAuth(EvenniaTest): result, error = DefaultAccount.validate_username('xx') self.assertFalse(result, "2-character username passed validation.") - def test_absolute_url(self): - "Get URL for account detail page on website" - self.account = create.create_account("TestAccount%s" % randint(100000, 999999), - email="test@test.com", password="testpassword", typeclass=DefaultAccount) - self.assertTrue(self.account.web_get_detail_url()) - - def test_admin_url(self): - "Get object's URL for access via Admin pane" - self.account = create.create_account("TestAccount%s" % randint(100000, 999999), - email="test@test.com", password="testpassword", typeclass=DefaultAccount) - self.assertTrue(self.account.web_get_admin_url()) - self.assertTrue(self.account.web_get_admin_url() != '#') - def test_password_validation(self): "Check password validators deny bad passwords" - self.account = create.create_account("TestAccount%s" % randint(100000, 999999), + 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(self.account.validate_password(bad, account=self.account)[0]) + 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(self.account.validate_password(better, account=self.account)[0]) + self.assertTrue(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), + 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) + self.assertRaises(ValidationError, account.set_password, bad) # Try setting a better password (test for False; returns None on success) - self.assertFalse(self.account.set_password('Mxyzptlk')) + self.assertFalse(account.set_password('Mxyzptlk')) class TestDefaultAccount(TestCase): "Check DefaultAccount class" @@ -285,18 +274,17 @@ class TestDefaultAccount(TestCase): 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) + self.assertFalse(self.account.db._playable_characters, 'Playable character list is not empty! %s' % self.account.db._playable_characters) \ No newline at end of file From 0b6d8699022e2b30b34332970fd4ce6538212c00 Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 22 Oct 2018 22:26:29 +0000 Subject: [PATCH 77/79] Fixes failed tests, hopefully for real this time. --- evennia/accounts/tests.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 2cfd9350cb..5e8e58cf9f 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -111,8 +111,9 @@ class TestDefaultAccountAuth(EvenniaTest): self.assertTrue(account, 'New account should have been created.') # Try creating a duplicate account - account, errors = DefaultAccount.create(username='Ziggy', password='starman11') - self.assertFalse(account, 'Duplicate account name should not have been allowed.') + 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." @@ -147,10 +148,11 @@ class TestDefaultAccountAuth(EvenniaTest): "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(0, 9), + account = create.create_account("TestAccount%s" % randint(100000, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) from django.core.exceptions import ValidationError @@ -160,6 +162,7 @@ class TestDefaultAccountAuth(EvenniaTest): # 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" From b6b07ccdb51449def09b4d5db8941d8d379608eb Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 23 Oct 2018 01:04:25 +0200 Subject: [PATCH 78/79] Update CHANGELOG, pep8 fixes --- CHANGELOG.md | 26 +++- evennia/accounts/accounts.py | 160 ++++++++++++------------- evennia/accounts/tests.py | 48 ++++---- evennia/commands/default/unloggedin.py | 30 ++--- evennia/objects/objects.py | 146 +++++++++++----------- evennia/objects/tests.py | 13 +- evennia/scripts/scripts.py | 14 +-- evennia/server/throttle.py | 55 +++++---- evennia/server/validators.py | 41 ++++--- evennia/settings_default.py | 21 ++-- 10 files changed, 290 insertions(+), 264 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a31aecb97..da326f766f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,30 @@ - Add the Portal uptime to the `@time` command. - Make the `@link` command first make a local search before a global search. +### Typeclasses + +- Add new methods on all typeclasses, useful specifically for viewing the object in the web/admin: + + `web_get_admin_url()`: Returns a path that, if followed, will display the object in the Admin backend. + + `web_get_create_url()`: Returns a path for a view allowing the creation of new instances of this object. + + `web_get_absolute_url()`: Django construct; returns a path that should display the object in a DetailView. + + `web_get_update_url()`: Returns a path that should display the object in an UpdateView. + + `web_get_delete_url()`: Returns a path that should display the object in a DeleteView. +- All typeclasses has 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 you can't fake names with similar-looking Unicode + chars. + + `validate_username`: Mechanism for validating a username. + + `validate_password`: Mechanism for validating a password. + + `set_password`: Apply password to account, using validation checks. + + + ### Utils - Added more unit tests. @@ -34,7 +58,7 @@ to terminal and can be stopped with Ctrl-C. Using `evennia reload`, or reloading in-game, will return Server to normal daemon operation. - For validating passwords, use safe Django password-validation backend instead of custom Evennia one. -- Alias `evennia restart` to mean the same as `evennia reload`. +- Alias `evennia restart` to mean the same as `evennia reload`. ### Prototype changes diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 7e1ddb80e8..3fed8dc25e 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -370,40 +370,40 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): 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: @@ -413,49 +413,49 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): 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 + 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: @@ -465,19 +465,19 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): 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: @@ -486,49 +486,49 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): 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 + 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 + 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: @@ -541,14 +541,14 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): 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): """ @@ -608,48 +608,48 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): super(DefaultAccount, self).set_password(password) 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 + 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) @@ -678,50 +678,50 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): "\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, + account.key, account, ip=ip, typeclass=character_typeclass, permissions=permissions, home=character_home ) errors.extend(errs) - + if character: # Update playable character list account.db._playable_characters.append(character) - + # We need to set this to have @ic auto-connect to this character account.db._last_puppet = character @@ -731,7 +731,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): # 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 @@ -1384,7 +1384,7 @@ class DefaultGuest(DefaultAccount): This class is used for guest logins. Unlike Accounts, Guests and their characters are deleted after disconnection. """ - + @classmethod def create(cls, **kwargs): """ @@ -1392,31 +1392,31 @@ class DefaultGuest(DefaultAccount): 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: @@ -1433,20 +1433,20 @@ class DefaultGuest(DefaultAccount): 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, + 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, @@ -1454,7 +1454,7 @@ class DefaultGuest(DefaultAccount): 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): diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 5e8e58cf9f..1d31e93e2c 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -7,10 +7,8 @@ from unittest import TestCase from django.test import override_settings from evennia.accounts.accounts import AccountSessionHandler from evennia.accounts.accounts import DefaultAccount, DefaultGuest -from evennia.server.session import Session from evennia.utils.test_resources import EvenniaTest from evennia.utils import create -from evennia.utils.test_resources import EvenniaTest from django.conf import settings @@ -61,78 +59,78 @@ class TestAccountSessionHandler(TestCase): def test_count(self): "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.") @@ -277,17 +275,17 @@ class TestDefaultAccount(TestCase): 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) \ No newline at end of file + self.assertFalse(self.account.db._playable_characters, 'Playable character list is not empty! %s' % self.account.db._playable_characters) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 7ff2c9a29a..607c95439b 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -2,17 +2,11 @@ Commands that are available from the connect screen. """ import re -import time import datetime 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.comms.models import ChannelDB -from evennia.server.models import ServerConfig from evennia.server.sessionhandler import SESSIONS -from evennia.server.throttle import Throttle from evennia.utils import class_from_module, create, logger, utils, gametime from evennia.commands.cmdhandler import CMD_LOGINSTART @@ -26,6 +20,7 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate", MULTISESSION_MODE = settings.MULTISESSION_MODE CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE + def create_guest_account(session): """ Creates a guest account/character for this session, if one is available. @@ -40,10 +35,10 @@ def create_guest_account(session): """ enabled = settings.GUEST_ENABLED address = session.address - + # Get account class Guest = class_from_module(settings.BASE_GUEST_TYPECLASS) - + # Get an available guest account # authenticate() handles its own throttling account, errors = Guest.authenticate(ip=address) @@ -53,6 +48,7 @@ def create_guest_account(session): session.msg("|R%s|n" % '\n'.join(errors)) return enabled, None + def create_normal_account(session, name, password): """ Creates an account with the given name and password. @@ -67,9 +63,9 @@ def create_normal_account(session, name, password): """ # Get account class Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) - + address = session.address - + # Match account name and check password # authenticate() handles all its own throttling account, errors = Account.authenticate(username=name, password=password, ip=address, session=session) @@ -108,19 +104,19 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS): """ session = self.caller address = session.address - + args = self.args # extract double quote parts parts = [part.strip() for part in re.split(r"\"", args) if part.strip()] 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": # Get Guest typeclass Guest = class_from_module(settings.BASE_GUEST_TYPECLASS) - + account, errors = Guest.authenticate(ip=address) if account: session.sessionhandler.login(session, account) @@ -128,14 +124,14 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS): else: session.msg("|R%s|n" % '\n'.join(errors)) return - + if len(parts) != 2: session.msg("\n\r Usage (without <>): connect ") return # Get account class Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) - + name, password = parts account, errors = Account.authenticate(username=name, password=password, ip=address, session=session) if account: @@ -168,7 +164,7 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): args = self.args.strip() address = session.address - + # Get account class Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) @@ -182,7 +178,7 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): "\nIf or contains spaces, enclose it in double quotes." session.msg(string) return - + username, password = parts # everything's ok. Create the new account account. diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index b829ab7165..f41dd02d02 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -195,7 +195,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): # 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 @@ -863,66 +863,66 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): string = "This place should not exist ... contact an admin." 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): @@ -1895,81 +1895,81 @@ class DefaultCharacter(DefaultObject): # 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)" - + @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: # 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 - + # 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): """ Setup character-specific security. @@ -2097,60 +2097,60 @@ class DefaultRoom(DefaultObject): """ 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): @@ -2230,13 +2230,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. @@ -2274,72 +2274,72 @@ class DefaultExit(DefaultObject): return exit_cmdset # 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): diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index cb999bc616..6bfe34248f 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -1,10 +1,11 @@ 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) @@ -12,7 +13,7 @@ class DefaultObjectTest(EvenniaTest): 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) @@ -20,7 +21,7 @@ class DefaultObjectTest(EvenniaTest): 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) @@ -28,7 +29,7 @@ class DefaultObjectTest(EvenniaTest): 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) @@ -43,4 +44,4 @@ class DefaultObjectTest(EvenniaTest): 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()) \ No newline at end of file + self.assertTrue('admin' in self.room1.web_get_admin_url()) diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index ba6f7edff2..e611ede0e2 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -323,31 +323,31 @@ class DefaultScript(ScriptBase): or describe a state that changes under certain conditions. """ - + @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): diff --git a/evennia/server/throttle.py b/evennia/server/throttle.py index 944b7f880e..a1e6092844 100644 --- a/evennia/server/throttle.py +++ b/evennia/server/throttle.py @@ -2,25 +2,26 @@ 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 @@ -32,67 +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, failmsg='Exceeded threshold.'): """ 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) @@ -110,5 +111,3 @@ class Throttle(object): return False else: return False - - \ No newline at end of file diff --git a/evennia/server/validators.py b/evennia/server/validators.py index fdadeda6e3..faa1aa68c8 100644 --- a/evennia/server/validators.py +++ b/evennia/server/validators.py @@ -4,31 +4,32 @@ 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: @@ -37,33 +38,36 @@ class EvenniaUsernameAvailabilityValidator: 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): @@ -76,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 - ) \ No newline at end of file + "%s From a terminal client, you can also use a phrase of multiple words if " + "you enclose the password in double quotes." % self.policy + ) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 7926ed276c..40c36e6f7e 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -222,7 +222,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 +411,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" @@ -810,7 +813,7 @@ AUTH_PASSWORD_VALIDATORS = [ {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, {'NAME': 'evennia.server.validators.EvenniaPasswordValidator'}] - + # Username validation plugins AUTH_USERNAME_VALIDATORS = [ {'NAME': 'django.contrib.auth.validators.ASCIIUsernameValidator'}, @@ -830,7 +833,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. From 3954586f0777ea6a13895f3f62ce1a3a7ab53ff3 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 23 Oct 2018 15:17:59 +0200 Subject: [PATCH 79/79] Fix lagging pillow requirement for Windows. Resolves #1702. --- win_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/win_requirements.txt b/win_requirements.txt index 5e23f6fe67..02984ec6ca 100644 --- a/win_requirements.txt +++ b/win_requirements.txt @@ -5,7 +5,7 @@ pypiwin32 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