From 263a3f79d649ea9fc6014eb193e8dfecce0f2754 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 31 Mar 2018 12:40:57 +0200 Subject: [PATCH 001/187] Fix of output handling in msg() when text is None --- evennia/accounts/accounts.py | 16 +++++++++------- evennia/objects/objects.py | 16 +++++++++------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 48d03d8dc8..fe7693cce0 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -421,17 +421,19 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): kwargs["options"] = options - if not (isinstance(text, basestring) or isinstance(text, tuple)): - # sanitize text before sending across the wire - try: - text = to_str(text, force_string=True) - except Exception: - text = repr(text) + if text is not None: + if not (isinstance(text, basestring) or isinstance(text, tuple)): + # sanitize text before sending across the wire + try: + text = to_str(text, force_string=True) + except Exception: + text = repr(text) + kwargs['text'] = text # session relay sessions = make_iter(session) if session else self.sessions.all() for session in sessions: - session.data_out(text=text, **kwargs) + session.data_out(**kwargs) def execute_cmd(self, raw_string, session=None, **kwargs): """ diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index f583570707..8cdf546706 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -535,17 +535,19 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): except Exception: logger.log_trace() - if not (isinstance(text, basestring) or isinstance(text, tuple)): - # sanitize text before sending across the wire - try: - text = to_str(text, force_string=True) - except Exception: - text = repr(text) + if text is not None: + if not (isinstance(text, basestring) or isinstance(text, tuple)): + # sanitize text before sending across the wire + try: + text = to_str(text, force_string=True) + except Exception: + text = repr(text) + kwargs['text'] = text # relay to session(s) sessions = make_iter(session) if session else self.sessions.all() for session in sessions: - session.data_out(text=text, **kwargs) + session.data_out(**kwargs) def for_contents(self, func, exclude=None, **kwargs): From 1cf408e653484506114b935d16583f600d537420 Mon Sep 17 00:00:00 2001 From: CloudKeeper1 Date: Sat, 14 Apr 2018 00:23:52 +1000 Subject: [PATCH 002/187] Wrong symbol on line 499 Wrong symbol on line 499 --- evennia/commands/default/general.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 2f4c51a227..f9634ed675 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -496,7 +496,7 @@ class CmdWhisper(COMMAND_DEFAULT_CLASS): Usage: whisper = - whisper , = , = Talk privately to one or more characters in your current location, without others in the room being informed. From 2f1b0de92162b2cf75e519ec815d8abd391f7216 Mon Sep 17 00:00:00 2001 From: Aditya Arora Date: Mon, 16 Apr 2018 18:41:02 +0530 Subject: [PATCH 003/187] Update rpsystem.py --- evennia/contrib/rpsystem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/rpsystem.py b/evennia/contrib/rpsystem.py index a56d2de731..efba0fe7fd 100644 --- a/evennia/contrib/rpsystem.py +++ b/evennia/contrib/rpsystem.py @@ -1088,7 +1088,7 @@ class CmdMask(RPCommand): if self.cmdstring == "mask": # wear a mask if not self.args: - caller.msg("Usage: (un)wearmask sdesc") + caller.msg("Usage: (un)mask sdesc") return if caller.db.unmasked_sdesc: caller.msg("You are already wearing a mask.") @@ -1111,7 +1111,7 @@ class CmdMask(RPCommand): del caller.db.unmasked_sdesc caller.locks.remove("enable_recog") caller.sdesc.add(old_sdesc) - caller.msg("You remove your mask and is again '%s'." % old_sdesc) + caller.msg("You remove your mask and are again '%s'." % old_sdesc) class RPSystemCmdSet(CmdSet): From f9b636676d4be64e3a5141df4c6787f2a21ff848 Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 1 Oct 2018 20:12:24 +0000 Subject: [PATCH 004/187] Extends normalize_username() function to strip excessive spaces. --- evennia/accounts/accounts.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 2c33e5c1f8..07fb8f318c 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -10,7 +10,7 @@ character object, so you should customize that instead for most things). """ - +import re import time from django.conf import settings from django.contrib.auth import password_validation @@ -359,6 +359,27 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): puppet = property(__get_single_puppet) # utility methods + @classmethod + def normalize_username(cls, username): + """ + Django: Applies NFKC Unicode normalization to usernames so that visually + identical characters with different Unicode code points are considered + identical. + + (This deals with the Turkish "i" problem and similar + annoyances. Only relevant if you go out of your way to allow Unicode + usernames though-- Evennia accepts ASCII by default.) + + In this case we're simply piggybacking on this feature to apply + additional normalization per Evennia's standards. + """ + username = super(DefaultAccount, cls).normalize_username(username) + + # strip excessive spaces in accountname + username = re.sub(r"\s+", " ", username).strip() + + return username + @classmethod def validate_password(cls, password, account=None): """ From c5b7577021200e8958c932a1f8a6e884f41bbd01 Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 1 Oct 2018 21:24:33 +0000 Subject: [PATCH 005/187] Adds username normalization/validation and authentication methods to Account class. --- evennia/accounts/accounts.py | 110 ++++++++++++++++++++++++++++++++++- evennia/accounts/tests.py | 29 +++++++++ evennia/server/validators.py | 35 +++++++++++ evennia/settings_default.py | 22 +++++++ 4 files changed, 194 insertions(+), 2 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 07fb8f318c..8da9a7ea9b 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -13,9 +13,10 @@ instead for most things). import re import time from django.conf import settings -from django.contrib.auth import password_validation -from django.core.exceptions import ValidationError +from django.contrib.auth import authenticate, password_validation +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.utils import timezone +from django.utils.module_loading import import_string from evennia.typeclasses.models import TypeclassBase from evennia.accounts.manager import AccountManager from evennia.accounts.models import AccountDB @@ -359,6 +360,74 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): puppet = property(__get_single_puppet) # utility methods + @classmethod + def get_username_validators(cls, validator_config=getattr(settings, 'AUTH_USERNAME_VALIDATORS', [])): + """ + Retrieves and instantiates validators for usernames. + + Args: + validator_config (list): List of dicts comprising the battery of + validators to apply to a username. + + Returns: + validators (list): List of instantiated Validator objects. + """ + + objs = [] + for validator in validator_config: + try: + klass = import_string(validator['NAME']) + except ImportError: + msg = "The module in NAME could not be imported: %s. Check your AUTH_USERNAME_VALIDATORS setting." + raise ImproperlyConfigured(msg % validator['NAME']) + objs.append(klass(**validator.get('OPTIONS', {}))) + return objs + + @classmethod + def authenticate(cls, username, password, ip=None): + """ + Checks the given username/password against the database to see if the + credentials are valid. + + Note that this simply checks credentials and returns a valid reference + to the user-- it does not log them in! + + To finish the job: + After calling this from a Command, associate the account with a Session: + - session.sessionhandler.login(session, account) + + ...or after calling this from a View, associate it with an HttpRequest: + - django.contrib.auth.login(account, request) + + Args: + username (str): Username of account + password (str): Password of account + ip (str, optional): IP address of client + + Returns: + account (DefaultAccount, None): Account whose credentials were + provided if not banned. + errors (list): Error messages of any failures. + + """ + errors = [] + if ip: ip = str(ip) + + # Authenticate and get Account object + account = authenticate(username=username, password=password) + if not account: + # User-facing message + errors.append('Username and/or password is incorrect.') + + # System log message + logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip)) + + return None, errors + + # Account successfully authenticated + logger.log_sec('Authentication Success: %s (IP: %s).' % (account, ip)) + return account, errors + @classmethod def normalize_username(cls, username): """ @@ -379,6 +448,43 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): username = re.sub(r"\s+", " ", username).strip() return username + + @classmethod + def validate_username(cls, username): + """ + Checks the given username against the username validator associated with + Account objects, and also checks the database to make sure it is unique. + + Args: + username (str): Username to validate + + Returns: + valid (bool): Whether or not the password passed validation + errors (list): Error messages of any failures + + """ + valid = [] + errors = [] + + # Make sure we're at least using the default validator + validators = cls.get_username_validators() + if not validators: + validators = [cls.username_validator] + + # Try username against all enabled validators + for validator in validators: + try: + valid.append(not validator(username)) + except ValidationError as e: + valid.append(False) + [errors.append(x) for x in e.messages] + + # Disqualify if any check failed + if False in valid: + valid = False + else: valid = True + + return valid, errors @classmethod def validate_password(cls, password, account=None): diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 2855dd0ca2..f1a9f16c28 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + from mock import Mock from random import randint from unittest import TestCase @@ -59,6 +61,33 @@ class TestDefaultAccount(TestCase): self.s1 = Session() self.s1.puppet = None self.s1.sessid = 0 + + self.password = "testpassword" + self.account = create.create_account("TestAccount%s" % randint(100000, 999999), email="test@test.com", password=self.password, typeclass=DefaultAccount) + + def test_authentication(self): + "Confirm Account authentication method is authenticating/denying users." + # Valid credentials + obj, errors = DefaultAccount.authenticate(self.account.name, self.password) + self.assertTrue(obj, 'Account did not authenticate given valid credentials.') + + # Invalid credentials + obj, errors = DefaultAccount.authenticate(self.account.name, 'xyzzy') + self.assertFalse(obj, 'Account authenticated using invalid credentials.') + + def test_username_validation(self): + "Check username validators deny relevant usernames" + # Should not accept Unicode by default, lest users pick names like this + result, error = DefaultAccount.validate_username('¯\_(ツ)_/¯') + self.assertFalse(result, "Validator allowed kanji in username.") + + # Should not allow duplicate username + result, error = DefaultAccount.validate_username(self.account.name) + self.assertFalse(result, "Duplicate username should not have passed validation.") + + # Should not allow username too short + result, error = DefaultAccount.validate_username('xx') + self.assertFalse(result, "2-character username passed validation.") def test_password_validation(self): "Check password validators deny bad passwords" diff --git a/evennia/server/validators.py b/evennia/server/validators.py index b10f990a8a..bccbde6b51 100644 --- a/evennia/server/validators.py +++ b/evennia/server/validators.py @@ -1,7 +1,42 @@ +from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ +from evennia.accounts.models import AccountDB import re +class EvenniaUsernameAvailabilityValidator: + """ + Checks to make sure a given username is not taken or otherwise reserved. + """ + + def __call__(self, username): + """ + Validates a username to make sure it is not in use or reserved. + + Args: + username (str): Username to validate + + Returns: + None (None): None if password successfully validated, + raises ValidationError otherwise. + + """ + + # Check guest list + if settings.GUEST_LIST and username.lower() in (guest.lower() for guest in settings.GUEST_LIST): + raise ValidationError( + _('Sorry, that username is reserved.'), + code='evennia_username_reserved', + ) + + # Check database + exists = AccountDB.objects.filter(username__iexact=username).exists() + if exists: + raise ValidationError( + _('Sorry, that username is already taken.'), + code='evennia_username_taken', + ) + class EvenniaPasswordValidator: def __init__(self, regex=r"^[\w. @+\-',]+$", policy="Password should contain a mix of letters, spaces, digits and @/./+/-/_/'/, only."): diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 9efbb6314b..04c928ac99 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -809,6 +809,28 @@ AUTH_PASSWORD_VALIDATORS = [ {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, {'NAME': 'evennia.server.validators.EvenniaPasswordValidator'}] + +# Username validation plugins +AUTH_USERNAME_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.validators.ASCIIUsernameValidator', + }, + { + 'NAME': 'django.core.validators.MinLengthValidator', + 'OPTIONS': { + 'limit_value': 3, + } + }, + { + 'NAME': 'django.core.validators.MaxLengthValidator', + 'OPTIONS': { + 'limit_value': 30, + } + }, + { + 'NAME': 'evennia.server.validators.EvenniaUsernameAvailabilityValidator', + }, +] # Use a custom test runner that just tests Evennia-specific apps. TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner' From bce4d3cb4200c2bfac6ef980f7d98bee3829c8b2 Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 1 Oct 2018 23:58:12 +0000 Subject: [PATCH 006/187] Moves LOGIN and CREATION throttles from Command module to Account module. --- evennia/accounts/accounts.py | 20 ++++++++++++++++++-- evennia/commands/default/unloggedin.py | 10 ++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 8da9a7ea9b..1ce0f4d9f9 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -23,6 +23,7 @@ from evennia.accounts.models import AccountDB from evennia.objects.models import ObjectDB from evennia.comms.models import ChannelDB from evennia.commands import cmdhandler +from evennia.server.throttle import Throttle from evennia.utils import logger from evennia.utils.utils import (lazy_property, to_str, make_iter, to_unicode, is_iter, @@ -44,6 +45,9 @@ _MAX_NR_CHARACTERS = settings.MAX_NR_CHARACTERS _CMDSET_ACCOUNT = settings.CMDSET_ACCOUNT _CONNECT_CHANNEL = None +# Create throttles for too many account-creations and login attempts +CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60) +LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60) class AccountSessionHandler(object): """ @@ -413,15 +417,27 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): errors = [] if ip: ip = str(ip) + # See if authentication is currently being throttled + if ip and LOGIN_THROTTLE.check(ip): + errors.append('Too many login failures; please try again in a few minutes.') + + # With throttle active, do not log continued hits-- it is a + # waste of storage and can be abused to make your logs harder to + # read and fill up your disk. + return None, errors + # Authenticate and get Account object account = authenticate(username=username, password=password) if not account: # User-facing message errors.append('Username and/or password is incorrect.') - # System log message + # Log auth failures while throttle is inactive logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip)) + # Update throttle + if ip: LOGIN_THROTTLE.update(ip) + return None, errors # Account successfully authenticated @@ -543,7 +559,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): if error: raise error super(DefaultAccount, self).set_password(password) - logger.log_info("Password succesfully changed for %s." % self) + logger.log_sec("Password successfully changed for %s." % self) self.at_password_change() def delete(self, *args, **kwargs): diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 8ae700e2c1..d77d7ee2b9 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -7,12 +7,13 @@ import datetime from random import getrandbits from django.conf import settings from django.contrib.auth import authenticate +from evennia.accounts.accounts import CREATION_THROTTLE, LOGIN_THROTTLE from evennia.accounts.models import AccountDB from evennia.objects.models import ObjectDB -from evennia.server.models import ServerConfig -from evennia.server.throttle import Throttle from evennia.comms.models import ChannelDB +from evennia.server.models import ServerConfig from evennia.server.sessionhandler import SESSIONS +from evennia.server.throttle import Throttle from evennia.utils import create, logger, utils, gametime from evennia.commands.cmdhandler import CMD_LOGINSTART @@ -26,11 +27,8 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate", MULTISESSION_MODE = settings.MULTISESSION_MODE CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE -# Create throttles for too many connections, account-creations and login attempts +# Create throttles for too many connections CONNECTION_THROTTLE = Throttle(limit=5, timeout=1 * 60) -CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60) -LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60) - def create_guest_account(session): """ From 1fe44704f709cf2801bbb18d3f9067001e34c0bf Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 2 Oct 2018 00:05:07 +0000 Subject: [PATCH 007/187] Adds logging of throttle activation and customizable message upon update. --- evennia/accounts/accounts.py | 2 +- evennia/server/throttle.py | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 1ce0f4d9f9..a6a29fab72 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -436,7 +436,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip)) # Update throttle - if ip: LOGIN_THROTTLE.update(ip) + if ip: LOGIN_THROTTLE.update(ip, 'Too many authentication failures') return None, errors diff --git a/evennia/server/throttle.py b/evennia/server/throttle.py index 56c88c63f2..944b7f880e 100644 --- a/evennia/server/throttle.py +++ b/evennia/server/throttle.py @@ -1,4 +1,5 @@ from collections import defaultdict, deque +from evennia.utils import logger import time class Throttle(object): @@ -50,22 +51,34 @@ class Throttle(object): if ip: return self.storage.get(ip, deque(maxlen=self.cache_size)) else: return self.storage - def update(self, ip): + def update(self, ip, failmsg='Exceeded threshold.'): """ - Store the time of the latest failure/ + Store the time of the latest failure. Args: ip (str): IP address of requestor + failmsg (str, optional): Message to display in logs upon activation + of throttle. Returns: None """ + # Get current status + previously_throttled = self.check(ip) + # Enforce length limits if not self.storage[ip].maxlen: self.storage[ip] = deque(maxlen=self.cache_size) self.storage[ip].append(time.time()) + + # See if this update caused a change in status + currently_throttled = self.check(ip) + + # If this makes it engage, log a single activation event + if (not previously_throttled and currently_throttled): + logger.log_sec('Throttle Activated: %s (IP: %s, %i hits in %i seconds.)' % (failmsg, ip, self.limit, self.timeout)) def check(self, ip): """ From d570be49be3fa12733510c8e0df719c19e47dbf8 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 2 Oct 2018 20:23:23 +0000 Subject: [PATCH 008/187] Moves account creation logic from Commands module to Account class. --- evennia/accounts/accounts.py | 269 ++++++++++++++++++++++++- evennia/accounts/tests.py | 37 +++- evennia/commands/default/unloggedin.py | 222 +++++--------------- evennia/server/validators.py | 2 +- 4 files changed, 354 insertions(+), 176 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index a6a29fab72..6bd812b6fa 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -23,8 +23,9 @@ from evennia.accounts.models import AccountDB from evennia.objects.models import ObjectDB from evennia.comms.models import ChannelDB from evennia.commands import cmdhandler +from evennia.server.models import ServerConfig from evennia.server.throttle import Throttle -from evennia.utils import logger +from evennia.utils import create, logger from evennia.utils.utils import (lazy_property, to_str, make_iter, to_unicode, is_iter, variable_from_module) @@ -34,6 +35,7 @@ from evennia.commands.cmdsethandler import CmdSetHandler from django.utils.translation import ugettext as _ from future.utils import with_metaclass +from random import getrandbits __all__ = ("DefaultAccount",) @@ -364,6 +366,31 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): puppet = property(__get_single_puppet) # utility methods + @classmethod + def is_banned(cls, **kwargs): + """ + Checks if a given username or IP is banned. + + Kwargs: + ip (str, optional): IP address. + username (str, optional): Username. + + Returns: + is_banned (bool): Whether either is banned or not. + + """ + + ip = kwargs.get('ip', '').strip() + username = kwargs.get('username', '').lower().strip() + + # Check IP and/or name bans + bans = ServerConfig.objects.conf("server_bans") + if bans and (any(tup[0] == username for tup in bans if username) or + any(tup[2].match(ip) for tup in bans if ip and tup[2])): + return True + + return False + @classmethod def get_username_validators(cls, validator_config=getattr(settings, 'AUTH_USERNAME_VALIDATORS', [])): """ @@ -386,9 +413,85 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): raise ImproperlyConfigured(msg % validator['NAME']) objs.append(klass(**validator.get('OPTIONS', {}))) return objs + + @classmethod + def authenticate_guest(cls, **kwargs): + """ + Gets or creates a Guest account object. + + Kwargs: + ip (str, optional): IP address of requestor; used for ban checking, + throttling and logging + + """ + errors = [] + account = None + username = None + ip = kwargs.get('ip', '').strip() + + # check if guests are enabled. + if not settings.GUEST_ENABLED: + errors.append('Guest accounts are not enabled on this server.') + return None, errors + + # See if authentication is currently being throttled + if ip and LOGIN_THROTTLE.check(ip): + errors.append('Too many login failures; please try again in a few minutes.') + + # With throttle active, do not log continued hits-- it is a + # waste of storage and can be abused to make your logs harder to + # read and/or fill up your disk. + return None, errors + + # check if IP banned + if ip and cls.is_banned(ip=ip): + errors.append("|rYou have been banned and cannot continue from here." \ + "\nIf you feel this ban is in error, please email an admin.|x") + logger.log_sec('Authentication Denied (Banned): %s (IP: %s).' % ('guest', ip)) + LOGIN_THROTTLE.update(ip, 'Too many sightings of banned IP.') + return None, errors + + try: + # Find an available guest name. + for name in settings.GUEST_LIST: + if not AccountDB.objects.filter(username__iexact=name).count(): + username = name + break + if not username: + errors.append("All guest accounts are in use. Please try again later.") + if ip: LOGIN_THROTTLE.update(ip, 'Too many requests for Guest access.') + return None, errors + else: + # build a new account with the found guest username + password = "%016x" % getrandbits(64) + home = ObjectDB.objects.get_id(settings.GUEST_HOME) + permissions = settings.PERMISSION_GUEST_DEFAULT + character_typeclass = settings.BASE_CHARACTER_TYPECLASS + account_typeclass = settings.BASE_GUEST_TYPECLASS + account, errs = cls.create( + guest=True, + username=username, + password=password, + permissions=permissions, + account_typeclass=account_typeclass, + character_typeclass=character_typeclass, + ip=ip, + ) + errors.extend(errs) + return account, errors + + except Exception: + # We are in the middle between logged in and -not, so we have + # to handle tracebacks ourselves at this point. If we don't, + # we won't see any errors at all. + errors.append("An error occurred. Please e-mail an admin if the problem persists.") + logger.log_trace() + return None, errors + + return account, errors @classmethod - def authenticate(cls, username, password, ip=None): + def authenticate(cls, username, password, ip='', **kwargs): """ Checks the given username/password against the database to see if the credentials are valid. @@ -408,6 +511,9 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): password (str): Password of account ip (str, optional): IP address of client + Kwargs: + session (Session, optional): Session requesting authentication + Returns: account (DefaultAccount, None): Account whose credentials were provided if not banned. @@ -423,7 +529,17 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): # With throttle active, do not log continued hits-- it is a # waste of storage and can be abused to make your logs harder to - # read and fill up your disk. + # read and/or fill up your disk. + return None, errors + + # Check IP and/or name bans + banned = cls.is_banned(username=username, ip=ip) + if banned: + # this is a banned IP or name! + errors.append("|rYou have been banned and cannot continue from here." \ + "\nIf you feel this ban is in error, please email an admin.|x") + logger.log_sec('Authentication Denied (Banned): %s (IP: %s).' % (username, ip)) + LOGIN_THROTTLE.update(ip, 'Too many sightings of banned artifact.') return None, errors # Authenticate and get Account object @@ -436,7 +552,14 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip)) # Update throttle - if ip: LOGIN_THROTTLE.update(ip, 'Too many authentication failures') + if ip: LOGIN_THROTTLE.update(ip, 'Too many authentication failures.') + + # Try to call post-failure hook + session = kwargs.get('session', None) + if session: + account = AccountDB.objects.get_account_from_name(username) + if account: + account.at_failed_login(session) return None, errors @@ -493,7 +616,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): valid.append(not validator(username)) except ValidationError as e: valid.append(False) - [errors.append(x) for x in e.messages] + errors.extend(e.messages) # Disqualify if any check failed if False in valid: @@ -561,6 +684,142 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): super(DefaultAccount, self).set_password(password) logger.log_sec("Password successfully changed for %s." % self) self.at_password_change() + + @classmethod + def create(cls, *args, **kwargs): + """ + Creates an Account (or Account/Character pair for MM<2) with default + (or overridden) permissions and having joined them to the appropriate + default channels. + + Kwargs: + username (str): Username of Account owner + password (str): Password of Account owner + email (str, optional): Email address of Account owner + ip (str, optional): IP address of requesting connection + guest (bool, optional): Whether or not this is to be a Guest account + + permissions (str, optional): Default permissions for the Account + account_typeclass (str, optional): Typeclass to use for new Account + character_typeclass (str, optional): Typeclass to use for new char + when applicable. + + Returns: + account (Account): Account if successfully created; None if not + errors (list): List of error messages in string form + + """ + + account = None + errors = [] + + username = kwargs.get('username') + password = kwargs.get('password') + email = kwargs.get('email', '').strip() + guest = kwargs.get('guest', False) + + permissions = kwargs.get('permissions', settings.PERMISSION_ACCOUNT_DEFAULT) + account_typeclass = kwargs.get('account_typeclass', settings.BASE_ACCOUNT_TYPECLASS) + character_typeclass = kwargs.get('character_typeclass', settings.BASE_CHARACTER_TYPECLASS) + + ip = kwargs.get('ip', '') + if ip and CREATION_THROTTLE.check(ip): + errors.append("You are creating too many accounts. Please log into an existing account.") + return None, errors + + # Normalize username + username = cls.normalize_username(username) + + # Validate username + if not guest: + valid, errs = cls.validate_username(username) + if not valid: + # this echoes the restrictions made by django's auth + # module (except not allowing spaces, for convenience of + # logging in). + errors.extend(errs) + return None, errors + + # Validate password + # Have to create a dummy Account object to check username similarity + valid, errs = cls.validate_password(password, account=cls(username=username)) + if not valid: + errors.extend(errs) + return None, errors + + # Check IP and/or name bans + banned = cls.is_banned(username=username, ip=ip) + if banned: + # this is a banned IP or name! + string = "|rYou have been banned and cannot continue from here." \ + "\nIf you feel this ban is in error, please email an admin.|x" + errors.append(string) + return None, errors + + # everything's ok. Create the new account account. + try: + try: + account = create.create_account(username, email, password, permissions=permissions, typeclass=account_typeclass) + logger.log_sec('Account Created: %s (IP: %s).' % (account, ip)) + + except Exception as e: + errors.append("There was an error creating the Account. If this problem persists, contact an admin.") + logger.log_trace() + return None, errors + + # This needs to be set so the engine knows this account is + # logging in for the first time. (so it knows to call the right + # hooks during login later) + account.db.FIRST_LOGIN = True + + # Record IP address of creation, if available + if ip: account.db.creator_ip = ip + + # join the new account to the public channel + pchannel = ChannelDB.objects.get_channel(settings.DEFAULT_CHANNELS[0]["key"]) + if not pchannel or not pchannel.connect(account): + string = "New account '%s' could not connect to public channel!" % account.key + errors.append(string) + logger.log_err(string) + + if account: + if settings.MULTISESSION_MODE < 2: + default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME) + + try: + character = create.create_object(character_typeclass, key=account.key, home=default_home, permissions=permissions) + + # set playable character list + account.db._playable_characters.append(character) + + # allow only the character itself and the account to puppet this character (and Developers). + character.locks.add("puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer)" % + (character.id, account.id)) + + # If no description is set, set a default description + if not character.db.desc: + character.db.desc = "This is a character." + # We need to set this to have @ic auto-connect to this character + account.db._last_puppet = character + + # Record creator id and creation IP + if ip: character.db.creator_ip = ip + character.db.creator_id = account.id + + except Exception as e: + errors.append("There was an error creating a Character. If this problem persists, contact an admin.") + logger.log_trace() + + except Exception: + # We are in the middle between logged in and -not, so we have + # to handle tracebacks ourselves at this point. If we don't, + # we won't see any errors at all. + errors.append("An error occurred. Please e-mail an admin if the problem persists.") + logger.log_trace() + + # Update the throttle to indicate a new account was created from this IP + if ip and not guest: CREATION_THROTTLE.update(ip, 'Too many accounts being created.') + return account, errors def delete(self, *args, **kwargs): """ diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index f1a9f16c28..72f13bbb3a 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -75,6 +75,41 @@ class TestDefaultAccount(TestCase): obj, errors = DefaultAccount.authenticate(self.account.name, 'xyzzy') self.assertFalse(obj, 'Account authenticated using invalid credentials.') + def test_create(self): + "Confirm Account creation is working as expected." + # Create a normal account + account, errors = DefaultAccount.create(username='ziggy', password='stardust11') + self.assertTrue(account, 'New account should have been created.') + + # Try creating a duplicate account + account, errors = DefaultAccount.create(username='Ziggy', password='starman11') + self.assertFalse(account, 'Duplicate account name should not have been allowed.') + + # Guest account should not be permitted + account, errors = DefaultAccount.authenticate_guest() + self.assertFalse(account, 'Guest account was created despite being disabled.') + + settings.GUEST_ENABLED = True + settings.GUEST_LIST = ['bruce_wayne'] + + # Create a guest account + account, errors = DefaultAccount.authenticate_guest() + self.assertTrue(account, 'Guest account should have been created.') + + # Create a second guest account + account, errors = DefaultAccount.authenticate_guest() + self.assertFalse(account, 'Two guest accounts were created despite a single entry on the guest list!') + + settings.GUEST_ENABLED = False + + def test_throttle(self): + "Confirm throttle activates on too many failures." + for x in xrange(20): + obj, errors = DefaultAccount.authenticate(self.account.name, 'xyzzy', ip='12.24.36.48') + self.assertFalse(obj, 'Authentication was provided a bogus password; this should NOT have returned an account!') + + self.assertTrue('too many login failures' in errors[-1].lower(), 'Failed logins should have been throttled.') + def test_username_validation(self): "Check username validators deny relevant usernames" # Should not accept Unicode by default, lest users pick names like this @@ -92,7 +127,7 @@ class TestDefaultAccount(TestCase): def test_password_validation(self): "Check password validators deny bad passwords" - self.account = create.create_account("TestAccount%s" % randint(0, 9), + self.account = create.create_account("TestAccount%s" % randint(100000, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'): self.assertFalse(self.account.validate_password(bad, account=self.account)[0]) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index d77d7ee2b9..7cf8b40c88 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -4,10 +4,9 @@ Commands that are available from the connect screen. import re import time import datetime -from random import getrandbits + from django.conf import settings from django.contrib.auth import authenticate -from evennia.accounts.accounts import CREATION_THROTTLE, LOGIN_THROTTLE from evennia.accounts.models import AccountDB from evennia.objects.models import ObjectDB from evennia.comms.models import ChannelDB @@ -15,7 +14,7 @@ from evennia.server.models import ServerConfig from evennia.server.sessionhandler import SESSIONS from evennia.server.throttle import Throttle -from evennia.utils import create, logger, utils, gametime +from evennia.utils import class_from_module, create, logger, utils, gametime from evennia.commands.cmdhandler import CMD_LOGINSTART COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -27,9 +26,6 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate", MULTISESSION_MODE = settings.MULTISESSION_MODE CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE -# Create throttles for too many connections -CONNECTION_THROTTLE = Throttle(limit=5, timeout=1 * 60) - def create_guest_account(session): """ Creates a guest account/character for this session, if one is available. @@ -42,50 +38,20 @@ def create_guest_account(session): the boolean is whether guest accounts are enabled at all. the Account which was created from an available guest name. """ - # check if guests are enabled. - if not settings.GUEST_ENABLED: - return False, None - - # Check IP bans. - bans = ServerConfig.objects.conf("server_bans") - if bans and any(tup[2].match(session.address) for tup in bans if tup[2]): - # this is a banned IP! - string = "|rYou have been banned and cannot continue from here." \ - "\nIf you feel this ban is in error, please email an admin.|x" - session.msg(string) - session.sessionhandler.disconnect(session, "Good bye! Disconnecting.") - return True, None - - try: - # Find an available guest name. - accountname = None - for name in settings.GUEST_LIST: - if not AccountDB.objects.filter(username__iexact=accountname).count(): - accountname = name - break - if not accountname: - session.msg("All guest accounts are in use. Please try again later.") - return True, None - else: - # build a new account with the found guest accountname - password = "%016x" % getrandbits(64) - home = ObjectDB.objects.get_id(settings.GUEST_HOME) - permissions = settings.PERMISSION_GUEST_DEFAULT - typeclass = settings.BASE_CHARACTER_TYPECLASS - ptypeclass = settings.BASE_GUEST_TYPECLASS - new_account = _create_account(session, accountname, password, permissions, ptypeclass) - if new_account: - _create_character(session, new_account, typeclass, home, permissions) - return True, new_account - - except Exception: - # We are in the middle between logged in and -not, so we have - # to handle tracebacks ourselves at this point. If we don't, - # we won't see any errors at all. - session.msg("An error occurred. Please e-mail an admin if the problem persists.") - logger.log_trace() - raise - + enabled = settings.GUEST_ENABLED + address = session.address + + # Get account class + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + + # Get an available guest account + # authenticate_guest() handles its own throttling + account, errors = Account.authenticate_guest(ip=address) + if account: + return enabled, account + else: + session.msg("|R%s|n" % '\n'.join(errors)) + return enabled, None def create_normal_account(session, name, password): """ @@ -99,38 +65,17 @@ def create_normal_account(session, name, password): Returns: account (Account): the account which was created from the name and password. """ - # check for too many login errors too quick. + # Get account class + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + address = session.address - if isinstance(address, tuple): - address = address[0] - - if LOGIN_THROTTLE.check(address): - session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n") - return None - + # Match account name and check password - account = authenticate(username=name, password=password) - + # authenticate() handles all its own throttling + account, errors = Account.authenticate(username=name, password=password, ip=address, session=session) if not account: # No accountname or password match - session.msg("Incorrect login information given.") - # this just updates the throttle - LOGIN_THROTTLE.update(address) - # calls account hook for a failed login if possible. - account = AccountDB.objects.get_account_from_name(name) - if account: - account.at_failed_login(session) - return None - - # Check IP and/or name bans - bans = ServerConfig.objects.conf("server_bans") - if bans and (any(tup[0] == account.name.lower() for tup in bans) or - any(tup[2].match(session.address) for tup in bans if tup[2])): - # this is a banned IP or name! - string = "|rYou have been banned and cannot continue from here." \ - "\nIf you feel this ban is in error, please email an admin.|x" - session.msg(string) - session.sessionhandler.disconnect(session, "Good bye! Disconnecting.") + session.msg("|R%s|n" % '\n'.join(errors)) return None return account @@ -162,15 +107,10 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS): there is no object yet before the account has logged in) """ session = self.caller - - # check for too many login errors too quick. address = session.address - if isinstance(address, tuple): - address = address[0] - if CONNECTION_THROTTLE.check(address): - # timeout is 5 minutes. - session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n") - return + + # Get account class + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) args = self.args # extract double quote parts @@ -178,23 +118,27 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS): if len(parts) == 1: # this was (hopefully) due to no double quotes being found, or a guest login parts = parts[0].split(None, 1) + # Guest login if len(parts) == 1 and parts[0].lower() == "guest": - enabled, new_account = create_guest_account(session) - if new_account: - session.sessionhandler.login(session, new_account) - if enabled: + account, errors = Account.authenticate_guest(ip=address) + if account: + session.sessionhandler.login(session, account) return - + else: + session.msg("|R%s|n" % '\n'.join(errors)) + return + if len(parts) != 2: session.msg("\n\r Usage (without <>): connect ") return - CONNECTION_THROTTLE.update(address) name, password = parts - account = create_normal_account(session, name, password) + account, errors = Account.authenticate(username=name, password=password, ip=address, session=session) if account: session.sessionhandler.login(session, account) + else: + session.msg("|R%s|n" % '\n'.join(errors)) class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): @@ -220,14 +164,10 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): session = self.caller args = self.args.strip() - # Rate-limit account creation. address = session.address - - if isinstance(address, tuple): - address = address[0] - if CREATION_THROTTLE.check(address): - session.msg("|RYou are creating too many accounts. Try again in a few minutes.|n") - return + + # Get account class + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) # extract double quoted parts parts = [part.strip() for part in re.split(r"\"", args) if part.strip()] @@ -239,77 +179,21 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): "\nIf or contains spaces, enclose it in double quotes." session.msg(string) return - accountname, password = parts - - # sanity checks - if not re.findall(r"^[\w. @+\-']+$", accountname) or not (0 < len(accountname) <= 30): - # this echoes the restrictions made by django's auth - # module (except not allowing spaces, for convenience of - # logging in). - string = "\n\r Accountname can max be 30 characters or fewer. Letters, spaces, digits and @/./+/-/_/' only." - session.msg(string) - return - # strip excessive spaces in accountname - accountname = re.sub(r"\s+", " ", accountname).strip() - if AccountDB.objects.filter(username__iexact=accountname): - # account already exists (we also ignore capitalization here) - session.msg("Sorry, there is already an account with the name '%s'." % accountname) - return - # Reserve accountnames found in GUEST_LIST - if settings.GUEST_LIST and accountname.lower() in (guest.lower() for guest in settings.GUEST_LIST): - string = "\n\r That name is reserved. Please choose another Accountname." - session.msg(string) - return - - # Validate password - Account = utils.class_from_module(settings.BASE_ACCOUNT_TYPECLASS) - # Have to create a dummy Account object to check username similarity - valid, error = Account.validate_password(password, account=Account(username=accountname)) - if error: - errors = [e for suberror in error.messages for e in error.messages] - string = "\n".join(errors) - session.msg(string) - return - - # Check IP and/or name bans - bans = ServerConfig.objects.conf("server_bans") - if bans and (any(tup[0] == accountname.lower() for tup in bans) or - - any(tup[2].match(session.address) for tup in bans if tup[2])): - # this is a banned IP or name! - string = "|rYou have been banned and cannot continue from here." \ - "\nIf you feel this ban is in error, please email an admin.|x" - session.msg(string) - session.sessionhandler.disconnect(session, "Good bye! Disconnecting.") - return + + username, password = parts # everything's ok. Create the new account account. - try: - permissions = settings.PERMISSION_ACCOUNT_DEFAULT - typeclass = settings.BASE_CHARACTER_TYPECLASS - new_account = _create_account(session, accountname, password, permissions) - if new_account: - if MULTISESSION_MODE < 2: - default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME) - _create_character(session, new_account, typeclass, default_home, permissions) - - # Update the throttle to indicate a new account was created from this IP - CREATION_THROTTLE.update(address) - - # tell the caller everything went well. - string = "A new account '%s' was created. Welcome!" - if " " in accountname: - string += "\n\nYou can now log in with the command 'connect \"%s\" '." - else: - string += "\n\nYou can now log with the command 'connect %s '." - session.msg(string % (accountname, accountname)) - - except Exception: - # We are in the middle between logged in and -not, so we have - # to handle tracebacks ourselves at this point. If we don't, - # we won't see any errors at all. - session.msg("An error occurred. Please e-mail an admin if the problem persists.") - logger.log_trace() + account, errors = Account.create(username=username, password=password, ip=address, session=session) + if account: + # tell the caller everything went well. + string = "A new account '%s' was created. Welcome!" + if " " in username: + string += "\n\nYou can now log in with the command 'connect \"%s\" '." + else: + string += "\n\nYou can now log with the command 'connect %s '." + session.msg(string % (username, username)) + else: + session.msg("|R%s|n" % '\n'.join(errors)) class CmdUnconnectedQuit(COMMAND_DEFAULT_CLASS): diff --git a/evennia/server/validators.py b/evennia/server/validators.py index bccbde6b51..fdadeda6e3 100644 --- a/evennia/server/validators.py +++ b/evennia/server/validators.py @@ -23,7 +23,7 @@ class EvenniaUsernameAvailabilityValidator: """ # Check guest list - if settings.GUEST_LIST and username.lower() in (guest.lower() for guest in settings.GUEST_LIST): + if (settings.GUEST_LIST and username.lower() in (guest.lower() for guest in settings.GUEST_LIST)): raise ValidationError( _('Sorry, that username is reserved.'), code='evennia_username_reserved', From 10c86f81c08b1ac590e247c3166cf3d374bda6c4 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 4 Oct 2018 00:08:23 +0000 Subject: [PATCH 009/187] Updates Bootstrap to v4 stable. --- evennia/web/website/templates/website/base.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/evennia/web/website/templates/website/base.html b/evennia/web/website/templates/website/base.html index a2172b308f..556aad0306 100644 --- a/evennia/web/website/templates/website/base.html +++ b/evennia/web/website/templates/website/base.html @@ -12,7 +12,7 @@ - + @@ -56,7 +56,7 @@ - - + + From eb1d89d953bacc293bc3bbdd3ecb25490a7ed34b Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 4 Oct 2018 00:32:38 +0000 Subject: [PATCH 010/187] Adds template tag to override body. --- evennia/web/website/templates/website/base.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/evennia/web/website/templates/website/base.html b/evennia/web/website/templates/website/base.html index 556aad0306..2004425849 100644 --- a/evennia/web/website/templates/website/base.html +++ b/evennia/web/website/templates/website/base.html @@ -29,6 +29,8 @@ {{game_name}} - {% if flatpage %}{{flatpage.title}}{% else %}{% block titleblock %}{{page_title}}{% endblock %}{% endif %} + {% block body %} + {% include "website/_menu.html" %}
@@ -53,6 +55,8 @@
{% endblock %} + + {% endblock %} From cc2b9f22aa5e67780574fa5c2f1743e273478c3a Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 4 Oct 2018 00:33:32 +0000 Subject: [PATCH 011/187] Fixes failure to display error messages and display form as standalone. --- .../templates/website/registration/login.html | 73 ++++++++++--------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/evennia/web/website/templates/website/registration/login.html b/evennia/web/website/templates/website/registration/login.html index 8b2213a251..2aa32d5cff 100644 --- a/evennia/web/website/templates/website/registration/login.html +++ b/evennia/web/website/templates/website/registration/login.html @@ -4,44 +4,49 @@ Login {% endblock %} -{% block content %} +{% block body %} {% load addclass %} - -
-
-
-
-

