Fix merge conflicts

This commit is contained in:
Griatch 2019-01-01 15:19:20 +01:00
commit 56ce402f97
89 changed files with 5435 additions and 818 deletions

View file

@ -11,6 +11,77 @@ Update to Python 3
- Removed default `@delaccount` command, incorporating as `@account/delete` instead. Added confirmation
question.
- Add new `@force` command to have another object perform a command.
- Add the Portal uptime to the `@time` command.
- Make the `@link` command first make a local search before a global search.
### Web
Web/Django standard initiative (@strikaco)
- Features
- Adds a series of web-based forms and generic class-based views
- Accounts
- Register - Enhances registration; allows optional collection of email address
- Form - Adds a generic Django form for creating Accounts from the web
- Characters
- Create - Authenticated users can create new characters from the website (requires associated form)
- Detail - Authenticated and authorized users can view select details about characters
- List - Authenticated and authorized users can browse a list of all characters
- Manage - Authenticated users can edit or delete owned characters from the web
- Form - Adds a generic Django form for creating characters from the web
- Channels
- Detail - Authorized users can view channel logs from the web
- List - Authorized users can browse a list of all channels
- Help Entries
- Detail - Authorized users can view help entries from the web
- List - Authorized users can browse a list of all help entries from the web
- Navbar changes
- Characters - Link to character list
- Channels - Link to channel list
- Help - Link to help entry list
- Puppeting
- Users can puppet their own characters within the context of the website
- Dropdown
- Link to create characters
- Link to manage characters
- Link to quick-select puppets
- Link to password change workflow
- Functions
- Updates Bootstrap to v4 stable
- Enables use of Django Messages framework to communicate with users in browser
- Implements webclient/website `_shared_login` functionality as Django middleware
- 'account' and 'puppet' are added to all request contexts for authenticated users
- Adds unit tests for all web views
- Cosmetic
- Prettifies Django 'forgot password' workflow (requires SMTP to actually function)
- Prettifies Django 'change password' workflow
- Bugfixes
- Fixes bug on login page where error messages were not being displayed
### Typeclasses
- Add new methods on all typeclasses, useful specifically for object handling from the website/admin:
+ `web_get_admin_url()`: Returns the path to the object detail page in the Admin backend.
+ `web_get_create_url()`: Returns the path to the typeclass' creation page on the website, if implemented.
+ `web_get_absolute_url()`: Returns the path to the object's detail page on the website, if implemented.
+ `web_get_update_url()`: Returns the path to the object's update page on the website, if implemented.
+ `web_get_delete_url()`: Returns the path to the object's delete page on the website, if implemented.
- All typeclasses have new helper class method `create`, which encompasses useful functionality
that used to be embedded for example in the respective `@create` or `@connect` commands.
- DefaultAccount now has new class methods implementing many things that used to be in unloggedin
commands (these can now be customized on the class instead):
+ `is_banned()`: Checks if a given username or IP is banned.
+ `get_username_validators`: Return list of validators for username validation (see
`settings.AUTH_USERNAME_VALIDATORS`)
+ `authenticate`: Method to check given username/password.
+ `normalize_username`: Normalizes names so (for Unicode environments) users cannot mimic existing usernames by replacing select characters with visually-similar Unicode chars.
+ `validate_username`: Mechanism for validating a username based on predefined Django validators.
+ `validate_password`: Mechanism for validating a password based on predefined Django validators.
+ `set_password`: Apply password to account, using validation checks.
### Utils
- Added more unit tests.
### Utils
@ -20,6 +91,11 @@ Update to Python 3
## Evennia 0.8 (2018)
### Requirements
- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0
- Add `inflect` dependency for automatic pluralization of object names.
### Server/Portal
- Removed `evennia_runner`, completely refactor `evennia_launcher.py` (the 'evennia' program)
@ -103,7 +179,6 @@ Update to Python 3
### General
- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0
- Start structuring the `CHANGELOG` to list features in more detail.
- Docker image `evennia/evennia:develop` is now auto-built, tracking the develop branch.
- Inflection and grouping of multiple objects in default room (an box, three boxes)

7
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,7 @@
# Contributing to Evennia
Evennia utilizes GitHub for issue tracking and contributions:
- Reporting Issues issues/bugs and making feature requests can be done [in the issue tracker](https://github.com/evennia/evennia/issues).
- Evennia's documentation is a [wiki](https://github.com/evennia/evennia/wiki) that everyone can contribute to. Further
instructions and details about contributing is found [here](https://github.com/evennia/evennia/wiki/Contributing).

View file

@ -105,7 +105,7 @@ def _create_version():
"git rev-parse --short HEAD",
shell=True, cwd=root, stderr=STDOUT).strip().decode()
version = "%s (rev %s)" % (version, rev)
except (IOError, CalledProcessError):
except (IOError, CalledProcessError, OSError):
# ignore if we cannot get to git
pass
return version

View file

@ -10,19 +10,22 @@ character object, so you should customize that
instead for most things).
"""
import re
import time
from django.conf import settings
from django.contrib.auth import password_validation
from django.core.exceptions import ValidationError
from django.contrib.auth import authenticate, password_validation
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.utils import timezone
from django.utils.module_loading import import_string
from evennia.typeclasses.models import TypeclassBase
from evennia.accounts.manager import AccountManager
from evennia.accounts.models import AccountDB
from evennia.objects.models import ObjectDB
from evennia.comms.models import ChannelDB
from evennia.commands import cmdhandler
from evennia.utils import logger
from evennia.server.models import ServerConfig
from evennia.server.throttle import Throttle
from evennia.utils import class_from_module, create, logger
from evennia.utils.utils import (lazy_property, to_str,
make_iter, is_iter,
variable_from_module)
@ -32,6 +35,7 @@ from evennia.commands.cmdsethandler import CmdSetHandler
from django.utils.translation import ugettext as _
from future.utils import with_metaclass
from random import getrandbits
__all__ = ("DefaultAccount",)
@ -43,6 +47,9 @@ _MAX_NR_CHARACTERS = settings.MAX_NR_CHARACTERS
_CMDSET_ACCOUNT = settings.CMDSET_ACCOUNT
_CONNECT_CHANNEL = None
# Create throttles for too many account-creations and login attempts
CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60)
LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60)
class AccountSessionHandler(object):
"""
@ -189,6 +196,19 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
@lazy_property
def sessions(self):
return AccountSessionHandler(self)
# Do not make this a lazy property; the web UI will not refresh it!
@property
def characters(self):
# Get playable characters list
objs = self.db._playable_characters
# Rebuild the list if legacy code left null values after deletion
if None in objs:
objs = [x for x in self.db._playable_characters if x]
self.db._playable_characters = objs
return objs
# session-related methods
@ -359,6 +379,189 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
puppet = property(__get_single_puppet)
# utility methods
@classmethod
def is_banned(cls, **kwargs):
"""
Checks if a given username or IP is banned.
Kwargs:
ip (str, optional): IP address.
username (str, optional): Username.
Returns:
is_banned (bool): Whether either is banned or not.
"""
ip = kwargs.get('ip', '').strip()
username = kwargs.get('username', '').lower().strip()
# Check IP and/or name bans
bans = ServerConfig.objects.conf("server_bans")
if bans and (any(tup[0] == username for tup in bans if username) or
any(tup[2].match(ip) for tup in bans if ip and tup[2])):
return True
return False
@classmethod
def get_username_validators(cls, validator_config=getattr(settings, 'AUTH_USERNAME_VALIDATORS', [])):
"""
Retrieves and instantiates validators for usernames.
Args:
validator_config (list): List of dicts comprising the battery of
validators to apply to a username.
Returns:
validators (list): List of instantiated Validator objects.
"""
objs = []
for validator in validator_config:
try:
klass = import_string(validator['NAME'])
except ImportError:
msg = "The module in NAME could not be imported: %s. Check your AUTH_USERNAME_VALIDATORS setting."
raise ImproperlyConfigured(msg % validator['NAME'])
objs.append(klass(**validator.get('OPTIONS', {})))
return objs
@classmethod
def authenticate(cls, username, password, ip='', **kwargs):
"""
Checks the given username/password against the database to see if the
credentials are valid.
Note that this simply checks credentials and returns a valid reference
to the user-- it does not log them in!
To finish the job:
After calling this from a Command, associate the account with a Session:
- session.sessionhandler.login(session, account)
...or after calling this from a View, associate it with an HttpRequest:
- django.contrib.auth.login(account, request)
Args:
username (str): Username of account
password (str): Password of account
ip (str, optional): IP address of client
Kwargs:
session (Session, optional): Session requesting authentication
Returns:
account (DefaultAccount, None): Account whose credentials were
provided if not banned.
errors (list): Error messages of any failures.
"""
errors = []
if ip: ip = str(ip)
# See if authentication is currently being throttled
if ip and LOGIN_THROTTLE.check(ip):
errors.append('Too many login failures; please try again in a few minutes.')
# With throttle active, do not log continued hits-- it is a
# waste of storage and can be abused to make your logs harder to
# read and/or fill up your disk.
return None, errors
# Check IP and/or name bans
banned = cls.is_banned(username=username, ip=ip)
if banned:
# this is a banned IP or name!
errors.append("|rYou have been banned and cannot continue from here." \
"\nIf you feel this ban is in error, please email an admin.|x")
logger.log_sec('Authentication Denied (Banned): %s (IP: %s).' % (username, ip))
LOGIN_THROTTLE.update(ip, 'Too many sightings of banned artifact.')
return None, errors
# Authenticate and get Account object
account = authenticate(username=username, password=password)
if not account:
# User-facing message
errors.append('Username and/or password is incorrect.')
# Log auth failures while throttle is inactive
logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip))
# Update throttle
if ip: LOGIN_THROTTLE.update(ip, 'Too many authentication failures.')
# Try to call post-failure hook
session = kwargs.get('session', None)
if session:
account = AccountDB.objects.get_account_from_name(username)
if account:
account.at_failed_login(session)
return None, errors
# Account successfully authenticated
logger.log_sec('Authentication Success: %s (IP: %s).' % (account, ip))
return account, errors
@classmethod
def normalize_username(cls, username):
"""
Django: Applies NFKC Unicode normalization to usernames so that visually
identical characters with different Unicode code points are considered
identical.
(This deals with the Turkish "i" problem and similar
annoyances. Only relevant if you go out of your way to allow Unicode
usernames though-- Evennia accepts ASCII by default.)
In this case we're simply piggybacking on this feature to apply
additional normalization per Evennia's standards.
"""
username = super(DefaultAccount, cls).normalize_username(username)
# strip excessive spaces in accountname
username = re.sub(r"\s+", " ", username).strip()
return username
@classmethod
def validate_username(cls, username):
"""
Checks the given username against the username validator associated with
Account objects, and also checks the database to make sure it is unique.
Args:
username (str): Username to validate
Returns:
valid (bool): Whether or not the password passed validation
errors (list): Error messages of any failures
"""
valid = []
errors = []
# Make sure we're at least using the default validator
validators = cls.get_username_validators()
if not validators:
validators = [cls.username_validator]
# Try username against all enabled validators
for validator in validators:
try:
valid.append(not validator(username))
except ValidationError as e:
valid.append(False)
errors.extend(e.messages)
# Disqualify if any check failed
if False in valid:
valid = False
else: valid = True
return valid, errors
@classmethod
def validate_password(cls, password, account=None):
"""
@ -416,9 +619,137 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
if error: raise error
super(DefaultAccount, self).set_password(password)
logger.log_info("Password succesfully changed for %s." % self)
logger.log_sec("Password successfully changed for %s." % self)
self.at_password_change()
@classmethod
def create(cls, *args, **kwargs):
"""
Creates an Account (or Account/Character pair for MULTISESSION_MODE<2)
with default (or overridden) permissions and having joined them to the
appropriate default channels.
Kwargs:
username (str): Username of Account owner
password (str): Password of Account owner
email (str, optional): Email address of Account owner
ip (str, optional): IP address of requesting connection
guest (bool, optional): Whether or not this is to be a Guest account
permissions (str, optional): Default permissions for the Account
typeclass (str, optional): Typeclass to use for new Account
character_typeclass (str, optional): Typeclass to use for new char
when applicable.
Returns:
account (Account): Account if successfully created; None if not
errors (list): List of error messages in string form
"""
account = None
errors = []
username = kwargs.get('username')
password = kwargs.get('password')
email = kwargs.get('email', '').strip()
guest = kwargs.get('guest', False)
permissions = kwargs.get('permissions', settings.PERMISSION_ACCOUNT_DEFAULT)
typeclass = kwargs.get('typeclass', settings.BASE_ACCOUNT_TYPECLASS)
ip = kwargs.get('ip', '')
if ip and CREATION_THROTTLE.check(ip):
errors.append("You are creating too many accounts. Please log into an existing account.")
return None, errors
# Normalize username
username = cls.normalize_username(username)
# Validate username
if not guest:
valid, errs = cls.validate_username(username)
if not valid:
# this echoes the restrictions made by django's auth
# module (except not allowing spaces, for convenience of
# logging in).
errors.extend(errs)
return None, errors
# Validate password
# Have to create a dummy Account object to check username similarity
valid, errs = cls.validate_password(password, account=cls(username=username))
if not valid:
errors.extend(errs)
return None, errors
# Check IP and/or name bans
banned = cls.is_banned(username=username, ip=ip)
if banned:
# this is a banned IP or name!
string = "|rYou have been banned and cannot continue from here." \
"\nIf you feel this ban is in error, please email an admin.|x"
errors.append(string)
return None, errors
# everything's ok. Create the new account account.
try:
try:
account = create.create_account(username, email, password, permissions=permissions, typeclass=typeclass)
logger.log_sec('Account Created: %s (IP: %s).' % (account, ip))
except Exception as e:
errors.append("There was an error creating the Account. If this problem persists, contact an admin.")
logger.log_trace()
return None, errors
# This needs to be set so the engine knows this account is
# logging in for the first time. (so it knows to call the right
# hooks during login later)
account.db.FIRST_LOGIN = True
# Record IP address of creation, if available
if ip: account.db.creator_ip = ip
# join the new account to the public channel
pchannel = ChannelDB.objects.get_channel(settings.DEFAULT_CHANNELS[0]["key"])
if not pchannel or not pchannel.connect(account):
string = "New account '%s' could not connect to public channel!" % account.key
errors.append(string)
logger.log_err(string)
if account and settings.MULTISESSION_MODE < 2:
# Load the appropriate Character class
character_typeclass = kwargs.get('character_typeclass', settings.BASE_CHARACTER_TYPECLASS)
character_home = kwargs.get('home')
Character = class_from_module(character_typeclass)
# Create the character
character, errs = Character.create(
account.key, account, ip=ip, typeclass=character_typeclass,
permissions=permissions, home=character_home
)
errors.extend(errs)
if character:
# Update playable character list
if character not in account.characters:
account.db._playable_characters.append(character)
# We need to set this to have @ic auto-connect to this character
account.db._last_puppet = character
except Exception:
# We are in the middle between logged in and -not, so we have
# to handle tracebacks ourselves at this point. If we don't,
# we won't see any errors at all.
errors.append("An error occurred. Please e-mail an admin if the problem persists.")
logger.log_trace()
# Update the throttle to indicate a new account was created from this IP
if ip and not guest: CREATION_THROTTLE.update(ip, 'Too many accounts being created.')
return account, errors
def delete(self, *args, **kwargs):
"""
Deletes the account permanently.
@ -1067,6 +1398,78 @@ class DefaultGuest(DefaultAccount):
their characters are deleted after disconnection.
"""
@classmethod
def create(cls, **kwargs):
"""
Forwards request to cls.authenticate(); returns a DefaultGuest object
if one is available for use.
"""
return cls.authenticate(**kwargs)
@classmethod
def authenticate(cls, **kwargs):
"""
Gets or creates a Guest account object.
Kwargs:
ip (str, optional): IP address of requestor; used for ban checking,
throttling and logging
Returns:
account (Object): Guest account object, if available
errors (list): List of error messages accrued during this request.
"""
errors = []
account = None
username = None
ip = kwargs.get('ip', '').strip()
# check if guests are enabled.
if not settings.GUEST_ENABLED:
errors.append('Guest accounts are not enabled on this server.')
return None, errors
try:
# Find an available guest name.
for name in settings.GUEST_LIST:
if not AccountDB.objects.filter(username__iexact=name).count():
username = name
break
if not username:
errors.append("All guest accounts are in use. Please try again later.")
if ip: LOGIN_THROTTLE.update(ip, 'Too many requests for Guest access.')
return None, errors
else:
# build a new account with the found guest username
password = "%016x" % getrandbits(64)
home = settings.GUEST_HOME
permissions = settings.PERMISSION_GUEST_DEFAULT
typeclass = settings.BASE_GUEST_TYPECLASS
# Call parent class creator
account, errs = super(DefaultGuest, cls).create(
guest=True,
username=username,
password=password,
permissions=permissions,
typeclass=typeclass,
home=home,
ip=ip,
)
errors.extend(errs)
return account, errors
except Exception as e:
# We are in the middle between logged in and -not, so we have
# to handle tracebacks ourselves at this point. If we don't,
# we won't see any errors at all.
errors.append("An error occurred. Please e-mail an admin if the problem persists.")
logger.log_trace()
return None, errors
return account, errors
def at_post_login(self, session=None, **kwargs):
"""
In theory, guests only have one character regardless of which

View file

@ -1,11 +1,13 @@
# -*- coding: utf-8 -*-
from mock import Mock, MagicMock
from random import randint
from unittest import TestCase
from django.test import override_settings
from evennia.accounts.accounts import AccountSessionHandler
from evennia.accounts.accounts import DefaultAccount
from evennia.server.session import Session
from evennia.accounts.accounts import DefaultAccount, DefaultGuest
from evennia.utils.test_resources import EvenniaTest
from evennia.utils import create
from django.conf import settings
@ -58,6 +60,107 @@ class TestAccountSessionHandler(TestCase):
"Check count method"
self.assertEqual(self.handler.count(), len(self.handler.get()))
class TestDefaultGuest(EvenniaTest):
"Check DefaultGuest class"
ip = '212.216.134.22'
def test_authenticate(self):
# Guest account should not be permitted
account, errors = DefaultGuest.authenticate(ip=self.ip)
self.assertFalse(account, 'Guest account was created despite being disabled.')
settings.GUEST_ENABLED = True
settings.GUEST_LIST = ['bruce_wayne']
# Create a guest account
account, errors = DefaultGuest.authenticate(ip=self.ip)
self.assertTrue(account, 'Guest account should have been created.')
# Create a second guest account
account, errors = DefaultGuest.authenticate(ip=self.ip)
self.assertFalse(account, 'Two guest accounts were created with a single entry on the guest list!')
settings.GUEST_ENABLED = False
class TestDefaultAccountAuth(EvenniaTest):
def setUp(self):
super(TestDefaultAccountAuth, self).setUp()
self.password = "testpassword"
self.account.delete()
self.account = create.create_account("TestAccount%s" % randint(100000, 999999), email="test@test.com", password=self.password, typeclass=DefaultAccount)
def test_authentication(self):
"Confirm Account authentication method is authenticating/denying users."
# Valid credentials
obj, errors = DefaultAccount.authenticate(self.account.name, self.password)
self.assertTrue(obj, 'Account did not authenticate given valid credentials.')
# Invalid credentials
obj, errors = DefaultAccount.authenticate(self.account.name, 'xyzzy')
self.assertFalse(obj, 'Account authenticated using invalid credentials.')
def test_create(self):
"Confirm Account creation is working as expected."
# Create a normal account
account, errors = DefaultAccount.create(username='ziggy', password='stardust11')
self.assertTrue(account, 'New account should have been created.')
# Try creating a duplicate account
account2, errors = DefaultAccount.create(username='Ziggy', password='starman11')
self.assertFalse(account2, 'Duplicate account name should not have been allowed.')
account.delete()
def test_throttle(self):
"Confirm throttle activates on too many failures."
for x in xrange(20):
obj, errors = DefaultAccount.authenticate(self.account.name, 'xyzzy', ip='12.24.36.48')
self.assertFalse(obj, 'Authentication was provided a bogus password; this should NOT have returned an account!')
self.assertTrue('too many login failures' in errors[-1].lower(), 'Failed logins should have been throttled.')
def test_username_validation(self):
"Check username validators deny relevant usernames"
# Should not accept Unicode by default, lest users pick names like this
result, error = DefaultAccount.validate_username('¯\_(ツ)_/¯')
self.assertFalse(result, "Validator allowed kanji in username.")
# Should not allow duplicate username
result, error = DefaultAccount.validate_username(self.account.name)
self.assertFalse(result, "Duplicate username should not have passed validation.")
# Should not allow username too short
result, error = DefaultAccount.validate_username('xx')
self.assertFalse(result, "2-character username passed validation.")
def test_password_validation(self):
"Check password validators deny bad passwords"
account = create.create_account("TestAccount%s" % randint(100000, 999999),
email="test@test.com", password="testpassword", typeclass=DefaultAccount)
for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'):
self.assertFalse(account.validate_password(bad, account=self.account)[0])
"Check validators allow sufficiently complex passwords"
for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"):
self.assertTrue(account.validate_password(better, account=self.account)[0])
account.delete()
def test_password_change(self):
"Check password setting and validation is working as expected"
account = create.create_account("TestAccount%s" % randint(100000, 999999),
email="test@test.com", password="testpassword", typeclass=DefaultAccount)
from django.core.exceptions import ValidationError
# Try setting some bad passwords
for bad in ('', '#', 'TestAccount', 'password'):
self.assertRaises(ValidationError, account.set_password, bad)
# Try setting a better password (test for False; returns None on success)
self.assertFalse(account.set_password('Mxyzptlk'))
account.delete()
class TestDefaultAccount(TestCase):
"Check DefaultAccount class"
@ -66,36 +169,6 @@ class TestDefaultAccount(TestCase):
self.s1 = MagicMock()
self.s1.puppet = None
self.s1.sessid = 0
self.s1.data_outj
def tearDown(self):
if hasattr(self, "account"):
self.account.delete()
def test_password_validation(self):
"Check password validators deny bad passwords"
self.account = create.create_account("TestAccount%s" % randint(0, 9),
email="test@test.com", password="testpassword", typeclass=DefaultAccount)
for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'):
self.assertFalse(self.account.validate_password(bad, account=self.account)[0])
"Check validators allow sufficiently complex passwords"
for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"):
self.assertTrue(self.account.validate_password(better, account=self.account)[0])
def test_password_change(self):
"Check password setting and validation is working as expected"
self.account = create.create_account("TestAccount%s" % randint(0, 9),
email="test@test.com", password="testpassword", typeclass=DefaultAccount)
from django.core.exceptions import ValidationError
# Try setting some bad passwords
for bad in ('', '#', 'TestAccount', 'password'):
self.assertRaises(ValidationError, self.account.set_password, bad)
# Try setting a better password (test for False; returns None on success)
self.assertFalse(self.account.set_password('Mxyzptlk'))
def test_puppet_object_no_object(self):
"Check puppet_object method called with no object param"
@ -199,3 +272,20 @@ class TestDefaultAccount(TestCase):
account.puppet_object(self.s1, obj)
self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("is already puppeted by another Account."))
self.assertIsNone(obj.at_post_puppet.call_args)
class TestAccountPuppetDeletion(EvenniaTest):
@override_settings(MULTISESSION_MODE=2)
def test_puppet_deletion(self):
# Check for existing chars
self.assertFalse(self.account.db._playable_characters, 'Account should not have any chars by default.')
# Add char1 to account's playable characters
self.account.db._playable_characters.append(self.char1)
self.assertTrue(self.account.db._playable_characters, 'Char was not added to account.')
# See what happens when we delete char1.
self.char1.delete()
# Playable char list should be empty.
self.assertFalse(self.account.db._playable_characters, 'Playable character list is not empty! %s' % self.account.db._playable_characters)

