diff --git a/CHANGELOG.md b/CHANGELOG.md index 4046b5fdcc..50deca4eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,9 @@ without arguments starts a full interactive Python console. required by Django. - Fixes to `spawn`, make updating an existing prototype/object work better. Add `/raw` switch to `spawn` command to extract the raw prototype dict for manual editing. +- `list_to_string` is now `iter_to_string` (but old name still works as legacy alias). It will + now accept any input, including generators and single values. + ## Evennia 0.9 (2018-2019) diff --git a/docs/source/Contribs/Arxcode-installing-help.md b/docs/source/Contribs/Arxcode-installing-help.md index e7fba4fbc2..e8837ae768 100644 --- a/docs/source/Contribs/Arxcode-installing-help.md +++ b/docs/source/Contribs/Arxcode-installing-help.md @@ -31,7 +31,7 @@ important if referring to newer Evennia documentation. If you are new to Evennia it's *highly* recommended that you run through the instructions in full - including initializing and starting a new empty game and connecting to it. That way you can be sure Evennia works correctly as a base line. If you have trouble, make sure to -read the [Troubleshooting instructions](Getting-Started#troubleshooting) for your +read the [Troubleshooting instructions](./Getting-Started#troubleshooting) for your operating system. You can also drop into our [forums](https://groups.google.com/forum/#%21forum/evennia), join `#evennia` on `irc.freenode.net` or chat from the linked [Discord Server](https://discord.gg/NecFePw). diff --git a/docs/source/Setup/Extended-Installation.md b/docs/source/Setup/Extended-Installation.md index c41ac5f0fc..a33aa446ce 100644 --- a/docs/source/Setup/Extended-Installation.md +++ b/docs/source/Setup/Extended-Installation.md @@ -68,10 +68,10 @@ Twisted packages ## Linux Install If you run into any issues during the installation and first start, please -check out [Linux Troubleshooting](Getting-Started#linux-troubleshooting). +check out [Linux Troubleshooting](./Getting-Started#linux-troubleshooting). For Debian-derived systems (like Ubuntu, Mint etc), start a terminal and -install the [dependencies](Getting-Started#requirements): +install the [dependencies](./Getting-Started#requirements): ``` sudo apt-get update @@ -175,7 +175,7 @@ evennia start # (create a superuser when asked. Email is optional.) Your game should now be running! Open a web browser at `http://localhost:4001` or point a telnet client to `localhost:4000` and log in with the user you -created. Check out [where to go next](Getting-Started#where-to-go-next). +created. Check out [where to go next](./Getting-Started#where-to-go-next). ## Mac Install @@ -184,7 +184,7 @@ The Evennia server is a terminal program. Open the terminal e.g. from *Applications->Utilities->Terminal*. [Here is an introduction to the Mac terminal](http://blog.teamtreehouse.com/introduction-to-the-mac-os-x-command-line) if you are unsure how it works. If you run into any issues during the -installation, please check out [Mac Troubleshooting](Getting-Started#mac-troubleshooting). +installation, please check out [Mac Troubleshooting](./Getting-Started#mac-troubleshooting). * Python should already be installed but you must make sure it's a high enough version. ([This](http://docs.python-guide.org/en/latest/starting/install/osx/) discusses @@ -287,13 +287,13 @@ evennia start # (create a superuser when asked. Email is optional.) Your game should now be running! Open a web browser at `http://localhost:4001` or point a telnet client to `localhost:4000` and log in with the user you -created. Check out [where to go next](Getting-Started#where-to-go-next). +created. Check out [where to go next](./Getting-Started#where-to-go-next). ## Windows Install If you run into any issues during the installation, please check out -[Windows Troubleshooting](Getting-Started#windows-troubleshooting). +[Windows Troubleshooting](./Getting-Started#windows-troubleshooting). > If you are running Windows10, consider using the Windows Subsystem for Linux > ([WSL](https://en.wikipedia.org/wiki/Windows_Subsystem_for_Linux)) instead. @@ -428,7 +428,7 @@ evennia start # (create a superuser when asked. Email is optional.) Your game should now be running! Open a web browser at `http://localhost:4001` or point a telnet client to `localhost:4000` and log in with the user you -created. Check out [where to go next](Getting-Started#where-to-go-next). +created. Check out [where to go next](./Getting-Started#where-to-go-next). ## Where to Go Next diff --git a/docs/source/Setup/Installing-on-Android.md b/docs/source/Setup/Installing-on-Android.md index cce67117f0..b686110e64 100644 --- a/docs/source/Setup/Installing-on-Android.md +++ b/docs/source/Setup/Installing-on-Android.md @@ -120,7 +120,7 @@ $ cd ~ && source evenv/bin/activate (evenv) $ evennia start ``` -You may wish to look at the [Linux Instructions](Getting-Started#linux-install) for more. +You may wish to look at the [Linux Instructions](./Getting-Started#linux-install) for more. ## Caveats diff --git a/docs/source/_static/favicon.ico b/docs/source/_static/favicon.ico new file mode 100644 index 0000000000..547a1686d6 Binary files /dev/null and b/docs/source/_static/favicon.ico differ diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index c765c83a5a..73741131d3 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -651,6 +651,51 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): logger.log_sec(f"Password successfully changed for {self}.") self.at_password_change() + def create_character(self, *args, **kwargs): + """ + Create a character linked to this account. + + Args: + key (str, optional): If not given, use the same name as the account. + typeclass (str, optional): Typeclass to use for this character. If + not given, use settings.BASE_CHARACTER_TYPECLASS. + permissions (list, optional): If not given, use the account's permissions. + ip (str, optiona): The client IP creating this character. Will fall back to the + one stored for the account if not given. + kwargs (any): Other kwargs will be used in the create_call. + Returns: + Object: A new character of the `character_typeclass` type. None on an error. + list or None: A list of errors, or None. + + """ + # parse inputs + character_key = kwargs.pop("key", self.key) + character_ip = kwargs.pop("ip", self.db.creator_ip) + character_permissions = kwargs.pop("permissions", self.permissions) + + # Load the appropriate Character class + character_typeclass = kwargs.pop("typeclass", None) + character_typeclass = character_typeclass if character_typeclass else settings.BASE_CHARACTER_TYPECLASS + Character = class_from_module(character_typeclass) + + # Create the character + character, errs = Character.create( + character_key, + self, + ip=character_ip, + typeclass=character_typeclass, + permissions=character_permissions, + **kwargs + ) + if character: + # Update playable character list + if character not in self.characters: + self.db._playable_characters.append(character) + + # We need to set this to have @ic auto-connect to this character + self.db._last_puppet = character + return character, errs + @classmethod def create(cls, *args, **kwargs): """ @@ -759,31 +804,11 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): 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) + # Auto-create a character to go with this account - # 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 + character, errs = account.create_character(typeclass=kwargs.get("character_typeclass")) + if errs: + errors.extend(errs) except Exception: # We are in the middle between logged in and -not, so we have @@ -1548,7 +1573,7 @@ class DefaultGuest(DefaultAccount): try: # Find an available guest name. for name in settings.GUEST_LIST: - if not AccountDB.objects.filter(username__iexact=name).count(): + if not AccountDB.objects.filter(username__iexact=name).exists(): username = name break if not username: @@ -1574,6 +1599,15 @@ class DefaultGuest(DefaultAccount): ip=ip, ) errors.extend(errs) + + if not account.characters: + # this can happen for multisession_mode > 1. For guests we + # always auto-create a character, regardless of multi-session-mode. + character, errs = account.create_character() + + if errs: + errors.extend(errs) + return account, errors except Exception as e: diff --git a/evennia/accounts/admin.py b/evennia/accounts/admin.py index 9e245068db..327d2b4eb2 100644 --- a/evennia/accounts/admin.py +++ b/evennia/accounts/admin.py @@ -4,13 +4,26 @@ # from django import forms from django.conf import settings -from django.contrib import admin +from django.contrib import admin, messages +from django.contrib.admin.options import IS_POPUP_VAR from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.forms import UserChangeForm, UserCreationForm +from django.contrib.admin.utils import unquote +from django.template.response import TemplateResponse +from django.http import Http404, HttpResponseRedirect +from django.core.exceptions import PermissionDenied +from django.views.decorators.debug import sensitive_post_parameters +from django.utils.decorators import method_decorator +from django.utils.html import escape +from django.urls import path, reverse +from django.contrib.auth import update_session_auth_hash + from evennia.accounts.models import AccountDB from evennia.typeclasses.admin import AttributeInline, TagInline from evennia.utils import create +sensitive_post_parameters_m = method_decorator(sensitive_post_parameters()) + # handle the custom User editor class AccountDBChangeForm(UserChangeForm): @@ -88,6 +101,7 @@ class AccountForm(forms.ModelForm): class Meta(object): model = AccountDB fields = "__all__" + app_label = "accounts" db_key = forms.RegexField( label="Username", @@ -259,6 +273,71 @@ class AccountDBAdmin(BaseUserAdmin): ), ) + @sensitive_post_parameters_m + def user_change_password(self, request, id, form_url=''): + user = self.get_object(request, unquote(id)) + if not self.has_change_permission(request, user): + raise PermissionDenied + if user is None: + raise Http404('%(name)s object with primary key %(key)r does not exist.') % { + 'name': self.model._meta.verbose_name, + 'key': escape(id), + } + if request.method == 'POST': + form = self.change_password_form(user, request.POST) + if form.is_valid(): + form.save() + change_message = self.construct_change_message(request, form, None) + self.log_change(request, user, change_message) + msg = 'Password changed successfully.' + messages.success(request, msg) + update_session_auth_hash(request, form.user) + return HttpResponseRedirect( + reverse( + '%s:%s_%s_change' % ( + self.admin_site.name, + user._meta.app_label, + # the model_name is something we need to hardcode + # since our accountdb is a proxy: + "accountdb", + ), + args=(user.pk,), + ) + ) + else: + form = self.change_password_form(user) + + fieldsets = [(None, {'fields': list(form.base_fields)})] + adminForm = admin.helpers.AdminForm(form, fieldsets, {}) + + context = { + 'title': 'Change password: %s' % escape(user.get_username()), + 'adminForm': adminForm, + 'form_url': form_url, + 'form': form, + 'is_popup': (IS_POPUP_VAR in request.POST or + IS_POPUP_VAR in request.GET), + 'add': True, + 'change': False, + 'has_delete_permission': False, + 'has_change_permission': True, + 'has_absolute_url': False, + 'opts': self.model._meta, + 'original': user, + 'save_as': False, + 'show_save': True, + **self.admin_site.each_context(request), + } + + request.current_app = self.admin_site.name + + return TemplateResponse( + request, + self.change_user_password_template or + 'admin/auth/user/change_password.html', + context, + ) + def save_model(self, request, obj, form, change): """ Custom save actions. diff --git a/evennia/accounts/models.py b/evennia/accounts/models.py index 53ba05ea78..18f2dd9bf0 100644 --- a/evennia/accounts/models.py +++ b/evennia/accounts/models.py @@ -109,8 +109,8 @@ class AccountDB(TypedObject, AbstractUser): __applabel__ = "accounts" __settingsclasspath__ = settings.BASE_SCRIPT_TYPECLASS - class Meta(object): - verbose_name = "Account" + # class Meta: + # verbose_name = "Account" # cmdset_storage property # This seems very sensitive to caching, so leaving it be for now /Griatch diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index 41337433d2..009dac0c67 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -472,13 +472,13 @@ def get_and_merge_cmdsets(caller, session, account, obj, callertype, raw_string) tempmergers[prio] = cmdset # sort cmdsets after reverse priority (highest prio are merged in last) - cmdsets = yield sorted(list(tempmergers.values()), key=lambda x: x.priority) + sorted_cmdsets = yield sorted(list(tempmergers.values()), key=lambda x: x.priority) # Merge all command sets into one, beginning with the lowest-prio one - cmdset = cmdsets[0] - for merging_cmdset in cmdsets[1:]: + cmdset = sorted_cmdsets[0] + for merging_cmdset in sorted_cmdsets[1:]: cmdset = yield cmdset + merging_cmdset - # store the full sets for diagnosis + # store the original, ungrouped set for diagnosis cmdset.merged_from = cmdsets # cache _CMDSET_MERGE_CACHE[mergehash] = cmdset diff --git a/evennia/commands/cmdset.py b/evennia/commands/cmdset.py index f124bbbb06..f296982905 100644 --- a/evennia/commands/cmdset.py +++ b/evennia/commands/cmdset.py @@ -443,12 +443,12 @@ class CmdSet(object, metaclass=_CmdSetMeta): # print "__add__ for %s (prio %i) called with %s (prio %i)." % (self.key, self.priority, cmdset_a.key, cmdset_a.priority) # return the system commands to the cmdset - cmdset_c.add(sys_commands) + cmdset_c.add(sys_commands, allow_duplicates=True) return cmdset_c - def add(self, cmd): + def add(self, cmd, allow_duplicates=False): """ - Add a new command or commands to this CmdSetcommand, a list of + Add a new command or commands to this CmdSet, a list of commands or a cmdset to this cmdset. Note that this is *not* a merge operation (that is handled by the + operator). @@ -456,6 +456,9 @@ class CmdSet(object, metaclass=_CmdSetMeta): cmd (Command, list, Cmdset): This allows for adding one or more commands to this Cmdset in one go. If another Cmdset is given, all its commands will be added. + allow_duplicates (bool, optional): If set, will not try to remove + duplicate cmds in the set. This is needed during the merge process + to avoid wiping commands coming from cmdsets with duplicate=True. Notes: If cmd already exists in set, it will replace the old one @@ -498,8 +501,10 @@ class CmdSet(object, metaclass=_CmdSetMeta): commands[ic] = cmd # replace except ValueError: commands.append(cmd) - # extra run to make sure to avoid doublets - self.commands = list(set(commands)) + self.commands = commands + if not allow_duplicates: + # extra run to make sure to avoid doublets + self.commands = list(set(self.commands)) # add system_command to separate list as well, # for quick look-up if cmd.key.startswith("__"): diff --git a/evennia/commands/default/batchprocess.py b/evennia/commands/default/batchprocess.py index de3c0819d1..f2e00be9ae 100644 --- a/evennia/commands/default/batchprocess.py +++ b/evennia/commands/default/batchprocess.py @@ -50,35 +50,6 @@ _UTF8_ERROR = """ Error reported was: '%s' """ -_PROCPOOL_BATCHCMD_SOURCE = """ -from evennia.commands.default.batchprocess import batch_cmd_exec, step_pointer, BatchSafeCmdSet -caller.ndb.batch_stack = commands -caller.ndb.batch_stackptr = 0 -caller.ndb.batch_batchmode = "batch_commands" -caller.cmdset.add(BatchSafeCmdSet) -for inum in range(len(commands)): - print "command:", inum - caller.cmdset.add(BatchSafeCmdSet) - if not batch_cmd_exec(caller): - break - step_pointer(caller, 1) -print "leaving run ..." -""" -_PROCPOOL_BATCHCODE_SOURCE = """ -from evennia.commands.default.batchprocess import batch_code_exec, step_pointer, BatchSafeCmdSet -caller.ndb.batch_stack = codes -caller.ndb.batch_stackptr = 0 -caller.ndb.batch_batchmode = "batch_code" -caller.cmdset.add(BatchSafeCmdSet) -for inum in range(len(codes)): - print "code:", inum - caller.cmdset.add(BatchSafeCmdSet) - if not batch_code_exec(caller): - break - step_pointer(caller, 1) -print "leaving run ..." -""" - # ------------------------------------------------------------- # Helper functions @@ -300,42 +271,17 @@ class CmdBatchCommands(_COMMAND_DEFAULT_CLASS): "for %s (this might take some time) ..." % python_path ) - procpool = False - if "PythonProcPool" in utils.server_services(): - if utils.uses_database("sqlite3"): - caller.msg("Batchprocessor disabled ProcPool under SQLite3.") - else: - procpool = True - - if procpool: - # run in parallel process - def callback(r): - caller.msg(" |GBatchfile '%s' applied." % python_path) - purge_processor(caller) - - def errback(e): - caller.msg(" |RError from processor: '%s'" % e) - purge_processor(caller) - - utils.run_async( - _PROCPOOL_BATCHCMD_SOURCE, - commands=commands, - caller=caller, - at_return=callback, - at_err=errback, - ) - else: - # run in-process (might block) - for _ in range(len(commands)): - # loop through the batch file - if not batch_cmd_exec(caller): - return - step_pointer(caller, 1) - # clean out the safety cmdset and clean out all other - # temporary attrs. - string = " Batchfile '%s' applied." % python_path - caller.msg("|G%s" % string) - purge_processor(caller) + # run in-process (might block) + for _ in range(len(commands)): + # loop through the batch file + if not batch_cmd_exec(caller): + return + step_pointer(caller, 1) + # clean out the safety cmdset and clean out all other + # temporary attrs. + string = " Batchfile '%s' applied." % python_path + caller.msg("|G%s" % string) + purge_processor(caller) class CmdBatchCode(_COMMAND_DEFAULT_CLASS): @@ -420,41 +366,16 @@ class CmdBatchCode(_COMMAND_DEFAULT_CLASS): else: caller.msg("Running Batch-code processor - Automatic mode for %s ..." % python_path) - procpool = False - if "PythonProcPool" in utils.server_services(): - if utils.uses_database("sqlite3"): - caller.msg("Batchprocessor disabled ProcPool under SQLite3.") - else: - procpool = True - if procpool: - # run in parallel process - def callback(r): - caller.msg(" |GBatchfile '%s' applied." % python_path) - purge_processor(caller) - - def errback(e): - caller.msg(" |RError from processor: '%s'" % e) - purge_processor(caller) - - utils.run_async( - _PROCPOOL_BATCHCODE_SOURCE, - codes=codes, - caller=caller, - at_return=callback, - at_err=errback, - ) - else: - # un in-process (will block) - for _ in range(len(codes)): - # loop through the batch file - if not batch_code_exec(caller): - return - step_pointer(caller, 1) - # clean out the safety cmdset and clean out all other - # temporary attrs. - string = " Batchfile '%s' applied." % python_path - caller.msg("|G%s" % string) - purge_processor(caller) + for _ in range(len(codes)): + # loop through the batch file + if not batch_code_exec(caller): + return + step_pointer(caller, 1) + # clean out the safety cmdset and clean out all other + # temporary attrs. + string = " Batchfile '%s' applied." % python_path + caller.msg("|G%s" % string) + purge_processor(caller) # ------------------------------------------------------------- diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index ace7b7a7b7..65016bb23d 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -1407,6 +1407,7 @@ class CmdOpen(ObjManipCommand): locks = "cmd:perm(open) or perm(Builder)" help_category = "Building" + new_obj_lockstring = "control:id({id}) or perm(Admin);delete:id({id}) or perm(Admin)" # a custom member method to chug out exits and do checks def create_exit(self, exit_name, location, destination, exit_aliases=None, typeclass=None): """ @@ -1452,10 +1453,11 @@ class CmdOpen(ObjManipCommand): else: # exit does not exist before. Create a new one. + lockstring = self.new_obj_lockstring.format(id=caller.id) if not typeclass: typeclass = settings.BASE_EXIT_TYPECLASS exit_obj = create.create_object( - typeclass, key=exit_name, location=location, aliases=exit_aliases, report_to=caller + typeclass, key=exit_name, location=location, aliases=exit_aliases, locks=lockstring, report_to=caller ) if exit_obj: # storing a destination is what makes it an exit! diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 9ed9a4129b..cf3c8f9053 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -17,7 +17,7 @@ import datetime from anything import Anything from django.conf import settings -from mock import Mock, mock +from unittest.mock import patch, Mock, MagicMock from evennia import DefaultRoom, DefaultExit, ObjectDB from evennia.commands.default.cmdset_character import CharacterCmdSet @@ -56,6 +56,7 @@ _RE = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE) # ------------------------------------------------------------ +@patch("evennia.server.portal.portal.LoopingCall", new=MagicMock()) class CommandTest(EvenniaTest): """ Tests a command @@ -518,7 +519,7 @@ class TestBuilding(CommandTest): self.call(building.CmdSetAttribute(), "Obj2/test2", "Attribute Obj2/test2 = value2") self.call(building.CmdSetAttribute(), "Obj2/NotFound", "Obj2 has no attribute 'notfound'.") - with mock.patch("evennia.commands.default.building.EvEditor") as mock_ed: + with patch("evennia.commands.default.building.EvEditor") as mock_ed: self.call(building.CmdSetAttribute(), "/edit Obj2/test3") mock_ed.assert_called_with(self.char1, Anything, Anything, key="Obj2/test3") @@ -802,7 +803,7 @@ class TestBuilding(CommandTest): ) self.call(building.CmdDesc(), "", "Usage: ") - with mock.patch("evennia.commands.default.building.EvEditor") as mock_ed: + with patch("evennia.commands.default.building.EvEditor") as mock_ed: self.call(building.CmdDesc(), "/edit") mock_ed.assert_called_with( self.char1, @@ -1017,9 +1018,9 @@ class TestBuilding(CommandTest): } ) ] - with mock.patch( + with patch( "evennia.commands.default.building.protlib.search_prototype", - new=mock.MagicMock(return_value=test_prototype), + new=MagicMock(return_value=test_prototype), ) as mprot: self.call( building.CmdTypeclass(), @@ -1085,7 +1086,7 @@ class TestBuilding(CommandTest): self.call(building.CmdFind(), "/exact Obj", "One Match") # Test multitype filtering - with mock.patch( + with patch( "evennia.commands.default.building.CHAR_TYPECLASS", "evennia.objects.objects.DefaultCharacter", ): @@ -1553,11 +1554,11 @@ class TestSystemCommands(CommandTest): self.call(multimatch, "look", "") - @mock.patch("evennia.commands.default.syscommands.ChannelDB") + @patch("evennia.commands.default.syscommands.ChannelDB") def test_channelcommand(self, mock_channeldb): - channel = mock.MagicMock() - channel.msg = mock.MagicMock() - mock_channeldb.objects.get_channel = mock.MagicMock(return_value=channel) + channel = MagicMock() + channel.msg = MagicMock() + mock_channeldb.objects.get_channel = MagicMock(return_value=channel) self.call(syscommands.SystemSendToChannel(), "public:Hello") channel.msg.assert_called() diff --git a/evennia/locks/lockfuncs.py b/evennia/locks/lockfuncs.py index eda4c2d733..4b23aae515 100644 --- a/evennia/locks/lockfuncs.py +++ b/evennia/locks/lockfuncs.py @@ -539,7 +539,9 @@ def objtag(accessing_obj, accessed_obj, *args, **kwargs): Only true if accessed_obj has the specified tag and optional category. """ - return bool(accessed_obj.tags.get(*args)) + tagkey = args[0] if args else None + category = args[1] if len(args) > 1 else None + return bool(accessed_obj.tags.get(tagkey, category=category)) def inside(accessing_obj, accessed_obj, *args, **kwargs): diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index d2385a0aa7..0c5132d796 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -236,7 +236,7 @@ class LockHandler(object): elist.append(_("Lock: lock-function '%s' is not available.") % funcstring) continue args = list(arg.strip() for arg in rest.split(",") if arg and "=" not in arg) - kwargs = dict([arg.split("=", 1) for arg in rest.split(",") if arg and "=" in arg]) + kwargs = dict([(part.strip() for part in arg.split("=", 1)) for arg in rest.split(",") if arg and "=" in arg]) lock_funcs.append((func, args, kwargs)) evalstring = evalstring.replace(funcstring, "%s") if len(lock_funcs) < nfuncs: diff --git a/evennia/objects/models.py b/evennia/objects/models.py index 8b42832687..5c36452880 100644 --- a/evennia/objects/models.py +++ b/evennia/objects/models.py @@ -25,7 +25,7 @@ from evennia.utils import logger from evennia.utils.utils import make_iter, dbref, lazy_property -class ContentsHandler(object): +class ContentsHandler: """ Handles and caches the contents of an object to avoid excessive lookups (this is done very often due to cmdhandler needing to look diff --git a/evennia/scripts/manager.py b/evennia/scripts/manager.py index 3080c56162..a18ffc40a5 100644 --- a/evennia/scripts/manager.py +++ b/evennia/scripts/manager.py @@ -73,7 +73,7 @@ class ScriptDBManager(TypedObjectManager): Get all scripts in the database. Args: - key (str, optional): Restrict result to only those + key (str or int, optional): Restrict result to only those with matching key or dbref. Returns: @@ -83,12 +83,9 @@ class ScriptDBManager(TypedObjectManager): if key: script = [] dbref = self.dbref(key) - if dbref or dbref == 0: - # return either [] or a valid list (never [None]) - script = [res for res in [self.dbref_search(dbref)] if res] - if not script: - script = self.filter(db_key=key) - return script + if dbref: + return self.filter(id=dbref) + return self.filter(db_key__iexact=key.strip()) return self.all() def delete_script(self, dbref): @@ -231,7 +228,7 @@ class ScriptDBManager(TypedObjectManager): ostring = ostring.strip() dbref = self.dbref(ostring) - if dbref or dbref == 0: + if dbref: # this is a dbref, try to find the script directly dbref_match = self.dbref_search(dbref) if dbref_match and not ( diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index d883671d7f..688282bdde 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -13,6 +13,7 @@ import time from os.path import dirname, abspath from twisted.application import internet, service +from twisted.internet.task import LoopingCall from twisted.internet import protocol, reactor from twisted.python.log import ILogObserver @@ -20,6 +21,7 @@ import django django.setup() from django.conf import settings +from django.db import connection import evennia @@ -101,10 +103,29 @@ except ImportError: WEB_PLUGINS_MODULE = None INFO_DICT["errors"] = ( "WARNING: settings.WEB_PLUGINS_MODULE not found - " - "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf." + "copy 'evennia/game_template/server/conf/web_plugins.py to " + "mygame/server/conf." ) +_MAINTENANCE_COUNT = 0 + + +def _portal_maintenance(): + """ + Repeated maintenance tasks for the portal. + + """ + global _MAINTENANCE_COUNT + + _MAINTENANCE_COUNT += 1 + + if _MAINTENANCE_COUNT % (3600 * 7) == 0: + # drop database connection every 7 hrs to avoid default timeouts on MySQL + # (see https://github.com/evennia/evennia/issues/1376) + connection.close() + + # ------------------------------------------------------------- # Portal Service object # ------------------------------------------------------------- @@ -143,6 +164,9 @@ class Portal(object): self.start_time = time.time() + self.maintenance_task = LoopingCall(_portal_maintenance) + self.maintenance_task.start(60, now=True) # call every minute + # in non-interactive portal mode, this gets overwritten by # cmdline sent by the evennia launcher self.server_twistd_cmd = self._get_backup_server_twistd_cmd() diff --git a/evennia/server/portal/webclient.py b/evennia/server/portal/webclient.py index dff0592b5c..d1097e17d8 100644 --- a/evennia/server/portal/webclient.py +++ b/evennia/server/portal/webclient.py @@ -249,6 +249,8 @@ class WebSocketClient(WebSocketServerProtocol, _BASE_SESSION_CLASS): return else: return + # just to be sure + text = to_str(text) flags = self.protocol_flags diff --git a/evennia/server/tests/test_amp_connection.py b/evennia/server/tests/test_amp_connection.py index 8f41150866..2c09eb48aa 100644 --- a/evennia/server/tests/test_amp_connection.py +++ b/evennia/server/tests/test_amp_connection.py @@ -6,7 +6,7 @@ Test AMP client import pickle from model_mommy import mommy from unittest import TestCase -from mock import MagicMock, patch +from unittest.mock import MagicMock, patch from twisted.trial.unittest import TestCase as TwistedTestCase from evennia.server import amp_client from evennia.server.portal import amp_server @@ -36,6 +36,7 @@ class _TestAMP(TwistedTestCase): self.server.sessions[1] = self.session self.portal = portal.Portal(MagicMock()) + self.portal.maintenance_task.stop() self.portalsession = session.Session() self.portalsession.sessid = 1 self.portal.sessions[1] = self.portalsession diff --git a/evennia/server/tests/testrunner.py b/evennia/server/tests/testrunner.py index 63bbad49e0..c2eb334a7d 100644 --- a/evennia/server/tests/testrunner.py +++ b/evennia/server/tests/testrunner.py @@ -6,6 +6,7 @@ Runs as part of the Evennia's test suite with 'evennia test evennia" """ from django.test.runner import DiscoverRunner +from unittest import mock class EvenniaTestSuiteRunner(DiscoverRunner): @@ -21,9 +22,16 @@ class EvenniaTestSuiteRunner(DiscoverRunner): Build a test suite for Evennia. test_labels is a list of apps to test. If not given, a subset of settings.INSTALLED_APPS will be used. """ + # the portal looping call starts before the unit-test suite so we + # can't mock it - instead we stop it before starting the test - otherwise + # we'd get unclean reactor errors across test boundaries. + from evennia.server.portal.portal import PORTAL + PORTAL.maintenance_task.stop() + import evennia evennia._init() return super(EvenniaTestSuiteRunner, self).build_suite( test_labels, extra_tests=extra_tests, **kwargs ) + diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 4f8dc5116e..ff3c15c886 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -110,10 +110,38 @@ class TypeclassBase(SharedMemoryModelBase): attrs["typename"] = name attrs["path"] = "%s.%s" % (attrs["__module__"], name) - # typeclass proxy setup - if "Meta" not in attrs: + def _get_dbmodel(bases): + """Recursively get the dbmodel""" + if not hasattr(bases, "__iter__"): + bases = [bases] + for base in bases: + try: + if base._meta.proxy or base._meta.abstract: + for kls in base._meta.parents: + return _get_dbmodel(kls) + except AttributeError: + # this happens if trying to parse a non-typeclass mixin parent, + # without a _meta + continue + else: + return base + return None - class Meta(object): + dbmodel = _get_dbmodel(bases) + + if not dbmodel: + raise TypeError(f"{name} does not appear to inherit from a database model.") + + + # typeclass proxy setup + # first check explicit __applabel__ on the typeclass, then figure + # it out from the dbmodel + if "__applabel__" not in attrs: + # find the app-label in one of the bases, usually the dbmodel + attrs["__applabel__"] = dbmodel._meta.app_label + + if "Meta" not in attrs: + class Meta: proxy = True app_label = attrs.get("__applabel__", "typeclasses") @@ -122,9 +150,20 @@ class TypeclassBase(SharedMemoryModelBase): new_class = ModelBase.__new__(cls, name, bases, attrs) + # django doesn't support inheriting proxy models so we hack support for + # it here by injecting `proxy_for_model` to the actual dbmodel. + # Unfortunately we cannot also set the correct model_name, because this + # would block multiple-inheritance of typeclasses (Django doesn't allow + # multiple bases of the same model). + if dbmodel: + new_class._meta.proxy_for_model = dbmodel + # Maybe Django will eventually handle this in the future: + # new_class._meta.model_name = dbmodel._meta.model_name + # attach signals signals.post_save.connect(call_at_first_save, sender=new_class) - signals.pre_delete.connect(remove_attributes_on_delete, sender=new_class) + signals.pre_delete.connect( + remove_attributes_on_delete, sender=new_class) return new_class diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 20d5b0db15..aaf6641320 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -9,6 +9,7 @@ be of use when designing your own game. import os import gc import sys +import copy import types import math import re @@ -29,6 +30,8 @@ from django.conf import settings from django.utils import timezone from django.utils.translation import gettext as _ from django.apps import apps +from django.core.validators import validate_email as django_validate_email +from django.core.exceptions import ValidationError as DjangoValidationError from evennia.utils import logger _MULTIMATCH_TEMPLATE = settings.SEARCH_MULTIMATCH_TEMPLATE @@ -340,14 +343,16 @@ def columnize(string, columns=2, spacing=4, align="l", width=None): return "\n".join(rows) -def list_to_string(inlist, endsep="and", addquote=False): +def iter_to_string(initer, endsep="and", addquote=False): """ - This pretty-formats a list as string output, adding an optional + This pretty-formats an iterable list as string output, adding an optional alternative separator to the second to last entry. If `addquote` is `True`, the outgoing strings will be surrounded by quotes. Args: - inlist (list): The list to print. + initer (any): Usually an iterable to print. Each element must be possible to + present with a string. Note that if this is a generator, it will be + consumed by this operation. endsep (str, optional): If set, the last item separator will be replaced with this value. addquote (bool, optional): This will surround all outgoing @@ -372,16 +377,20 @@ def list_to_string(inlist, endsep="and", addquote=False): endsep = "," else: endsep = " " + endsep - if not inlist: + if not initer: return "" + initer = tuple(str(val) for val in make_iter(initer)) if addquote: - if len(inlist) == 1: - return '"%s"' % inlist[0] - return ", ".join('"%s"' % v for v in inlist[:-1]) + "%s %s" % (endsep, '"%s"' % inlist[-1]) + if len(initer) == 1: + return '"%s"' % initer[0] + return ", ".join('"%s"' % v for v in initer[:-1]) + "%s %s" % (endsep, '"%s"' % initer[-1]) else: - if len(inlist) == 1: - return str(inlist[0]) - return ", ".join(str(v) for v in inlist[:-1]) + "%s %s" % (endsep, inlist[-1]) + if len(initer) == 1: + return str(initer[0]) + return ", ".join(str(v) for v in initer[:-1]) + "%s %s" % (endsep, initer[-1]) + +# legacy alias +list_to_string = iter_to_string def wildcard_to_regexp(instring): @@ -906,69 +915,25 @@ def to_str(text, session=None): def validate_email_address(emailaddress): """ - Checks if an email address is syntactically correct. + Checks if an email address is syntactically correct. Makes use + of the django email-validator for consistency. Args: emailaddress (str): Email address to validate. Returns: - is_valid (bool): If this is a valid email or not. - - Notes. - (This snippet was adapted from - http://commandline.org.uk/python/email-syntax-check.) + bool: If this is a valid email or not. """ - - emailaddress = r"%s" % emailaddress - - domains = ( - "aero", - "asia", - "biz", - "cat", - "com", - "coop", - "edu", - "gov", - "info", - "int", - "jobs", - "mil", - "mobi", - "museum", - "name", - "net", - "org", - "pro", - "tel", - "travel", - ) - - # Email address must be more than 7 characters in total. - if len(emailaddress) < 7: - return False # Address too short. - - # Split up email address into parts. try: - localpart, domainname = emailaddress.rsplit("@", 1) - host, toplevel = domainname.rsplit(".", 1) - except ValueError: - return False # Address does not have enough parts. - - # Check for Country code or Generic Domain. - if len(toplevel) != 2 and toplevel not in domains: - return False # Not a domain name. - - for i in "-_.%+.": - localpart = localpart.replace(i, "") - for i in "-_.": - host = host.replace(i, "") - - if localpart.isalnum() and host.isalnum(): - return True # Email address is fine. + django_validate_email(str(emailaddress)) + except DjangoValidationError: + return False + except Exception: + logger.log_trace() + return False else: - return False # Email address has funny characters. + return True def inherits_from(obj, parent): diff --git a/evennia/utils/validatorfuncs.py b/evennia/utils/validatorfuncs.py index dd80e6bfdb..29f747967f 100644 --- a/evennia/utils/validatorfuncs.py +++ b/evennia/utils/validatorfuncs.py @@ -11,10 +11,8 @@ They can employ more paramters at your leisure. import re as _re import pytz as _pytz import datetime as _dt -from django.core.exceptions import ValidationError as _error -from django.core.validators import validate_email as _val_email from evennia.utils.ansi import strip_ansi -from evennia.utils.utils import string_partial_matching as _partial +from evennia.utils.utils import string_partial_matching as _partial, validate_email_address from django.utils.translation import gettext as _ _TZ_DICT = {str(tz): _pytz.timezone(tz) for tz in _pytz.common_timezones} @@ -214,9 +212,8 @@ def timezone(entry, option_key="Timezone", **kwargs): def email(entry, option_key="Email Address", **kwargs): if not entry: raise ValueError("Email address field empty!") - try: - _val_email(str(entry)) # offloading the hard work to Django! - except _error: + valid = validate_email_address(entry) + if not valid: raise ValueError(f"That isn't a valid {option_key}!") return entry