Login

-
- {% if user.is_authenticated %} -

You are already logged in!

- {% else %} - {% if form.has_errors %} -

Your username and password didn't match. Please try again.

- {% endif %} - -
- {% csrf_token %} - -
- - {{ form.username | addclass:"form-control" }} -
- -
- - {{ form.password | addclass:"form-control" }} -
- -
- - -
-
+
+
+
+
+
+

Login

+
+ {% if user.is_authenticated %} + + {% else %} + {% if form.errors %} + + {% endif %} + {% endif %} + + {% if not user.is_authenticated %} +
+ {% csrf_token %} + +
+ + {{ form.username | addclass:"form-control" }} +
+ +
+ + {{ form.password | addclass:"form-control" }} +
+ +
+
+ + +
+
+ + {% endif %} +
-{% endif %} {% endblock %} From bebd621bd5437f61222dd02f3c2f78c600606441 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 4 Oct 2018 17:19:52 +0000 Subject: [PATCH 012/187] Adds link to password reset form. --- .../web/website/templates/website/registration/login.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/evennia/web/website/templates/website/registration/login.html b/evennia/web/website/templates/website/registration/login.html index 2aa32d5cff..87f5d7b9f7 100644 --- a/evennia/web/website/templates/website/registration/login.html +++ b/evennia/web/website/templates/website/registration/login.html @@ -35,6 +35,12 @@ Login {{ form.password | addclass:"form-control" }}
+ +
+
+ +
Sign Up
+