View file

@ -163,8 +163,8 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS):
home=default_home,
permissions=permissions)
# only allow creator (and developers) to puppet this char
new_character.locks.add("puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer)" %
(new_character.id, account.id))
new_character.locks.add("puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer);delete:id(%i) or perm(Admin)" %
(new_character.id, account.id, account.id))
account.db._playable_characters.append(new_character)
if desc:
new_character.db.desc = desc
@ -223,6 +223,12 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS):
match = match[0]
account.ndb._char_to_delete = match
# Return if caller has no permission to delete this
if not match.access(account, 'delete'):
self.msg("You do not have permission to delete this character.")
return
prompt = "|rThis will permanently destroy '%s'. This cannot be undone.|n Continue yes/[no]?"
get_input(account, prompt % match.key, _callback)

View file

@ -17,7 +17,7 @@ PERMISSION_HIERARCHY = [p.lower() for p in settings.PERMISSION_HIERARCHY]
# limit members for API inclusion
__all__ = ("CmdBoot", "CmdBan", "CmdUnban",
"CmdEmit", "CmdNewPassword", "CmdPerm", "CmdWall")
"CmdEmit", "CmdNewPassword", "CmdPerm", "CmdWall", "CmdForce")
class CmdBoot(COMMAND_DEFAULT_CLASS):
@ -513,3 +513,33 @@ class CmdWall(COMMAND_DEFAULT_CLASS):
message = "%s shouts \"%s\"" % (self.caller.name, self.args)
self.msg("Announcing to all connected sessions ...")
SESSIONS.announce_all(message)
class CmdForce(COMMAND_DEFAULT_CLASS):
"""
forces an object to execute a command
Usage:
@force <object>=<command string>
Example:
@force bob=get stick
"""
key = "@force"
locks = "cmd:perm(spawn) or perm(Builder)"
help_category = "Building"
perm_used = "edit"
def func(self):
"""Implements the force command"""
if not self.lhs or not self.rhs:
self.caller.msg("You must provide a target and a command string to execute.")
return
targ = self.caller.search(self.lhs)
if not targ:
return
if not targ.access(self.caller, self.perm_used):
self.caller.msg("You don't have permission to force them to execute commands.")
return
targ.execute_cmd(self.rhs)
self.caller.msg("You have forced %s to: %s" % (targ, self.rhs))

View file

@ -10,7 +10,7 @@ from evennia.objects.models import ObjectDB
from evennia.locks.lockhandler import LockException
from evennia.commands.cmdhandler import get_and_merge_cmdsets
from evennia.utils import create, utils, search
from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses
from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses, variable_from_module
from evennia.utils.eveditor import EvEditor
from evennia.utils.evmore import EvMore
from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus
@ -612,12 +612,12 @@ class CmdDesc(COMMAND_DEFAULT_CLASS):
self.edit_handler()
return
if self.rhs:
if '=' in self.args:
# We have an =
obj = caller.search(self.lhs)
if not obj:
return
desc = self.rhs
desc = self.rhs or ''
else:
obj = caller.location or self.msg("|rYou can't describe oblivion.|n")
if not obj:
@ -1022,10 +1022,17 @@ class CmdLink(COMMAND_DEFAULT_CLASS):
object_name = self.lhs
# get object
obj = caller.search(object_name, global_search=True)
if not obj:
return
# try to search locally first
results = caller.search(object_name, quiet=True)
if len(results) > 1: # local results was a multimatch. Inform them to be more specific
_AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit('.', 1))
return _AT_SEARCH_RESULT(results, caller, query=object_name)
elif len(results) == 1: # A unique local match
obj = results[0]
else: # No matches. Search globally
obj = caller.search(object_name, global_search=True)
if not obj:
return
if self.rhs:
# this means a target name was given
@ -2854,7 +2861,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
key = "@spawn"
aliases = ["olc"]
switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc", "update")
switch_options = ("noloc", "search", "list", "show", "examine", "save", "delete", "menu", "olc", "update", "edit")
locks = "cmd:perm(spawn) or perm(Builder)"
help_category = "Building"
@ -2905,12 +2912,13 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
caller = self.caller
if self.cmdstring == "olc" or 'menu' in self.switches or 'olc' in self.switches:
if self.cmdstring == "olc" or 'menu' in self.switches \
or 'olc' in self.switches or 'edit' in self.switches:
# OLC menu mode
prototype = None
if self.lhs:
key = self.lhs
prototype = spawner.search_prototype(key=key, return_meta=True)
prototype = protlib.search_prototype(key=key)
if len(prototype) > 1:
caller.msg("More than one match for {}:\n{}".format(
key, "\n".join(proto.get('prototype_key', '') for proto in prototype)))
@ -2918,6 +2926,10 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
elif prototype:
# one match
prototype = prototype[0]
else:
# no match
caller.msg("No prototype '{}' was found.".format(key))
return
olc_menus.start_olc(caller, session=self.session, prototype=prototype)
return
@ -3034,7 +3046,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
caller.msg("|rDeletion cancelled.|n")
return
try:
success = protlib.delete_db_prototype(caller, self.args)
success = protlib.delete_prototype(self.args)
except protlib.PermissionError as err:
caller.msg("|rError deleting:|R {}|n".format(err))
caller.msg("Deletion {}.".format(
@ -3084,7 +3096,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
return
# we have a prototype, check access
prototype = prototypes[0]
if not caller.locks.check_lockstring(caller, prototype.get('prototype_locks', ''), access_type='spawn'):
if not caller.locks.check_lockstring(
caller, prototype.get('prototype_locks', ''), access_type='spawn', default=True):
caller.msg("You don't have access to use this prototype.")
return

View file

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

View file

@ -66,7 +66,7 @@ class CmdHelp(Command):
if type(self).help_more:
usemore = True
if self.session.protocol_key in ("websocket", "ajax/comet"):
if self.session and self.session.protocol_key in ("websocket", "ajax/comet"):
try:
options = self.account.db._saved_webclient_options
if options and options["helppopup"]:

View file

@ -47,6 +47,7 @@ class CmdReload(COMMAND_DEFAULT_CLASS):
@reset to purge) and at_reload() hooks will be called.
"""
key = "@reload"
aliases = ['@restart']
locks = "cmd:perm(reload) or perm(Developer)"
help_category = "System"
@ -710,6 +711,7 @@ class CmdTime(COMMAND_DEFAULT_CLASS):
"""Show server time data in a table."""
table1 = EvTable("|wServer time", "", align="l", width=78)
table1.add_row("Current uptime", utils.time_format(gametime.uptime(), 3))
table1.add_row("Portal uptime", utils.time_format(gametime.portal_uptime(), 3))
table1.add_row("Total runtime", utils.time_format(gametime.runtime(), 2))
table1.add_row("First start", datetime.datetime.fromtimestamp(gametime.server_epoch()))
table1.add_row("Current time", datetime.datetime.now())

View file

@ -243,6 +243,9 @@ class TestAdmin(CommandTest):
def test_ban(self):
self.call(admin.CmdBan(), "Char", "Name-Ban char was added.")
def test_force(self):
self.call(admin.CmdForce(), "Char2=say test", 'Char2(#7) says, "test"|You have forced Char2 to: say test')
class TestAccount(CommandTest):
@ -280,6 +283,32 @@ class TestAccount(CommandTest):
def test_char_create(self):
self.call(account.CmdCharCreate(), "Test1=Test char",
"Created new character Test1. Use @ic Test1 to enter the game", caller=self.account)
def test_char_delete(self):
# Chardelete requires user input; this test is mainly to confirm
# whether permissions are being checked
# Add char to account playable characters
self.account.db._playable_characters.append(self.char1)
# Try deleting as Developer
self.call(account.CmdCharDelete(), "Char", "This will permanently destroy 'Char'. This cannot be undone. Continue yes/[no]?", caller=self.account)
# Downgrade permissions on account
self.account.permissions.add('Player')
self.account.permissions.remove('Developer')
# Set lock on character object to prevent deletion
self.char1.locks.add('delete:none()')
# Try deleting as Player
self.call(account.CmdCharDelete(), "Char", "You do not have permission to delete this character.", caller=self.account)
# Set lock on character object to allow self-delete
self.char1.locks.add('delete:pid(%i)' % self.account.id)
# Try deleting as Player again
self.call(account.CmdCharDelete(), "Char", "This will permanently destroy 'Char'. This cannot be undone. Continue yes/[no]?", caller=self.account)
def test_quell(self):
self.call(account.CmdQuell(), "", "Quelling to current puppet's permissions (developer).", caller=self.account)
@ -315,6 +344,24 @@ class TestBuilding(CommandTest):
def test_desc(self):
self.call(building.CmdDesc(), "Obj2=TestDesc", "The description was set on Obj2(#5).")
def test_empty_desc(self):
"""
empty desc sets desc as ''
"""
o2d = self.obj2.db.desc
r1d = self.room1.db.desc
self.call(building.CmdDesc(), "Obj2=", "The description was set on Obj2(#5).")
assert self.obj2.db.desc == '' and self.obj2.db.desc != o2d
assert self.room1.db.desc == r1d
def test_desc_default_to_room(self):
"""no rhs changes room's desc"""
o2d = self.obj2.db.desc
r1d = self.room1.db.desc
self.call(building.CmdDesc(), "Obj2", "The description was set on Room(#1).")
assert self.obj2.db.desc == o2d
assert self.room1.db.desc == 'Obj2' and self.room1.db.desc != r1d
def test_wipe(self):
confirm = building.CmdDestroy.confirm
building.CmdDestroy.confirm = False
@ -334,6 +381,14 @@ class TestBuilding(CommandTest):
self.call(building.CmdOpen(), "TestExit1=Room2", "Created new Exit 'TestExit1' from Room to Room2")
self.call(building.CmdLink(), "TestExit1=Room", "Link created TestExit1 -> Room (one way).")
self.call(building.CmdUnLink(), "TestExit1", "Former exit TestExit1 no longer links anywhere.")
self.char1.location = self.room2
self.call(building.CmdOpen(), "TestExit2=Room", "Created new Exit 'TestExit2' from Room2 to Room.")
# ensure it matches locally first
self.call(building.CmdLink(), "TestExit=Room2", "Link created TestExit2 -> Room2 (one way).")
# ensure can still match globally when not a local name
self.call(building.CmdLink(), "TestExit1=Room2", "Note: TestExit1(#8) did not have a destination set before. "
"Make sure you linked the right thing.\n"
"Link created TestExit1 -> Room2 (one way).")
def test_set_home(self):
self.call(building.CmdSetHome(), "Obj = Room2", "Obj's home location was changed from Room")
@ -460,6 +515,61 @@ class TestBuilding(CommandTest):
# Test listing commands
self.call(building.CmdSpawn(), "/list", "Key ")
# @spawn/edit (missing prototype)
# brings up olc menu
msg = self.call(
building.CmdSpawn(),
'/edit')
assert 'Prototype wizard' in msg
# @spawn/edit with valid prototype
# brings up olc menu loaded with prototype
msg = self.call(
building.CmdSpawn(),
'/edit testball')
assert 'Prototype wizard' in msg
assert hasattr(self.char1.ndb._menutree, "olc_prototype")
assert dict == type(self.char1.ndb._menutree.olc_prototype) \
and 'prototype_key' in self.char1.ndb._menutree.olc_prototype \
and 'key' in self.char1.ndb._menutree.olc_prototype \
and 'testball' == self.char1.ndb._menutree.olc_prototype['prototype_key'] \
and 'Ball' == self.char1.ndb._menutree.olc_prototype['key']
assert 'Ball' in msg and 'testball' in msg
# @spawn/edit with valid prototype (synomym)
msg = self.call(
building.CmdSpawn(),
'/edit BALL')
assert 'Prototype wizard' in msg
assert 'Ball' in msg and 'testball' in msg
# @spawn/edit with invalid prototype
msg = self.call(
building.CmdSpawn(),
'/edit NO_EXISTS',
"No prototype 'NO_EXISTS' was found.")
# @spawn/examine (missing prototype)
# lists all prototypes that exist
msg = self.call(
building.CmdSpawn(),
'/examine')
assert 'testball' in msg and 'testprot' in msg
# @spawn/examine with valid prototype
# prints the prototype
msg = self.call(
building.CmdSpawn(),
'/examine BALL')
assert 'Ball' in msg and 'testball' in msg
# @spawn/examine with invalid prototype
# shows error
self.call(
building.CmdSpawn(),
'/examine NO_EXISTS',
"No prototype 'NO_EXISTS' was found.")
class TestComms(CommandTest):

View file

@ -4,17 +4,11 @@ Commands that are available from the connect screen.
import re
import datetime
from codecs import lookup as codecs_lookup
from random import getrandbits
from django.conf import settings
from django.contrib.auth import authenticate
from evennia.accounts.models import AccountDB
from evennia.objects.models import ObjectDB
from evennia.server.models import ServerConfig
from evennia.server.throttle import Throttle
from evennia.comms.models import ChannelDB
from evennia.server.sessionhandler import SESSIONS
from evennia.utils import create, logger, utils, gametime
from evennia.utils import class_from_module, create, logger, utils, gametime
from evennia.commands.cmdhandler import CMD_LOGINSTART
COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
@ -26,11 +20,6 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate",
MULTISESSION_MODE = settings.MULTISESSION_MODE
CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
# Create throttles for too many connections, account-creations and login attempts
CONNECTION_THROTTLE = Throttle(limit=5, timeout=1 * 60)
CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60)
LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60)
def create_guest_account(session):
"""
@ -44,49 +33,20 @@ def create_guest_account(session):
the boolean is whether guest accounts are enabled at all.
the Account which was created from an available guest name.
"""
# check if guests are enabled.
if not settings.GUEST_ENABLED:
return False, None
enabled = settings.GUEST_ENABLED
address = session.address
# Check IP bans.
bans = ServerConfig.objects.conf("server_bans")
if bans and any(tup[2].match(session.address) for tup in bans if tup[2]):
# this is a banned IP!
string = "|rYou have been banned and cannot continue from here." \
"\nIf you feel this ban is in error, please email an admin.|x"
session.msg(string)
session.sessionhandler.disconnect(session, "Good bye! Disconnecting.")
return True, None
# Get account class
Guest = class_from_module(settings.BASE_GUEST_TYPECLASS)
try:
# Find an available guest name.
accountname = None
for name in settings.GUEST_LIST:
if not AccountDB.objects.filter(username__iexact=accountname).count():
accountname = name
break
if not accountname:
session.msg("All guest accounts are in use. Please try again later.")
return True, None
else:
# build a new account with the found guest accountname
password = "%016x" % getrandbits(64)
home = ObjectDB.objects.get_id(settings.GUEST_HOME)
permissions = settings.PERMISSION_GUEST_DEFAULT
typeclass = settings.BASE_CHARACTER_TYPECLASS
ptypeclass = settings.BASE_GUEST_TYPECLASS
new_account = _create_account(session, accountname, password, permissions, ptypeclass)
if new_account:
_create_character(session, new_account, typeclass, home, permissions)
return True, new_account
except Exception:
# We are in the middle between logged in and -not, so we have
# to handle tracebacks ourselves at this point. If we don't,
# we won't see any errors at all.
session.msg("An error occurred. Please e-mail an admin if the problem persists.")
logger.log_trace()
raise
# Get an available guest account
# authenticate() handles its own throttling
account, errors = Guest.authenticate(ip=address)
if account:
return enabled, account
else:
session.msg("|R%s|n" % '\n'.join(errors))
return enabled, None
def create_normal_account(session, name, password):
@ -101,38 +61,17 @@ def create_normal_account(session, name, password):
Returns:
account (Account): the account which was created from the name and password.
"""
# check for too many login errors too quick.
address = session.address
if isinstance(address, tuple):
address = address[0]
# Get account class
Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
if LOGIN_THROTTLE.check(address):
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
return None
address = session.address
# Match account name and check password
account = authenticate(username=name, password=password)
# authenticate() handles all its own throttling
account, errors = Account.authenticate(username=name, password=password, ip=address, session=session)
if not account:
# No accountname or password match
session.msg("Incorrect login information given.")
# this just updates the throttle
LOGIN_THROTTLE.update(address)
# calls account hook for a failed login if possible.
account = AccountDB.objects.get_account_from_name(name)
if account:
account.at_failed_login(session)
return None
# Check IP and/or name bans
bans = ServerConfig.objects.conf("server_bans")
if bans and (any(tup[0] == account.name.lower() for tup in bans) or
any(tup[2].match(session.address) for tup in bans if tup[2])):
# this is a banned IP or name!
string = "|rYou have been banned and cannot continue from here." \
"\nIf you feel this ban is in error, please email an admin.|x"
session.msg(string)
session.sessionhandler.disconnect(session, "Good bye! Disconnecting.")
session.msg("|R%s|n" % '\n'.join(errors))
return None
return account
@ -164,15 +103,7 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
there is no object yet before the account has logged in)
"""
session = self.caller
# check for too many login errors too quick.
address = session.address
if isinstance(address, tuple):
address = address[0]
if CONNECTION_THROTTLE.check(address):
# timeout is 5 minutes.
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
return
args = self.args
# extract double quote parts
@ -180,23 +111,33 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
if len(parts) == 1:
# this was (hopefully) due to no double quotes being found, or a guest login
parts = parts[0].split(None, 1)
# Guest login
if len(parts) == 1 and parts[0].lower() == "guest":
enabled, new_account = create_guest_account(session)
if new_account:
session.sessionhandler.login(session, new_account)
if enabled:
# Get Guest typeclass
Guest = class_from_module(settings.BASE_GUEST_TYPECLASS)
account, errors = Guest.authenticate(ip=address)
if account:
session.sessionhandler.login(session, account)
return
else:
session.msg("|R%s|n" % '\n'.join(errors))
return
if len(parts) != 2:
session.msg("\n\r Usage (without <>): connect <name> <password>")
return
CONNECTION_THROTTLE.update(address)
# Get account class
Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
name, password = parts
account = create_normal_account(session, name, password)
account, errors = Account.authenticate(username=name, password=password, ip=address, session=session)
if account:
session.sessionhandler.login(session, account)
else:
session.msg("|R%s|n" % '\n'.join(errors))
class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
@ -222,14 +163,10 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
session = self.caller
args = self.args.strip()
# Rate-limit account creation.
address = session.address
if isinstance(address, tuple):
address = address[0]
if CREATION_THROTTLE.check(address):
session.msg("|RYou are creating too many accounts. Try again in a few minutes.|n")
return
# Get account class
Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
# extract double quoted parts
parts = [part.strip() for part in re.split(r"\"", args) if part.strip()]
@ -241,77 +178,21 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
"\nIf <name> or <password> contains spaces, enclose it in double quotes."
session.msg(string)
return
accountname, password = parts
# sanity checks
if not re.findall(r"^[\w. @+\-']+$", accountname) or not (0 < len(accountname) <= 30):
# this echoes the restrictions made by django's auth
# module (except not allowing spaces, for convenience of
# logging in).
string = "\n\r Accountname can max be 30 characters or fewer. Letters, spaces, digits and @/./+/-/_/' only."
session.msg(string)
return
# strip excessive spaces in accountname
accountname = re.sub(r"\s+", " ", accountname).strip()
if AccountDB.objects.filter(username__iexact=accountname):
# account already exists (we also ignore capitalization here)
session.msg("Sorry, there is already an account with the name '%s'." % accountname)
return
# Reserve accountnames found in GUEST_LIST
if settings.GUEST_LIST and accountname.lower() in (guest.lower() for guest in settings.GUEST_LIST):
string = "\n\r That name is reserved. Please choose another Accountname."
session.msg(string)
return
# Validate password
Account = utils.class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
# Have to create a dummy Account object to check username similarity
valid, error = Account.validate_password(password, account=Account(username=accountname))
if error:
errors = [e for suberror in error.messages for e in error.messages]
string = "\n".join(errors)
session.msg(string)
return
# Check IP and/or name bans
bans = ServerConfig.objects.conf("server_bans")
if bans and (any(tup[0] == accountname.lower() for tup in bans) or
any(tup[2].match(session.address) for tup in bans if tup[2])):
# this is a banned IP or name!
string = "|rYou have been banned and cannot continue from here." \
"\nIf you feel this ban is in error, please email an admin.|x"
session.msg(string)
session.sessionhandler.disconnect(session, "Good bye! Disconnecting.")
return
username, password = parts
# everything's ok. Create the new account account.
try:
permissions = settings.PERMISSION_ACCOUNT_DEFAULT
typeclass = settings.BASE_CHARACTER_TYPECLASS
new_account = _create_account(session, accountname, password, permissions)
if new_account:
if MULTISESSION_MODE < 2:
default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME)
_create_character(session, new_account, typeclass, default_home, permissions)
# Update the throttle to indicate a new account was created from this IP
CREATION_THROTTLE.update(address)
# tell the caller everything went well.
string = "A new account '%s' was created. Welcome!"
if " " in accountname:
string += "\n\nYou can now log in with the command 'connect \"%s\" <your password>'."
else:
string += "\n\nYou can now log with the command 'connect %s <your password>'."
session.msg(string % (accountname, accountname))
except Exception:
# We are in the middle between logged in and -not, so we have
# to handle tracebacks ourselves at this point. If we don't,
# we won't see any errors at all.
session.msg("An error occurred. Please e-mail an admin if the problem persists.")
logger.log_trace()
account, errors = Account.create(username=username, password=password, ip=address, session=session)
if account:
# tell the caller everything went well.
string = "A new account '%s' was created. Welcome!"
if " " in username:
string += "\n\nYou can now log in with the command 'connect \"%s\" <your password>'."
else:
string += "\n\nYou can now log with the command 'connect %s <your password>'."
session.msg(string % (username, username))
else:
session.msg("|R%s|n" % '\n'.join(errors))
class CmdUnconnectedQuit(COMMAND_DEFAULT_CLASS):
@ -395,6 +276,9 @@ Next you can connect to the game: |wconnect Anna c67jHL8p|n
You can use the |wlook|n command if you want to see the connect screen again.
"""
if settings.STAFF_CONTACT_EMAIL:
string += 'For support, please contact: %s' % settings.STAFF_CONTACT_EMAIL
self.caller.msg(string)

View file

@ -2,10 +2,14 @@
Base typeclass for in-game Channels.
"""
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.text import slugify
from evennia.typeclasses.models import TypeclassBase
from evennia.comms.models import TempMsg, ChannelDB
from evennia.comms.managers import ChannelManager
from evennia.utils import logger
from evennia.utils import create, logger
from evennia.utils.utils import make_iter
from future.utils import with_metaclass
_CHANNEL_HANDLER = None
@ -220,6 +224,50 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
return self.locks.check(accessing_obj, access_type=access_type,
default=default, no_superuser_bypass=no_superuser_bypass)
@classmethod
def create(cls, key, account=None, *args, **kwargs):
"""
Creates a basic Channel with default parameters, unless otherwise
specified or extended.
Provides a friendlier interface to the utils.create_channel() function.
Args:
key (str): This must be unique.
account (Account): Account to attribute this object to.
Kwargs:
aliases (list of str): List of alternative (likely shorter) keynames.
description (str): A description of the channel, for use in listings.
locks (str): Lockstring.
keep_log (bool): Log channel throughput.
typeclass (str or class): The typeclass of the Channel (not
often used).
ip (str): IP address of creator (for object auditing).
Returns:
channel (Channel): A newly created Channel.
errors (list): A list of errors in string form, if any.
"""
errors = []
obj = None
ip = kwargs.pop('ip', '')
try:
kwargs['desc'] = kwargs.pop('description', '')
obj = create.create_channel(key, *args, **kwargs)
# Record creator id and creation IP
if ip: obj.db.creator_ip = ip
if account: obj.db.creator_id = account.id
except Exception as exc:
errors.append("An error occurred while creating this '%s' object." % key)
logger.log_err(exc)
return obj, errors
def delete(self):
"""
Deletes channel while also cleaning up channelhandler.
@ -578,3 +626,151 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
"""
pass
#
# Web/Django methods
#
def web_get_admin_url(self):
"""
Returns the URI path for the Django Admin page for this object.
ex. Account#1 = '/admin/accounts/accountdb/1/change/'
Returns:
path (str): URI path to Django Admin page for object.
"""
content_type = ContentType.objects.get_for_model(self.__class__)
return reverse("admin:%s_%s_change" % (content_type.app_label,
content_type.model), args=(self.id,))
@classmethod
def web_get_create_url(cls):
"""
Returns the URI path for a View that allows users to create new
instances of this object.
ex. Chargen = '/characters/create/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'channel-create' would be referenced by this method.
ex.
url(r'channels/create/', ChannelCreateView.as_view(), name='channel-create')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can create new objects is the
developer's responsibility.
Returns:
path (str): URI path to object creation page, if defined.
"""
try:
return reverse('%s-create' % slugify(cls._meta.verbose_name))
except:
return '#'
def web_get_detail_url(self):
"""
Returns the URI path for a View that allows users to view details for
this object.
ex. Oscar (Character) = '/characters/oscar/1/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'channel-detail' would be referenced by this method.
ex.
url(r'channels/(?P<slug>[\w\d\-]+)/$',
ChannelDetailView.as_view(), name='channel-detail')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can view this object is the developer's
responsibility.
Returns:
path (str): URI path to object detail page, if defined.
"""
try:
return reverse('%s-detail' % slugify(self._meta.verbose_name),
kwargs={'slug': slugify(self.db_key)})
except:
return '#'
def web_get_update_url(self):
"""
Returns the URI path for a View that allows users to update this
object.
ex. Oscar (Character) = '/characters/oscar/1/change/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'channel-update' would be referenced by this method.
ex.
url(r'channels/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$',
ChannelUpdateView.as_view(), name='channel-update')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can modify objects is the developer's
responsibility.
Returns:
path (str): URI path to object update page, if defined.
"""
try:
return reverse('%s-update' % slugify(self._meta.verbose_name),
kwargs={'slug': slugify(self.db_key)})
except:
return '#'
def web_get_delete_url(self):
"""
Returns the URI path for a View that allows users to delete this object.
ex. Oscar (Character) = '/characters/oscar/1/delete/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'channel-delete' would be referenced by this method.
ex.
url(r'channels/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$',
ChannelDeleteView.as_view(), name='channel-delete')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can delete this object is the developer's
responsibility.
Returns:
path (str): URI path to object deletion page, if defined.
"""
try:
return reverse('%s-delete' % slugify(self._meta.verbose_name),
kwargs={'slug': slugify(self.db_key)})
except:
return '#'
# Used by Django Sites/Admin
get_absolute_url = web_get_detail_url