From 4fc7318e4c3eeb5182d268882a4b7674d4fb0a9e Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 4 Oct 2018 18:17:32 +0000 Subject: [PATCH 013/187] Updates style of password reset forms to use Bootstrap instead of Django Admin. --- .../registration/password_reset_complete.html | 31 +++++++++++ .../registration/password_reset_confirm.html | 55 +++++++++++++++++++ .../registration/password_reset_done.html | 34 ++++++++++++ .../registration/password_reset_email.html | 15 +++++ .../registration/password_reset_form.html | 48 ++++++++++++++++ 5 files changed, 183 insertions(+) create mode 100644 evennia/web/website/templates/website/registration/password_reset_complete.html create mode 100644 evennia/web/website/templates/website/registration/password_reset_confirm.html create mode 100644 evennia/web/website/templates/website/registration/password_reset_done.html create mode 100644 evennia/web/website/templates/website/registration/password_reset_email.html create mode 100644 evennia/web/website/templates/website/registration/password_reset_form.html diff --git a/evennia/web/website/templates/website/registration/password_reset_complete.html b/evennia/web/website/templates/website/registration/password_reset_complete.html new file mode 100644 index 0000000000..697b4bc4ad --- /dev/null +++ b/evennia/web/website/templates/website/registration/password_reset_complete.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% block titleblock %} +Forgot Password - Reset Successful +{% endblock %} + +{% block body %} + +{% load addclass %} +
+
+
+
+
+

Password Reset

+
+ {% if user.is_authenticated %} + + {% else %} + +

Your password has been successfully reset!

+ +

You may now log in using it here.

+ + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/evennia/web/website/templates/website/registration/password_reset_confirm.html b/evennia/web/website/templates/website/registration/password_reset_confirm.html new file mode 100644 index 0000000000..a7bdc683be --- /dev/null +++ b/evennia/web/website/templates/website/registration/password_reset_confirm.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} + +{% block titleblock %} +Forgot Password - Reset +{% endblock %} + +{% block body %} + +{% load addclass %} +
+
+
+
+
+

Reset Password

+
+ {% if not validlink %} + + {% else %} + + {% if form.errors %} + {% for field in form %} + {% for error in field.errors %} + + {% endfor %} + {% endfor %} + {% endif %} + +
+ {% csrf_token %} + +
+ + {{ form.new_password1 | addclass:"form-control" }} +
+ +
+ + {{ form.new_password2 | addclass:"form-control" }} +
+ +
+
+ + +
+
+ + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/evennia/web/website/templates/website/registration/password_reset_done.html b/evennia/web/website/templates/website/registration/password_reset_done.html new file mode 100644 index 0000000000..d248c56d0f --- /dev/null +++ b/evennia/web/website/templates/website/registration/password_reset_done.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block titleblock %} +Forgot Password - Reset Link Sent +{% endblock %} + +{% block body %} + +{% load addclass %} +
+
+
+
+
+