13
evennia/comms/tests.py Normal file
View file

@ -0,0 +1,13 @@
from evennia.utils.test_resources import EvenniaTest
from evennia import DefaultChannel
class ObjectCreationTest(EvenniaTest):
def test_channel_create(self):
description = "A place to talk about coffee."
obj, errors = DefaultChannel.create('coffeetalk', description=description)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)

View file

@ -1,7 +1,7 @@
# Contrib folder
This folder contains 'contributions': extra snippets of code that are
`evennia/contrib/` contains 'contributions': extra snippets of code that are
potentially very useful for the game coder but which are considered
too game-specific to be a part of the main Evennia game server. These
modules are not used unless you explicitly import them. See each file
@ -17,7 +17,7 @@ things you want from here into your game folder and change them there.
* Barter system (Griatch 2012) - A safe and effective barter-system
for any game. Allows safe trading of any goods (including coin).
* Building menu (vincent-lg 2018) - An @edit command for modifying
* Building menu (vincent-lg 2018) - An `@edit` command for modifying
objects using a generated menu. Customizable for different games.
* CharGen (Griatch 2011) - A simple Character creator for OOC mode.
Meant as a starting point for a more fleshed-out system.
@ -60,9 +60,6 @@ things you want from here into your game folder and change them there.
* Tree Select (FlutterSprite 2017) - A simple system for creating a
branching EvMenu with selection options sourced from a single
multi-line string.
* Turnbattle (Tim Ashley Jenkins 2017) - This is a framework for a turn-based
combat system with different levels of complexity, including versions with
equipment and magic as well as ranged combat.
* Wilderness (titeuf87 2017) - Make infinitely large wilderness areas
with dynamically created locations.
* UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax.
@ -75,7 +72,8 @@ things you want from here into your game folder and change them there.
objects and events using Python from in-game.
* Turnbattle (FlutterSprite 2017) - A turn-based combat engine meant
as a start to build from. Has attack/disengage and turn timeouts,
and includes optional expansions for equipment and combat movement.
and includes optional expansions for equipment and combat movement, magic
and ranged combat.
* Tutorial examples (Griatch 2011, 2015) - A folder of basic
example objects, commands and scripts.
* Tutorial world (Griatch 2011, 2015) - A folder containing the

View file

@ -744,7 +744,7 @@ hole
the remains of the castle. There is also a standing archway
offering passage to a path along the old |wsouth|nern inner wall.
#
@detail portoculis;fall;fallen;grating =
@detail portcullis;fall;fallen;grating =
This heavy iron grating used to block off the inner part of the gate house, now it has fallen
to the ground together with the stone archway that once help it up.
#
@ -786,7 +786,7 @@ archway
The buildings make a half-circle along the main wall, here and there
broken by falling stone and rubble. At one end (the |wnorth|nern) of
this half-circle is the entrance to the castle, the ruined
gatehoue. |wEast|nwards from here is some sort of open courtyard.
gatehouse. |wEast|nwards from here is some sort of open courtyard.
#------------------------------------------------------------
#
@ -808,7 +808,7 @@ archway
Previously one could probably continue past the obelisk and eastward
into the castle keep itself, but that way is now completely blocked
by fallen rubble. To the |wwest|n is the gatehouse and entrance to
the castle, whereas |wsouth|nwards the collumns make way for a wide
the castle, whereas |wsouth|nwards the columns make way for a wide
open courtyard.
#
@set here/tutorial_info =

View file

@ -0,0 +1,15 @@
This directory contains Evennia's log files. The existence of this README.md file is also necessary
to correctly include the log directory in git (since log files are ignored by git and you can't
commit an empty directory).
- `server.log` - log file from the game Server.
- `portal.log` - log file from Portal proxy (internet facing)
Usually these logs are viewed together with `evennia -l`. They are also rotated every week so as not
to be too big. Older log names will have a name appended by `_month_date`.
- `lockwarnings.log` - warnings from the lock system.
- `http_requests.log` - this will generally be empty unless turning on debugging inside the server.
- `channel_<channelname>.log` - these are channel logs for the in-game channels They are also used
by the `/history` flag in-game to get the latest message history.

View file

@ -11,7 +11,11 @@ game world, policy info, rules and similar.
"""
from builtins import object
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from evennia.utils.idmapper.models import SharedMemoryModel
from evennia.help.manager import HelpEntryManager
from evennia.typeclasses.models import Tag, TagHandler, AliasHandler
@ -107,3 +111,158 @@ class HelpEntry(SharedMemoryModel):
default - what to return if no lock of access_type was found
"""
return self.locks.check(accessing_obj, access_type=access_type, default=default)
#
# Web/Django methods
#
def web_get_admin_url(self):
"""
Returns the URI path for the Django Admin page for this object.
ex. Account#1 = '/admin/accounts/accountdb/1/change/'
Returns:
path (str): URI path to Django Admin page for object.
"""
content_type = ContentType.objects.get_for_model(self.__class__)
return reverse("admin:%s_%s_change" % (content_type.app_label,
content_type.model), args=(self.id,))
@classmethod
def web_get_create_url(cls):
"""
Returns the URI path for a View that allows users to create new
instances of this object.
ex. Chargen = '/characters/create/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-create' would be referenced by this method.
ex.
url(r'characters/create/', ChargenView.as_view(), name='character-create')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can create new objects is the
developer's responsibility.
Returns:
path (str): URI path to object creation page, if defined.
"""
try:
return reverse('%s-create' % slugify(cls._meta.verbose_name))
except:
return '#'
def web_get_detail_url(self):
"""
Returns the URI path for a View that allows users to view details for
this object.
ex. Oscar (Character) = '/characters/oscar/1/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-detail' would be referenced by this method.
ex.
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$',
CharDetailView.as_view(), name='character-detail')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can view this object is the developer's
responsibility.
Returns:
path (str): URI path to object detail page, if defined.
"""
try:
return reverse('%s-detail' % slugify(self._meta.verbose_name),
kwargs={
'category': slugify(self.db_help_category),
'topic': slugify(self.db_key)})
except Exception as e:
print(e)
return '#'
def web_get_update_url(self):
"""
Returns the URI path for a View that allows users to update this
object.
ex. Oscar (Character) = '/characters/oscar/1/change/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-update' would be referenced by this method.
ex.
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$',
CharUpdateView.as_view(), name='character-update')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can modify objects is the developer's
responsibility.
Returns:
path (str): URI path to object update page, if defined.
"""
try:
return reverse('%s-update' % slugify(self._meta.verbose_name),
kwargs={
'category': slugify(self.db_help_category),
'topic': slugify(self.db_key)})
except:
return '#'
def web_get_delete_url(self):
"""
Returns the URI path for a View that allows users to delete this object.
ex. Oscar (Character) = '/characters/oscar/1/delete/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-detail' would be referenced by this method.
ex.
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$',
CharDeleteView.as_view(), name='character-delete')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can delete this object is the developer's
responsibility.
Returns:
path (str): URI path to object deletion page, if defined.
"""
try:
return reverse('%s-delete' % slugify(self._meta.verbose_name),
kwargs={
'category': slugify(self.db_help_category),
'topic': slugify(self.db_key)})
except:
return '#'
# Used by Django Sites/Admin
get_absolute_url = web_get_detail_url

View file

@ -1,253 +1,346 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-03-02 16:22-0500\n"
"PO-Revision-Date: 2016-03-04 11:51-0500\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"Last-Translator: \n"
"Language-Team: \n"
"X-Generator: Poedit 1.7.6\n"
#: commands/cmdhandler.py:485
msgid "There were multiple matches."
msgstr "Il y a eu plusieurs correspondances."
#: commands/cmdhandler.py:513
#, python-format
msgid "Command '%s' is not available."
msgstr "Commande '%s' n'est pas disponible."
#: commands/cmdhandler.py:518
#, python-format
msgid " Maybe you meant %s?"
msgstr "Voulez-vous dire %s?"
#: commands/cmdhandler.py:518
msgid "or"
msgstr "ou"
#: commands/cmdhandler.py:520
msgid " Type \"help\" for help."
msgstr "Tapez \"help\" pour de l'aide."
#: commands/cmdparser.py:183
#, python-format
msgid "Could not find '%s'."
msgstr "Peut pas trouver '%s'."
#: commands/cmdsethandler.py:130
#, python-format
msgid ""
"\n"
"(Unsuccessfully tried '%s.' + '%s.%s')."
msgstr ""
"\n"
"(Essayé sans succès '%s.' + '%s.%s')."
#: commands/cmdsethandler.py:151
#, python-brace-format
msgid ""
"\n"
"Error loading cmdset {path}: \"{error}\""
msgstr ""
"\n"
"Erreur de chargement de cmdset {path}: \"{error}\""
#: commands/cmdsethandler.py:155
#, python-brace-format
msgid ""
"\n"
"Error in loading cmdset: No cmdset class '{classname}' in {path}."
msgstr ""
"\n"
"Erreur lors du chargement de cmdset: Pas de classe cmdset '{classname}' in "
"{path}."
#: commands/cmdsethandler.py:159
#, python-brace-format
msgid ""
"\n"
"SyntaxError encountered when loading cmdset '{path}': \"{error}\"."
msgstr ""
"\n"
"Erreur de syntaxe lors du chargement de cmdset '{path}': \"{error}\"."
#: commands/cmdsethandler.py:163
#, python-brace-format
msgid ""
"\n"
"Compile/Run error when loading cmdset '{path}': \"{error}\"."
msgstr ""
"\n"
"Erreur de compilation/exécution lors du chargement de cmdset '{path}': "
"\"{error}\"."
#: commands/cmdsethandler.py:174
msgid ""
"\n"
" (See log for details.)"
msgstr ""
"\n"
"(Voir registre pour plus de détails.)"
#: commands/cmdsethandler.py:247
#, python-brace-format
msgid "custom {mergetype} on cmdset '{cmdset}'"
msgstr "custom {mergetype} sur cmdset '{cmdset}'"
#: commands/cmdsethandler.py:250
#, python-brace-format
msgid " <Merged {mergelist} {mergetype}, prio {prio}>: {current}"
msgstr " <Fusionné {mergelist} {mergetype}, prio {prio}>: {current}"
#: commands/cmdsethandler.py:258
#, python-brace-format
msgid ""
" <{key} ({mergetype}, prio {prio}, {permstring})>:\n"
" {keylist}"
msgstr ""
" <{key} ({mergetype}, prio {prio}, {permstring})>:\n"
" {keylist}"
#: commands/cmdsethandler.py:347
msgid "Only CmdSets can be added to the cmdsethandler!"
msgstr "Seulement CmdSets peuvent être ajoutés au cmdsethandler!"
#: comms/channelhandler.py:94
msgid " (channel)"
msgstr " (channel)"
#: locks/lockhandler.py:230
#, python-format
msgid "Lock: lock-function '%s' is not available."
msgstr "Vérou: lock-function '%s' n'est pas disponible."
#: locks/lockhandler.py:243
#, python-format
msgid "Lock: definition '%s' has syntax errors."
msgstr "Vérou: définition '%s' a des erreurs de syntaxe."
#: locks/lockhandler.py:247
#, python-format
msgid ""
"LockHandler on %(obj)s: access type '%(access_type)s' changed from "
"'%(source)s' to '%(goal)s' "
msgstr ""
"Gestionnaire de vérrou sur %(obj)s: type d'accès '%(access_type)s' a changé "
"de '%(source)s' à '%(goal)s'"
#: locks/lockhandler.py:304
#, python-format
msgid "Lock: '%s' contains no colon (:)."
msgstr "Verrou: '%s' contient pas de deux points (:)."
#: locks/lockhandler.py:308
#, python-format
msgid "Lock: '%s' has no access_type (left-side of colon is empty)."
msgstr ""
"Verrou: '%s' n'a pas de access_type (côté gauche du deux point est vide)."
#: locks/lockhandler.py:311
#, python-format
msgid "Lock: '%s' has mismatched parentheses."
msgstr "Verrou: '%s' a des parenthèses dépareillées."
#: locks/lockhandler.py:314
#, python-format
msgid "Lock: '%s' has no valid lock functions."
msgstr "Verrou: '%s' n'a pas de fonctions verrou valides."
#: objects/objects.py:528
#, python-format
msgid "Couldn't perform move ('%s'). Contact an admin."
msgstr "Ne pouvait effectuer le coup ('%s'). Contactez un administrateur."
#: objects/objects.py:538
msgid "The destination doesn't exist."
msgstr "La destination n'existe pas."
#: objects/objects.py:651
#, python-format
msgid "Could not find default home '(#%d)'."
msgstr "Ne peut trouver la maison '(#%d)' par défaut."
#: objects/objects.py:667
msgid "Something went wrong! You are dumped into nowhere. Contact an admin."
msgstr ""
"Quelque chose a mal tourné! Vous êtes nulle part. Contactez un "
"administrateur."
#: objects/objects.py:747
#, python-format
msgid "Your character %s has been destroyed."
msgstr "Votre personnage %s a été détruit."
#: players/players.py:356
msgid "Player being deleted."
msgstr "Suppression de joueur."
#: scripts/scripthandler.py:50
#, python-format
msgid ""
"\n"
" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s repeats): %(desc)s"
msgstr ""
"\n"
" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s répète): %(desc)s"
#: scripts/scripts.py:202
#, python-format
msgid ""
"Script %(key)s(#%(dbid)s) of type '%(cname)s': at_repeat() error '%(err)s'."
msgstr ""
"Scripte %(key)s(#%(dbid)s) de type '%(cname)s': at_repeat() erreur "
"'%(err)s'."
#: server/initial_setup.py:29
msgid ""
"\n"
"Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com if "
"you need\n"
"help, want to contribute, report issues or just join the community.\n"
"As Player #1 you can create a demo/tutorial area with |w@batchcommand "
"tutorial_world.build|n.\n"
" "
msgstr ""
"\n"
"Bienvenue dans ton nouveau jeu basé sur |wEvennia|n ! Visitez http://www."
"evennia.com si vous avez besoin\n"
"d'aide, si vous voulez contribuer, rapporter des problèmes ou faire partie "
"de la communauté.\n"
"En tant que Joueur #1 vous pouvez créer une zone de démo/tutoriel avec "
"|w@batchcommand tutorial_world.build|n.\n"
" "
#: server/initial_setup.py:102
msgid "This is User #1."
msgstr "Utilisateur #1."
#: server/initial_setup.py:111
msgid "Limbo"
msgstr "Limbes."
#: server/sessionhandler.py:258
msgid " ... Server restarted."
msgstr " ... Serveur redémarré."
#: server/sessionhandler.py:408
msgid "Logged in from elsewhere. Disconnecting."
msgstr "Connecté d'ailleurs. Déconnexion."
#: server/sessionhandler.py:432
msgid "Idle timeout exceeded, disconnecting."
msgstr "Délai d'inactivité dépassé, déconnexion."
# The French translation for the Evennia server.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the Evennia package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
# Maintained by: vincent-lg <vincent.legoff.srs@gmail.com>, 2018-
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-11-22 08:45+0100\n"
"PO-Revision-Date: 2016-03-04 11:51-0500\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Generator: Poedit 1.7.6\n"
#: .\accounts\accounts.py:440
#, fuzzy
msgid "Account being deleted."
msgstr "Suppression du compte."
#: .\commands\cmdhandler.py:681
msgid "There were multiple matches."
msgstr "Il y a plusieurs correspondances possibles."
#: .\commands\cmdhandler.py:704
#, python-format
msgid "Command '%s' is not available."
msgstr "La commande '%s' n'est pas disponible."
#: .\commands\cmdhandler.py:709
#, python-format
msgid " Maybe you meant %s?"
msgstr " Vouliez-vous dire %s ?"
#: .\commands\cmdhandler.py:709
msgid "or"
msgstr "ou"
#: .\commands\cmdhandler.py:711
msgid " Type \"help\" for help."
msgstr " Tapez \"help\" pour obtenir de l'aide."
#: .\commands\cmdsethandler.py:89
#, python-brace-format
msgid ""
"{traceback}\n"
"Error loading cmdset '{path}'\n"
"(Traceback was logged {timestamp})"
msgstr ""
"{traceback}\n"
"Une erreur s'est produite lors du chargement du cmdset '{path}'\n"
"(Référence de l'erreur : {timestamp})"
#: .\commands\cmdsethandler.py:94
#, fuzzy, python-brace-format
msgid ""
"Error loading cmdset: No cmdset class '{classname}' in '{path}'.\n"
"(Traceback was logged {timestamp})"
msgstr ""
"\n"
"Une erreur s'est produite lors du chargement de cmdset : la classe cmdset '{classname}' est introuvable dans "
"{path}.\n"
"(Référence de l'erreur : {timestamp})"
#: .\commands\cmdsethandler.py:98
#, fuzzy, python-brace-format
msgid ""
"{traceback}\n"
"SyntaxError encountered when loading cmdset '{path}'.\n"
"(Traceback was logged {timestamp})"
msgstr ""
"\n"
"Erreur de syntaxe lors du chargement de cmdset '{path}' : \"{error}\".\n"
"(Référence de l'erreur : {timestamp})"
#: .\commands\cmdsethandler.py:103
#, fuzzy, python-brace-format
msgid ""
"{traceback}\n"
"Compile/Run error when loading cmdset '{path}'.\",\n"
"(Traceback was logged {timestamp})"
msgstr ""
"\n"
"Erreur de compilation/exécution lors du chargement de cmdset '{path}' : "
"\"{error}\".\n"
"(Référence de l'erreur : {timestamp})"
#: .\commands\cmdsethandler.py:108
#, python-brace-format
msgid ""
"\n"
"Error encountered for cmdset at path '{path}'.\n"
"Replacing with fallback '{fallback_path}'.\n"
msgstr ""
"\n"
"Une erreur a été rencontrée lors du chargement du cmdset '{path}'.\n"
"Le cmdset '{fallback_path}' est utilisé en remplacement.\n"
#: .\commands\cmdsethandler.py:114
#, python-brace-format
msgid "Fallback path '{fallback_path}' failed to generate a cmdset."
msgstr "Impossible de générer le cmdset de remplacement : '{fallback_path}'."
#: .\commands\cmdsethandler.py:182 .\commands\cmdsethandler.py:192
#, fuzzy, python-format
msgid ""
"\n"
"(Unsuccessfully tried '%s')."
msgstr ""
"\n"
"(Essayé sans succès '%s.' + '%s.%s')."
#: .\commands\cmdsethandler.py:311
#, python-brace-format
msgid "custom {mergetype} on cmdset '{cmdset}'"
msgstr "custom {mergetype} sur cmdset '{cmdset}'"
#: .\commands\cmdsethandler.py:314
#, python-brace-format
msgid " <Merged {mergelist} {mergetype}, prio {prio}>: {current}"
msgstr " <Fusionné {mergelist} {mergetype}, prio {prio}>: {current}"
#: .\commands\cmdsethandler.py:322
#, python-brace-format
msgid ""
" <{key} ({mergetype}, prio {prio}, {permstring})>:\n"
" {keylist}"
msgstr ""
" <{key} ({mergetype}, prio {prio}, {permstring})>:\n"
" {keylist}"
#: .\commands\cmdsethandler.py:426
msgid "Only CmdSets can be added to the cmdsethandler!"
msgstr "Seuls des CmdSets peuvent être ajoutés au cmdsethandler !"
#: .\comms\channelhandler.py:100
msgid "Say what?"
msgstr "Que voulez-vous dire ?"
#: .\comms\channelhandler.py:105
#, python-format
msgid "Channel '%s' not found."
msgstr "Le canal '%s' ne semble pas exister."
#: .\comms\channelhandler.py:108
#, python-format
msgid "You are not connected to channel '%s'."
msgstr "Vous n'êtes pas connecté au canal '%s'."
#: .\comms\channelhandler.py:112
#, python-format
msgid "You are not permitted to send to channel '%s'."
msgstr "Vous n'avez pas le droit de parler sur le canal '%s'."
#: .\comms\channelhandler.py:155
msgid " (channel)"
msgstr " (canal)"
#: .\locks\lockhandler.py:236
#, python-format
msgid "Lock: lock-function '%s' is not available."
msgstr "Verrou : lock-function '%s' n'est pas disponible."
#: .\locks\lockhandler.py:249
#, python-format
msgid "Lock: definition '%s' has syntax errors."
msgstr "Verrou : la définition '%s' a des erreurs de syntaxe."
#: .\locks\lockhandler.py:253
#, python-format
msgid ""
"LockHandler on %(obj)s: access type '%(access_type)s' changed from "
"'%(source)s' to '%(goal)s' "
msgstr ""
"Gestionnaire de verrous sur %(obj)s: type d'accès '%(access_type)s' a changé "
"de '%(source)s' à '%(goal)s'"
#: .\locks\lockhandler.py:320
#, fuzzy, python-brace-format
msgid "Lock: '{lockdef}' contains no colon (:)."
msgstr "Verrou : '%s' ne contient pas de deux points (:)."
#: .\locks\lockhandler.py:328
#, fuzzy, python-brace-format
msgid "Lock: '{lockdef}' has no access_type (left-side of colon is empty)."
msgstr ""
"Verrou : '%s' n'a pas de 'access_type' (il n'y a rien avant les deux points)."
#: .\locks\lockhandler.py:336
#, fuzzy, python-brace-format
msgid "Lock: '{lockdef}' has mismatched parentheses."
msgstr "Verrou : '%s' a des parenthèses déséquilibrées."
#: .\locks\lockhandler.py:343
#, fuzzy, python-brace-format
msgid "Lock: '{lockdef}' has no valid lock functions."
msgstr "Verrou : '%s' n'a pas de lock-function valide."
#: .\objects\objects.py:729
#, python-format
msgid "Couldn't perform move ('%s'). Contact an admin."
msgstr "Impossible de se déplacer vers ('%s'). Veuillez contacter un administrateur."
#: .\objects\objects.py:739
msgid "The destination doesn't exist."
msgstr "La destination est inconnue."
#: .\objects\objects.py:830
#, python-format
msgid "Could not find default home '(#%d)'."
msgstr "Impossible de trouver la salle de départ (default home) par défaut : '#%d'."
#: .\objects\objects.py:846
msgid "Something went wrong! You are dumped into nowhere. Contact an admin."
msgstr ""
"Quelque chose a mal tourné ! Vous vous trouvez au milieu de nulle part. "
"Veuillez contacter un administrateur."
#: .\objects\objects.py:912
#, python-format
msgid "Your character %s has been destroyed."
msgstr "Votre personnage %s a été détruit."
#: .\scripts\scripthandler.py:53
#, python-format
msgid ""
"\n"
" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s repeats): %(desc)s"
msgstr ""
"\n"
" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s répète) : %(desc)s"
#: .\scripts\scripts.py:205
#, python-format
msgid ""
"Script %(key)s(#%(dbid)s) of type '%(cname)s': at_repeat() error '%(err)s'."
msgstr ""
"Le script %(key)s(#%(dbid)s) de type '%(cname)s' a rencontré une erreur "
"durant at_repeat() : '%(err)s'."
#: .\server\initial_setup.py:28
#, fuzzy
msgid ""
"\n"
"Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com if "
"you need\n"
"help, want to contribute, report issues or just join the community.\n"
"As Account #1 you can create a demo/tutorial area with |w@batchcommand "
"tutorial_world.build|n.\n"
" "
msgstr ""
"\n"
"Bienvenue dans votre nouveau jeu basé sur |wEvennia|n ! Visitez le site Web\n"
"http://www.evennia.com si vous avez besoin d'aide, pour contribuer au projet,\n"
"afin de rapporter des bugs ou faire partie de la communauté.\n"
"En tant que premier personnage (#1), vous pouvez créer une zone de\n"
"démo/tutoriel en entrant la commande |w@batchcommand tutorial_world.build|n.\n"
" "
#: .\server\initial_setup.py:92
msgid "This is User #1."
msgstr "C'est l'utilisateur #1."
#: .\server\initial_setup.py:105
msgid "Limbo"
msgstr "Limbes"
#: .\server\server.py:139
#, fuzzy
msgid "idle timeout exceeded"
msgstr "Délai d'inactivité dépassé, déconnexion."
#: .\server\sessionhandler.py:386
msgid " ... Server restarted."
msgstr " ... Serveur redémarré."
#: .\server\sessionhandler.py:606
msgid "Logged in from elsewhere. Disconnecting."
msgstr "Connexion d'une autre session. Déconnexion de celle-ci."
#: .\server\sessionhandler.py:634
msgid "Idle timeout exceeded, disconnecting."
msgstr "Délai d'inactivité dépassé, déconnexion."
#: .\server\validators.py:50
#, python-format
msgid ""
"%s From a terminal client, you can also use a phrase of multiple words if "
"you enclose the password in double quotes."
msgstr ""
"%s Depuis votre client, vous pouvez également préciser une phrase contenant "
"plusieurs mots séparés par un espace, dès lors que cette phrase est entourée de guillemets."
#: .\utils\evmenu.py:192
#, python-brace-format
msgid ""
"Menu node '{nodename}' is either not implemented or caused an error. Make "
"another choice."
msgstr ""
"Ce choix '{nodename}' n'est pas implémenté, ou bien a créé une erreur. "
"Faies un autre choix."
#: .\utils\evmenu.py:194
#, python-brace-format
msgid "Error in menu node '{nodename}'."
msgstr "Une erreur s'est produite dans le choix '{nodename}'."
#: .\utils\evmenu.py:195
msgid "No description."
msgstr "Description non renseignée."
#: .\utils\evmenu.py:196
msgid "Commands: <menu option>, help, quit"
msgstr "Utilisez une des commandes : <menu option>, help, quit"
#: .\utils\evmenu.py:197
msgid "Commands: <menu option>, help"
msgstr "Utilisez une des commandes : <menu option>, help"
#: .\utils\evmenu.py:198
msgid "Commands: help, quit"
msgstr "Utilisez une des commandes : help, quit"
#: .\utils\evmenu.py:199
msgid "Commands: help"
msgstr "Utilisez la commande : help"
#: .\utils\evmenu.py:200
msgid "Choose an option or try 'help'."
msgstr "Choisissez une option ou entrez la commande 'help'."
#: .\utils\utils.py:1866
#, python-format
msgid "Could not find '%s'."
msgstr "Impossible de trouver '%s'."
#: .\utils\utils.py:1873
#, python-format
msgid "More than one match for '%s' (please narrow target):\n"
msgstr "Plus d'une possibilité pour '%s' (veuillez préciser) :\n"

View file

@ -21,6 +21,7 @@ from evennia.scripts.scripthandler import ScriptHandler
from evennia.commands import cmdset, command
from evennia.commands.cmdsethandler import CmdSetHandler
from evennia.commands import cmdhandler
from evennia.utils import create
from evennia.utils import search
from evennia.utils import logger
from evennia.utils import ansi
@ -191,6 +192,10 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
without `obj.save()` having to be called explicitly.
"""
# lockstring of newly created objects, for easy overloading.
# Will be formatted with the appropriate attributes.
lockstring = "control:id({account_id}) or perm(Admin);delete:id({account_id}) or perm(Admin)"
objects = ObjectManager()
# on-object properties
@ -214,7 +219,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
@property
def is_connected(self):
# we get an error for objects subscribed to channels without this
if self.account: # seems sane to pass on the account
if self.account: # seems sane to pass on the account
return self.account.is_connected
else:
return False
@ -425,7 +430,8 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
# only allow exact matching if searching the entire database
# or unique #dbrefs
exact = True
elif candidates is None:
else:
# TODO: write code...if candidates is None:
# no custom candidates given - get them automatically
if location:
# location(s) were given
@ -857,6 +863,67 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
obj.msg(_(string))
obj.move_to(home)
@classmethod
def create(cls, key, account=None, **kwargs):
"""
Creates a basic object with default parameters, unless otherwise
specified or extended.
Provides a friendlier interface to the utils.create_object() function.
Args:
key (str): Name of the new object.
account (Account): Account to attribute this object to.
Kwargs:
description (str): Brief description for this object.
ip (str): IP address of creator (for object auditing).
Returns:
object (Object): A newly created object of the given typeclass.
errors (list): A list of errors in string form, if any.
"""
errors = []
obj = None
# Get IP address of creator, if available
ip = kwargs.pop('ip', '')
# If no typeclass supplied, use this class
kwargs['typeclass'] = kwargs.pop('typeclass', cls)
# Set the supplied key as the name of the intended object
kwargs['key'] = key
# Get a supplied description, if any
description = kwargs.pop('description', '')
# Create a sane lockstring if one wasn't supplied
lockstring = kwargs.get('locks')
if account and not lockstring:
lockstring = cls.lockstring.format(account_id=account.id)
kwargs['locks'] = lockstring
# Create object
try:
obj = create.create_object(**kwargs)
# Record creator id and creation IP
if ip: obj.db.creator_ip = ip
if account: obj.db.creator_id = account.id
# Set description if there is none, or update it if provided
if description or not obj.db.desc:
desc = description if description else "You see nothing special."
obj.db.desc = desc
except Exception as e:
errors.append("An error occurred while creating this '%s' object." % key)
logger.log_err(e)
return obj, errors
def copy(self, new_key=None):
"""
Makes an identical copy of this object, identical except for a
@ -912,8 +979,12 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
# no need to disconnect, Account just jumps to OOC mode.
# sever the connection (important!)
if self.account:
# Remove the object from playable characters list
if self in self.account.db._playable_characters:
self.account.db._playable_characters = [x for x in self.account.db._playable_characters if x != self]
for session in self.sessions.all():
self.account.unpuppet_object(session)
self.account = None
for script in _ScriptDB.objects.get_all_scripts_on_obj(self):
@ -1820,6 +1891,91 @@ class DefaultCharacter(DefaultObject):
a character avatar controlled by an account.
"""
# lockstring of newly created rooms, for easy overloading.
# Will be formatted with the appropriate attributes.
lockstring = "puppet:id({character_id}) or pid({account_id}) or perm(Developer) or pperm(Developer);delete:id({account_id}) or perm(Admin)"
@classmethod
def create(cls, key, account, **kwargs):
"""
Creates a basic Character with default parameters, unless otherwise
specified or extended.
Provides a friendlier interface to the utils.create_character() function.
Args:
key (str): Name of the new Character.
account (obj): Account to associate this Character with. Required as
an argument, but one can fake it out by supplying None-- it will
change the default lockset and skip creator attribution.
Kwargs:
description (str): Brief description for this object.
ip (str): IP address of creator (for object auditing).
Returns:
character (Object): A newly created Character of the given typeclass.
errors (list): A list of errors in string form, if any.
"""
errors = []
obj = None
# Get IP address of creator, if available
ip = kwargs.pop('ip', '')
# If no typeclass supplied, use this class
kwargs['typeclass'] = kwargs.pop('typeclass', cls)
# Set the supplied key as the name of the intended object
kwargs['key'] = key
# Get home for character
kwargs['home'] = ObjectDB.objects.get_id(kwargs.get('home', settings.DEFAULT_HOME))
# Get permissions
kwargs['permissions'] = kwargs.get('permissions', settings.PERMISSION_ACCOUNT_DEFAULT)
# Get description if provided
description = kwargs.pop('description', '')
# Get locks if provided
locks = kwargs.pop('locks', '')
try:
# Check to make sure account does not have too many chars
if len(account.characters) >= settings.MAX_NR_CHARACTERS:
errors.append("There are too many characters associated with this account.")
return obj, errors
# Create the Character
obj = create.create_object(**kwargs)
# Record creator id and creation IP
if ip: obj.db.creator_ip = ip
if account:
obj.db.creator_id = account.id
if obj not in account.characters:
account.db._playable_characters.append(obj)
# Add locks
if not locks and account:
# Allow only the character itself and the creator account to puppet this character (and Developers).
locks = cls.lockstring.format(**{'character_id': obj.id, 'account_id': account.id})
elif not locks and not account:
locks = cls.lockstring.format(**{'character_id': obj.id, 'account_id': -1})
obj.locks.add(locks)
# If no description is set, set a default description
if description or not obj.db.desc:
obj.db.desc = description if description else "This is a character."
except Exception as e:
errors.append("An error occurred while creating this '%s' object." % key)
logger.log_err(e)
return obj, errors
def basetype_setup(self):
"""
@ -1937,6 +2093,72 @@ class DefaultRoom(DefaultObject):
This is the base room object. It's just like any Object except its
location is always `None`.
"""
# lockstring of newly created rooms, for easy overloading.
# Will be formatted with the {id} of the creating object.
lockstring = "control:id({id}) or perm(Admin); " \
"delete:id({id}) or perm(Admin); " \
"edit:id({id}) or perm(Admin)"
@classmethod
def create(cls, key, account, **kwargs):
"""
Creates a basic Room with default parameters, unless otherwise
specified or extended.
Provides a friendlier interface to the utils.create_object() function.
Args:
key (str): Name of the new Room.
account (obj): Account to associate this Room with.
Kwargs:
description (str): Brief description for this object.
ip (str): IP address of creator (for object auditing).
Returns:
room (Object): A newly created Room of the given typeclass.
errors (list): A list of errors in string form, if any.
"""
errors = []
obj = None
# Get IP address of creator, if available
ip = kwargs.pop('ip', '')
# If no typeclass supplied, use this class
kwargs['typeclass'] = kwargs.pop('typeclass', cls)
# Set the supplied key as the name of the intended object
kwargs['key'] = key
# Get who to send errors to
kwargs['report_to'] = kwargs.pop('report_to', account)
# Get description, if provided
description = kwargs.pop('description', '')
try:
# Create the Room
obj = create.create_object(**kwargs)
# Set appropriate locks
lockstring = kwargs.get('locks', cls.lockstring.format(id=account.id))
obj.locks.add(lockstring)
# Record creator id and creation IP
if ip: obj.db.creator_ip = ip
if account: obj.db.creator_id = account.id
# If no description is set, set a default description
if description or not obj.db.desc:
obj.db.desc = description if description else "This is a room."
except Exception as e:
errors.append("An error occurred while creating this '%s' object." % key)
logger.log_err(e)
return obj, errors
def basetype_setup(self):
"""
@ -2015,6 +2237,13 @@ class DefaultExit(DefaultObject):
exit_command = ExitCommand
priority = 101
# lockstring of newly created exits, for easy overloading.
# Will be formatted with the {id} of the creating object.
lockstring = "control:id({id}) or perm(Admin); " \
"delete:id({id}) or perm(Admin); " \
"edit:id({id}) or perm(Admin)"
# Helper classes and methods to implement the Exit. These need not
# be overloaded unless one want to change the foundation for how
# Exits work. See the end of the class for hook methods to overload.
@ -2053,6 +2282,73 @@ class DefaultExit(DefaultObject):
# Command hooks
@classmethod
def create(cls, key, account, source, dest, **kwargs):
"""
Creates a basic Exit with default parameters, unless otherwise
specified or extended.
Provides a friendlier interface to the utils.create_object() function.
Args:
key (str): Name of the new Exit, as it should appear from the
source room.
account (obj): Account to associate this Exit with.
source (Room): The room to create this exit in.
dest (Room): The room to which this exit should go.
Kwargs:
description (str): Brief description for this object.
ip (str): IP address of creator (for object auditing).
Returns:
exit (Object): A newly created Room of the given typeclass.
errors (list): A list of errors in string form, if any.
"""
errors = []
obj = None
# Get IP address of creator, if available
ip = kwargs.pop('ip', '')
# If no typeclass supplied, use this class
kwargs['typeclass'] = kwargs.pop('typeclass', cls)
# Set the supplied key as the name of the intended object
kwargs['key'] = key
# Get who to send errors to
kwargs['report_to'] = kwargs.pop('report_to', account)
# Set to/from rooms
kwargs['location'] = source
kwargs['destination'] = dest
description = kwargs.pop('description', '')
try:
# Create the Exit
obj = create.create_object(**kwargs)
# Set appropriate locks
lockstring = kwargs.get('locks', cls.lockstring.format(id=account.id))
obj.locks.add(lockstring)
# Record creator id and creation IP
if ip: obj.db.creator_ip = ip
if account: obj.db.creator_id = account.id
# If no description is set, set a default description
if description or not obj.db.desc:
obj.db.desc = description if description else "This is an exit."
except Exception as e:
errors.append("An error occurred while creating this '%s' object." % key)
logger.log_err(e)
return obj, errors
def basetype_setup(self):
"""
Setup exit-security

47
evennia/objects/tests.py Normal file
View file

@ -0,0 +1,47 @@
from evennia.utils.test_resources import EvenniaTest
from evennia import DefaultObject, DefaultCharacter, DefaultRoom, DefaultExit
class DefaultObjectTest(EvenniaTest):
ip = '212.216.139.14'
def test_object_create(self):
description = 'A home for a grouch.'
obj, errors = DefaultObject.create('trashcan', self.account, description=description, ip=self.ip)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip)
def test_character_create(self):
description = 'A furry green monster, reeking of garbage.'
obj, errors = DefaultCharacter.create('oscar', self.account, description=description, ip=self.ip)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip)
def test_room_create(self):
description = 'A dimly-lit alley behind the local Chinese restaurant.'
obj, errors = DefaultRoom.create('alley', self.account, description=description, ip=self.ip)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip)
def test_exit_create(self):
description = 'The steaming depths of the dumpster, ripe with refuse in various states of decomposition.'
obj, errors = DefaultExit.create('in', self.account, self.room1, self.room2, description=description, ip=self.ip)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip)
def test_urls(self):
"Make sure objects are returning URLs"
self.assertTrue(self.char1.get_absolute_url())
self.assertTrue('admin' in self.char1.web_get_admin_url())
self.assertTrue(self.room1.get_absolute_url())
self.assertTrue('admin' in self.room1.web_get_admin_url())

View file

@ -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 = []

View file

@ -37,12 +37,15 @@ prototype key (this value must be possible to serialize in an Attribute).
from ast import literal_eval
from random import randint as base_randint, random as base_random, choice as base_choice
import re
from evennia.utils import search
from evennia.utils.utils import justify as base_justify, is_iter, to_str
_PROTLIB = None
_RE_DBREF = re.compile(r"\#[0-9]+")
# default protfuncs
@ -325,3 +328,14 @@ def objlist(*args, **kwargs):
"""
return ["#{}".format(obj.id) for obj in _obj_search(return_list=True, *args, **kwargs)]
def dbref(*args, **kwargs):
"""
Usage $dbref(<#dbref>)
Returns one Object searched globally by #dbref. Error if #dbref is invalid.
"""
if not args or len(args) < 1 or _RE_DBREF.match(args[0]) is None:
raise ValueError('$dbref requires a valid #dbref argument.')
return obj(args[0])

View file