Reset Sent

+
+ {% if user.is_authenticated %} + + {% else %} + +

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.

+ +

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.

+ + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/evennia/web/website/templates/website/registration/password_reset_email.html b/evennia/web/website/templates/website/registration/password_reset_email.html new file mode 100644 index 0000000000..28e5a0daa2 --- /dev/null +++ b/evennia/web/website/templates/website/registration/password_reset_email.html @@ -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 %} \ No newline at end of file diff --git a/evennia/web/website/templates/website/registration/password_reset_form.html b/evennia/web/website/templates/website/registration/password_reset_form.html new file mode 100644 index 0000000000..f13c532a58 --- /dev/null +++ b/evennia/web/website/templates/website/registration/password_reset_form.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block titleblock %} +Forgot Password +{% endblock %} + +{% block body %} + +{% load addclass %} +
+
+
+
+
+

Forgot Password

+
+ {% if user.is_authenticated %} + + {% else %} + {% if form.errors %} + + {% endif %} + {% endif %} + + {% if not user.is_authenticated %} +
+ {% csrf_token %} + +
+ + {{ form.email | addclass:"form-control" }} + The email address you provided at registration. If you left it blank, your password cannot be reset through this form. +
+ +
+
+ + +
+
+ + {% endif %} +
+
+
+
+
+{% endblock %} From f53fdbd681e8d8511b26a0ebe56277e7623576da Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 4 Oct 2018 20:04:37 +0000 Subject: [PATCH 014/187] Enables django.contrib.messages via INSTALLED_APPS and adds a context processor for it. --- evennia/settings_default.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 9efbb6314b..4922b08996 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -751,6 +751,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. @@ -785,6 +786,7 @@ INSTALLED_APPS = ( 'django.contrib.flatpages', 'django.contrib.sites', 'django.contrib.staticfiles', + 'django.contrib.messages', 'sekizai', 'evennia.utils.idmapper', 'evennia.server', From 82a95195672301e49bde340011d63898e82e32b2 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 4 Oct 2018 20:05:32 +0000 Subject: [PATCH 015/187] Adds hook to retrieve messsages and an include for the actual blocks. --- evennia/web/website/templates/website/base.html | 2 ++ evennia/web/website/templates/website/messages.html | 9 +++++++++ 2 files changed, 11 insertions(+) create mode 100644 evennia/web/website/templates/website/messages.html diff --git a/evennia/web/website/templates/website/base.html b/evennia/web/website/templates/website/base.html index 2004425849..e690d61d2b 100644 --- a/evennia/web/website/templates/website/base.html +++ b/evennia/web/website/templates/website/base.html @@ -42,6 +42,8 @@
{% endif %} diff --git a/evennia/web/website/templates/website/messages.html b/evennia/web/website/templates/website/messages.html new file mode 100644 index 0000000000..7b237180eb --- /dev/null +++ b/evennia/web/website/templates/website/messages.html @@ -0,0 +1,9 @@ +{% if messages %} + +{% for message in messages %} +
+ {{ message }} +
+{% endfor %} + +{% endif %} \ No newline at end of file From 95cf405ab4f78892526b24a3d41d3dfe93ad6b93 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 4 Oct 2018 20:05:55 +0000 Subject: [PATCH 016/187] Adds include block for messages. --- evennia/web/website/templates/website/registration/login.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/web/website/templates/website/registration/login.html b/evennia/web/website/templates/website/registration/login.html index 87f5d7b9f7..27339c9480 100644 --- a/evennia/web/website/templates/website/registration/login.html +++ b/evennia/web/website/templates/website/registration/login.html @@ -14,6 +14,7 @@ Login

Login


+ {% include 'website/messages.html' %} {% if user.is_authenticated %} {% else %} @@ -39,7 +40,7 @@ Login

From cc26e12e9ff90702ec0f99bf4a3f72ec66c9471a Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 4 Oct 2018 20:21:02 +0000 Subject: [PATCH 017/187] Adds account registration form. --- evennia/web/website/forms.py | 13 +++++ .../web/website/templates/website/_menu.html | 2 +- .../templates/website/registration/login.html | 2 +- .../website/registration/register.html | 56 +++++++++++++++++++ evennia/web/website/urls.py | 3 +- evennia/web/website/views.py | 42 ++++++++++++++ 6 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 evennia/web/website/forms.py create mode 100644 evennia/web/website/templates/website/registration/register.html diff --git a/evennia/web/website/forms.py b/evennia/web/website/forms.py new file mode 100644 index 0000000000..cc7677926c --- /dev/null +++ b/evennia/web/website/forms.py @@ -0,0 +1,13 @@ +from django import forms +from django.conf import settings +from django.contrib.auth.forms import UserCreationForm, UsernameField +from evennia.utils import class_from_module + +class AccountCreationForm(UserCreationForm): + + class Meta: + model = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + fields = ("username", "email") + field_classes = {'username': UsernameField} + + email = forms.EmailField(help_text="A valid email address. Optional; used for password resets.", required=False) \ No newline at end of file diff --git a/evennia/web/website/templates/website/_menu.html b/evennia/web/website/templates/website/_menu.html index 8430d0f805..6a81d04866 100644 --- a/evennia/web/website/templates/website/_menu.html +++ b/evennia/web/website/templates/website/_menu.html @@ -51,7 +51,7 @@ folder and edit it to add/remove links to the menu. Log In
  • - Register + Register
  • {% endif %} {% endblock %} diff --git a/evennia/web/website/templates/website/registration/login.html b/evennia/web/website/templates/website/registration/login.html index 27339c9480..cf798e8f76 100644 --- a/evennia/web/website/templates/website/registration/login.html +++ b/evennia/web/website/templates/website/registration/login.html @@ -40,7 +40,7 @@ Login

    diff --git a/evennia/web/website/templates/website/registration/register.html b/evennia/web/website/templates/website/registration/register.html new file mode 100644 index 0000000000..5475d922be --- /dev/null +++ b/evennia/web/website/templates/website/registration/register.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} + +{% block titleblock %} +Register +{% endblock %} + +{% block body %} + +{% load addclass %} +
    +
    +
    +
    +
    +

    Register

    +
    + {% if user.is_authenticated %} + + {% else %} + {% if form.errors %} + {% for field in form %} + {% for error in field.errors %} + + {% endfor %} + {% endfor %} + {% endif %} + {% endif %} + + {% if not user.is_authenticated %} +
    + {% csrf_token %} + + {% for field in form %} +
    + {{ field.label_tag }} + {{ field | addclass:"form-control" }} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
    + {% endfor %} + +
    +
    + + +
    +
    + + {% endif %} +
    +
    +
    +
    +
    +{% endblock %} diff --git a/evennia/web/website/urls.py b/evennia/web/website/urls.py index f906b6b142..1cc7fe75d8 100644 --- a/evennia/web/website/urls.py +++ b/evennia/web/website/urls.py @@ -13,7 +13,8 @@ urlpatterns = [ 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/', include('django.contrib.auth.urls')), + url(r'^auth/register', website_views.AccountCreationView.as_view(), name="register"), # Django original admin page. Make this URL is always available, whether # we've chosen to use Evennia's custom admin or not. diff --git a/evennia/web/website/views.py b/evennia/web/website/views.py index fe93b06426..e4342ebc6d 100644 --- a/evennia/web/website/views.py +++ b/evennia/web/website/views.py @@ -7,14 +7,19 @@ templates on the fly. """ from django.contrib.admin.sites import site from django.conf import settings +from django.contrib import messages from django.contrib.auth import authenticate from django.contrib.admin.views.decorators import staff_member_required +from django.http import HttpResponseRedirect from django.shortcuts import render +from django.urls import reverse, reverse_lazy +from django.views.generic import View, DetailView, ListView, FormView from evennia import SESSION_HANDLER from evennia.objects.models import ObjectDB from evennia.accounts.models import AccountDB from evennia.utils import logger +from evennia.web.website.forms import AccountCreationForm from django.contrib.auth import login @@ -134,3 +139,40 @@ def admin_wrapper(request): Wrapper that allows us to properly use the base Django admin site, if needed. """ return staff_member_required(site.index)(request) + +class AccountCreationView(FormView): + form_class = AccountCreationForm + template_name = 'website/registration/register.html' + success_url = reverse_lazy('login') + + def form_valid(self, form): + # Check to make sure basics validated + valid = super(AccountCreationView, self).form_valid(form) + if not valid: return self.form_invalid(form) + + username = form.cleaned_data['username'] + password = form.cleaned_data['password1'] + email = form.cleaned_data.get('email', '') + + # Create a fake session object to intercept calls to the terminal + from mock import Mock + session = self.request + session.address = self.request.META.get('REMOTE_ADDR', '') + session.msg = Mock() + + # Create account + from evennia.commands.default.unloggedin import _create_account + permissions = settings.PERMISSION_ACCOUNT_DEFAULT + account = _create_account(session, username, password, permissions) + + # If unsuccessful, get messages passed to session.msg + if not account: + [messages.error(self.request, call) for call in session.msg.call_args_list] + return self.form_invalid(form) + + # Append email address if given + account.email = email + account.save() + + messages.success(self.request, "Your account '%s' was successfully created! You may log in using it now." % account.name) + return HttpResponseRedirect(self.success_url) \ No newline at end of file From 2d9cbb9a20cfe225fabdbe77e7b5ef91dc970eb9 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 4 Oct 2018 22:54:35 +0000 Subject: [PATCH 018/187] Adds authenticated dropdown with links to password change form, create/manage characters, and character quickselect. --- evennia/accounts/accounts.py | 4 ++++ .../web/website/templates/website/_menu.html | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 2c33e5c1f8..f4711299cf 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -189,6 +189,10 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): @lazy_property def sessions(self): return AccountSessionHandler(self) + + @lazy_property + def characters(self): + return self.db._playable_characters # session-related methods diff --git a/evennia/web/website/templates/website/_menu.html b/evennia/web/website/templates/website/_menu.html index 6a81d04866..ea01404967 100644 --- a/evennia/web/website/templates/website/_menu.html +++ b/evennia/web/website/templates/website/_menu.html @@ -40,8 +40,21 @@ folder and edit it to add/remove links to the menu. {% endblock %} {% block navbar_user %} {% if user.is_authenticated %} -
  • Log Out From 2a8799acbb8e8ee5651718f18280cffa894f7413 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 4 Oct 2018 22:54:48 +0000 Subject: [PATCH 019/187] Stylizes password_change form. --- .../registration/password_change_form.html | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 evennia/web/website/templates/website/registration/password_change_form.html diff --git a/evennia/web/website/templates/website/registration/password_change_form.html b/evennia/web/website/templates/website/registration/password_change_form.html new file mode 100644 index 0000000000..bae8c90962 --- /dev/null +++ b/evennia/web/website/templates/website/registration/password_change_form.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block titleblock %} +Password Change +{% endblock %} + +{% block content %} + +{% load addclass %} +
    +
    +
    +
    +
    +

    Password Change

    +
    + + {% if form.errors %} + {% for field in form %} + {% for error in field.errors %} + + {% endfor %} + {% endfor %} + {% endif %} + +
    + {% csrf_token %} + + {% for field in form %} +
    + {{ field.label_tag }} + {{ field | addclass:"form-control" }} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
    + {% endfor %} + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +{% endblock %} From 3c0f02d66dd21f12194e05d193ecee3403c0b7b1 Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 5 Oct 2018 18:59:55 +0000 Subject: [PATCH 020/187] Implements web-based character creation. --- evennia/web/website/forms.py | 131 +++++++++++++++++- .../web/website/templates/website/_menu.html | 2 +- .../templates/website/chargen_form.html | 51 +++++++ evennia/web/website/urls.py | 3 + evennia/web/website/views.py | 61 +++++++- 5 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 evennia/web/website/templates/website/chargen_form.html diff --git a/evennia/web/website/forms.py b/evennia/web/website/forms.py index cc7677926c..ca93dd2c3b 100644 --- a/evennia/web/website/forms.py +++ b/evennia/web/website/forms.py @@ -2,6 +2,7 @@ from django import forms from django.conf import settings from django.contrib.auth.forms import UserCreationForm, UsernameField from evennia.utils import class_from_module +from random import choice, randint class AccountCreationForm(UserCreationForm): @@ -10,4 +11,132 @@ class AccountCreationForm(UserCreationForm): fields = ("username", "email") field_classes = {'username': UsernameField} - email = forms.EmailField(help_text="A valid email address. Optional; used for password resets.", required=False) \ No newline at end of file + email = forms.EmailField(help_text="A valid email address. Optional; used for password resets.", required=False) + +class CharacterCreationForm(forms.Form): + name = forms.CharField(help_text="The name of your intended character.") + age = forms.IntegerField(min_value=3, max_value=99, help_text="How old your character should be once spawned.") + description = forms.CharField(widget=forms.Textarea(attrs={'rows': 3}), max_length=2048, min_length=160, required=False) + + @classmethod + def assign_attributes(cls, attribute_list, points, min_points, max_points): + """ + Randomly distributes a number of points across the given attributes, + while also ensuring each attribute gets at least a certain amount + and at most a certain amount. + + Args: + attribute_list (iterable): List or tuple of attribute names to assign + points to. + points (int): Starting number of points + min_points (int): Least amount of points each attribute should have + max_points (int): Most amount of points each attribute should have + + Returns: + spread (dict): Dict of attributes and a point assignment. + + """ + num_buckets = len(attribute_list) + point_spread = (x for x in self.random_distribution(points, num_buckets, min_points, max_points)) + + # For each field, get the point calculation for the next attribute value generated + return {attribute: next(point_spread) for k in attribute_list} + + @classmethod + def random_distribution(cls, points, num_buckets, min_points, max_points): + """ + Distributes a set number of points randomly across a number of 'buckets' + while also attempting to ensure each bucket's value finishes within a + certain range. + + If your math doesn't add up (you try to distribute 5 points across 100 + buckets and insist each bucket has at least 20 points), the algorithm + will return the best spread it could achieve but will not raise an error + (so in this case, 5 random buckets would get 1 point each and that's all). + + Args: + points (int): The number of points to distribute. + num_buckets (int): The number of 'buckets' (or stats, skills, etc) + you wish to distribute points to. + min_points (int): The least amount of points each bucket should have. + max_points (int): The most points each bucket should have. + + Returns: + buckets (list): List of random point assignments. + + """ + buckets = [0 for x in range(num_buckets)] + indices = [i for (i, value) in enumerate(buckets)] + + # Do this while we have eligible buckets, points to assign and we haven't + # maxed out all the buckets. + while indices and points and sum(buckets) <= (max_points * num_buckets): + # Pick a random bucket index + index = choice(indices) + + # Add to bucket + buckets[index] = buckets[index] + 1 + points = points - 1 + + # Get the indices of eligible buckets + indices = [i for (i, value) in enumerate(buckets) if (value < min_points) or (value < max_points)] + + return buckets + +class ExtendedCharacterCreationForm(forms.Form): + + GENDERS = ( + ('male', 'Male'), + ('female', 'Female'), + ('androgynous', 'Androgynous'), + ('special', 'Special') + ) + + RACES = ( + ('human', 'Human'), + ('elf', 'Elf'), + ('orc', 'Orc'), + ) + + CLASSES = ( + ('civilian', 'Civilian'), + ('warrior', 'Warrior'), + ('thief', 'Thief'), + ('cleric', 'Cleric') + ) + + PERKS = ( + ('strong', 'Extra strength'), + ('nimble', 'Quick on their toes'), + ('diplomatic', 'Fast talker') + ) + + name = forms.CharField(help_text="The name of your intended character.") + age = forms.IntegerField(min_value=3, max_value=99, help_text="How old your character should be once spawned.") + gender = forms.ChoiceField(choices=GENDERS, help_text="Which end of the multidimensional spectrum does your character most closely align with, in terms of gender?") + race = forms.ChoiceField(choices=RACES, help_text="What race does your character belong to?") + job = forms.ChoiceField(choices=CLASSES, help_text="What profession or role does your character fulfill or is otherwise destined to?") + + perks = forms.MultipleChoiceField(choices=PERKS, help_text="What extraordinary abilities does your character possess?") + description = forms.CharField(widget=forms.Textarea(attrs={'rows': 3}), max_length=2048, min_length=160, required=False) + + strength = forms.IntegerField(min_value=1, max_value=10) + perception = forms.IntegerField(min_value=1, max_value=10) + intelligence = forms.IntegerField(min_value=1, max_value=10) + dexterity = forms.IntegerField(min_value=1, max_value=10) + charisma = forms.IntegerField(min_value=1, max_value=10) + vitality = forms.IntegerField(min_value=1, max_value=10) + magic = forms.IntegerField(min_value=1, max_value=10) + + def __init__(self, *args, **kwargs): + # Do all the normal initizliation stuff that would otherwise be happening + super(ExtendedCharacterCreationForm, self).__init__(*args, **kwargs) + + # Given a pool of points, let's randomly distribute them across attributes. + # First get a list of attributes + attributes = ('strength', 'perception', 'intelligence', 'dexterity', 'charisma', 'vitality', 'magic') + # Distribute a random number of points across them + attrs = self.assign_attributes(attributes, 50, 1, 10) + # Initialize the form with the results of the point distribution + for field in attrs.keys(): + self.initial[field] = attrs[field] \ No newline at end of file diff --git a/evennia/web/website/templates/website/_menu.html b/evennia/web/website/templates/website/_menu.html index ea01404967..432b6e4827 100644 --- a/evennia/web/website/templates/website/_menu.html +++ b/evennia/web/website/templates/website/_menu.html @@ -43,7 +43,7 @@ folder and edit it to add/remove links to the menu.
  • diff --git a/evennia/web/website/templates/website/pagination.html b/evennia/web/website/templates/website/pagination.html new file mode 100644 index 0000000000..e2cb6bed73 --- /dev/null +++ b/evennia/web/website/templates/website/pagination.html @@ -0,0 +1,34 @@ +{% if page_obj %} + +{% endif %} \ No newline at end of file From 4ca72cc9fed119902ed11a15fff5388bf1bab497 Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 5 Oct 2018 21:06:42 +0000 Subject: [PATCH 028/187] Adds template for character management list view. --- .../website/character_manage_list.html | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 evennia/web/website/templates/website/character_manage_list.html diff --git a/evennia/web/website/templates/website/character_manage_list.html b/evennia/web/website/templates/website/character_manage_list.html new file mode 100644 index 0000000000..f4e112fd7a --- /dev/null +++ b/evennia/web/website/templates/website/character_manage_list.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block titleblock %} +Manage Characters +{% endblock %} + +{% block content %} + +{% load addclass %} +
    +
    +
    +
    +
    +

    Manage Characters

    +
    + + {% for object in object_list %} +
    + +
    +

    {{ object.db_date_created }} +
    Delete +
    Edit

    +
    {{ object }} {% if object.subtitle %}{{ object.subtitle }}{% endif %}
    +

    {{ object.db.desc }}

    +
    +
    + {% endfor %} + +
    +
    +
    +
    +
    +{% endblock %} + + From c31a2f079618a5c3eeb3d6dd606dfdbf04bb7dce Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 5 Oct 2018 21:07:07 +0000 Subject: [PATCH 029/187] Removes character update form. --- evennia/web/website/forms.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/evennia/web/website/forms.py b/evennia/web/website/forms.py index 937c6be6e8..8759d681e8 100644 --- a/evennia/web/website/forms.py +++ b/evennia/web/website/forms.py @@ -82,10 +82,6 @@ class CharacterForm(forms.Form): indices = [i for (i, value) in enumerate(buckets) if (value < min_points) or (value < max_points)] return buckets - -class CharacterUpdateForm(CharacterForm): - class Meta: - fields = ('description',) class ExtendedCharacterForm(CharacterForm): From d972251d5c1c49bf6eba81a2187bcb75fdcf44ea Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 5 Oct 2018 21:07:44 +0000 Subject: [PATCH 030/187] Adds character management/deletion views and some other changes. --- evennia/web/website/views.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/evennia/web/website/views.py b/evennia/web/website/views.py index d25c3493ec..0ae996027c 100644 --- a/evennia/web/website/views.py +++ b/evennia/web/website/views.py @@ -8,13 +8,19 @@ templates on the fly. from django.contrib.admin.sites import site from django.conf import settings from django.contrib.auth import authenticate +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.admin.views.decorators import staff_member_required +from django.db.models.functions import Lower from django.shortcuts import render +from django.urls import reverse_lazy +from django.views.generic import View, TemplateView, ListView, DetailView, FormView +from django.views.generic.edit import DeleteView from evennia import SESSION_HANDLER from evennia.objects.models import ObjectDB from evennia.accounts.models import AccountDB from evennia.utils import logger +from evennia.web.website.forms import AccountForm, CharacterForm from django.contrib.auth import login from django.utils.text import slugify @@ -185,23 +191,37 @@ class AccountCreationView(FormView): class CharacterManageView(LoginRequiredMixin, ListView): model = ObjectDB + paginate_by = 10 + template_name = 'website/character_manage_list.html' def get_queryset(self): # Get IDs of characters owned by account ids = [getattr(x, 'id') for x in self.request.user.db._playable_characters] # Return a queryset consisting of those characters - return self.model.filter(id__in=ids) + return self.model.objects.filter(id__in=ids).order_by(Lower('db_key')) class CharacterUpdateView(LoginRequiredMixin, FormView): - form_class = CharacterUpdateForm + form_class = CharacterForm template_name = 'website/generic_form.html' - success_url = '/'#reverse_lazy('character-manage') + success_url = reverse_lazy('manage-characters') + fields = ('description',) + +class CharacterDeleteView(LoginRequiredMixin, ObjectDetailView, DeleteView): + model = ObjectDB + + def get_queryset(self): + # Restrict characters available for deletion to those owned by + # the authenticated account + ids = [getattr(x, 'id') for x in self.request.user.db._playable_characters] + + # Return a queryset consisting of those characters + return self.model.objects.filter(id__in=ids).order_by(Lower('db_key')) class CharacterCreateView(LoginRequiredMixin, FormView): form_class = CharacterForm - template_name = 'website/chargen_form.html' - success_url = '/'#reverse_lazy('character-manage') + template_name = 'website/character_create_form.html' + success_url = reverse_lazy('manage-characters') def form_valid(self, form): # Get account ref From d52ff85a50a77faca113a60fa1b73709782623b1 Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 5 Oct 2018 21:08:00 +0000 Subject: [PATCH 031/187] Adds links to charman views. --- evennia/web/website/templates/website/_menu.html | 2 +- evennia/web/website/urls.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/web/website/templates/website/_menu.html b/evennia/web/website/templates/website/_menu.html index 9874b7ef4a..90eabba4eb 100644 --- a/evennia/web/website/templates/website/_menu.html +++ b/evennia/web/website/templates/website/_menu.html @@ -44,7 +44,7 @@ folder and edit it to add/remove links to the menu.