@ -5,7 +5,6 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos
"""
import re
import hashlib
import time
from ast import literal_eval
@ -33,8 +32,6 @@ _PROTOTYPE_TAG_CATEGORY = "from_prototype"
_PROTOTYPE_TAG_META_CATEGORY = "db_prototype"
PROT_FUNCS = {}
_RE_DBREF = re.compile(r"(?<!\$obj\()(#[0-9]+)")
class PermissionError(RuntimeError):
pass
@ -258,7 +255,7 @@ def delete_prototype(prototype_key, caller=None):
stored_prototype = stored_prototype[0]
if caller:
if not stored_prototype.access(caller, 'edit'):
raise PermissionError("{} does not have permission to "
raise PermissionError("{} needs explicit 'edit' permissions to "
"delete prototype {}.".format(caller, prototype_key))
stored_prototype.delete()
return True
@ -374,14 +371,14 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed
display_tuples = []
for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')):
lock_use = caller.locks.check_lockstring(
caller, prototype.get('prototype_locks', ''), access_type='spawn')
caller, prototype.get('prototype_locks', ''), access_type='spawn', default=True)
if not show_non_use and not lock_use:
continue
if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES:
lock_edit = False
else:
lock_edit = caller.locks.check_lockstring(
caller, prototype.get('prototype_locks', ''), access_type='edit')
caller, prototype.get('prototype_locks', ''), access_type='edit', default=True)
if not show_non_edit and not lock_edit:
continue
ptags = []
@ -576,9 +573,6 @@ def protfunc_parser(value, available_functions=None, testing=False, stacktrace=F
available_functions = PROT_FUNCS if available_functions is None else available_functions
# insert $obj(#dbref) for #dbref
value = _RE_DBREF.sub("$obj(\\1)", value)
result = inlinefuncs.parse_inlinefunc(
value, available_funcs=available_functions,
stacktrace=stacktrace, testing=testing, **kwargs)
@ -713,7 +707,8 @@ def check_permission(prototype_key, action, default=True):
lockstring = prototype.get("prototype_locks")
if lockstring:
return check_lockstring(None, lockstring, default=default, access_type=action)
return check_lockstring(None, lockstring,
default=default, access_type=action)
return default

View file

@ -11,6 +11,7 @@ from evennia.utils.test_resources import EvenniaTest
from evennia.utils.tests.test_evmenu import TestEvMenu
from evennia.prototypes import spawner, prototypes as protlib
from evennia.prototypes import menus as olc_menus
from evennia.prototypes import protfuncs as protofuncs
from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY
@ -312,11 +313,83 @@ class TestProtFuncs(EvenniaTest):
self.assertEqual(protlib.protfunc_parser(
"$eval({'test': '1', 2:3, 3: $toint(3.5)})"), {'test': '1', 2: 3, 3: 3})
self.assertEqual(protlib.protfunc_parser("$obj(#1)", session=self.session), '#1')
self.assertEqual(protlib.protfunc_parser("#1", session=self.session), '#1')
self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6')
self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6')
self.assertEqual(protlib.protfunc_parser("$objlist(#1)", session=self.session), ['#1'])
# no object search
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("obj(#1)", session=self.session), 'obj(#1)')
mocked__obj_search.assert_not_called()
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("dbref(#1)", session=self.session), 'dbref(#1)')
mocked__obj_search.assert_not_called()
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("stone(#12345)", session=self.session), 'stone(#12345)')
mocked__obj_search.assert_not_called()
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("#1", session=self.session), '#1')
mocked__obj_search.assert_not_called()
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("#12345", session=self.session), '#12345')
mocked__obj_search.assert_not_called()
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("nothing(#1)", session=self.session), 'nothing(#1)')
mocked__obj_search.assert_not_called()
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("(#12345)", session=self.session), '(#12345)')
mocked__obj_search.assert_not_called()
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("obj(Char)", session=self.session), 'obj(Char)')
mocked__obj_search.assert_not_called()
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("objlist(#1)", session=self.session), 'objlist(#1)')
mocked__obj_search.assert_not_called()
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("dbref(Char)", session=self.session), 'dbref(Char)')
mocked__obj_search.assert_not_called()
# obj search happens
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("$objlist(#1)", session=self.session), ['#1'])
mocked__obj_search.assert_called_once()
assert ('#1',) == mocked__obj_search.call_args[0]
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("$obj(#1)", session=self.session), '#1')
mocked__obj_search.assert_called_once()
assert ('#1',) == mocked__obj_search.call_args[0]
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("$dbref(#1)", session=self.session), '#1')
mocked__obj_search.assert_called_once()
assert ('#1',) == mocked__obj_search.call_args[0]
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6')
mocked__obj_search.assert_called_once()
assert ('Char',) == mocked__obj_search.call_args[0]
# bad invocation
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("$badfunc(#1)", session=self.session), '<UNKNOWN>')
mocked__obj_search.assert_not_called()
with mock.patch("evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search) as mocked__obj_search:
self.assertRaises(ValueError, protlib.protfunc_parser, "$dbref(Char)")
mocked__obj_search.assert_not_called()
self.assertEqual(protlib.value_to_obj(
protlib.protfunc_parser("#6", session=self.session)), self.char1)

View file

@ -12,7 +12,7 @@ from django.utils.translation import ugettext as _
from evennia.typeclasses.models import TypeclassBase
from evennia.scripts.models import ScriptDB
from evennia.scripts.manager import ScriptManager
from evennia.utils import logger
from evennia.utils import create, logger
from future.utils import with_metaclass
__all__ = ["DefaultScript", "DoNothing", "Store"]
@ -324,6 +324,32 @@ class DefaultScript(ScriptBase):
"""
@classmethod
def create(cls, key, **kwargs):
"""
Provides a passthrough interface to the utils.create_script() function.
Args:
key (str): Name of the new object.
Returns:
object (Object): A newly created object of the given typeclass.
errors (list): A list of errors in string form, if any.
"""
errors = []
obj = None
kwargs['key'] = key
try:
obj = create.create_script(**kwargs)
except Exception as e:
errors.append("The script '%s' encountered errors and could not be created." % key)
logger.log_err(e)
return obj, errors
def at_script_creation(self):
"""
Only called once, when script is first created.

View file

@ -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"

View file

@ -223,6 +223,7 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol):
server_restart_mode = kwargs.get("server_restart_mode", "shutdown")
self.factory.server.run_init_hooks(server_restart_mode)
server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata"))
server_sessionhandler.portal_start_time = kwargs.get("portal_start_time")
elif operation == amp.SRELOAD: # server reload
# shut down in reload mode

View file

@ -92,7 +92,10 @@ TWISTED_MIN = '18.0.0'
DJANGO_MIN = '1.11'
DJANGO_REC = '1.11'
sys.path[1] = EVENNIA_ROOT
try:
sys.path[1] = EVENNIA_ROOT
except IndexError:
sys.path.append(EVENNIA_ROOT)
# ------------------------------------------------------------
#
@ -216,6 +219,19 @@ RECREATED_SETTINGS = \
their accounts with their old passwords.
"""
ERROR_INITMISSING = \
"""
ERROR: 'evennia --initmissing' must be called from the root of
your game directory, since it tries to create any missing files
in the server/ subfolder.
"""
RECREATED_MISSING = \
"""
(Re)created any missing directories or files. Evennia should
be ready to run now!
"""
ERROR_DATABASE = \
"""
ERROR: Your database does not seem to be set up correctly.
@ -255,7 +271,7 @@ INFO_WINDOWS_BATFILE = \
twistd.bat to point to the actual location of the Twisted
executable (usually called twistd.py) on your machine.
This procedure is only done once. Run evennia.py again when you
This procedure is only done once. Run `evennia` again when you
are ready to start the server.
"""
@ -319,7 +335,7 @@ MENU = \
| 7) Kill Server only (send kill signal to process) |
| 8) Kill Portal + Server |
+--- Information -----------------------------------------------+
| 9) Tail log files (quickly see errors) |
| 9) Tail log files (quickly see errors - Ctrl-C to exit) |
| 10) Status |
| 11) Port info |
+--- Testing ---------------------------------------------------+
@ -1207,7 +1223,7 @@ def evennia_version():
"git rev-parse --short HEAD",
shell=True, cwd=EVENNIA_ROOT, stderr=STDOUT).strip().decode()
version = "%s (rev %s)" % (version, rev)
except (IOError, CalledProcessError):
except (IOError, CalledProcessError, OSError):
# move on if git is not answering
pass
return version
@ -1337,7 +1353,10 @@ def create_settings_file(init=True, secret_settings=False):
else:
print("Reset the settings file.")
default_settings_path = os.path.join(EVENNIA_TEMPLATE, "server", "conf", "settings.py")
if secret_settings:
default_settings_path = os.path.join(EVENNIA_TEMPLATE, "server", "conf", "secret_settings.py")
else:
default_settings_path = os.path.join(EVENNIA_TEMPLATE, "server", "conf", "settings.py")
shutil.copy(default_settings_path, settings_path)
with open(settings_path, 'r') as f:
@ -1631,7 +1650,7 @@ def error_check_python_modules():
#
# ------------------------------------------------------------
def init_game_directory(path, check_db=True):
def init_game_directory(path, check_db=True, need_gamedir=True):
"""
Try to analyze the given path to find settings.py - this defines
the game directory and also sets PYTHONPATH as well as the django
@ -1640,15 +1659,17 @@ def init_game_directory(path, check_db=True):
Args:
path (str): Path to new game directory, including its name.
check_db (bool, optional): Check if the databae exists.
need_gamedir (bool, optional): set to False if Evennia doesn't require to be run in a valid game directory.
"""
# set the GAMEDIR path
set_gamedir(path)
if need_gamedir:
set_gamedir(path)
# Add gamedir to python path
sys.path.insert(0, GAMEDIR)
if TEST_MODE:
if TEST_MODE or not need_gamedir:
if ENFORCED_SETTING:
print(NOTE_TEST_CUSTOM.format(settings_dotpath=SETTINGS_DOTPATH))
os.environ['DJANGO_SETTINGS_MODULE'] = SETTINGS_DOTPATH
@ -1675,6 +1696,10 @@ def init_game_directory(path, check_db=True):
if check_db:
check_database()
# if we don't have to check the game directory, return right away
if not need_gamedir:
return
# set up the Evennia executables and log file locations
global AMP_PORT, AMP_HOST, AMP_INTERFACE
global SERVER_PY_FILE, PORTAL_PY_FILE
@ -1920,6 +1945,10 @@ def main():
'--initsettings', action='store_true', dest="initsettings",
default=False,
help="create a new, empty settings file as\n gamedir/server/conf/settings.py")
parser.add_argument(
'--initmissing', action='store_true', dest="initmissing",
default=False,
help="checks for missing secret_settings or server logs\n directory, and adds them if needed")
parser.add_argument(
'--profiler', action='store_true', dest='profiler', default=False,
help="start given server component under the Python profiler")
@ -1993,6 +2022,21 @@ def main():
print(ERROR_INITSETTINGS)
sys.exit()
if args.initmissing:
try:
log_path = os.path.join(SERVERDIR, "logs")
if not os.path.exists(log_path):
os.makedirs(log_path)
settings_path = os.path.join(CONFDIR, "secret_settings.py")
if not os.path.exists(settings_path):
create_settings_file(init=False, secret_settings=True)
print(RECREATED_MISSING)
except IOError:
print(ERROR_INITMISSING)
sys.exit()
if args.tail_log:
# set up for tailing the log files
global NO_REACTOR_STOP
@ -2061,6 +2105,10 @@ def main():
elif option != "noop":
# pass-through to django manager
check_db = False
need_gamedir = True
# some commands don't require the presence of a game directory to work
if option in ('makemessages', 'compilemessages'):
need_gamedir = False
# handle special django commands
if option in ('runserver', 'testserver'):
@ -2073,7 +2121,7 @@ def main():
global TEST_MODE
TEST_MODE = True
init_game_directory(CURRENT_DIR, check_db=check_db)
init_game_directory(CURRENT_DIR, check_db=check_db, need_gamedir=need_gamedir)
# pass on to the manager
args = [option]
@ -2089,6 +2137,11 @@ def main():
kwargs[arg.lstrip("--")] = value
else:
args.append(arg)
# makemessages needs a special syntax to not conflict with the -l option
if len(args) > 1 and args[0] == "makemessages":
args.insert(1, "-l")
try:
django.core.management.call_command(*args, **kwargs)
except django.core.management.base.CommandError as exc:

View file

@ -440,7 +440,8 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol):
self.send_AdminPortal2Server(amp.DUMMYSESSION,
amp.PSYNC,
server_restart_mode=server_restart_mode,
sessiondata=sessdata)
sessiondata=sessdata,
portal_start_time=self.factory.portal.start_time)
self.factory.portal.sessions.at_server_connection()
if self.factory.server_connection:

View file

@ -421,7 +421,7 @@ class IRCBotFactory(protocol.ReconnectingClientFactory):
def clientConnectionLost(self, connector, reason):
"""
Called when Client looses connection.
Called when Client loses connection.
Args:
connector (Connection): Represents the connection.

View file

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

View file

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

View file

@ -8,9 +8,24 @@ try:
except ImportError:
import unittest
from mock import Mock
import string
from evennia.server.portal import irc
from twisted.conch.telnet import IAC, WILL, DONT, SB, SE, NAWS, DO
from twisted.test import proto_helpers
from twisted.trial.unittest import TestCase as TwistedTestCase
from .telnet import TelnetServerFactory, TelnetProtocol
from .portal import PORTAL_SESSIONS
from .suppress_ga import SUPPRESS_GA
from .naws import DEFAULT_HEIGHT, DEFAULT_WIDTH
from .ttype import TTYPE, IS
from .mccp import MCCP
from .mssp import MSSP
from .mxp import MXP
from .telnet_oob import MSDP, MSDP_VAL, MSDP_VAR
class TestIRC(TestCase):
@ -73,3 +88,64 @@ class TestIRC(TestCase):
s = r'|wthis|Xis|gis|Ma|C|complex|*string'
self.assertEqual(irc.parse_irc_to_ansi(irc.parse_ansi_to_irc(s)), s)
class TestTelnet(TwistedTestCase):
def setUp(self):
super(TestTelnet, self).setUp()
factory = TelnetServerFactory()
factory.protocol = TelnetProtocol
factory.sessionhandler = PORTAL_SESSIONS
factory.sessionhandler.portal = Mock()
self.proto = factory.buildProtocol(("localhost", 0))
self.transport = proto_helpers.StringTransport()
self.addCleanup(factory.sessionhandler.disconnect_all)
def test_mudlet_ttype(self):
self.transport.client = ["localhost"]
self.transport.setTcpKeepAlive = Mock()
d = self.proto.makeConnection(self.transport)
# test suppress_ga
self.assertTrue(self.proto.protocol_flags["NOGOAHEAD"])
self.proto.dataReceived(IAC + DONT + SUPPRESS_GA)
self.assertFalse(self.proto.protocol_flags["NOGOAHEAD"])
self.assertEqual(self.proto.handshakes, 7)
# test naws
self.assertEqual(self.proto.protocol_flags['SCREENWIDTH'], {0: DEFAULT_WIDTH})
self.assertEqual(self.proto.protocol_flags['SCREENHEIGHT'], {0: DEFAULT_HEIGHT})
self.proto.dataReceived(IAC + WILL + NAWS)
self.proto.dataReceived([IAC, SB, NAWS, '', 'x', '', 'd', IAC, SE])
self.assertEqual(self.proto.protocol_flags['SCREENWIDTH'][0], 120)
self.assertEqual(self.proto.protocol_flags['SCREENHEIGHT'][0], 100)
self.assertEqual(self.proto.handshakes, 6)
# test ttype
self.assertTrue(self.proto.protocol_flags["FORCEDENDLINE"])
self.assertFalse(self.proto.protocol_flags["TTYPE"])
self.assertTrue(self.proto.protocol_flags["ANSI"])
self.proto.dataReceived(IAC + WILL + TTYPE)
self.proto.dataReceived([IAC, SB, TTYPE, IS, "MUDLET", IAC, SE])
self.assertTrue(self.proto.protocol_flags["XTERM256"])
self.assertEqual(self.proto.protocol_flags["CLIENTNAME"], "MUDLET")
self.proto.dataReceived([IAC, SB, TTYPE, IS, "XTERM", IAC, SE])
self.proto.dataReceived([IAC, SB, TTYPE, IS, "MTTS 137", IAC, SE])
self.assertEqual(self.proto.handshakes, 5)
# test mccp
self.proto.dataReceived(IAC + DONT + MCCP)
self.assertFalse(self.proto.protocol_flags['MCCP'])
self.assertEqual(self.proto.handshakes, 4)
# test mssp
self.proto.dataReceived(IAC + DONT + MSSP)
self.assertEqual(self.proto.handshakes, 3)
# test oob
self.proto.dataReceived(IAC + DO + MSDP)
self.proto.dataReceived([IAC, SB, MSDP, MSDP_VAR, "LIST", MSDP_VAL, "COMMANDS", IAC, SE])
self.assertTrue(self.proto.protocol_flags['OOB'])
self.assertEqual(self.proto.handshakes, 2)
# test mxp
self.proto.dataReceived(IAC + DONT + MXP)
self.assertFalse(self.proto.protocol_flags['MXP'])
self.assertEqual(self.proto.handshakes, 1)
# clean up to prevent Unclean reactor
self.proto.nop_keep_alive.stop()
self.proto._handshake_delay.cancel()
return d

View file

@ -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.

View file

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

View file

@ -284,6 +284,8 @@ class ServerSessionHandler(SessionHandler):
super().__init__(*args, **kwargs)
self.server = None # set at server initialization
self.server_data = {"servername": _SERVERNAME}
# will be set on psync
self.portal_start_time = 0.0
def _run_cmd_login(self, session):
"""

View file

@ -1,25 +1,27 @@
from collections import defaultdict, deque
from evennia.utils import logger
import time
class Throttle(object):
"""
Keeps a running count of failed actions per IP address.
Keeps a running count of failed actions per IP address.
Available methods indicate whether or not the number of failures exceeds a
particular threshold.
This version of the throttle is usable by both the terminal server as well
as the web server, imposes limits on memory consumption by using deques
with length limits instead of open-ended lists, and removes sparse keys when
no recent failures have been recorded.
"""
error_msg = 'Too many failed attempts; you must wait a few minutes before trying again.'
def __init__(self, **kwargs):
"""
Allows setting of throttle parameters.
Kwargs:
limit (int): Max number of failures before imposing limiter
timeout (int): number of timeout seconds after
@ -31,55 +33,67 @@ class Throttle(object):
self.storage = defaultdict(deque)
self.cache_size = self.limit = kwargs.get('limit', 5)
self.timeout = kwargs.get('timeout', 5 * 60)
def get(self, ip=None):
"""
Convenience function that returns the storage table, or part of.
Args:
ip (str, optional): IP address of requestor
Returns:
storage (dict): When no IP is provided, returns a dict of all
current IPs being tracked and the timestamps of their recent
storage (dict): When no IP is provided, returns a dict of all
current IPs being tracked and the timestamps of their recent
failures.
timestamps (deque): When an IP is provided, returns a deque of
timestamps (deque): When an IP is provided, returns a deque of
timestamps of recent failures only for that IP.
"""
if ip: return self.storage.get(ip, deque(maxlen=self.cache_size))
else: return self.storage
def update(self, ip):
def update(self, ip, failmsg='Exceeded threshold.'):
"""
Store the time of the latest failure/
Store the time of the latest failure.
Args:
ip (str): IP address of requestor
failmsg (str, optional): Message to display in logs upon activation
of throttle.
Returns:
None
"""
# Get current status
previously_throttled = self.check(ip)
# Enforce length limits
if not self.storage[ip].maxlen:
self.storage[ip] = deque(maxlen=self.cache_size)
self.storage[ip].append(time.time())
# See if this update caused a change in status
currently_throttled = self.check(ip)
# If this makes it engage, log a single activation event
if (not previously_throttled and currently_throttled):
logger.log_sec('Throttle Activated: %s (IP: %s, %i hits in %i seconds.)' % (failmsg, ip, self.limit, self.timeout))
def check(self, ip):
"""
This will check the session's address against the
storage dictionary to check they haven't spammed too many
storage dictionary to check they haven't spammed too many
fails recently.
Args:
ip (str): IP address of requestor
Returns:
throttled (bool): True if throttling is active,
False otherwise.
"""
now = time.time()
ip = str(ip)
@ -97,5 +111,3 @@ class Throttle(object):
return False
else:
return False

View file

@ -1,34 +1,73 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from evennia.accounts.models import AccountDB
import re
class EvenniaUsernameAvailabilityValidator:
"""
Checks to make sure a given username is not taken or otherwise reserved.
"""
def __call__(self, username):
"""
Validates a username to make sure it is not in use or reserved.
Args:
username (str): Username to validate
Returns:
None (None): None if password successfully validated,
raises ValidationError otherwise.
"""
# Check guest list
if (settings.GUEST_LIST and username.lower() in (guest.lower() for guest in settings.GUEST_LIST)):
raise ValidationError(
_('Sorry, that username is reserved.'),
code='evennia_username_reserved',
)
# Check database
exists = AccountDB.objects.filter(username__iexact=username).exists()
if exists:
raise ValidationError(
_('Sorry, that username is already taken.'),
code='evennia_username_taken',
)
class EvenniaPasswordValidator:
def __init__(self, regex=r"^[\w. @+\-',]+$", policy="Password should contain a mix of letters, spaces, digits and @/./+/-/_/'/, only."):
def __init__(self, regex=r"^[\w. @+\-',]+$",
policy="Password should contain a mix of letters, "
"spaces, digits and @/./+/-/_/'/, only."):
"""
Constructs a standard Django password validator.
Args:
regex (str): Regex pattern of valid characters to allow.
policy (str): Brief explanation of what the defined regex permits.
"""
self.regex = regex
self.policy = policy
def validate(self, password, user=None):
"""
Validates a password string to make sure it meets predefined Evennia
acceptable character policy.
Args:
password (str): Password to validate
user (None): Unused argument but required by Django
Returns:
None (None): None if password successfully validated,
raises ValidationError otherwise.
"""
# Check complexity
if not re.findall(self.regex, password):
@ -41,11 +80,12 @@ class EvenniaPasswordValidator:
"""
Returns a user-facing explanation of the password policy defined
by this validator.
Returns:
text (str): Explanation of password policy.
"""
return _(
"%s From a terminal client, you can also use a phrase of multiple words if you enclose the password in double quotes." % self.policy
)
"%s From a terminal client, you can also use a phrase of multiple words if "
"you enclose the password in double quotes." % self.policy
)

View file

@ -13,6 +13,7 @@ always be sure of what you have changed and what is default behaviour.
"""
from builtins import range
from django.contrib.messages import constants as messages
from django.urls import reverse_lazy
import os
@ -116,7 +117,7 @@ AMP_INTERFACE = '127.0.0.1'
EVENNIA_DIR = os.path.dirname(os.path.abspath(__file__))
# Path to the game directory (containing the server/conf/settings.py file)
# This is dynamically created- there is generally no need to change this!
if sys.argv[1] == 'test' if len(sys.argv) > 1 else False:
if EVENNIA_DIR.lower() == os.getcwd().lower() or (sys.argv[1] == 'test' if len(sys.argv) > 1 else False):
# unittesting mode
GAME_DIR = os.getcwd()
else:
@ -139,7 +140,7 @@ HTTP_LOG_FILE = os.path.join(LOG_DIR, 'http_requests.log')
LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, 'lockwarnings.log')
# Rotate log files when server and/or portal stops. This will keep log
# file sizes down. Turn off to get ever growing log files and never
# loose log info.
# lose log info.
CYCLE_LOGFILES = True
# Number of lines to append to rotating channel logs when they rotate
CHANNEL_LOG_NUM_TAIL_LINES = 20
@ -222,7 +223,8 @@ COMMAND_RATE_WARNING = "You entered commands too fast. Wait a moment and try aga
# 0 or less.
MAX_CHAR_LIMIT = 6000
# The warning to echo back to users if they enter a very large string
MAX_CHAR_LIMIT_WARNING = "You entered a string that was too long. Please break it up into multiple parts."
MAX_CHAR_LIMIT_WARNING = ("You entered a string that was too long. "
"Please break it up into multiple parts.")
# If this is true, errors and tracebacks from the engine will be
# echoed as text in-game as well as to the log. This can speed up
# debugging. OBS: Showing full tracebacks to regular users could be a
@ -410,12 +412,14 @@ CMDSET_CHARACTER = "commands.default_cmdsets.CharacterCmdSet"
CMDSET_ACCOUNT = "commands.default_cmdsets.AccountCmdSet"
# Location to search for cmdsets if full path not given
CMDSET_PATHS = ["commands", "evennia", "contribs"]
# Fallbacks for cmdset paths that fail to load. Note that if you change the path for your default cmdsets,
# you will also need to copy CMDSET_FALLBACKS after your change in your settings file for it to detect the change.
CMDSET_FALLBACKS = {CMDSET_CHARACTER: 'evennia.commands.default.cmdset_character.CharacterCmdSet',
CMDSET_ACCOUNT: 'evennia.commands.default.cmdset_account.AccountCmdSet',
CMDSET_SESSION: 'evennia.commands.default.cmdset_session.SessionCmdSet',
CMDSET_UNLOGGEDIN: 'evennia.commands.default.cmdset_unloggedin.UnloggedinCmdSet'}
# Fallbacks for cmdset paths that fail to load. Note that if you change the path for your
# default cmdsets, you will also need to copy CMDSET_FALLBACKS after your change in your
# settings file for it to detect the change.
CMDSET_FALLBACKS = {
CMDSET_CHARACTER: 'evennia.commands.default.cmdset_character.CharacterCmdSet',
CMDSET_ACCOUNT: 'evennia.commands.default.cmdset_account.AccountCmdSet',
CMDSET_SESSION: 'evennia.commands.default.cmdset_session.SessionCmdSet',
CMDSET_UNLOGGEDIN: 'evennia.commands.default.cmdset_unloggedin.UnloggedinCmdSet'}
# Parent class for all default commands. Changing this class will
# modify all default commands, so do so carefully.
COMMAND_DEFAULT_CLASS = "evennia.commands.default.muxcommand.MuxCommand"
@ -666,6 +670,9 @@ DEBUG = False
ADMINS = () # 'Your Name', 'your_email@domain.com'),)
# These guys get broken link notifications when SEND_BROKEN_LINK_EMAILS is True.
MANAGERS = ADMINS
# This is a public point of contact for players or the public to contact
# a staff member or administrator of the site. It is publicly posted.
STAFF_CONTACT_EMAIL = None
# Absolute path to the directory that holds file uploads from web apps.
# Example: "/home/media/media.lawrence.com"
MEDIA_ROOT = os.path.join(GAME_DIR, "web", "media")
@ -752,6 +759,7 @@ TEMPLATES = [{
'django.contrib.auth.context_processors.auth',
'django.template.context_processors.media',
'django.template.context_processors.debug',
'django.contrib.messages.context_processors.messages',
'sekizai.context_processors.sekizai',
'evennia.web.utils.general_context.general_context'],
# While true, show "pretty" error messages for template syntax errors.
@ -762,14 +770,15 @@ TEMPLATES = [{
# MiddleWare are semi-transparent extensions to Django's functionality.
# see http://www.djangoproject.com/documentation/middleware/ for a more detailed
# explanation.
MIDDLEWARE_CLASSES = (
MIDDLEWARE = (
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', # 1.4?
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.admindocs.middleware.XViewMiddleware',
'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware',)
'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware',
'evennia.web.utils.middleware.SharedLoginMiddleware',)
######################################################################
# Evennia components
@ -786,6 +795,7 @@ INSTALLED_APPS = (
'django.contrib.flatpages',
'django.contrib.sites',
'django.contrib.staticfiles',
'django.contrib.messages',
'sekizai',
'evennia.utils.idmapper',
'evennia.server',
@ -811,9 +821,24 @@ AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
{'NAME': 'evennia.server.validators.EvenniaPasswordValidator'}]
# Username validation plugins
AUTH_USERNAME_VALIDATORS = [
{'NAME': 'django.contrib.auth.validators.ASCIIUsernameValidator'},
{'NAME': 'django.core.validators.MinLengthValidator',
'OPTIONS': {'limit_value': 3}},
{'NAME': 'django.core.validators.MaxLengthValidator',
'OPTIONS': {'limit_value': 30}},
{'NAME': 'evennia.server.validators.EvenniaUsernameAvailabilityValidator'}]
# Use a custom test runner that just tests Evennia-specific apps.
TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner'
# Messages and Bootstrap don't classify events the same way; this setting maps
# messages.error() to Bootstrap 'danger' classes.
MESSAGE_TAGS = {
messages.ERROR: 'danger',
}
######################################################################
# Django extensions
######################################################################
@ -821,7 +846,7 @@ TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner'
# Django extesions are useful third-party tools that are not
# always included in the default django distro.
try:
import django_extensions
import django_extensions # noqa
INSTALLED_APPS = INSTALLED_APPS + ('django_extensions',)
except ImportError:
# Django extensions are not installed in all distros.

View file

@ -31,9 +31,12 @@ from django.db.models import signals
from django.db.models.base import ModelBase
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.conf import settings
from django.urls import reverse
from django.utils.encoding import smart_str
from django.utils.text import slugify
from evennia.typeclasses.attributes import Attribute, AttributeHandler, NAttributeHandler
from evennia.typeclasses.tags import Tag, TagHandler, AliasHandler, PermissionHandler
@ -733,3 +736,182 @@ class TypedObject(SharedMemoryModel):
"""
pass
#
# Web/Django methods
#
def web_get_admin_url(self):
"""
Returns the URI path for the Django Admin page for this object.
ex. Account#1 = '/admin/accounts/accountdb/1/change/'
Returns:
path (str): URI path to Django Admin page for object.
"""
content_type = ContentType.objects.get_for_model(self.__class__)
return reverse("admin:%s_%s_change" % (content_type.app_label,
content_type.model), args=(self.id,))
@classmethod
def web_get_create_url(cls):
"""
Returns the URI path for a View that allows users to create new
instances of this object.
ex. Chargen = '/characters/create/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-create' would be referenced by this method.
ex.
url(r'characters/create/', ChargenView.as_view(), name='character-create')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can create new objects is the
developer's responsibility.
Returns:
path (str): URI path to object creation page, if defined.
"""
try:
return reverse('%s-create' % slugify(cls._meta.verbose_name))
except:
return '#'
def web_get_detail_url(self):
"""
Returns the URI path for a View that allows users to view details for
this object.
ex. Oscar (Character) = '/characters/oscar/1/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-detail' would be referenced by this method.
ex.
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$',
CharDetailView.as_view(), name='character-detail')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can view this object is the developer's
responsibility.
Returns:
path (str): URI path to object detail page, if defined.
"""
try:
return reverse('%s-detail' % slugify(self._meta.verbose_name),
kwargs={'pk': self.pk, 'slug': slugify(self.name)})
except:
return '#'
def web_get_puppet_url(self):
"""
Returns the URI path for a View that allows users to puppet a specific
object.
ex. Oscar (Character) = '/characters/oscar/1/puppet/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-puppet' would be referenced by this method.
ex.
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/puppet/$',
CharPuppetView.as_view(), name='character-puppet')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can view this object is the developer's
responsibility.
Returns:
path (str): URI path to object puppet page, if defined.
"""
try:
return reverse('%s-puppet' % slugify(self._meta.verbose_name),
kwargs={'pk': self.pk, 'slug': slugify(self.name)})
except:
return '#'
def web_get_update_url(self):
"""
Returns the URI path for a View that allows users to update this
object.
ex. Oscar (Character) = '/characters/oscar/1/change/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-update' would be referenced by this method.
ex.
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$',
CharUpdateView.as_view(), name='character-update')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can modify objects is the developer's
responsibility.
Returns:
path (str): URI path to object update page, if defined.
"""
try:
return reverse('%s-update' % slugify(self._meta.verbose_name),
kwargs={'pk': self.pk, 'slug': slugify(self.name)})
except:
return '#'
def web_get_delete_url(self):
"""
Returns the URI path for a View that allows users to delete this object.
ex. Oscar (Character) = '/characters/oscar/1/delete/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-detail' would be referenced by this method.
ex.
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$',
CharDeleteView.as_view(), name='character-delete')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can delete this object is the developer's
responsibility.
Returns:
path (str): URI path to object deletion page, if defined.
"""
try:
return reverse('%s-delete' % slugify(self._meta.verbose_name),
kwargs={'pk': self.pk, 'slug': slugify(self.name)})
except:
return '#'
# Used by Django Sites/Admin
get_absolute_url = web_get_detail_url

View file

@ -229,6 +229,12 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None,
# at_first_save hook on the typeclass, where the _createdict
# can be used.
new_script.save()
if not new_script.id:
# this happens in the case of having a repeating script with `repeats=1` and
# `start_delay=False` - the script will run once and immediately stop before save is over.
return None
return new_script

View file

@ -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.

View file

@ -398,6 +398,11 @@ class SharedMemoryModel(with_metaclass(SharedMemoryModelBase, Model)):
super().save(*args, **kwargs)
callFromThread(_save_callback, self, *args, **kwargs)
if not self.pk:
# this can happen if some of the startup methods immediately
# delete the object (an example are Scripts that start and die immediately)
return
# update field-update hooks and eventual OOB watchers
new = False
if "update_fields" in kwargs and kwargs["update_fields"]:
@ -422,6 +427,7 @@ class SharedMemoryModel(with_metaclass(SharedMemoryModelBase, Model)):
# fieldtracker = "_oob_at_%s_postsave" % fieldname
# if hasattr(self, fieldtracker):
# _GA(self, fieldtracker)(fieldname)
pass
class WeakSharedMemoryModelBase(SharedMemoryModelBase):

View file

@ -0,0 +1,79 @@
"""
Tests of create functions
"""
from evennia.utils.test_resources import EvenniaTest
from evennia.scripts.scripts import DefaultScript
from evennia.utils import create
class TestCreateScript(EvenniaTest):
def test_create_script(self):
class TestScriptA(DefaultScript):
def at_script_creation(self):
self.key = 'test_script'
self.interval = 10
self.persistent = False
script = create.create_script(TestScriptA, key='test_script')
assert script is not None
assert script.interval == 10
assert script.key == 'test_script'
script.stop()
def test_create_script_w_repeats_equal_1(self):
class TestScriptB(DefaultScript):
def at_script_creation(self):
self.key = 'test_script'
self.interval = 10
self.repeats = 1
self.persistent = False
# script is already stopped (interval=1, start_delay=False)
script = create.create_script(TestScriptB, key='test_script')
assert script is None
def test_create_script_w_repeats_equal_1_persisted(self):
class TestScriptB1(DefaultScript):
def at_script_creation(self):
self.key = 'test_script'
self.interval = 10
self.repeats = 1
self.persistent = True
# script is already stopped (interval=1, start_delay=False)
script = create.create_script(TestScriptB1, key='test_script')
assert script is None
def test_create_script_w_repeats_equal_2(self):
class TestScriptC(DefaultScript):
def at_script_creation(self):
self.key = 'test_script'
self.interval = 10
self.repeats = 2
self.persistent = False
script = create.create_script(TestScriptC, key='test_script')
assert script is not None
assert script.interval == 10
assert script.repeats == 2
assert script.key == 'test_script'
script.stop()
def test_create_script_w_repeats_equal_1_and_delayed(self):
class TestScriptD(DefaultScript):
def at_script_creation(self):
self.key = 'test_script'
self.interval = 10
self.start_delay = True
self.repeats = 1
self.persistent = False
script = create.create_script(TestScriptD, key='test_script')
assert script is not None
assert script.interval == 10
assert script.repeats == 1
assert script.key == 'test_script'
script.stop()

View file

@ -67,7 +67,17 @@ def general_context(request):
Returns common Evennia-related context stuff, which
is automatically added to context of all views.
"""
account = None
if request.user.is_authenticated(): account = request.user
puppet = None
if account and request.session.get('puppet'):
pk = int(request.session.get('puppet'))
puppet = next((x for x in account.characters if x.pk == pk), None)
return {
'account': account,
'puppet': puppet,
'game_name': GAME_NAME,
'game_slogan': GAME_SLOGAN,
'evennia_userapps': ACCOUNT_RELATED,

View file

@ -0,0 +1,61 @@
from django.contrib.auth import authenticate, login
from evennia.accounts.models import AccountDB
from evennia.utils import logger
class SharedLoginMiddleware(object):
"""
Handle the shared login between website and webclient.
"""
def __init__(self, get_response):
# One-time configuration and initialization.
self.get_response = get_response
def __call__(self, request):
# Code to be executed for each request before
# the view (and later middleware) are called.
# Synchronize credentials between webclient and website
# Must be performed *before* rendering the view (issue #1723)
self.make_shared_login(request)
# Process view
response = self.get_response(request)
# Code to be executed for each request/response after
# the view is called.
# Return processed view
return response
@classmethod
def make_shared_login(cls, request):
csession = request.session
account = request.user
website_uid = csession.get("website_authenticated_uid", None)
webclient_uid = csession.get("webclient_authenticated_uid", None)
if not csession.session_key:
# this is necessary to build the sessid key
csession.save()
if account.is_authenticated():
# Logged into website
if not website_uid:
# fresh website login (just from login page)
csession["website_authenticated_uid"] = account.id
if webclient_uid is None:
# auto-login web client
csession["webclient_authenticated_uid"] = account.id
elif webclient_uid:
# Not logged into website, but logged into webclient
if not website_uid:
csession["website_authenticated_uid"] = account.id
account = AccountDB.objects.get(id=webclient_uid)
try:
# calls our custom authenticate, in web/utils/backend.py
authenticate(autologin=account)
login(request, account)
except AttributeError:
logger.log_trace()

View file

@ -1,10 +1,8 @@
from mock import Mock, patch
from django.test import TestCase
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory, TestCase
from mock import MagicMock, patch
from . import general_context
class TestGeneralContext(TestCase):
maxDiff = None
@ -15,8 +13,18 @@ class TestGeneralContext(TestCase):
@patch('evennia.web.utils.general_context.WEBSOCKET_PORT', "websocket_client_port_testvalue")
@patch('evennia.web.utils.general_context.WEBSOCKET_URL', "websocket_client_url_testvalue")
def test_general_context(self):
request = Mock()
self.assertEqual(general_context.general_context(request), {
request = RequestFactory().get('/')
request.user = AnonymousUser()
request.session = {
'account': None,
'puppet': None,
}
response = general_context.general_context(request)
self.assertEqual(response, {
'account': None,
'puppet': None,
'game_name': "test_name",
'game_slogan': "test_game_slogan",
'evennia_userapps': ['Accounts'],

View file

@ -102,7 +102,7 @@ An "emitter" object must have a function
return;
}
this.connection.connect();
log('Evenna reconnecting.')
log('Evennia reconnecting.')
},
// Returns true if the connection is open.

View file

@ -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;

View file

@ -30,32 +30,31 @@ plugin_handler.add('hotbuttons', (function () {
// Add Buttons
var addButtonsUI = function () {
var buttons = $( [
'<div id="buttons" class="split split-vertical">',
' <div id="buttonsform" class="wrapper">',
' <div id="buttonscontrol" class="input-group">',
' <button class="btn" id="assign_button0" type="button" value="button0">unassigned</button>',
' <button class="btn" id="assign_button1" type="button" value="button1">unassigned</button>',
' <button class="btn" id="assign_button2" type="button" value="button2">unassigned</button>',
' <button class="btn" id="assign_button3" type="button" value="button3">unassigned</button>',
' <button class="btn" id="assign_button4" type="button" value="button4">unassigned</button>',
' <button class="btn" id="assign_button5" type="button" value="button5">unassigned</button>',
' <button class="btn" id="assign_button6" type="button" value="button6">unassigned</button>',
' <button class="btn" id="assign_button7" type="button" value="button7">unassigned</button>',
' <button class="btn" id="assign_button8" type="button" value="button8">unassigned</button>',
' </div>',
' </div>',
'</div>',
].join("\n") );
'<div id="buttons" class="split split-vertical">',
' <div id="buttonsform">',
' <div id="buttonscontrol" class="input-group">',
' <button class="btn" id="assign_button0" type="button" value="button0">unassigned</button>',
' <button class="btn" id="assign_button1" type="button" value="button1">unassigned</button>',
' <button class="btn" id="assign_button2" type="button" value="button2">unassigned</button>',
' <button class="btn" id="assign_button3" type="button" value="button3">unassigned</button>',
' <button class="btn" id="assign_button4" type="button" value="button4">unassigned</button>',
' <button class="btn" id="assign_button5" type="button" value="button5">unassigned</button>',
' <button class="btn" id="assign_button6" type="button" value="button6">unassigned</button>',
' <button class="btn" id="assign_button7" type="button" value="button7">unassigned</button>',
' <button class="btn" id="assign_button8" type="button" value="button8">unassigned</button>',
' </div>',
' </div>',
'</div>',
].join("\n") );
// Add buttons in front of the existing #inputform
buttons.insertBefore('#inputform');
$('#inputform').addClass('split split-vertical');
$('#input').prev().replaceWith(buttons);
Split(['#buttons','#inputform'], {
Split(['#main','#buttons','#input'], {
sizes: [85,5,10],
direction: 'vertical',
sizes: [50,50],
gutterSize: 4,
minSize: 150,
minSize: [150,20,50],
});
}

View file

@ -77,10 +77,12 @@ let options_plugin = (function () {
if (code === 27) { // Escape key
if ($('#helpdialog').is(':visible')) {
plugins['popups'].closePopup("#helpdialog");
} else {
plugins['popups'].closePopup("#optionsdialog");
return true;
}
if ($('#optionsdialog').is(':visible')) {
plugins['popups'].closePopup("#optionsdialog");
return true;
}
return true;
}
return false;
}
@ -129,6 +131,21 @@ let options_plugin = (function () {
plugins['popups'].closePopup("#helpdialog");
}
//
// Make sure to close any dialogs on connection lost
var onText = function (args, kwargs) {
// is helppopup set? and if so, does this Text have type 'help'?
if ('helppopup' in options && options['helppopup'] ) {
if (kwargs && ('type' in kwargs) && (kwargs['type'] == 'help') ) {
$('#helpdialogcontent').append('<div>'+ args + '</div>');
plugins['popups'].togglePopup("#helpdialog");
return true;
}
}
return false;
}
//
// Register and init plugin
var init = function () {
@ -155,6 +172,7 @@ let options_plugin = (function () {
onGotOptions: onGotOptions,
onPrompt: onPrompt,
onConnectionClose: onConnectionClose,
onText: onText,
}
})()
plugin_handler.add('options', options_plugin);

View file

@ -183,12 +183,12 @@ let splithandler_plugin = (function () {
var dialog = $("#splitdialogcontent");
dialog.empty();
var selection = '<select name="pane">';
var selection = '<select name="pane">';
for ( var pane in split_panes ) {
selection = selection + '<option value="' + pane + '">' + pane + '</option>';
selection = selection + '<option value="' + pane + '">' + pane + '</option>';
}
selection = "Pane to split: " + selection + "</select> ";
dialog.append(selection);
selection = "Pane to split: " + selection + "</select> ";
dialog.append(selection);
dialog.append('<input type="radio" name="direction" value="vertical" checked>top/bottom </>');
dialog.append('<input type="radio" name="direction" value="horizontal">side-by-side <hr />');
@ -203,7 +203,7 @@ let splithandler_plugin = (function () {
dialog.append('<input type="radio" name="flow2" value="replace">replace </>');
dialog.append('<input type="radio" name="flow2" value="append">append <hr />');
dialog.append('<div id="splitclose" class="btn btn-large btn-outline-primary float-right">Split</div>');
dialog.append('<div id="splitclose" class="btn btn-large btn-outline-primary float-right">Split</div>');
$("#splitclose").bind("click", onSplitDialogClose);
@ -251,21 +251,21 @@ let splithandler_plugin = (function () {
var dialog = $("#panedialogcontent");
dialog.empty();
var selection = '<select name="assign-pane">';
var selection = '<select name="assign-pane">';
for ( var pane in split_panes ) {
selection = selection + '<option value="' + pane + '">' + pane + '</option>';
selection = selection + '<option value="' + pane + '">' + pane + '</option>';
}
selection = "Assign to pane: " + selection + "</select> <hr />";
dialog.append(selection);
selection = "Assign to pane: " + selection + "</select> <hr />";
dialog.append(selection);
var multiple = '<select multiple name="assign-type">';
var multiple = '<select multiple name="assign-type">';
for ( var type in known_types ) {
multiple = multiple + '<option value="' + known_types[type] + '">' + known_types[type] + '</option>';
multiple = multiple + '<option value="' + known_types[type] + '">' + known_types[type] + '</option>';
}
multiple = "Content types: " + multiple + "</select> <hr />";
dialog.append(multiple);
multiple = "Content types: " + multiple + "</select> <hr />";
dialog.append(multiple);
dialog.append('<div id="paneclose" class="btn btn-large btn-outline-primary float-right">Assign</div>');
dialog.append('<div id="paneclose" class="btn btn-large btn-outline-primary float-right">Assign</div>');
$("#paneclose").bind("click", onPaneControlDialogClose);
@ -276,9 +276,9 @@ let splithandler_plugin = (function () {
// Close "Pane Controls" dialog
var onPaneControlDialogClose = function () {
var pane = $("select[name=assign-pane]").val();
var types = $("select[name=assign-type]").val();
var types = $("select[name=assign-type]").val();
// var types = new Array;
// var types = new Array;
// $('#splitdialogcontent input[type=checkbox]:checked').each(function() {
// types.push( $(this).attr('value') );
// });
@ -287,24 +287,24 @@ let splithandler_plugin = (function () {
plugins['popups'].closePopup("#panedialog");
}
//
// helper function sending text to a pane
var txtToPane = function (panekey, txt) {
var pane = split_panes[panekey];
var text_div = $('#' + panekey + '-sub');
var pane = split_panes[panekey];
var text_div = $('#' + panekey + '-sub');
if ( pane['update_method'] == 'replace' ) {
text_div.html(txt)
} else if ( pane['update_method'] == 'append' ) {
text_div.append(txt);
var scrollHeight = text_div.parent().prop("scrollHeight");
text_div.parent().animate({ scrollTop: scrollHeight }, 0);
} else { // line feed
text_div.append("<div class='out'>" + txt + "</div>");
var scrollHeight = text_div.parent().prop("scrollHeight");
text_div.parent().animate({ scrollTop: scrollHeight }, 0);
}
if ( pane['update_method'] == 'replace' ) {
text_div.html(txt)
} else if ( pane['update_method'] == 'append' ) {
text_div.append(txt);
var scrollHeight = text_div.parent().prop("scrollHeight");
text_div.parent().animate({ scrollTop: scrollHeight }, 0);
} else { // line feed
text_div.append("<div class='out'>" + txt + "</div>");
var scrollHeight = text_div.parent().prop("scrollHeight");
text_div.parent().animate({ scrollTop: scrollHeight }, 0);
}
}
@ -316,53 +316,76 @@ let splithandler_plugin = (function () {
//
// Accept plugin onText events
var onText = function (args, kwargs) {
// If the message is not itself tagged, we'll assume it
// should go into any panes with 'all' or 'rest' set
// If the message is not itself tagged, we'll assume it
// should go into any panes with 'all' or 'rest' set
var msgtype = "rest";
if ( kwargs && 'type' in kwargs ) {
msgtype = kwargs['type'];
msgtype = kwargs['type'];
if ( ! known_types.includes(msgtype) ) {
// this is a new output type that can be mapped to panes
console.log('detected new output type: ' + msgtype)
known_types.push(msgtype);
}
}
var target_panes = [];
var rest_panes = [];
for (var key in split_panes) {
var pane = split_panes[key];
// is this message type mapped to this pane (or does the pane has an 'all' type)?
if (pane['types'].length > 0) {
if (pane['types'].includes(msgtype) || pane['types'].includes('all')) {
target_panes.push(key);
} else if (pane['types'].includes('rest')) {
// store rest-panes in case we have no explicit to send to
rest_panes.push(key);
}
} else {
// unassigned panes are assumed to be rest-panes too
rest_panes.push(key);
}
}
var ntargets = target_panes.length;
var nrests = rest_panes.length;
if (ntargets > 0) {
// we have explicit target panes to send to
for (var i=0; i<ntargets; i++) {
txtToPane(target_panes[i], args[0]);
}
return true;
} else if (nrests > 0) {
// no targets, send remainder to rest-panes/unassigned
for (var i=0; i<nrests; i++) {
txtToPane(rest_panes[i], args[0]);
}
return true;
}
// unhandled message
}
var target_panes = [];
var rest_panes = [];
for (var key in split_panes) {
var pane = split_panes[key];
// is this message type mapped to this pane (or does the pane has an 'all' type)?
if (pane['types'].length > 0) {
if (pane['types'].includes(msgtype) || pane['types'].includes('all')) {
target_panes.push(key);
} else if (pane['types'].includes('rest')) {
// store rest-panes in case we have no explicit to send to
rest_panes.push(key);
}
} else {
// unassigned panes are assumed to be rest-panes too
rest_panes.push(key);
}
}
var ntargets = target_panes.length;
var nrests = rest_panes.length;
if (ntargets > 0) {
// we have explicit target panes to send to
for (var i=0; i<ntargets; i++) {
txtToPane(target_panes[i], args[0]);
}
return true;
} else if (nrests > 0) {
// no targets, send remainder to rest-panes/unassigned
for (var i=0; i<nrests; i++) {
txtToPane(rest_panes[i], args[0]);
}
return true;
}
// unhandled message
return false;
}
//
// onKeydown check for 'ESC' key.
var onKeydown = function (event) {
var code = event.which;
if (code === 27) { // Escape key
if ($('#splitdialog').is(':visible')) {
plugins['popups'].closePopup("#splitdialog");
return true;
}
if ($('#panedialog').is(':visible')) {
plugins['popups'].closePopup("#panedialog");
return true;
}
}
// capture all keys while one of our "modal" dialogs is open
if ($('#splitdialogcontent').is(':visible') || $('#panedialogcontent').is(':visible')) {
return true;
}
return false;
}
@ -409,6 +432,7 @@ let splithandler_plugin = (function () {
dynamic_split: dynamic_split,
undo_split: undo_split,
set_pane_types: set_pane_types,
onKeydown: onKeydown,
}
})()
plugin_handler.add('splithandler', splithandler_plugin);

View file

@ -64,7 +64,7 @@ JQuery available.
<script src={% static "webclient/js/evennia.js" %} language="javascript" type="text/javascript" charset="utf-8"/></script>
<!-- set up splits before loading the GUI -->
<script src="https://unpkg.com/split.js/split.min.js"></script>
<script src="https://unpkg.com/split.js@1.5.9/dist/split.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mustache.js/2.3.0/mustache.min.js"></script>
<!-- Load gui library -->
@ -73,10 +73,10 @@ JQuery available.
<script src={% static "webclient/js/plugins/popups.js" %} language="javascript" type="text/javascript"></script>
<script src={% static "webclient/js/plugins/options.js" %} language="javascript" type="text/javascript"></script>
<script src={% static "webclient/js/plugins/history.js" %} language="javascript" type="text/javascript"></script>
<script src={% static "webclient/js/plugins/splithandler.js" %} language="javascript" type="text/javascript"></script>
<script src={% static "webclient/js/plugins/default_in.js" %} language="javascript" type="text/javascript"></script>
<script src={% static "webclient/js/plugins/oob.js" %} language="javascript" type="text/javascript"></script>
<script src={% static "webclient/js/plugins/notifications.js" %} language="javascript" type="text/javascript"></script>
<script src={% static "webclient/js/plugins/splithandler.js" %} language="javascript" type="text/javascript"></script>
<script src={% static "webclient/js/plugins/default_out.js" %} language="javascript" type="text/javascript"></script>
{% endblock %}

View file

@ -5,6 +5,8 @@ page and serve it eventual static content.
"""
from django.conf import settings
from django.http import Http404
from django.shortcuts import render
from django.contrib.auth import login, authenticate
@ -12,51 +14,16 @@ from evennia.accounts.models import AccountDB
from evennia.utils import logger
def _shared_login(request):
"""
Handle the shared login between website and webclient.
"""
csession = request.session
account = request.user
# these can have 3 values:
# None - previously unused (auto-login)
# False - actively logged out (don't auto-login)
# <uid> - logged in User/Account id
website_uid = csession.get("website_authenticated_uid", None)
webclient_uid = csession.get("webclient_authenticated_uid", None)
# check if user has authenticated to website
if not csession.session_key:
# this is necessary to build the sessid key
csession.save()
if webclient_uid:
# The webclient has previously registered a login to this browser_session
if not account.is_authenticated() and not website_uid:
try:
account = AccountDB.objects.get(id=webclient_uid)
except AccountDB.DoesNotExist:
# this can happen e.g. for guest accounts or deletions
csession["website_authenticated_uid"] = False
csession["webclient_authenticated_uid"] = False
return
try:
# calls our custom authenticate in web/utils/backends.py
account = authenticate(autologin=account)
login(request, account)
csession["website_authenticated_uid"] = webclient_uid
except AttributeError:
logger.log_trace()
def webclient(request):
"""
Webclient page template loading.
"""
# handle webclient-website shared login
_shared_login(request)
# auto-login is now handled by evennia.web.utils.middleware
# check if webclient should be enabled
if not settings.WEBCLIENT_ENABLED:
raise Http404
# make sure to store the browser session's hash so the webclient can get to it!
pagevars = {'browser_sessid': request.session.session_key}

View file

@ -0,0 +1,157 @@
from django import forms
from django.conf import settings
from django.contrib.auth.forms import UserCreationForm, UsernameField
from django.forms import ModelForm
from django.utils.html import escape
from evennia.utils import class_from_module
class EvenniaForm(forms.Form):
"""
This is a stock Django form, but modified so that all values provided
through it are escaped (sanitized). Validation is performed by the fields
you define in the form.
This has little to do with Evennia itself and is more general web security-
related.
https://www.owasp.org/index.php/Input_Validation_Cheat_Sheet#Goals_of_Input_Validation
"""
def clean(self):
"""
Django hook. Performed on form submission.
Returns:
cleaned (dict): Dictionary of key:value pairs submitted on the form.
"""
# Call parent function
cleaned = super(EvenniaForm, self).clean()
# Escape all values provided by user
cleaned = {k:escape(v) for k,v in cleaned.items()}
return cleaned
class AccountForm(UserCreationForm):
"""
This is a generic Django form tailored to the Account model.
In this incarnation it does not allow getting/setting of attributes, only
core User model fields (username, email, password).
"""
class Meta:
"""
This is a Django construct that provides additional configuration to
the form.
"""
# The model/typeclass this form creates
model = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
# The fields to display on the form, in the given order
fields = ("username", "email")
# Any overrides of field classes
field_classes = {'username': UsernameField}
# Username is collected as part of the core UserCreationForm, so we just need
# to add a field to (optionally) capture email.
email = forms.EmailField(help_text="A valid email address. Optional; used for password resets.", required=False)
class ObjectForm(EvenniaForm, ModelForm):
"""
This is a Django form for generic Evennia Objects that allows modification
of attributes when called from a descendent of ObjectUpdate or ObjectCreate
views.
It defines no fields by default; you have to do that by extending this class
and defining what fields you want to be recorded. See the CharacterForm for
a simple example of how to do this.
"""
class Meta:
"""
This is a Django construct that provides additional configuration to
the form.
"""
# The model/typeclass this form creates
model = class_from_module(settings.BASE_OBJECT_TYPECLASS)
# The fields to display on the form, in the given order
fields = ("db_key",)
# This lets us rename ugly db-specific keys to something more human
labels = {
'db_key': 'Name',
}
class CharacterForm(ObjectForm):
"""
This is a Django form for Evennia Character objects.
Since Evennia characters only have one attribute by default, this form only
defines a field for that single attribute. The names of fields you define should
correspond to their names as stored in the dbhandler; you can display
'prettier' versions of the fieldname on the form using the 'label' kwarg.
The basic field types are CharFields and IntegerFields, which let you enter
text and numbers respectively. IntegerFields have some neat validation tricks
they can do, like mandating values fall within a certain range.
For example, a complete "age" field (which stores its value to
`character.db.age` might look like:
age = forms.IntegerField(
label="Your Age",
min_value=18, max_value=9000,
help_text="Years since your birth.")
Default input fields are generic single-line text boxes. You can control what
sort of input field users will see by specifying a "widget." An example of
this is used for the 'desc' field to show a Textarea box instead of a Textbox.
For help in building out your form, please see:
https://docs.djangoproject.com/en/1.11/topics/forms/#building-a-form-in-django
For more information on fields and their capabilities, see:
https://docs.djangoproject.com/en/1.11/ref/forms/fields/
For more on widgets, see:
https://docs.djangoproject.com/en/1.11/ref/forms/widgets/
"""
class Meta:
"""
This is a Django construct that provides additional configuration to
the form.
"""
# Get the correct object model
model = class_from_module(settings.BASE_CHARACTER_TYPECLASS)
# Allow entry of the 'key' field
fields = ("db_key",)
# Rename 'key' to something more intelligible
labels = {
'db_key': 'Name',
}
# Fields pertaining to configurable attributes on the Character object.
desc = forms.CharField(label='Description', max_length=2048, required=False,
widget=forms.Textarea(attrs={'rows': 3}),
help_text="A brief description of your character.")
class CharacterUpdateForm(CharacterForm):
"""
This is a Django form for updating Evennia Character objects.
By default it is the same as the CharacterForm, but if there are circumstances
in which you don't want to let players edit all the same attributes they had
access to during creation, you can redefine this form with those fields you do
wish to allow.
"""
pass

View file

@ -23,35 +23,67 @@ folder and edit it to add/remove links to the menu.
<ul class="navbar-nav">
{% block nabvar_left %}
<li>
<a class="nav-link" href="/">Home</a>
<a class="nav-link" href="{% url 'index' %}">Home</a>
</li>
<!-- evennia documentation -->
<li>
<a class="nav-link" href="https://github.com/evennia/evennia/wiki/Evennia-Introduction/">About</a>
</li>
<li><a class="nav-link" href="https://github.com/evennia/evennia/wiki">Documentation</a></li>
<li><a class="nav-link" href="{% url 'admin:index' %}">Admin Interface</a></li>
<!-- end evennia documentation -->
<!-- game views -->
<li><a class="nav-link" href="{% url 'characters' %}">Characters</a></li>
<li><a class="nav-link" href="{% url 'channels' %}">Channels</a></li>
<li><a class="nav-link" href="{% url 'help' %}">Help</a></li>
<!-- end game views -->
{% if webclient_enabled %}
<li><a class="nav-link" href="{% url 'webclient:index' %}">Play Online</a></li>
{% endif %}
{% if user.is_staff %}
<li><a class="nav-link" href="{% url 'admin:index' %}">Admin</a></li>
{% endif %}
{% endblock %}
</ul>
<ul class="nav navbar-nav ml-auto w-120 justify-content-end">
{% block navbar_right %}
{% endblock %}
{% block navbar_user %}
{% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link">Logged in as {{user.username}}</a>
{% if account %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-toggle="dropdown" href="#" id="user_options" aria-expanded="false">
{% if puppet %}
Welcome, {{ puppet }}! <span class="text-muted">({{ account.username }})</span> <span class="caret"></span>
{% else %}
Logged in as {{ account.username }} <span class="caret"></span>
{% endif %}
</a>
<div class="dropdown-menu" aria-labelledby="user_options">
<a class="dropdown-item" href="{% url 'character-create' %}">Create Character</a>
<a class="dropdown-item" href="{% url 'character-manage' %}">Manage Characters</a>
<div class="dropdown-divider"></div>
{% for character in account.characters|slice:"10" %}
<a class="dropdown-item" href="{{ character.web_get_puppet_url }}?next={{ request.path }}">{{ character }}</a>
{% empty %}
<a class="dropdown-item" href="#">No characters found!</a>
{% endfor %}
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="{% url 'password_change' %}">Change Password</a>
<a class="dropdown-item" href="{% url 'logout' %}">Log Out</a>
</div>
</li>
<li>
<a class="nav-link" href="{% url 'logout' %}">Log Out</a>
</li>
{% else %}
<li>
<a class="nav-link" href="{% url 'login' %}">Log In</a>
<a class="nav-link" href="{% url 'login' %}?next={{ request.path }}">Log In</a>
</li>
<li>
<a class="nav-link" href="{% url 'to_be_implemented' %}">Register</a>
<a class="nav-link" href="{% url 'register' %}">Register</a>
</li>
{% endif %}
{% endblock %}

View file

@ -12,7 +12,7 @@
<link rel="icon" type="image/x-icon" href="/static/website/images/evennia_logo.png" />
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<!-- Base CSS -->
<link rel="stylesheet" type="text/css" href="{% static "website/css/app.css" %}">
@ -29,6 +29,8 @@
<title>{{game_name}} - {% if flatpage %}{{flatpage.title}}{% else %}{% block titleblock %}{{page_title}}{% endblock %}{% endif %}</title>
</head>
<body>
{% block body %}
<div id="top"><a href="#main-content" class="sr-only sr-only-focusable">Skip to main content.</a></div>
{% include "website/_menu.html" %}
<div class="container main-content mt-4" id="main-copy">
@ -40,8 +42,12 @@
</div>
{% endif %}
<div class="{% if sidebar %}col-8{% else %}col{% endif %}">
{% include 'website/messages.html' %}
{% block content %}
{% endblock %}
{% include 'website/pagination.html' %}
</div>
</div>
</div>
@ -53,10 +59,12 @@
</div>
{% endblock %}
</footer>
{% endblock %}
<!-- jQuery first, then Tether, then Bootstrap JS. -->
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.11.0/umd/popper.min.js" integrity="sha384-b/U6ypiBEHpOf/4+1nzFpr53nxSS+GLCkfwBdFNTxtclqqenISfwAzpKaMNFNmj4" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/js/bootstrap.min.js" integrity="sha384-h0AbiXch4ZDo7tp9hKZ4TsHbi047NrKGLO3SEJAg45jXxnGIfYzk4Si90RDIqNm1" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
</body>
</html>

View file

@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }} ({{ object }})
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }} ({{ object }})</h1>
<hr />
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'channels' %}">Channels</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ object.db_key|title }}</li>
</ol>
<hr />
<div class="row">
<!-- left column -->
<div class="col-lg-9 col-sm-12">
<!-- heading -->
<div class="card border-light">
<div class="card-body">
{% if object.db.desc and object.db.desc != None %}{{ object.db.desc }}{% else %}No description provided.{% endif %}
</div>
</div>
<hr />
<!-- end heading -->
{% if object_list %}
<pre>{% for log in object_list %}
<a id="{{ log.key }}"></a>{{ log.timestamp }}: {{ log.message }}{% endfor %}</pre>
{% else %}
<pre>No recent log entries have been recorded for this channel.</pre>
{% endif %}
</div>
<!-- end left column -->
<!-- right column -->
<div class="col-lg-3 col-sm-12">
{% if request.user.is_staff %}
<!-- admin button -->
<a class="btn btn-info btn-block mb-3" href="{{ object.web_get_admin_url }}">Admin</a>
<!-- end admin button -->
<hr />
{% endif %}
<div class="card mb-3">
<div class="card-body">
<dl>
{% for attribute, value in attribute_list.items %}
<dt>{{ attribute }}</dt>
<dd>{{ value }}</dd>
{% endfor %}
</dl>
<dl>
<dt>Subscriptions</dt>
<dd>{{ object.subscriptions.all|length }}</dd>
</dl>
</div>
</div>
{% if object_filters %}
<div class="card mb-3">
<div class="card-header">Date Index</div>
<ul class="list-group list-group-flush">
{% for filter in object_filters %}
<a href="#{{ filter }}" class="list-group-item">{{ filter }}</a>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<!-- end right column -->
</div>
<hr />
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }}
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }}</h1>
<hr />
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'channels' %}">Channels</a></li>
</ol>
<hr />
<div class="row">
<!-- left column -->
<div class="col-lg-9 col-sm-12">
<div class="table-responsive">
<table class="table table-sm">
<thead class="thead-light">
<tr>
<th scope="col">Channel</th>
<th scope="col">Description</th>
<th scope="col">Subscriptions</th>
</tr>
</thead>
<tbody>
{% for object in object_list %}
<tr>
<td><a href="{{ object.web_get_detail_url }}">{{ object.name }}</a></td>
<td>{{ object.db.desc }}</td>
<td>{{ object.subscriptions.all|length }}</td>
</tr>
{% empty %}
<tr>
<td colspan="3">No channels were found!</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- end left column -->
<!-- right column -->
<div class="col-lg-3 col-sm-12">
{% if request.user.is_staff %}
<!-- admin button -->
<a class="btn btn-info btn-block mb-3" href="/admin/comms/channeldb/">Admin</a>
<!-- end admin button -->
<hr />
{% endif %}
{% if most_popular %}
<div class="card mb-3">
<div class="card-header">Most Popular</div>
<ul class="list-group list-group-flush">
{% for top in most_popular %}
<a href="{{ top.web_get_detail_url }}" class="list-group-item">{{ top }} <span class="badge badge-light float-right">{{ top.subscriptions.all|length }}</span></a>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<!-- end right column -->
</div>
<hr />
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }}
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }}</h1>
<hr />
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endfor %}
{% endfor %}
{% endif %}
<form method="post" action="?">
{% csrf_token %}
{% for field in form %}
<div class="form-field my-3">
{{ field.label_tag }}
{{ field | addclass:"form-control" }}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text|safe }}</small>
{% endif %}
</div>
{% endfor %}
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Submit" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }}
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }}</h1>
<hr />
<ul>
{% for object in object_list %}
<li><a href="{{ object.web_get_detail_url }}">{{ object }}</a></li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }}
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }}</h1>
<hr />
{% for object in object_list %}
<div class="media mb-4">
<a href="{{ object.web_get_detail_url }}"><img class="d-flex mr-3" src="http://placehold.jp/50x50.png" alt="" /></a>
<div class="media-body">
<p class="float-right ml-2">{{ object.db_date_created }}
<br /><a href="{{ object.web_get_delete_url }}">Delete</a>
<br /><a href="{{ object.web_get_update_url }}">Edit</a></p>
<h5 class="mt-0"><a href="{{ object.web_get_detail_url }}">{{ object }}</a> {% if object.subtitle %}<small class="text-muted" style="white-space:nowrap;">{{ object.subtitle }}</small>{% endif %}</h5>
<p>{{ object.db.desc }}</p>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -6,7 +6,7 @@
<div class="row">
<div class="col">
<div class="card">
<div class="card-block">
<div class="card-block p-4">
<h1 class="card-title">Admin</h1>
<p class="card-text">
Welcome to the Evennia Admin Page. Here, you can edit many facets of accounts, characters, and other parts of the game.

View file

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block titleblock %}
Form
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Form</h1>
<hr />
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endfor %}
{% endfor %}
{% endif %}
<form method="post" action="?">
{% csrf_token %}
{% for field in form %}
<div class="form-field my-3">
{{ field.label_tag }}
{{ field | addclass:"form-control" }}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text|safe }}</small>
{% endif %}
</div>
{% endfor %}
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Submit" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }} ({{ object|title }})
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col">
<!-- main content -->
<div class="card mb-3">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }} ({{ object|title }})</h1>
<hr />
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'help' %}">Compendium</a></li>
<li class="breadcrumb-item"><a href="{% url 'help' %}#{{ object.db_help_category }}">{{ object.db_help_category|title }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ object.db_key|title }}</li>
</ol>
<hr />
<div class="row">
<!-- left column -->
<div class="col-lg-9 col-sm-12">
<p>{{ entry_text }}</p>
{% if topic_previous or topic_next %}
<hr />
<!-- navigation -->
<nav aria-label="Topic Navigation">
<ul class="pagination justify-content-center">
{% if topic_previous %}
<li class="page-item">
<a class="page-link" href="{{ topic_previous.web_get_detail_url }}">Previous ({{ topic_previous|title }})</a>
</li>
{% endif %}
{% if topic_next %}
<li class="page-item">
<a class="page-link" href="{{ topic_next.web_get_detail_url }}">Next ({{ topic_next|title }})</a>
</li>
{% endif %}
</ul>
</nav>
<!-- end navigation -->
{% endif %}
</div>
<!-- end left column -->
<!-- right column (sidebar) -->
<div class="col-lg-3 col-sm-12">
{% if request.user.is_staff %}
<!-- admin button -->
<a class="btn btn-info btn-block mb-3" href="{{ object.web_get_admin_url }}">Admin</a>
<!-- end admin button -->
<hr />
{% endif %}
<div class="card mb-3">
<div class="card-header">{{ object.db_help_category|title }}</div>
<ul class="list-group list-group-flush">
{% for topic in topic_list %}
<a href="{{ topic.web_get_detail_url }}" class="list-group-item {% if topic == object %}active disabled{% endif %}">{{ topic|title }}</a>
{% endfor %}
</ul>
</div>
</div>
<!-- end right column -->
</div>
<hr />
</div>
</div>
<!-- end main content -->
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }}
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }}</h1>
<hr />
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'help' %}">Compendium</a></li>
</ol>
<hr />
<div class="row">
{% regroup object_list by help_category as category_list %}
{% if category_list %}
<!-- left column -->
<div class="col-lg-9 col-sm-12">
<!-- intro -->
<div class="card border-light">
<div class="card-body">
<p>This section of the site is a guide to understanding the mechanics behind {{ game_name }}.</p>
<p>It is organized first by category, then by topic. The box to the right will let you skip to particular categories.</p>
</div>
</div>
<hr />
<!-- end intro -->
<!-- index list -->
<div class="mx-3">
{% for help_category in category_list %}
<h5><a id="{{ help_category.grouper }}"></a>{{ help_category.grouper|title }}</h5>
<ul>
{% for object in help_category.list %}
<li><a href="{{ object.web_get_detail_url }}">{{ object|title }}</a></li>
{% endfor %}
</ul>
{% endfor %}
<!-- end index list -->
</div>
</div>
<!-- end left column -->
<!-- right column (index) -->
<div class="col-lg-3 col-sm-12">
{% if user.is_staff %}
<!-- admin button -->
<a class="btn btn-info btn-block mb-3" href="/admin/help/helpentry/">Admin</a>
<!-- end admin button -->
<hr />
{% endif %}
<div class="card mb-3">
<div class="card-header">Category Index</div>
<ul class="list-group list-group-flush">
{% for category in category_list %}
<a href="#{{ category.grouper }}" class="list-group-item">{{ category.grouper|title }}</a>
{% endfor %}
</ul>
</div>
</div>
<!-- end right column -->
{% else %}
{% if user.is_staff %}
<div class="col-lg-12 col-sm-12">
<div class="alert alert-warning" role="alert">
<h4 class="alert-heading">Hey, staff member {{ user.get_username }}!</h4>
<hr />
<p><strong>Your Help section is currently blank!</strong></p>
<p>You're missing out on an opportunity to attract visitors (and potentially new players) to {{ game_name }}!</p>
<p>Use Evennia's <a href="https://github.com/evennia/evennia/wiki/Help-System#database-help-entries" class="alert-link" target="_blank">Help System</a> to tell the world about the universe you've created, its lore and legends, its people and creatures, and their customs and conflicts!</p>
<p>You don't even need coding skills-- writing Help Entries is no more complicated than writing an email or blog post. Once you publish your first entry, these ugly boxes go away and this page will turn into an index of everything you've written about {{ game_name }}.</p>
<p>The documentation you write is eventually picked up by search engines, so the more you write about how {{ game_name }} works, the larger your web presence will be-- and the more traffic you'll attract.
<p>Everything you write can be viewed either on this site or within the game itself, using the in-game help commands.</p>
<hr>
<p class="mb-0"><a href="/admin/help/helpentry/add/" class="alert-link">Click here</a> to start writing about {{ game_name }}!</p>
</div>
</div>
{% endif %}
<div class="col-lg-12 col-sm-12">
<div class="alert alert-secondary" role="alert">
<h4 class="alert-heading">Under Construction!</h4>
<p>Thanks for your interest, but we're still working on developing and documenting the {{ game_name }} universe!</p>
<p>Check back later for more information as we publish it.</p>
<hr>
<p class="mb-0"><a href="{% url 'index' %}" class="alert-link">Click here</a> to go back to the main page.</p>
</div>
</div>
{% endif %}
</div>
<hr />
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -52,7 +52,7 @@
<div class="row">
<div class="col-12 col-md-4 mb-3">
<div class="card">
<h4 class="card-header">Accounts</h4>
<h4 class="card-header text-center">Accounts</h4>
<div class="card-body">
<p>

View file

@ -0,0 +1,9 @@
{% if messages %}
<!-- messages -->
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
<!-- end messages -->
{% endif %}

View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }}
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3 border border-danger">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }}</h1>
<hr />
<form method="post" action="?">
{% csrf_token %}
<p>Are you sure you want to delete "{{ object }}"?</p>
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-danger" type="submit" value="yes" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }} ({{ object }})
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }}</h1>
<hr />
<div class="row">
<!-- left/avatar column -->
<div class="col-lg-3 col-sm-12">
<img class="d-flex mr-3" src="http://placehold.jp/250x250.png" alt="Image of {{ object }}">
</div>
<!-- end left/avatar column -->
<!-- right/content column -->
<div class="col-lg-9 col-sm-12">
<dl>
{% for attribute, value in attribute_list.items %}
<dt>{{ attribute }}</dt>
<dd>{{ value }}</dd>
{% endfor %}
</dl>
</div>
<!-- end right/content column -->
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }}
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }}</h1>
<hr />
<ul>
{% for object in object_list %}
<li><a href="{{ object.web_get_detail_url }}">{{ object }}</a></li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,32 @@
{% if page_obj %}
<div class="row">
<div class="col">
<div class="card mt-3">
<div class="card-body">
<!-- Pagination -->
<ul class="pagination justify-content-center mb-0">
<li class="page-item {% if page_obj.has_previous %}{% else %}disabled{% endif %}">
<a class="page-link" href="{% if page_obj.has_previous %}?{% if q %}q={{ q }}&{% endif %}page={{ page_obj.previous_page_number }}{% endif %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
<span class="sr-only">Previous</span>
</a>
</li>
{% for l in page_obj.paginator.page_range %}
{% if l <= page_obj.number|add:5 and l >= page_obj.number|add:-5 %}
<li class="page-item {% if forloop.counter == page_obj.number %}active{% endif %}"><a class="page-link" href="?{% if q %}q={{ q }}&{% endif %}page={{ forloop.counter }}">{{ forloop.counter }}</a></li>
{% endif %}
{% endfor %}
<li class="page-item {% if page_obj.has_next %}{% else %}disabled{% endif %}">
<a class="page-link" href="{% if page_obj.has_next %}?{% if q %}q={{ q }}&{% endif %}page={{ page_obj.next_page_number }}{% endif %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
<span class="sr-only">Next</span>
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
{% endif %}

View file

@ -4,44 +4,56 @@
Login
{% endblock %}
{% block content %}
{% block body %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">Login</h1>
<hr />
{% if user.is_authenticated %}
<p>You are already logged in!</p>
{% else %}
{% if form.has_errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}
<form method="post" action=".">
{% csrf_token %}
<div class="form-group">
<label for="id_username">Username:</label>
{{ form.username | addclass:"form-control" }}
</div>
<div class="form-group">
<label for="id_password">Password:</label>
{{ form.password | addclass:"form-control" }}
</div>
<div class="form-group">
<input class="form-control" type="submit" value="Login" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Login</h1>
<hr />
{% include 'website/messages.html' %}
{% if user.is_authenticated %}
<div class="alert alert-info" role="alert">You are already logged in!</div>
{% else %}
{% if form.errors %}
<div class="alert alert-danger" role="alert">Your username and password are incorrect. Please try again.</div>
{% endif %}
{% endif %}
{% if not user.is_authenticated %}
<form method="post" action=".">
{% csrf_token %}
<div class="form-group">
<label for="id_username">Username:</label>
{{ form.username | addclass:"form-control" }}
</div>
<div class="form-group">
<label for="id_password">Password:</label>
{{ form.password | addclass:"form-control" }}
</div>
<hr />
<div class="row">
<div class="col-lg-6 col-sm-12 text-center small"><a href="{% url 'password_reset' %}">Forgot Password?</a></div>
<div class="col-lg-6 col-sm-12 text-center small"><a href="{% url 'register' %}">Create Account</a></div>
</div>
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Login" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block titleblock %}
Password Changed
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Password Changed</h1>
<hr />
<p>Your password was changed.</p>
<p>Click <a href="{% url 'index' %}">here</a> to return to the index.</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block titleblock %}
Password Change
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col-lg-6 offset-lg-3 col-sm-12">
<div class="card">
<div class="card-body">
<h1 class="card-title">Password Change</h1>
<hr />
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endfor %}
{% endfor %}
{% endif %}
<form method="post" action="?">
{% csrf_token %}
{% for field in form %}
<div class="form-field my-3">
{{ field.label_tag }}
{{ field | addclass:"form-control" }}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text|safe }}</small>
{% endif %}
</div>
{% endfor %}
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Submit" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block titleblock %}
Forgot Password - Reset Successful
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Password Reset</h1>
<hr />
{% if user.is_authenticated %}
<div class="alert alert-info" role="alert">You are already logged in!</div>
{% else %}
<p>Your password has been successfully reset!</p>
<p>You may now log in using it <a href="{% url 'login' %}">here.</a></p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block titleblock %}
Forgot Password - Reset
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Reset Password</h1>
<hr />
{% if not validlink %}
<div class="alert alert-danger" role="alert">The password reset link has expired. Please request another to proceed.</div>
{% else %}
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endfor %}
{% endfor %}
{% endif %}
<form method="post" action=".">
{% csrf_token %}
<div class="form-group">
<label for="id_username">Enter new password:</label>
{{ form.new_password1 | addclass:"form-control" }}
</div>
<div class="form-group">
<label for="id_username">Confirm password:</label>
{{ form.new_password2 | addclass:"form-control" }}
</div>
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Login" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block titleblock %}
Forgot Password - Reset Link Sent
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Reset Sent</h1>
<hr />
{% if user.is_authenticated %}
<div class="alert alert-info" role="alert">You are already logged in!</div>
{% else %}
<p>Instructions for resetting your password will be emailed to the
address you provided, if that address matches the one we have on file
for your account. You should receive them shortly.</p>
<p>Please allow up to to a few hours for the email to transmit, and be
sure to check your spam folder if it doesn't show up in a timely manner.</p>
<hr />
<p><a href="{% url 'index' %}">Click here</a> to return to the main page.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% autoescape off %}
To initiate the password reset process for your {{ user.get_username }} {{ site_name }} account,
click the link below:
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
If clicking the link above doesn't work, please copy and paste the URL in a new browser
window instead.
If you did not request a password reset, please disregard this notice. Whoever requested it
cannot follow through on resetting your password without access to this message.
Sincerely,
{{ site_name }} Management.
{% endautoescape %}

View file

@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block titleblock %}
Forgot Password
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Forgot Password</h1>
<hr />
{% if user.is_authenticated %}
<div class="alert alert-info" role="alert">You are already logged in!</div>
{% else %}
{% if form.errors %}
<div class="alert alert-danger" role="alert">The email address provided is incorrect.</div>
{% endif %}
{% endif %}
{% if not user.is_authenticated %}
<form method="post" action=".">
{% csrf_token %}
<div class="form-group">
<label for="id_username">Email address:</label>
{{ form.email | addclass:"form-control" }}
<small id="emailHelp" class="form-text text-muted">The email address you provided at registration. If you left it blank, your password cannot be reset through this form.</small>
</div>
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Submit" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block titleblock %}
Register
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Register</h1>
<hr />
{% if user.is_authenticated %}
<div class="alert alert-info" role="alert">You are already registered!</div>
{% else %}
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endfor %}
{% endfor %}
{% endif %}
{% endif %}
{% if not user.is_authenticated %}
<form method="post" action="?">
{% csrf_token %}
{% for field in form %}
<div class="form-field my-3">
{{ field.label_tag }}
{{ field | addclass:"form-control" }}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text|safe }}</small>
{% endif %}
</div>
{% endfor %}
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Register" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,281 @@
from django.conf import settings
from django.utils.text import slugify
from django.test import Client, override_settings
from django.urls import reverse
from evennia.utils import class_from_module
from evennia.utils.test_resources import EvenniaTest
class EvenniaWebTest(EvenniaTest):
# Use the same classes the views are expecting
account_typeclass = settings.BASE_ACCOUNT_TYPECLASS
object_typeclass = settings.BASE_OBJECT_TYPECLASS
character_typeclass = settings.BASE_CHARACTER_TYPECLASS
exit_typeclass = settings.BASE_EXIT_TYPECLASS
room_typeclass = settings.BASE_ROOM_TYPECLASS
script_typeclass = settings.BASE_SCRIPT_TYPECLASS
channel_typeclass = settings.BASE_CHANNEL_TYPECLASS
# Default named url
url_name = 'index'
# Response to expect for unauthenticated requests
unauthenticated_response = 200
# Response to expect for authenticated requests
authenticated_response = 200
def setUp(self):
super(EvenniaWebTest, self).setUp()
# Add chars to account rosters
self.account.db._playable_characters = [self.char1]
self.account2.db._playable_characters = [self.char2]
for account in (self.account, self.account2):
# Demote accounts to Player permissions
account.permissions.add('Player')
account.permissions.remove('Developer')
# Grant permissions to chars
for char in account.db._playable_characters:
char.locks.add('edit:id(%s) or perm(Admin)' % account.pk)
char.locks.add('delete:id(%s) or perm(Admin)' % account.pk)
char.locks.add('view:all()')
def test_valid_chars(self):
"Make sure account has playable characters"
self.assertTrue(self.char1 in self.account.db._playable_characters)
self.assertTrue(self.char2 in self.account2.db._playable_characters)
def get_kwargs(self):
return {}
def test_get(self):
# Try accessing page while not logged in
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()))
self.assertEqual(response.status_code, self.unauthenticated_response)
def login(self):
return self.client.login(username='TestAccount', password='testpassword')
def test_get_authenticated(self):
logged_in = self.login()
self.assertTrue(logged_in, 'Account failed to log in!')
# Try accessing page while logged in
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True)
self.assertEqual(response.status_code, self.authenticated_response)
# ------------------------------------------------------------------------------
class AdminTest(EvenniaWebTest):
url_name = 'django_admin'
unauthenticated_response = 302
class IndexTest(EvenniaWebTest):
url_name = 'index'
class RegisterTest(EvenniaWebTest):
url_name = 'register'
class LoginTest(EvenniaWebTest):
url_name = 'login'
class LogoutTest(EvenniaWebTest):
url_name = 'logout'
class PasswordResetTest(EvenniaWebTest):
url_name = 'password_change'
unauthenticated_response = 302
class WebclientTest(EvenniaWebTest):
url_name = 'webclient:index'
@override_settings(WEBCLIENT_ENABLED=True)
def test_get(self):
self.authenticated_response = 200
self.unauthenticated_response = 200
super(WebclientTest, self).test_get()
@override_settings(WEBCLIENT_ENABLED=False)
def test_get_disabled(self):
self.authenticated_response = 404
self.unauthenticated_response = 404
super(WebclientTest, self).test_get()
class ChannelListTest(EvenniaWebTest):
url_name = 'channels'
class ChannelDetailTest(EvenniaWebTest):
url_name = 'channel-detail'
def setUp(self):
super(ChannelDetailTest, self).setUp()
klass = class_from_module(self.channel_typeclass)
# Create a channel
klass.create('demo')
def get_kwargs(self):
return {
'slug': slugify('demo')
}
class CharacterCreateView(EvenniaWebTest):
url_name = 'character-create'
unauthenticated_response = 302
@override_settings(MULTISESSION_MODE=0)
def test_valid_access_multisession_0(self):
"Account1 with no characters should be able to create a new one"
self.account.db._playable_characters = []
# Login account
self.login()
# Post data for a new character
data = {
'db_key': 'gannon',
'desc': 'Some dude.'
}
response = self.client.post(reverse(self.url_name), data=data, follow=True)
self.assertEqual(response.status_code, 200)
# Make sure the character was actually created
self.assertTrue(len(self.account.db._playable_characters) == 1, 'Account only has the following characters attributed to it: %s' % self.account.db._playable_characters)
@override_settings(MULTISESSION_MODE=2)
@override_settings(MAX_NR_CHARACTERS=10)
def test_valid_access_multisession_2(self):
"Account1 should be able to create a new character"
# Login account
self.login()
# Post data for a new character
data = {
'db_key': 'gannon',
'desc': 'Some dude.'
}
response = self.client.post(reverse(self.url_name), data=data, follow=True)
self.assertEqual(response.status_code, 200)
# Make sure the character was actually created
self.assertTrue(len(self.account.db._playable_characters) > 1, 'Account only has the following characters attributed to it: %s' % self.account.db._playable_characters)
class CharacterPuppetView(EvenniaWebTest):
url_name = 'character-puppet'
unauthenticated_response = 302
def get_kwargs(self):
return {
'pk': self.char1.pk,
'slug': slugify(self.char1.name)
}
def test_invalid_access(self):
"Account1 should not be able to puppet Account2:Char2"
# Login account
self.login()
# Try to access puppet page for char2
kwargs = {
'pk': self.char2.pk,
'slug': slugify(self.char2.name)
}
response = self.client.get(reverse(self.url_name, kwargs=kwargs), follow=True)
self.assertTrue(response.status_code >= 400, "Invalid access should return a 4xx code-- either obj not found or permission denied! (Returned %s)" % response.status_code)
class CharacterListView(EvenniaWebTest):
url_name = 'characters'
unauthenticated_response = 302
class CharacterManageView(EvenniaWebTest):
url_name = 'character-manage'
unauthenticated_response = 302
class CharacterUpdateView(EvenniaWebTest):
url_name = 'character-update'
unauthenticated_response = 302
def get_kwargs(self):
return {
'pk': self.char1.pk,
'slug': slugify(self.char1.name)
}
def test_valid_access(self):
"Account1 should be able to update Account1:Char1"
# Login account
self.login()
# Try to access update page for char1
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True)
self.assertEqual(response.status_code, 200)
# Try to update char1 desc
data = {'db_key': self.char1.db_key, 'desc': "Just a regular type of dude."}
response = self.client.post(reverse(self.url_name, kwargs=self.get_kwargs()), data=data, follow=True)
self.assertEqual(response.status_code, 200)
# Make sure the change was made successfully
self.assertEqual(self.char1.db.desc, data['desc'])
def test_invalid_access(self):
"Account1 should not be able to update Account2:Char2"
# Login account
self.login()
# Try to access update page for char2
kwargs = {
'pk': self.char2.pk,
'slug': slugify(self.char2.name)
}
response = self.client.get(reverse(self.url_name, kwargs=kwargs), follow=True)
self.assertEqual(response.status_code, 403)
class CharacterDeleteView(EvenniaWebTest):
url_name = 'character-delete'
unauthenticated_response = 302
def get_kwargs(self):
return {
'pk': self.char1.pk,
'slug': slugify(self.char1.name)
}
def test_valid_access(self):
"Account1 should be able to delete Account1:Char1"
# Login account
self.login()
# Try to access delete page for char1
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True)
self.assertEqual(response.status_code, 200)
# Proceed with deleting it
data = {'value': 'yes'}
response = self.client.post(reverse(self.url_name, kwargs=self.get_kwargs()), data=data, follow=True)
self.assertEqual(response.status_code, 200)
# Make sure it deleted
self.assertFalse(self.char1 in self.account.db._playable_characters, 'Char1 is still in Account playable characters list.')
def test_invalid_access(self):
"Account1 should not be able to delete Account2:Char2"
# Login account
self.login()
# Try to access delete page for char2
kwargs = {
'pk': self.char2.pk,
'slug': slugify(self.char2.name)
}
response = self.client.get(reverse(self.url_name, kwargs=kwargs), follow=True)
self.assertEqual(response.status_code, 403)

View file

@ -9,12 +9,30 @@ from django import views as django_views
from evennia.web.website import views as website_views
urlpatterns = [
url(r'^$', website_views.page_index, name="index"),
url(r'^$', website_views.EvenniaIndexView.as_view(), name="index"),
url(r'^tbi/', website_views.to_be_implemented, name='to_be_implemented'),
# User Authentication (makes login/logout url names available)
url(r'^authenticate/', include('django.contrib.auth.urls')),
url(r'^auth/register', website_views.AccountCreateView.as_view(), name="register"),
url(r'^auth/', include('django.contrib.auth.urls')),
# Help Topics
url(r'^help/$', website_views.HelpListView.as_view(), name="help"),
url(r'^help/(?P<category>[\w\d\-]+)/(?P<topic>[\w\d\-]+)/$', website_views.HelpDetailView.as_view(), name="help-entry-detail"),
# Channels
url(r'^channels/$', website_views.ChannelListView.as_view(), name="channels"),
url(r'^channels/(?P<slug>[\w\d\-]+)/$', website_views.ChannelDetailView.as_view(), name="channel-detail"),
# Character management
url(r'^characters/$', website_views.CharacterListView.as_view(), name="characters"),
url(r'^characters/create/$', website_views.CharacterCreateView.as_view(), name="character-create"),
url(r'^characters/manage/$', website_views.CharacterManageView.as_view(), name="character-manage"),
url(r'^characters/detail/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', website_views.CharacterDetailView.as_view(), name="character-detail"),
url(r'^characters/puppet/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', website_views.CharacterPuppetView.as_view(), name="character-puppet"),
url(r'^characters/update/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', website_views.CharacterUpdateView.as_view(), name="character-update"),
url(r'^characters/delete/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', website_views.CharacterDeleteView.as_view(), name="character-delete"),
# Django original admin page. Make this URL is always available, whether
# we've chosen to use Evennia's custom admin or not.
url(r'django_admin/', website_views.admin_wrapper, name="django_admin"),

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ pypiwin32
# general
django > 1.11, < 2.0
twisted >= 18.0.0, < 19.0.0
pillow == 2.9.0
pillow == 5.2.0
pytz
future >= 0.15.2
django-sekizai