diff --git a/.travis.yml b/.travis.yml index d97c609759..275b39a119 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,49 @@ +dist: xenial language: python +cache: pip + +services: + - postgresql + - mysql + python: - - "2.7" -sudo: false -install: - - pip install -e . + - "3.7" + +env: + - TESTING_DB=sqlite3 + - TESTING_DB=postgresql + - TESTING_DB=mysql + +before_install: + + # - psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE evennia TO evennia;" + - psql --version + - psql -U postgres -c "CREATE DATABASE evennia;" + - psql -U postgres -c "CREATE USER evennia WITH PASSWORD 'password';" + - psql -U postgres -c "ALTER USER evennia CREATEDB;" + - mysql --version + - mysql -u root -e "CREATE DATABASE evennia CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + - mysql -u root -e "CREATE USER 'evennia'@'localhost' IDENTIFIED BY 'password';" + - mysql -u root -e "GRANT ALL ON *.* TO 'evennia'@'localhost' IDENTIFIED BY 'password';" + +install: + - pip install psycopg2-binary + - pip install mysqlclient - pip install coveralls + - pip install codacy-coverage + - pip install -e . + before_script: - - evennia --init dummy - - cd dummy + - evennia --init testing_mygame + - cp .travis/${TESTING_DB}_settings.py testing_mygame/server/conf/settings.py + - cd testing_mygame - evennia migrate + - evennia collectstatic --noinput + script: - - coverage run --source=../evennia --omit=*/migrations/*,*/urls.py,*/test*.py,*.sh,*.txt,*.md,*.pyc,*.service ../bin/unix/evennia test evennia + - coverage run --source=../evennia --omit=*/migrations/*,*/urls.py,*/test*.py,*.sh,*.txt,*.md,*.pyc,*.service ../bin/unix/evennia test --settings=settings --keepdb evennia + after_success: - coveralls + - coverage xml + - python-codacy-coverage -r coverage.xml diff --git a/.travis/my.conf b/.travis/my.conf new file mode 100644 index 0000000000..51b810ecc7 --- /dev/null +++ b/.travis/my.conf @@ -0,0 +1 @@ +init_connect='SET collation_connection = utf8_general_ci; SET NAMES utf8;' diff --git a/.travis/mysql_settings.py b/.travis/mysql_settings.py new file mode 100644 index 0000000000..b0abfb8519 --- /dev/null +++ b/.travis/mysql_settings.py @@ -0,0 +1,71 @@ +""" +Evennia settings file. + +The available options are found in the default settings file found +here: + +/home/griatch/Devel/Home/evennia/evennia/evennia/settings_default.py + +Remember: + +Don't copy more from the default file than you actually intend to +change; this will make sure that you don't overload upstream updates +unnecessarily. + +When changing a setting requiring a file system path (like +path/to/actual/file.py), use GAME_DIR and EVENNIA_DIR to reference +your game folder and the Evennia library folders respectively. Python +paths (path.to.module) should be given relative to the game's root +folder (typeclasses.foo) whereas paths within the Evennia library +needs to be given explicitly (evennia.foo). + +If you want to share your game dir, including its settings, you can +put secret game- or server-specific settings in secret_settings.py. + +""" + +import os + +# Use the defaults from Evennia unless explicitly overridden +from evennia.settings_default import * + +###################################################################### +# Evennia base server config +###################################################################### + +# This is the name of your game. Make it catchy! +SERVERNAME = "testing_mygame" + +# Testing database types + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'evennia', + 'USER': 'evennia', + 'PASSWORD': 'password', + 'HOST': 'localhost', + 'PORT': '', # use default port + 'OPTIONS': { + 'charset': 'utf8mb4', + 'init_command': 'set collation_connection=utf8mb4_unicode_ci' + }, + 'TEST': { + 'NAME': 'default', + 'OPTIONS': { + 'charset': 'utf8mb4', + # 'init_command': 'set collation_connection=utf8mb4_unicode_ci' + 'init_command': "SET NAMES 'utf8mb4'" + } + } + } +} + + +###################################################################### +# Settings given in secret_settings.py override those in this file. +###################################################################### +try: + from server.conf.secret_settings import * +except ImportError: + print("secret_settings.py file not found or failed to import.") diff --git a/.travis/postgresql_settings.py b/.travis/postgresql_settings.py new file mode 100644 index 0000000000..e65737699e --- /dev/null +++ b/.travis/postgresql_settings.py @@ -0,0 +1,62 @@ +""" +Evennia settings file. + +The available options are found in the default settings file found +here: + +/home/griatch/Devel/Home/evennia/evennia/evennia/settings_default.py + +Remember: + +Don't copy more from the default file than you actually intend to +change; this will make sure that you don't overload upstream updates +unnecessarily. + +When changing a setting requiring a file system path (like +path/to/actual/file.py), use GAME_DIR and EVENNIA_DIR to reference +your game folder and the Evennia library folders respectively. Python +paths (path.to.module) should be given relative to the game's root +folder (typeclasses.foo) whereas paths within the Evennia library +needs to be given explicitly (evennia.foo). + +If you want to share your game dir, including its settings, you can +put secret game- or server-specific settings in secret_settings.py. + +""" + +import os + +# Use the defaults from Evennia unless explicitly overridden +from evennia.settings_default import * + +###################################################################### +# Evennia base server config +###################################################################### + +# This is the name of your game. Make it catchy! +SERVERNAME = "testing_mygame" + +# Testing database types + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': 'evennia', + 'USER': 'evennia', + 'PASSWORD': 'password', + 'HOST': 'localhost', + 'PORT': '', # use default + 'TEST': { + 'NAME': 'default' + } + } +} + + +###################################################################### +# Settings given in secret_settings.py override those in this file. +###################################################################### +try: + from server.conf.secret_settings import * +except ImportError: + print("secret_settings.py file not found or failed to import.") diff --git a/.travis/sqlite3_settings.py b/.travis/sqlite3_settings.py new file mode 100644 index 0000000000..cc4bae81c8 --- /dev/null +++ b/.travis/sqlite3_settings.py @@ -0,0 +1,47 @@ +""" +Evennia settings file. + +The available options are found in the default settings file found +here: + +/home/griatch/Devel/Home/evennia/evennia/evennia/settings_default.py + +Remember: + +Don't copy more from the default file than you actually intend to +change; this will make sure that you don't overload upstream updates +unnecessarily. + +When changing a setting requiring a file system path (like +path/to/actual/file.py), use GAME_DIR and EVENNIA_DIR to reference +your game folder and the Evennia library folders respectively. Python +paths (path.to.module) should be given relative to the game's root +folder (typeclasses.foo) whereas paths within the Evennia library +needs to be given explicitly (evennia.foo). + +If you want to share your game dir, including its settings, you can +put secret game- or server-specific settings in secret_settings.py. + +""" + +import os + +# Use the defaults from Evennia unless explicitly overridden +from evennia.settings_default import * + +###################################################################### +# Evennia base server config +###################################################################### + +# This is the name of your game. Make it catchy! +SERVERNAME = "testing_mygame" + +# Using default sqlite3 settings + +###################################################################### +# Settings given in secret_settings.py override those in this file. +###################################################################### +try: + from server.conf.secret_settings import * +except ImportError: + print("secret_settings.py file not found or failed to import.") diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b56c29d85..623cd07c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,203 @@ # Changelog +## Evennia 0.9 (2018-2019) + +### Distribution + +- New requirement: Python 3.7 (py2.7 support removed) +- Django 2.1 +- Twisted 19.2.1 +- Autobahn websockets (removed old tmwx) +- Docker image updated + +### Commands + +- Remove `@`-prefix from all default commands (prefixes still work, optional) +- Removed default `@delaccount` command, incorporating as `@account/delete` instead. Added confirmation + question. +- Add new `@force` command to have another object perform a command. +- Add the Portal uptime to the `@time` command. +- Make the `@link` command first make a local search before a global search. +- Have the default Unloggedin-look command look for optional `connection_screen()` callable in + `mygame/server/conf/connection_screen.py`. This allows for more flexible welcome screens + that are calculated on the fly. +- `@py` command now defaults to escaping html tags in its output when viewing in the webclient. + Use new `/clientraw` switch to get old behavior (issue #1369). +- Shorter and more informative, dynamic, listing of on-command vars if not + setting func() in child command class. +- New Command helper methods + - `.client_width()` returns client width of the session running the command. + - `.styled_table(*args, **kwargs)` returns a formatted evtable styled by user's options + - `.style_header(*args, **kwargs)` creates styled header entry + - `.style_separator(*args, **kwargs)` " separator + - `.style_footer(*args, **kwargs)` " footer + +### Web + +- Change webclient from old txws version to use more supported/feature-rich Autobahn websocket library + +#### Evennia game index + +- Made Evennia game index client a part of core - now configured from settings file (old configs + need to be moved) +- The `evennia connections` command starts a wizard that helps you connect your game to the game index. +- The game index now accepts games with no public telnet/webclient info (for early prototypes). + +#### New golden-layout based Webclient UI (@friarzen) +- Features + - Much slicker behavior and more professional look + - Allows tabbing as well as click and drag of panes in any grid position + - Renaming tabs, assignments of data tags and output types are simple per-pane menus now + - Any number of input panes, with separate histories + - Button UI (disabled in JS by default) + +#### Web/Django standard initiative (@strikaco) +- Features + - Adds a series of web-based forms and generic class-based views + - Accounts + - Register - Enhances registration; allows optional collection of email address + - Form - Adds a generic Django form for creating Accounts from the web + - Characters + - Create - Authenticated users can create new characters from the website (requires associated form) + - Detail - Authenticated and authorized users can view select details about characters + - List - Authenticated and authorized users can browse a list of all characters + - Manage - Authenticated users can edit or delete owned characters from the web + - Form - Adds a generic Django form for creating characters from the web + - Channels + - Detail - Authorized users can view channel logs from the web + - List - Authorized users can browse a list of all channels + - Help Entries + - Detail - Authorized users can view help entries from the web + - List - Authorized users can browse a list of all help entries from the web + - Navbar changes + - Characters - Link to character list + - Channels - Link to channel list + - Help - Link to help entry list + - Puppeting + - Users can puppet their own characters within the context of the website + - Dropdown + - Link to create characters + - Link to manage characters + - Link to quick-select puppets + - Link to password change workflow +- Functions + - Updates Bootstrap to v4 stable + - Enables use of Django Messages framework to communicate with users in browser + - Implements webclient/website `_shared_login` functionality as Django middleware + - 'account' and 'puppet' are added to all request contexts for authenticated users + - Adds unit tests for all web views +- Cosmetic + - Prettifies Django 'forgot password' workflow (requires SMTP to actually function) + - Prettifies Django 'change password' workflow +- Bugfixes + - Fixes bug on login page where error messages were not being displayed + - Remove strvalue field from admin; it made no sense to have here, being an optimization field + for internal use. + +### Prototypes + +- `evennia.prototypes.save_prototype` now takes the prototype as a normal + argument (`prototype`) instead of having to give it as `**prototype`. +- `evennia.prototypes.search_prototype` has a new kwarg `require_single=False` that + raises a KeyError exception if query gave 0 or >1 results. +- `evennia.prototypes.spawner` can now spawn by passing a `prototype_key` + +### Typeclasses + +- Add new methods on all typeclasses, useful specifically for object handling from the website/admin: + + `web_get_admin_url()`: Returns the path to the object detail page in the Admin backend. + + `web_get_create_url()`: Returns the path to the typeclass' creation page on the website, if implemented. + + `web_get_absolute_url()`: Returns the path to the object's detail page on the website, if implemented. + + `web_get_update_url()`: Returns the path to the object's update page on the website, if implemented. + + `web_get_delete_url()`: Returns the path to the object's delete page on the website, if implemented. +- All typeclasses have new helper class method `create`, which encompasses useful functionality + that used to be embedded for example in the respective `@create` or `@connect` commands. +- DefaultAccount now has new class methods implementing many things that used to be in unloggedin + commands (these can now be customized on the class instead): + + `is_banned()`: Checks if a given username or IP is banned. + + `get_username_validators`: Return list of validators for username validation (see + `settings.AUTH_USERNAME_VALIDATORS`) + + `authenticate`: Method to check given username/password. + + `normalize_username`: Normalizes names so (for Unicode environments) users cannot mimic existing usernames by replacing select characters with visually-similar Unicode chars. + + `validate_username`: Mechanism for validating a username based on predefined Django validators. + + `validate_password`: Mechanism for validating a password based on predefined Django validators. + + `set_password`: Apply password to account, using validation checks. +- `AttributeHandler.remove` and `TagHandler.remove` can now be used to delete by-category. If neither + key nor category is given, they now work the same as .clear(). + +### Protocols + +- Support for `Grapevine` MUD-chat network ("channels" supported) + +### Server + +- Convert ServerConf model to store its values as a Picklefield (same as + Attributes) instead of using a custom solution. +- OOB: Add support for MSDP LIST, REPORT, UNREPORT commands (re-mapped to `msdp_list`, + `msdp_report`, `msdp_unreport`, inlinefuncs) +- Added `evennia.ANSIString` to flat API. +- Server/Portal log files now cycle to names on the form `server_.log_19_03_08_` instead of `server.log___19.3.8`, retaining + unix file sorting order. +- Django signals fire for important events: Puppet/Unpuppet, Object create/rename, Login, + Logout, Login fail Disconnect, Account create/rename + +### Settings + +- `GLOBAL_SCRIPTS` - dict defining typeclasses of global scripts to store on the new + `evennia.GLOBAL_SCRIPTS` container. These will auto-start when Evennia start and will always + exist. +- `OPTIONS_ACCOUNTS_DEFAULT` - option dict with option defaults and Option classes +- `OPTION_CLASS_MODULES` - classes representing an on-Account Option, on special form +- `VALIDATOR_FUNC_MODULES` - (general) text validator functions, for verifying an input + is on a specific form. + +### Utils + +- `evennia` launcher now fully handles all django-admin commands, like running tests in parallel. +- `evennia.utils.create.account` now also takes `tags` and `attrs` keywords. +- `evennia.utils.interactive` decorator can now allow you to use yield(secs) to pause operation + in any function, not just in Command.func. Likewise, response = yield(question) will work + if the decorated function has an argument or kwarg `caller`. +- Added many more unit tests. +- Swap argument order of `evennia.set_trace` to `set_trace(term_size=(140, 40), debugger='auto')` + since the size is more likely to be changed on the command line. +- `utils.to_str(text, session=None)` now acts as the old `utils.to_unicode` (which was removed). + This converts to the str() type (not to a byte-string as in Evennia 0.8), trying different + encodings. This function will also force-convert any object passed to it into a string (so + `force_string` flag was removed and assumed always set). +- `utils.to_bytes(text, session=None)` replaces the old `utils.to_str()` functionality and converts + str to bytes. +- `evennia.MONITOR_HANDLER.all` now takes keyword argument `obj` to only retrieve monitors from that specific + Object (rather than all monitors in the entire handler). +- Support adding `\f` in command doc strings to force where EvMore puts page breaks. +- Validation Functions now added with standard API to homogenize user input validation. +- Option Classes added to make storing user-options easier and smoother. +- `evennia.VALIDATOR_CONTAINER` and `evennia.OPTION_CONTAINER` added to load these. + +### Contribs + +- Evscaperoom - a full puzzle engine for making multiplayer escape rooms in Evennia. Used to make + the entry for the MUD-Coder's Guild's 2019 Game Jam with the theme "One Room", where it ranked #1. +- Evennia game-index client no longer a contrib - moved into server core and configured with new + setting `GAME_INDEX_ENABLED`. +- The `extended_room` contrib saw some backwards-incompatible refactoring: + + All commands now begin with `CmdExtendedRoom`. So before it was `CmdExtendedLook`, now + it's `CmdExtendedRoomLook` etc. + + The `detail` command was broken out of the `desc` command and is now a new, stand-alone command + `CmdExtendedRoomDetail`. This was done to make things easier to extend and to mimic how the detail + command works in the tutorial-world. + + The `detail` command now also supports deleting details (like the tutorial-world version). + + The new `ExtendedRoomCmdSet` includes all the extended-room commands and is now the recommended way + to install the extended-room contrib. +- Reworked `menu_login` contrib to use latest EvMenu standards. Now also supports guest logins. +- Mail contrib was refactored to have optional Command classes `CmdMail` for OOC+IC mail (added + to the CharacterCmdSet and `CmdMailCharacter` for IC-only mailing between chars (added to CharacterCmdSet) + +### Translations + +- Simplified chinese, courtesy of user MaxAlex. + + ## Evennia 0.8 (2018) ### Requirements @@ -19,7 +217,7 @@ to terminal and can be stopped with Ctrl-C. Using `evennia reload`, or reloading in-game, will return Server to normal daemon operation. - For validating passwords, use safe Django password-validation backend instead of custom Evennia one. -- Alias `evennia restart` to mean the same as `evennia reload`. +- Alias `evennia restart` to mean the same as `evennia reload`. ### Prototype changes @@ -111,8 +309,22 @@ - `tb_items` - Extends `tb_equip` with item use with conditions/status effects. - `tb_magic` - Extends `tb_equip` with spellcasting. - `tb_range` - Adds system for abstract positioning and movement. + - The `extended_room` contrib saw some backwards-incompatible refactoring: + - All commands now begin with `CmdExtendedRoom`. So before it was `CmdExtendedLook`, now + it's `CmdExtendedRoomLook` etc. + - The `detail` command was broken out of the `desc` command and is now a new, stand-alone command + `CmdExtendedRoomDetail`. This was done to make things easier to extend and to mimic how the detail + command works in the tutorial-world. + - The `detail` command now also supports deleting details (like the tutorial-world version). + - The new `ExtendedRoomCmdSet` includes all the extended-room commands and is now the recommended way + to install the extended-room contrib. - Updates and some cleanup of existing contribs. + +### Internationalization + +- Polish translation by user ogotai + # Overviews ## Sept 2017: diff --git a/Dockerfile b/Dockerfile index 94f6ce6767..697cd2ef64 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ # Usage: # cd to a folder where you want your game data to be (or where it already is). # -# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia +# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4002:4002 -v $PWD:/usr/src/game evennia/evennia # # (If your OS does not support $PWD, replace it with the full path to your current # folder). @@ -16,8 +16,8 @@ # can install and run the game normally. Use Ctrl-D to exit the evennia docker container. # # You can also start evennia directly by passing arguments to the folder: -# -# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia evennia start -l +# +# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4002:4002 -v $PWD:/usr/src/game evennia/evennia evennia start -l # # This will start Evennia running as the core process of the container. Note that you *must* use -l # or one of the foreground modes (like evennia ipstart) since otherwise the container will immediately @@ -27,13 +27,13 @@ # as a base for creating your own custom containerized Evennia game. For more # info, see https://github.com/evennia/evennia/wiki/Running%20Evennia%20in%20Docker . # -FROM alpine +FROM python:3.7-alpine LABEL maintainer="www.evennia.com" # install compilation environment -RUN apk update && apk add bash gcc jpeg-dev musl-dev procps py-pip \ -py-setuptools py2-openssl python python-dev zlib-dev gettext +RUN apk update && apk add bash gcc jpeg-dev musl-dev procps \ +libffi-dev openssl-dev zlib-dev gettext # add the files required for pip installation COPY ./setup.py /usr/src/evennia/ @@ -69,4 +69,4 @@ ENV PS1 "evennia|docker \w $ " ENTRYPOINT ["/usr/src/evennia/bin/unix/evennia-docker-start.sh"] # expose the telnet, webserver and websocket client ports -EXPOSE 4000 4001 4005 +EXPOSE 4000 4001 4002 diff --git a/bin/player-account-step1.patch b/bin/player-account-step1.patch index c13e8e9d25..ede1d2e9b5 100644 --- a/bin/player-account-step1.patch +++ b/bin/player-account-step1.patch @@ -83,7 +83,7 @@ index b27c75c..6e40252 100644 - migrations.AddField( - model_name='objectdb', - name='db_account', -- field=models.ForeignKey(help_text=b'an Account connected to this object, if any.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.AccountDB', verbose_name=b'account'), +- field=models.ForeignKey(help_text='an Account connected to this object, if any.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.AccountDB', verbose_name='account'), - ), - ] + db_cursor = connection.cursor() @@ -95,7 +95,7 @@ index b27c75c..6e40252 100644 + migrations.AddField( + model_name='objectdb', + name='db_account', -+ field=models.ForeignKey(help_text=b'an Account connected to this object, if any.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.AccountDB', verbose_name=b'account'), ++ field=models.ForeignKey(help_text='an Account connected to this object, if any.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.AccountDB', verbose_name='account'), + ), + ] diff --git a/evennia/objects/migrations/0009_remove_objectdb_db_player.py b/evennia/objects/migrations/0009_remove_objectdb_db_player.py @@ -167,7 +167,7 @@ index 99baf70..23f6df9 100644 - migrations.AddField( - model_name='scriptdb', - name='db_account', -- field=models.ForeignKey(blank=True, help_text=b'the account to store this script on (should not be set if db_obj is set)', null=True, on_delete=django.db.models.deletion.CASCADE, to='accounts.AccountDB', verbose_name=b'scripted account'), +- field=models.ForeignKey(blank=True, help_text='the account to store this script on (should not be set if db_obj is set)', null=True, on_delete=django.db.models.deletion.CASCADE, to='accounts.AccountDB', verbose_name='scripted account'), - ), - ] + db_cursor = connection.cursor() @@ -179,7 +179,7 @@ index 99baf70..23f6df9 100644 + migrations.AddField( + model_name='scriptdb', + name='db_account', -+ field=models.ForeignKey(blank=True, help_text=b'the account to store this script on (should not be set if db_obj is set)', null=True, on_delete=django.db.models.deletion.CASCADE, to='accounts.AccountDB', verbose_name=b'scripted account'), ++ field=models.ForeignKey(blank=True, help_text='the account to store this script on (should not be set if db_obj is set)', null=True, on_delete=django.db.models.deletion.CASCADE, to='accounts.AccountDB', verbose_name='scripted account'), + ), + ] diff --git a/evennia/scripts/migrations/0011_remove_scriptdb_db_player.py b/evennia/scripts/migrations/0011_remove_scriptdb_db_player.py diff --git a/bin/project_rename.py b/bin/project_rename.py index 8f22d75f1c..f4c2d9c7ac 100644 --- a/bin/project_rename.py +++ b/bin/project_rename.py @@ -6,7 +6,7 @@ Created for the Player->Account renaming Griatch 2017, released under the BSD license. """ -from __future__ import print_function + import re import sys @@ -130,7 +130,7 @@ def rename_in_tree(path, in_list, out_list, excl_list, fileend_list, is_interact replacements in each file. """ - repl_mapping = zip(in_list, out_list) + repl_mapping = list(zip(in_list, out_list)) for root, dirs, files in os.walk(path): @@ -155,13 +155,13 @@ def rename_in_tree(path, in_list, out_list, excl_list, fileend_list, is_interact for src, dst in repl_mapping: new_file = _case_sensitive_replace(new_file, src, dst) if new_file != file: - inp = raw_input(_green("Rename %s\n -> %s\n Y/[N]? > " % (file, new_file))) + inp = input(_green("Rename %s\n -> %s\n Y/[N]? > " % (file, new_file))) if inp.upper() == 'Y': new_full_path = os.path.join(root, new_file) try: os.rename(full_path, new_full_path) except OSError as err: - raw_input(_red("Could not rename - %s (return to skip)" % err)) + input(_red("Could not rename - %s (return to skip)" % err)) else: print("... Renamed.") else: @@ -171,12 +171,12 @@ def rename_in_tree(path, in_list, out_list, excl_list, fileend_list, is_interact for src, dst in repl_mapping: new_root = _case_sensitive_replace(new_root, src, dst) if new_root != root: - inp = raw_input(_green("Dir Rename %s\n -> %s\n Y/[N]? > " % (root, new_root))) + inp = input(_green("Dir Rename %s\n -> %s\n Y/[N]? > " % (root, new_root))) if inp.upper() == 'Y': try: os.rename(root, new_root) except OSError as err: - raw_input(_red("Could not rename - %s (return to skip)" % err)) + input(_red("Could not rename - %s (return to skip)" % err)) else: print("... Renamed.") else: @@ -204,7 +204,7 @@ def rename_in_file(path, in_list, out_list, is_interactive): with open(path, 'r') as fil: org_text = fil.read() - repl_mapping = zip(in_list, out_list) + repl_mapping = list(zip(in_list, out_list)) if not is_interactive: # just replace everything immediately @@ -239,12 +239,12 @@ def rename_in_file(path, in_list, out_list, is_interactive): while True: - for iline, renamed_line in sorted(renamed.items(), key=lambda tup: tup[0]): + for iline, renamed_line in sorted(list(renamed.items()), key=lambda tup: tup[0]): print("%3i orig: %s" % (iline + 1, org_lines[iline])) print(" new : %s" % (_yellow(renamed_line))) print(_green("%s (%i lines changed)" % (path, len(renamed)))) - ret = raw_input(_green("Choose: " + ret = input(_green("Choose: " "[q]uit, " "[h]elp, " "[s]kip file, " @@ -275,12 +275,12 @@ def rename_in_file(path, in_list, out_list, is_interactive): print("Quit renaming program.") sys.exit() elif ret == "h": - raw_input(_HELP_TEXT.format(sources=in_list, targets=out_list)) + input(_HELP_TEXT.format(sources=in_list, targets=out_list)) elif ret.startswith("i"): # ignore one or more lines ignores = [int(ind) - 1 for ind in ret[1:].split(',') if ind.strip().isdigit()] if not ignores: - raw_input("Ignore example: i 2,7,34,133\n (return to continue)") + input("Ignore example: i 2,7,34,133\n (return to continue)") continue for ign in ignores: renamed.pop(ign, None) diff --git a/evennia/VERSION.txt b/evennia/VERSION.txt index a3df0a6959..c70836ca5c 100644 --- a/evennia/VERSION.txt +++ b/evennia/VERSION.txt @@ -1 +1 @@ -0.8.0 +0.9.0-dev diff --git a/evennia/__init__.py b/evennia/__init__.py index 00a2b6b780..7586bf2d87 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -16,9 +16,6 @@ to launch such a shell (using python or ipython depending on your install). See www.evennia.com for full documentation. """ -from __future__ import print_function -from __future__ import absolute_import -from builtins import object # docstring header @@ -104,6 +101,7 @@ EvTable = None EvForm = None EvEditor = None EvMore = None +ANSIString = None # Handlers SESSION_HANDLER = None @@ -112,6 +110,20 @@ TICKER_HANDLER = None MONITOR_HANDLER = None CHANNEL_HANDLER = None +# Containers +GLOBAL_SCRIPTS = None +OPTION_CLASSES = None + +# typeclasses +BASE_ACCOUNT_TYPECLASS = None +BASE_OBJECT_TYPECLASS = None +BASE_CHARACTER_TYPECLASS = None +BASE_ROOM_TYPECLASS = None +BASE_EXIT_TYPECLASS = None +BASE_CHANNEL_TYPECLASS = None +BASE_SCRIPT_TYPECLASS = None +BASE_GUEST_TYPECLASS = None + def _create_version(): """ @@ -128,7 +140,10 @@ def _create_version(): except IOError as err: print(err) try: - version = "%s (rev %s)" % (version, check_output("git rev-parse --short HEAD", shell=True, cwd=root, stderr=STDOUT).strip()) + rev = check_output( + "git rev-parse --short HEAD", + shell=True, cwd=root, stderr=STDOUT).strip().decode() + version = "%s (rev %s)" % (version, rev) except (IOError, CalledProcessError, OSError): # ignore if we cannot get to git pass @@ -138,6 +153,7 @@ def _create_version(): __version__ = _create_version() del _create_version + def _init(): """ This function is called automatically by the launcher only after @@ -148,11 +164,20 @@ def _init(): global DefaultRoom, DefaultExit, DefaultChannel, DefaultScript global ObjectDB, AccountDB, ScriptDB, ChannelDB, Msg global Command, CmdSet, default_cmds, syscmdkeys, InterruptCommand - global search_object, search_script, search_account, search_channel, search_help, search_tag, search_message - global create_object, create_script, create_account, create_channel, create_message, create_help_entry + global search_object, search_script, search_account, search_channel + global search_help, search_tag, search_message + global create_object, create_script, create_account, create_channel + global create_message, create_help_entry global settings, lockfuncs, logger, utils, gametime, ansi, spawn, managers - global contrib, TICKER_HANDLER, MONITOR_HANDLER, SESSION_HANDLER, CHANNEL_HANDLER, TASK_HANDLER + global contrib, TICKER_HANDLER, MONITOR_HANDLER, SESSION_HANDLER + global CHANNEL_HANDLER, TASK_HANDLER + global GLOBAL_SCRIPTS, OPTION_CLASSES global EvMenu, EvTable, EvForm, EvMore, EvEditor + global ANSIString + + global BASE_ACCOUNT_TYPECLASS, BASE_OBJECT_TYPECLASS, BASE_CHARACTER_TYPECLASS + global BASE_ROOM_TYPECLASS, BASE_EXIT_TYPECLASS, BASE_CHANNEL_TYPECLASS + global BASE_SCRIPT_TYPECLASS, BASE_GUEST_TYPECLASS from .accounts.accounts import DefaultAccount from .accounts.accounts import DefaultGuest @@ -203,6 +228,7 @@ def _init(): from .utils.evtable import EvTable from .utils.evform import EvForm from .utils.eveditor import EvEditor + from .utils.ansi import ANSIString # handlers from .scripts.tickerhandler import TICKER_HANDLER @@ -211,6 +237,10 @@ def _init(): from .comms.channelhandler import CHANNEL_HANDLER from .scripts.monitorhandler import MONITOR_HANDLER + # containers + from .utils.containers import GLOBAL_SCRIPTS + from .utils.containers import OPTION_CLASSES + # initialize the doc string global __doc__ __doc__ = ansi.parse_ansi(DOCSTRING) @@ -344,23 +374,30 @@ def _init(): del SystemCmds del _EvContainer - -del object -del absolute_import -del print_function + # typeclases + from .utils.utils import class_from_module + BASE_ACCOUNT_TYPECLASS = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + BASE_OBJECT_TYPECLASS = class_from_module(settings.BASE_OBJECT_TYPECLASS) + BASE_CHARACTER_TYPECLASS = class_from_module(settings.BASE_CHARACTER_TYPECLASS) + BASE_ROOM_TYPECLASS = class_from_module(settings.BASE_ROOM_TYPECLASS) + BASE_EXIT_TYPECLASS = class_from_module(settings.BASE_EXIT_TYPECLASS) + BASE_CHANNEL_TYPECLASS = class_from_module(settings.BASE_CHANNEL_TYPECLASS) + BASE_SCRIPT_TYPECLASS = class_from_module(settings.BASE_SCRIPT_TYPECLASS) + BASE_GUEST_TYPECLASS = class_from_module(settings.BASE_GUEST_TYPECLASS) + del class_from_module -def set_trace(debugger="auto", term_size=(140, 40)): +def set_trace(term_size=(140, 40), debugger="auto"): """ Helper function for running a debugger inside the Evennia event loop. Args: + term_size (tuple, optional): Only used for Pudb and defines the size of the terminal + (width, height) in number of characters. debugger (str, optional): One of 'auto', 'pdb' or 'pudb'. Pdb is the standard debugger. Pudb is an external package with a different, more 'graphical', ncurses-based UI. With 'auto', will use pudb if possible, otherwise fall back to pdb. Pudb is available through `pip install pudb`. - term_size (tuple, optional): Only used for Pudb and defines the size of the terminal - (width, height) in number of characters. Notes: To use: @@ -379,14 +416,12 @@ def set_trace(debugger="auto", term_size=(140, 40)): """ import sys dbg = None - pudb_mode = False if debugger in ('auto', 'pudb'): try: from pudb import debugger dbg = debugger.Debugger(stdout=sys.__stdout__, term_size=term_size) - pudb_mode = True except ImportError: if debugger == 'pudb': raise @@ -395,13 +430,11 @@ def set_trace(debugger="auto", term_size=(140, 40)): if not dbg: import pdb dbg = pdb.Pdb(stdout=sys.__stdout__) - pudb_mode = False - if pudb_mode: - # Stopped at breakpoint. Press 'n' to continue into the code. - dbg.set_trace() - else: + try: # Start debugger, forcing it up one stack frame (otherwise `set_trace` will start debugger # this point, not the actual code location) dbg.set_trace(sys._getframe().f_back) - + except Exception: + # Stopped at breakpoint. Press 'n' to continue into the code. + dbg.set_trace() diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 915f3dd4e4..caf6b5d5a0 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -10,28 +10,35 @@ character object, so you should customize that instead for most things). """ - +import re import time from django.conf import settings -from django.contrib.auth import password_validation -from django.core.exceptions import ValidationError +from django.contrib.auth import authenticate, password_validation +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.utils import timezone +from django.utils.module_loading import import_string from evennia.typeclasses.models import TypeclassBase from evennia.accounts.manager import AccountManager from evennia.accounts.models import AccountDB from evennia.objects.models import ObjectDB from evennia.comms.models import ChannelDB from evennia.commands import cmdhandler -from evennia.utils import logger +from evennia.server.models import ServerConfig +from evennia.server.throttle import Throttle +from evennia.utils import class_from_module, create, logger from evennia.utils.utils import (lazy_property, to_str, - make_iter, to_unicode, is_iter, + make_iter, is_iter, variable_from_module) +from evennia.server.signals import (SIGNAL_ACCOUNT_POST_CREATE, SIGNAL_OBJECT_POST_PUPPET, + SIGNAL_OBJECT_POST_UNPUPPET) from evennia.typeclasses.attributes import NickHandler from evennia.scripts.scripthandler import ScriptHandler from evennia.commands.cmdsethandler import CmdSetHandler +from evennia.utils.optionhandler import OptionHandler from django.utils.translation import ugettext as _ from future.utils import with_metaclass +from random import getrandbits __all__ = ("DefaultAccount",) @@ -43,6 +50,10 @@ _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): """ @@ -190,6 +201,28 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): def sessions(self): return AccountSessionHandler(self) + @lazy_property + def options(self): + return OptionHandler(self, + options_dict=settings.OPTIONS_ACCOUNT_DEFAULT, + savefunc=self.attributes.add, + loadfunc=self.attributes.get, + save_kwargs={"category": 'option'}, + load_kwargs={"category": 'option'}) + + # Do not make this a lazy property; the web UI will not refresh it! + @property + def characters(self): + # Get playable characters list + objs = self.db._playable_characters + + # Rebuild the list if legacy code left null values after deletion + if None in objs: + objs = [x for x in self.db._playable_characters if x] + self.db._playable_characters = objs + + return objs + # session-related methods def disconnect_session_from_account(self, session, reason=None): @@ -282,6 +315,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): obj.locks.cache_lock_bypass(obj) # final hook obj.at_post_puppet() + SIGNAL_OBJECT_POST_PUPPET.send(sender=obj, account=self, session=session) def unpuppet_object(self, session): """ @@ -304,6 +338,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): if not obj.sessions.count(): del obj.account obj.at_post_unpuppet(self, session=session) + SIGNAL_OBJECT_POST_UNPUPPET.send(sender=obj, session=session, account=self) # Just to be sure we're always clear. session.puppet = None session.puid = None @@ -359,6 +394,195 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): puppet = property(__get_single_puppet) # utility methods + @classmethod + def is_banned(cls, **kwargs): + """ + Checks if a given username or IP is banned. + + Kwargs: + ip (str, optional): IP address. + username (str, optional): Username. + + Returns: + is_banned (bool): Whether either is banned or not. + + """ + + ip = kwargs.get('ip', '').strip() + username = kwargs.get('username', '').lower().strip() + + # Check IP and/or name bans + bans = ServerConfig.objects.conf("server_bans") + if bans and (any(tup[0] == username for tup in bans if username) or + any(tup[2].match(ip) for tup in bans if ip and tup[2])): + return True + + return False + + @classmethod + def get_username_validators( + cls, validator_config=getattr( + settings, 'AUTH_USERNAME_VALIDATORS', [])): + """ + Retrieves and instantiates validators for usernames. + + Args: + validator_config (list): List of dicts comprising the battery of + validators to apply to a username. + + Returns: + validators (list): List of instantiated Validator objects. + """ + + objs = [] + for validator in validator_config: + try: + klass = import_string(validator['NAME']) + except ImportError: + msg = ("The module in NAME could not be imported: %s. " + "Check your AUTH_USERNAME_VALIDATORS setting.") + raise ImproperlyConfigured(msg % validator['NAME']) + objs.append(klass(**validator.get('OPTIONS', {}))) + return objs + + @classmethod + def authenticate(cls, username, password, ip='', **kwargs): + """ + Checks the given username/password against the database to see if the + credentials are valid. + + Note that this simply checks credentials and returns a valid reference + to the user-- it does not log them in! + + To finish the job: + After calling this from a Command, associate the account with a Session: + - session.sessionhandler.login(session, account) + + ...or after calling this from a View, associate it with an HttpRequest: + - django.contrib.auth.login(account, request) + + Args: + username (str): Username of account + password (str): Password of account + ip (str, optional): IP address of client + + Kwargs: + session (Session, optional): Session requesting authentication + + Returns: + account (DefaultAccount, None): Account whose credentials were + provided if not banned. + errors (list): Error messages of any failures. + + """ + errors = [] + if ip: + ip = str(ip) + + # See if authentication is currently being throttled + if ip and LOGIN_THROTTLE.check(ip): + errors.append('Too many login failures; please try again in a few minutes.') + + # With throttle active, do not log continued hits-- it is a + # waste of storage and can be abused to make your logs harder to + # read and/or fill up your disk. + return None, errors + + # Check IP and/or name bans + banned = cls.is_banned(username=username, ip=ip) + if banned: + # this is a banned IP or name! + errors.append("|rYou have been banned and cannot continue from here." + "\nIf you feel this ban is in error, please email an admin.|x") + logger.log_sec('Authentication Denied (Banned): %s (IP: %s).' % (username, ip)) + LOGIN_THROTTLE.update(ip, 'Too many sightings of banned artifact.') + return None, errors + + # Authenticate and get Account object + account = authenticate(username=username, password=password) + if not account: + # User-facing message + errors.append('Username and/or password is incorrect.') + + # Log auth failures while throttle is inactive + logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip)) + + # Update throttle + if ip: + LOGIN_THROTTLE.update(ip, 'Too many authentication failures.') + + # Try to call post-failure hook + session = kwargs.get('session', None) + if session: + account = AccountDB.objects.get_account_from_name(username) + if account: + account.at_failed_login(session) + + return None, errors + + # Account successfully authenticated + logger.log_sec('Authentication Success: %s (IP: %s).' % (account, ip)) + return account, errors + + @classmethod + def normalize_username(cls, username): + """ + Django: Applies NFKC Unicode normalization to usernames so that visually + identical characters with different Unicode code points are considered + identical. + + (This deals with the Turkish "i" problem and similar + annoyances. Only relevant if you go out of your way to allow Unicode + usernames though-- Evennia accepts ASCII by default.) + + In this case we're simply piggybacking on this feature to apply + additional normalization per Evennia's standards. + """ + username = super(DefaultAccount, cls).normalize_username(username) + + # strip excessive spaces in accountname + username = re.sub(r"\s+", " ", username).strip() + + return username + + @classmethod + def validate_username(cls, username): + """ + Checks the given username against the username validator associated with + Account objects, and also checks the database to make sure it is unique. + + Args: + username (str): Username to validate + + Returns: + valid (bool): Whether or not the password passed validation + errors (list): Error messages of any failures + + """ + valid = [] + errors = [] + + # Make sure we're at least using the default validator + validators = cls.get_username_validators() + if not validators: + validators = [cls.username_validator] + + # Try username against all enabled validators + for validator in validators: + try: + valid.append(not validator(username)) + except ValidationError as e: + valid.append(False) + errors.extend(e.messages) + + # Disqualify if any check failed + if False in valid: + valid = False + else: + valid = True + + return valid, errors + @classmethod def validate_password(cls, password, account=None): """ @@ -392,33 +616,153 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): return valid, error - def set_password(self, password, force=False): + def set_password(self, password, **kwargs): """ - Applies the given password to the account if it passes validation checks. - Can be overridden by using the 'force' flag. + Applies the given password to the account. Logs and triggers the `at_password_change` hook. Args: - password (str): Password to set + password (str): Password to set. - Kwargs: - force (bool): Sets password without running validation checks. - - Raises: - ValidationError - - Returns: - None (None): Does not return a value. + Notes: + This is called by Django also when logging in; it should not be mixed up with validation, since that + would mean old passwords in the database (pre validation checks) could get invalidated. """ - if not force: - # Run validation checks - valid, error = self.validate_password(password, account=self) - if error: raise error - super(DefaultAccount, self).set_password(password) - logger.log_info("Password succesfully changed for %s." % self) + logger.log_sec("Password successfully changed for %s." % self) self.at_password_change() + @classmethod + def create(cls, *args, **kwargs): + """ + Creates an Account (or Account/Character pair for MULTISESSION_MODE<2) + with default (or overridden) permissions and having joined them to the + appropriate default channels. + + Kwargs: + username (str): Username of Account owner + password (str): Password of Account owner + email (str, optional): Email address of Account owner + ip (str, optional): IP address of requesting connection + guest (bool, optional): Whether or not this is to be a Guest account + + permissions (str, optional): Default permissions for the Account + typeclass (str, optional): Typeclass to use for new Account + character_typeclass (str, optional): Typeclass to use for new char + when applicable. + + Returns: + account (Account): Account if successfully created; None if not + errors (list): List of error messages in string form + + """ + + account = None + errors = [] + + username = kwargs.get('username') + password = kwargs.get('password') + email = kwargs.get('email', '').strip() + guest = kwargs.get('guest', False) + + permissions = kwargs.get('permissions', settings.PERMISSION_ACCOUNT_DEFAULT) + typeclass = kwargs.get('typeclass', cls) + + ip = kwargs.get('ip', '') + if ip and CREATION_THROTTLE.check(ip): + errors.append("You are creating too many accounts. Please log into an existing account.") + return None, errors + + # Normalize username + username = cls.normalize_username(username) + + # Validate username + if not guest: + valid, errs = cls.validate_username(username) + if not valid: + # this echoes the restrictions made by django's auth + # module (except not allowing spaces, for convenience of + # logging in). + errors.extend(errs) + return None, errors + + # Validate password + # Have to create a dummy Account object to check username similarity + valid, errs = cls.validate_password(password, account=cls(username=username)) + if not valid: + errors.extend(errs) + return None, errors + + # Check IP and/or name bans + banned = cls.is_banned(username=username, ip=ip) + if banned: + # this is a banned IP or name! + string = "|rYou have been banned and cannot continue from here." \ + "\nIf you feel this ban is in error, please email an admin.|x" + errors.append(string) + return None, errors + + # everything's ok. Create the new account account. + try: + try: + account = create.create_account(username, email, password, permissions=permissions, typeclass=typeclass) + logger.log_sec('Account Created: %s (IP: %s).' % (account, ip)) + + except Exception as e: + errors.append("There was an error creating the Account. If this problem persists, contact an admin.") + logger.log_trace() + return None, errors + + # This needs to be set so the engine knows this account is + # logging in for the first time. (so it knows to call the right + # hooks during login later) + account.db.FIRST_LOGIN = True + + # Record IP address of creation, if available + if ip: + account.db.creator_ip = ip + + # join the new account to the public channel + pchannel = ChannelDB.objects.get_channel(settings.DEFAULT_CHANNELS[0]["key"]) + if not pchannel or not pchannel.connect(account): + string = "New account '%s' could not connect to public channel!" % account.key + errors.append(string) + logger.log_err(string) + + if account and settings.MULTISESSION_MODE < 2: + # Load the appropriate Character class + character_typeclass = kwargs.get('character_typeclass', settings.BASE_CHARACTER_TYPECLASS) + character_home = kwargs.get('home') + Character = class_from_module(character_typeclass) + + # Create the character + character, errs = Character.create( + account.key, account, ip=ip, typeclass=character_typeclass, + permissions=permissions, home=character_home + ) + errors.extend(errs) + + if character: + # Update playable character list + if character not in account.characters: + account.db._playable_characters.append(character) + + # We need to set this to have @ic auto-connect to this character + account.db._last_puppet = character + + except Exception: + # We are in the middle between logged in and -not, so we have + # to handle tracebacks ourselves at this point. If we don't, + # we won't see any errors at all. + errors.append("An error occurred. Please e-mail an admin if the problem persists.") + logger.log_trace() + + # Update the throttle to indicate a new account was created from this IP + if ip and not guest: + CREATION_THROTTLE.update(ip, 'Too many accounts being created.') + SIGNAL_ACCOUNT_POST_CREATE.send(sender=account, ip=ip) + return account, errors + def delete(self, *args, **kwargs): """ Deletes the account permanently. @@ -442,7 +786,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): self.attributes.clear() self.nicks.clear() self.aliases.clear() - super(DefaultAccount, self).delete(*args, **kwargs) + super().delete(*args, **kwargs) # methods inherited from database model def msg(self, text=None, from_obj=None, session=None, options=None, **kwargs): @@ -483,13 +827,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): kwargs["options"] = options 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 + kwargs['text'] = to_str(text) # session relay sessions = make_iter(session) if session else self.sessions.all() @@ -516,7 +854,6 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): commands at run-time. """ - raw_string = to_unicode(raw_string) raw_string = self.nicks.nickreplace(raw_string, categories=("inputline", "channel"), include_account=False) if not session and _MULTISESSION_MODE in (0, 1): # for these modes we use the first/only session @@ -562,7 +899,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): """ # handle me, self and *me, *self - if isinstance(searchdata, basestring): + if isinstance(searchdata, str): # handle wrapping of common terms if searchdata.lower() in ("me", "*me", "self", "*self",): return self @@ -602,8 +939,8 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): result (bool): Result of access check. """ - result = super(DefaultAccount, self).access(accessing_obj, access_type=access_type, - default=default, no_superuser_bypass=no_superuser_bypass) + result = super().access(accessing_obj, access_type=access_type, + default=default, no_superuser_bypass=no_superuser_bypass) self.at_access(result, accessing_obj, access_type, **kwargs) return result @@ -1009,19 +1346,28 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): # list of targets - make list to disconnect from db characters = list(tar for tar in target if tar) if target else [] sessions = self.sessions.all() + if not sessions: + # no sessions, nothing to report + return "" is_su = self.is_superuser # text shown when looking in the ooc area result = ["Account |g%s|n (you are Out-of-Character)" % self.key] nsess = len(sessions) - result.append(nsess == 1 and "\n\n|wConnected session:|n" or "\n\n|wConnected sessions (%i):|n" % nsess) + result.append(nsess == 1 and + "\n\n|wConnected session:|n" or + "\n\n|wConnected sessions (%i):|n" % nsess) for isess, sess in enumerate(sessions): csessid = sess.sessid addr = "%s (%s)" % (sess.protocol_key, isinstance(sess.address, tuple) and - str(sess.address[0]) or str(sess.address)) - result.append("\n %s %s" % (session.sessid == csessid and "|w* %s|n" % (isess + 1) or - " %s" % (isess + 1), addr)) + str(sess.address[0]) or + str(sess.address)) + result.append("\n %s %s" % ( + session and + session.sessid == csessid and + "|w* %s|n" % (isess + 1) or + " %s" % (isess + 1), addr)) result.append("\n\n |whelp|n - more commands") result.append("\n |wooc |n - talk on public channel") @@ -1068,6 +1414,79 @@ class DefaultGuest(DefaultAccount): their characters are deleted after disconnection. """ + @classmethod + def create(cls, **kwargs): + """ + Forwards request to cls.authenticate(); returns a DefaultGuest object + if one is available for use. + """ + return cls.authenticate(**kwargs) + + @classmethod + def authenticate(cls, **kwargs): + """ + Gets or creates a Guest account object. + + Kwargs: + ip (str, optional): IP address of requestor; used for ban checking, + throttling and logging + + Returns: + account (Object): Guest account object, if available + errors (list): List of error messages accrued during this request. + + """ + errors = [] + account = None + username = None + ip = kwargs.get('ip', '').strip() + + # check if guests are enabled. + if not settings.GUEST_ENABLED: + errors.append('Guest accounts are not enabled on this server.') + return None, errors + + try: + # Find an available guest name. + for name in settings.GUEST_LIST: + if not AccountDB.objects.filter(username__iexact=name).count(): + username = name + break + if not username: + errors.append("All guest accounts are in use. Please try again later.") + if ip: + LOGIN_THROTTLE.update(ip, 'Too many requests for Guest access.') + return None, errors + else: + # build a new account with the found guest username + password = "%016x" % getrandbits(64) + home = settings.GUEST_HOME + permissions = settings.PERMISSION_GUEST_DEFAULT + typeclass = settings.BASE_GUEST_TYPECLASS + + # Call parent class creator + account, errs = super(DefaultGuest, cls).create( + guest=True, + username=username, + password=password, + permissions=permissions, + typeclass=typeclass, + home=home, + ip=ip, + ) + errors.extend(errs) + return account, errors + + except Exception as e: + # We are in the middle between logged in and -not, so we have + # to handle tracebacks ourselves at this point. If we don't, + # we won't see any errors at all. + errors.append("An error occurred. Please e-mail an admin if the problem persists.") + logger.log_trace() + return None, errors + + return account, errors + def at_post_login(self, session=None, **kwargs): """ In theory, guests only have one character regardless of which @@ -1087,11 +1506,10 @@ class DefaultGuest(DefaultAccount): We repeat the functionality of `at_disconnect()` here just to be on the safe side. """ - super(DefaultGuest, self).at_server_shutdown() + super().at_server_shutdown() characters = self.db._playable_characters for character in characters: if character: - print "deleting Character:", character character.delete() def at_post_disconnect(self, **kwargs): @@ -1103,7 +1521,7 @@ class DefaultGuest(DefaultAccount): overriding the call (unused by default). """ - super(DefaultGuest, self).at_post_disconnect() + super().at_post_disconnect() characters = self.db._playable_characters for character in characters: if character: diff --git a/evennia/accounts/admin.py b/evennia/accounts/admin.py index 5678db1124..9986e2bf7c 100644 --- a/evennia/accounts/admin.py +++ b/evennia/accounts/admin.py @@ -247,9 +247,7 @@ class AccountDBAdmin(BaseUserAdmin): def response_add(self, request, obj, post_url_continue=None): from django.http import HttpResponseRedirect - from django.core.urlresolvers import reverse - if '_continue' in request.POST: - return HttpResponseRedirect(reverse("admin:accounts_accountdb_change", args=[obj.id])) + from django.urls import reverse return HttpResponseRedirect(reverse("admin:accounts_accountdb_change", args=[obj.id])) diff --git a/evennia/accounts/bots.py b/evennia/accounts/bots.py index b0a765fbba..35e73d4aaa 100644 --- a/evennia/accounts/bots.py +++ b/evennia/accounts/bots.py @@ -3,7 +3,7 @@ Bots are a special child typeclasses of Account that are controlled by the server. """ -from __future__ import print_function + import time from django.conf import settings from evennia.accounts.accounts import DefaultAccount @@ -15,6 +15,8 @@ _IDLE_TIMEOUT = settings.IDLE_TIMEOUT _IRC_ENABLED = settings.IRC_ENABLED _RSS_ENABLED = settings.RSS_ENABLED +_GRAPEVINE_ENABLED = settings.GRAPEVINE_ENABLED + _SESSIONS = None @@ -118,14 +120,14 @@ class Bot(DefaultAccount): Evennia -> outgoing protocol """ - super(Bot, self).msg(text=text, from_obj=from_obj, session=session, options=options, **kwargs) + super().msg(text=text, from_obj=from_obj, session=session, options=options, **kwargs) def execute_cmd(self, raw_string, session=None): """ Incoming protocol -> Evennia """ - super(Bot, self).msg(raw_string, session=session) + super().msg(raw_string, session=session) def at_server_shutdown(self): """ @@ -146,8 +148,11 @@ class IRCBot(Bot): Bot for handling IRC connections. """ + # override this on a child class to use custom factory + factory_path = "evennia.server.portal.irc.IRCBotFactory" - def start(self, ev_channel=None, irc_botname=None, irc_channel=None, irc_network=None, irc_port=None, irc_ssl=None): + def start(self, ev_channel=None, irc_botname=None, irc_channel=None, + irc_network=None, irc_port=None, irc_ssl=None): """ Start by telling the portal to start a new session. @@ -203,7 +208,7 @@ class IRCBot(Bot): "network": self.db.irc_network, "port": self.db.irc_port, "ssl": self.db.irc_ssl} - _SESSIONS.start_bot_session("evennia.server.portal.irc.IRCBotFactory", configdict) + _SESSIONS.start_bot_session(self.factory_path, configdict) def at_msg_send(self, **kwargs): "Shortcut here or we can end up in infinite loop" @@ -226,7 +231,7 @@ class IRCBot(Bot): if not hasattr(self, "_nicklist_callers"): self._nicklist_callers = [] self._nicklist_callers.append(caller) - super(IRCBot, self).msg(request_nicklist="") + super().msg(request_nicklist="") return def ping(self, caller): @@ -240,7 +245,7 @@ class IRCBot(Bot): if not hasattr(self, "_ping_callers"): self._ping_callers = [] self._ping_callers.append(caller) - super(IRCBot, self).msg(ping="") + super().msg(ping="") def reconnect(self): """ @@ -248,7 +253,7 @@ class IRCBot(Bot): having to destroy/recreate the bot "account". """ - super(IRCBot, self).msg(reconnect="") + super().msg(reconnect="") def msg(self, text=None, **kwargs): """ @@ -265,12 +270,15 @@ class IRCBot(Bot): """ from_obj = kwargs.get("from_obj", None) options = kwargs.get("options", None) or {} + if not self.ndb.ev_channel and self.db.ev_channel: # cache channel lookup self.ndb.ev_channel = self.db.ev_channel - if "from_channel" in options and text and self.ndb.ev_channel.dbid == options["from_channel"]: + + if ("from_channel" in options and text and + self.ndb.ev_channel.dbid == options["from_channel"]): if not from_obj or from_obj != [self]: - super(IRCBot, self).msg(channel=text) + super().msg(channel=text) def execute_cmd(self, session=None, txt=None, **kwargs): """ @@ -284,13 +292,16 @@ class IRCBot(Bot): Kwargs: user (str): The name of the user who sent the message. channel (str): The name of channel the message was sent to. - type (str): Nature of message. Either 'msg', 'action', 'nicklist' or 'ping'. - nicklist (list, optional): Set if `type='nicklist'`. This is a list of nicks returned by calling - the `self.get_nicklist`. It must look for a list `self._nicklist_callers` - which will contain all callers waiting for the nicklist. - timings (float, optional): Set if `type='ping'`. This is the return (in seconds) of a - ping request triggered with `self.ping`. The return must look for a list - `self._ping_callers` which will contain all callers waiting for the ping return. + type (str): Nature of message. Either 'msg', 'action', 'nicklist' + or 'ping'. + nicklist (list, optional): Set if `type='nicklist'`. This is a list + of nicks returned by calling the `self.get_nicklist`. It must look + for a list `self._nicklist_callers` which will contain all callers + waiting for the nicklist. + timings (float, optional): Set if `type='ping'`. This is the return + (in seconds) of a ping request triggered with `self.ping`. The + return must look for a list `self._ping_callers` which will contain + all callers waiting for the ping return. """ if kwargs["type"] == "nicklist": @@ -336,7 +347,7 @@ class IRCBot(Bot): text = "This is an Evennia IRC bot connecting from '%s'." % settings.SERVERNAME else: text = "I understand 'who' and 'about'." - super(IRCBot, self).msg(privmsg=((text,), {"user": user})) + super().msg(privmsg=((text,), {"user": user})) else: # something to send to the main channel if kwargs["type"] == "action": @@ -349,6 +360,7 @@ class IRCBot(Bot): if not self.ndb.ev_channel and self.db.ev_channel: # cache channel lookup self.ndb.ev_channel = self.db.ev_channel + if self.ndb.ev_channel: self.ndb.ev_channel.msg(text, senders=self) @@ -421,3 +433,100 @@ class RSSBot(Bot): self.ndb.ev_channel = self.db.ev_channel if self.ndb.ev_channel: self.ndb.ev_channel.msg(txt, senders=self.id) + + +# Grapevine bot + +class GrapevineBot(Bot): + """ + g Grapevine (https://grapevine.haus) relayer. The channel to connect to is the first + name in the settings.GRAPEVINE_CHANNELS list. + + """ + factory_path = "evennia.server.portal.grapevine.RestartingWebsocketServerFactory" + + def start(self, ev_channel=None, grapevine_channel=None): + """ + Start by telling the portal to connect to the grapevine network. + + """ + if not _GRAPEVINE_ENABLED: + self.delete() + return + + global _SESSIONS + if not _SESSIONS: + from evennia.server.sessionhandler import SESSIONS as _SESSIONS + + # connect to Evennia channel + if ev_channel: + # connect to Evennia channel + channel = search.channel_search(ev_channel) + if not channel: + raise RuntimeError("Evennia Channel '%s' not found." % ev_channel) + channel = channel[0] + channel.connect(self) + self.db.ev_channel = channel + + if grapevine_channel: + self.db.grapevine_channel = grapevine_channel + + # these will be made available as properties on the protocol factory + configdict = {"uid": self.dbid, + "grapevine_channel": self.db.grapevine_channel} + + _SESSIONS.start_bot_session(self.factory_path, configdict) + + def at_msg_send(self, **kwargs): + "Shortcut here or we can end up in infinite loop" + pass + + def msg(self, text=None, **kwargs): + """ + Takes text from connected channel (only). + + Args: + text (str, optional): Incoming text from channel. + + Kwargs: + options (dict): Options dict with the following allowed keys: + - from_channel (str): dbid of a channel this text originated from. + - from_obj (list): list of objects sending this text. + + """ + from_obj = kwargs.get("from_obj", None) + options = kwargs.get("options", None) or {} + + if not self.ndb.ev_channel and self.db.ev_channel: + # cache channel lookup + self.ndb.ev_channel = self.db.ev_channel + + if ("from_channel" in options and text and + self.ndb.ev_channel.dbid == options["from_channel"]): + if not from_obj or from_obj != [self]: + # send outputfunc channel(msg, chan, sender) + + # TODO we should refactor channel formatting to operate on the + # account/object level instead. For now, remove the channel/name + # prefix since we pass that explicitly anyway + prefix, text = text.split(":", 1) + + super().msg(channel=(text.strip(), self.db.grapevine_channel, + ", ".join(obj.key for obj in from_obj), {})) + + def execute_cmd(self, txt=None, session=None, event=None, grapevine_channel=None, + sender=None, game=None, **kwargs): + """ + Take incoming data from protocol and send it to connected channel. This is + triggered by the bot_data_in Inputfunc. + """ + if event == "channels/broadcast": + # A private message to the bot - a command. + + text = f"{sender}@{game}: {txt}" + + if not self.ndb.ev_channel and self.db.ev_channel: + # simple cache of channel lookup + self.ndb.ev_channel = self.db.ev_channel + if self.ndb.ev_channel: + self.ndb.ev_channel.msg(text, senders=self) diff --git a/evennia/accounts/manager.py b/evennia/accounts/manager.py index 5d9bda2ab9..e8be5e41a7 100644 --- a/evennia/accounts/manager.py +++ b/evennia/accounts/manager.py @@ -164,9 +164,9 @@ class AccountDBManager(TypedObjectManager, UserManager): if typeclass: # we accept both strings and actual typeclasses if callable(typeclass): - typeclass = u"%s.%s" % (typeclass.__module__, typeclass.__name__) + typeclass = "%s.%s" % (typeclass.__module__, typeclass.__name__) else: - typeclass = u"%s" % typeclass + typeclass = "%s" % typeclass query["db_typeclass_path"] = typeclass if exact: return self.filter(**query) diff --git a/evennia/accounts/migrations/0001_initial.py b/evennia/accounts/migrations/0001_initial.py index d8d267f20c..9e7386c88c 100644 --- a/evennia/accounts/migrations/0001_initial.py +++ b/evennia/accounts/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from __future__ import unicode_literals + from django.db import models, migrations import django.utils.timezone @@ -28,15 +28,15 @@ class Migration(migrations.Migration): ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('db_key', models.CharField(max_length=255, verbose_name=b'key', db_index=True)), - ('db_typeclass_path', models.CharField(help_text=b"this defines what 'type' of entity this is. This variable holds a Python path to a module with a valid Evennia Typeclass.", max_length=255, null=True, verbose_name=b'typeclass')), - ('db_date_created', models.DateTimeField(auto_now_add=True, verbose_name=b'creation date')), - ('db_lock_storage', models.TextField(help_text=b"locks limit access to an entity. A lock is defined as a 'lock string' on the form 'type:lockfunctions', defining what functionality is locked and how to determine access. Not defining a lock means no access is granted.", verbose_name=b'locks', blank=True)), - ('db_is_connected', models.BooleanField(default=False, help_text=b'If account is connected to game or not', verbose_name=b'is_connected')), - ('db_cmdset_storage', models.CharField(help_text=b'optional python path to a cmdset class. If creating a Character, this will default to settings.CMDSET_CHARACTER.', max_length=255, null=True, verbose_name=b'cmdset')), - ('db_is_bot', models.BooleanField(default=False, help_text=b'Used to identify irc/rss bots', verbose_name=b'is_bot')), - ('db_attributes', models.ManyToManyField(help_text=b'attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).', to='typeclasses.Attribute', null=True)), - ('db_tags', models.ManyToManyField(help_text=b'tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag', null=True)), + ('db_key', models.CharField(max_length=255, verbose_name='key', db_index=True)), + ('db_typeclass_path', models.CharField(help_text="this defines what 'type' of entity this is. This variable holds a Python path to a module with a valid Evennia Typeclass.", max_length=255, null=True, verbose_name='typeclass')), + ('db_date_created', models.DateTimeField(auto_now_add=True, verbose_name='creation date')), + ('db_lock_storage', models.TextField(help_text="locks limit access to an entity. A lock is defined as a 'lock string' on the form 'type:lockfunctions', defining what functionality is locked and how to determine access. Not defining a lock means no access is granted.", verbose_name='locks', blank=True)), + ('db_is_connected', models.BooleanField(default=False, help_text='If account is connected to game or not', verbose_name='is_connected')), + ('db_cmdset_storage', models.CharField(help_text='optional python path to a cmdset class. If creating a Character, this will default to settings.CMDSET_CHARACTER.', max_length=255, null=True, verbose_name='cmdset')), + ('db_is_bot', models.BooleanField(default=False, help_text='Used to identify irc/rss bots', verbose_name='is_bot')), + ('db_attributes', models.ManyToManyField(help_text='attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).', to='typeclasses.Attribute', null=True)), + ('db_tags', models.ManyToManyField(help_text='tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag', null=True)), ('groups', models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Group', blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of his/her group.', verbose_name='groups')), ('user_permissions', models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Permission', blank=True, help_text='Specific permissions for this user.', verbose_name='user permissions')), ], diff --git a/evennia/accounts/migrations/0002_move_defaults.py b/evennia/accounts/migrations/0002_move_defaults.py index 461525a4cc..f0b3d6dd51 100644 --- a/evennia/accounts/migrations/0002_move_defaults.py +++ b/evennia/accounts/migrations/0002_move_defaults.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from __future__ import unicode_literals + from django.db import models, migrations diff --git a/evennia/accounts/migrations/0003_auto_20150209_2234.py b/evennia/accounts/migrations/0003_auto_20150209_2234.py index ccdb2fb897..81bb39abdb 100644 --- a/evennia/accounts/migrations/0003_auto_20150209_2234.py +++ b/evennia/accounts/migrations/0003_auto_20150209_2234.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from __future__ import unicode_literals + from django.db import models, migrations diff --git a/evennia/accounts/migrations/0004_auto_20150403_2339.py b/evennia/accounts/migrations/0004_auto_20150403_2339.py index 36d8110122..2ce75241cf 100644 --- a/evennia/accounts/migrations/0004_auto_20150403_2339.py +++ b/evennia/accounts/migrations/0004_auto_20150403_2339.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from __future__ import unicode_literals + from django.db import models, migrations import evennia.accounts.manager @@ -22,7 +22,7 @@ class Migration(migrations.Migration): migrations.AlterModelManagers( name='accountdb', managers=[ - (b'objects', evennia.accounts.manager.AccountDBManager()), + ('objects', evennia.accounts.manager.AccountDBManager()), ], ), migrations.AlterField( diff --git a/evennia/accounts/migrations/0005_auto_20160905_0902.py b/evennia/accounts/migrations/0005_auto_20160905_0902.py index 7ceaad2ac0..22f5955c1b 100644 --- a/evennia/accounts/migrations/0005_auto_20160905_0902.py +++ b/evennia/accounts/migrations/0005_auto_20160905_0902.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.9 on 2016-09-05 09:02 -from __future__ import unicode_literals + import django.core.validators from django.db import migrations, models diff --git a/evennia/accounts/migrations/0006_auto_20170606_1731.py b/evennia/accounts/migrations/0006_auto_20170606_1731.py index e48114b23c..50e084d242 100644 --- a/evennia/accounts/migrations/0006_auto_20170606_1731.py +++ b/evennia/accounts/migrations/0006_auto_20170606_1731.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.2 on 2017-06-06 17:31 -from __future__ import unicode_literals + import django.contrib.auth.validators from django.db import migrations, models @@ -16,12 +16,12 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='accountdb', name='db_attributes', - field=models.ManyToManyField(help_text=b'attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).', to='typeclasses.Attribute'), + field=models.ManyToManyField(help_text='attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).', to='typeclasses.Attribute'), ), migrations.AlterField( model_name='accountdb', name='db_tags', - field=models.ManyToManyField(help_text=b'tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag'), + field=models.ManyToManyField(help_text='tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag'), ), migrations.AlterField( model_name='accountdb', diff --git a/evennia/accounts/migrations/0007_copy_player_to_account.py b/evennia/accounts/migrations/0007_copy_player_to_account.py index ea1e00448c..0e573f2fa5 100644 --- a/evennia/accounts/migrations/0007_copy_player_to_account.py +++ b/evennia/accounts/migrations/0007_copy_player_to_account.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.2 on 2017-07-03 19:17 -from __future__ import unicode_literals + from django.apps import apps as global_apps from django.db import migrations diff --git a/evennia/accounts/migrations/0008_auto_20190128_1820.py b/evennia/accounts/migrations/0008_auto_20190128_1820.py new file mode 100644 index 0000000000..dbef6f41fd --- /dev/null +++ b/evennia/accounts/migrations/0008_auto_20190128_1820.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2019-01-28 18:20 +from __future__ import unicode_literals + +import django.contrib.auth.validators +from django.db import migrations, models +import evennia.accounts.manager + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0007_copy_player_to_account'), + ] + + operations = [ + migrations.AlterModelManagers( + name='accountdb', + managers=[ + ('objects', evennia.accounts.manager.AccountDBManager()), + ], + ), + migrations.AlterField( + model_name='accountdb', + name='db_attributes', + field=models.ManyToManyField(help_text='attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).', to='typeclasses.Attribute'), + ), + migrations.AlterField( + model_name='accountdb', + name='db_cmdset_storage', + field=models.CharField(help_text='optional python path to a cmdset class. If creating a Character, this will default to settings.CMDSET_CHARACTER.', max_length=255, null=True, verbose_name='cmdset'), + ), + migrations.AlterField( + model_name='accountdb', + name='db_date_created', + field=models.DateTimeField(auto_now_add=True, verbose_name='creation date'), + ), + migrations.AlterField( + model_name='accountdb', + name='db_is_bot', + field=models.BooleanField(default=False, help_text='Used to identify irc/rss bots', verbose_name='is_bot'), + ), + migrations.AlterField( + model_name='accountdb', + name='db_is_connected', + field=models.BooleanField(default=False, help_text='If player is connected to game or not', verbose_name='is_connected'), + ), + migrations.AlterField( + model_name='accountdb', + name='db_key', + field=models.CharField(db_index=True, max_length=255, verbose_name='key'), + ), + migrations.AlterField( + model_name='accountdb', + name='db_lock_storage', + field=models.TextField(blank=True, help_text="locks limit access to an entity. A lock is defined as a 'lock string' on the form 'type:lockfunctions', defining what functionality is locked and how to determine access. Not defining a lock means no access is granted.", verbose_name='locks'), + ), + migrations.AlterField( + model_name='accountdb', + name='db_tags', + field=models.ManyToManyField(help_text='tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag'), + ), + migrations.AlterField( + model_name='accountdb', + name='db_typeclass_path', + field=models.CharField(help_text="this defines what 'type' of entity this is. This variable holds a Python path to a module with a valid Evennia Typeclass.", max_length=255, null=True, verbose_name='typeclass'), + ), + migrations.AlterField( + model_name='accountdb', + name='username', + field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username'), + ), + ] diff --git a/evennia/accounts/models.py b/evennia/accounts/models.py index bf391b2d82..a0283e2aef 100644 --- a/evennia/accounts/models.py +++ b/evennia/accounts/models.py @@ -25,6 +25,7 @@ from django.utils.encoding import smart_str from evennia.accounts.manager import AccountDBManager from evennia.typeclasses.models import TypedObject from evennia.utils.utils import make_iter +from evennia.server.signals import SIGNAL_ACCOUNT_POST_RENAME __all__ = ("AccountDB",) @@ -138,16 +139,18 @@ class AccountDB(TypedObject, AbstractUser): def __str__(self): return smart_str("%s(account %s)" % (self.name, self.dbid)) - def __unicode__(self): - return u"%s(account#%s)" % (self.name, self.dbid) + def __repr__(self): + return "%s(account#%s)" % (self.name, self.dbid) #@property def __username_get(self): return self.username def __username_set(self, value): + old_name = self.username self.username = value self.save(update_fields=["username"]) + SIGNAL_ACCOUNT_POST_RENAME.send(self, old_name=old_name, new_name=value) def __username_del(self): del self.username diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 78ee87f37d..67cca23246 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -1,12 +1,16 @@ -from mock import Mock, MagicMock +# -*- coding: utf-8 -*- + +import sys +from mock import Mock, MagicMock, patch from random import randint from unittest import TestCase from django.test import override_settings from evennia.accounts.accounts import AccountSessionHandler -from evennia.accounts.accounts import DefaultAccount -from evennia.server.session import Session +from evennia.accounts.accounts import DefaultAccount, DefaultGuest +from evennia.utils.test_resources import EvenniaTest from evennia.utils import create +from evennia.utils.utils import uses_database from django.conf import settings @@ -59,6 +63,139 @@ class TestAccountSessionHandler(TestCase): self.assertEqual(self.handler.count(), len(self.handler.get())) +@override_settings(GUEST_ENABLED=True, GUEST_LIST=["bruce_wayne"]) +class TestDefaultGuest(EvenniaTest): + "Check DefaultGuest class" + + ip = '212.216.134.22' + + @override_settings(GUEST_ENABLED=False) + def test_create_not_enabled(self): + # Guest account should not be permitted + account, errors = DefaultGuest.authenticate(ip=self.ip) + self.assertFalse(account, 'Guest account was created despite being disabled.') + + def test_authenticate(self): + # Create a guest account + account, errors = DefaultGuest.authenticate(ip=self.ip) + self.assertTrue(account, 'Guest account should have been created.') + + # Create a second guest account + account, errors = DefaultGuest.authenticate(ip=self.ip) + self.assertFalse(account, 'Two guest accounts were created with a single entry on the guest list!') + + @patch("evennia.accounts.accounts.ChannelDB.objects.get_channel") + def test_create(self, get_channel): + get_channel.connect = MagicMock(return_value=True) + account, errors = DefaultGuest.create() + self.assertTrue(account, "Guest account should have been created.") + self.assertFalse(errors) + + def test_at_post_login(self): + self.account.db._last_puppet = self.char1 + self.account.at_post_login(self.session) + self.account.at_post_login() + + def test_at_server_shutdown(self): + account, errors = DefaultGuest.create(ip=self.ip) + self.char1.delete = MagicMock() + account.db._playable_characters = [self.char1] + account.at_server_shutdown() + self.char1.delete.assert_called() + + def test_at_post_disconnect(self): + account, errors = DefaultGuest.create(ip=self.ip) + self.char1.delete = MagicMock() + account.db._playable_characters = [self.char1] + account.at_post_disconnect() + self.char1.delete.assert_called() + + +class TestDefaultAccountAuth(EvenniaTest): + + def setUp(self): + super(TestDefaultAccountAuth, self).setUp() + + self.password = "testpassword" + self.account.delete() + self.account = create.create_account("TestAccount%s" % randint(100000, 999999), email="test@test.com", password=self.password, typeclass=DefaultAccount) + + def test_authentication(self): + "Confirm Account authentication method is authenticating/denying users." + # Valid credentials + obj, errors = DefaultAccount.authenticate(self.account.name, self.password) + self.assertTrue(obj, 'Account did not authenticate given valid credentials.') + + # Invalid credentials + obj, errors = DefaultAccount.authenticate(self.account.name, 'xyzzy') + self.assertFalse(obj, 'Account authenticated using invalid credentials.') + + def test_create(self): + "Confirm Account creation is working as expected." + # Create a normal account + account, errors = DefaultAccount.create(username='ziggy', password='stardust11') + self.assertTrue(account, 'New account should have been created.') + + # Try creating a duplicate account + account2, errors = DefaultAccount.create(username='Ziggy', password='starman11') + self.assertFalse(account2, 'Duplicate account name should not have been allowed.') + account.delete() + + def test_throttle(self): + "Confirm throttle activates on too many failures." + for x in range(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 + + if not uses_database("mysql"): + # TODO As of Mar 2019, mysql does not pass this test due to collation problems + # that has not been possible to resolve + result, error = DefaultAccount.validate_username('¯\_(ツ)_/¯') + self.assertFalse(result, "Validator allowed kanji in username.") + + # Should not allow duplicate username + result, error = DefaultAccount.validate_username(self.account.name) + self.assertFalse(result, "Duplicate username should not have passed validation.") + + # Should not allow username too short + result, error = DefaultAccount.validate_username('xx') + self.assertFalse(result, "2-character username passed validation.") + + def test_password_validation(self): + "Check password validators deny bad passwords" + + account = create.create_account("TestAccount%s" % randint(100000, 999999), + email="test@test.com", password="testpassword", typeclass=DefaultAccount) + for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'): + self.assertFalse(account.validate_password(bad, account=self.account)[0]) + + "Check validators allow sufficiently complex passwords" + for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"): + self.assertTrue(account.validate_password(better, account=self.account)[0]) + account.delete() + + def test_password_change(self): + "Check password setting and validation is working as expected" + account = create.create_account("TestAccount%s" % randint(100000, 999999), + email="test@test.com", password="testpassword", typeclass=DefaultAccount) + + from django.core.exceptions import ValidationError + # Try setting some bad passwords + for bad in ('', '#', 'TestAccount', 'password'): + valid, error = account.validate_password(bad, account) + self.assertFalse(valid) + + # Try setting a better password (test for False; returns None on success) + self.assertFalse(account.set_password('Mxyzptlk')) + account.delete() + + class TestDefaultAccount(TestCase): "Check DefaultAccount class" @@ -66,36 +203,6 @@ class TestDefaultAccount(TestCase): self.s1 = MagicMock() self.s1.puppet = None self.s1.sessid = 0 - self.s1.data_outj - - def tearDown(self): - if hasattr(self, "account"): - self.account.delete() - - def test_password_validation(self): - "Check password validators deny bad passwords" - - self.account = create.create_account("TestAccount%s" % randint(0, 9), - email="test@test.com", password="testpassword", typeclass=DefaultAccount) - for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'): - self.assertFalse(self.account.validate_password(bad, account=self.account)[0]) - - "Check validators allow sufficiently complex passwords" - for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"): - self.assertTrue(self.account.validate_password(better, account=self.account)[0]) - - def test_password_change(self): - "Check password setting and validation is working as expected" - self.account = create.create_account("TestAccount%s" % randint(0, 9), - email="test@test.com", password="testpassword", typeclass=DefaultAccount) - - from django.core.exceptions import ValidationError - # Try setting some bad passwords - for bad in ('', '#', 'TestAccount', 'password'): - self.assertRaises(ValidationError, self.account.set_password, bad) - - # Try setting a better password (test for False; returns None on success) - self.assertFalse(self.account.set_password('Mxyzptlk')) def test_puppet_object_no_object(self): "Check puppet_object method called with no object param" @@ -199,3 +306,90 @@ class TestDefaultAccount(TestCase): account.puppet_object(self.s1, obj) self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("is already puppeted by another Account.")) self.assertIsNone(obj.at_post_puppet.call_args) + + +class TestAccountPuppetDeletion(EvenniaTest): + + @override_settings(MULTISESSION_MODE=2) + def test_puppet_deletion(self): + # Check for existing chars + self.assertFalse(self.account.db._playable_characters, + 'Account should not have any chars by default.') + + # Add char1 to account's playable characters + self.account.db._playable_characters.append(self.char1) + self.assertTrue(self.account.db._playable_characters, + 'Char was not added to account.') + + # See what happens when we delete char1. + self.char1.delete() + # Playable char list should be empty. + self.assertFalse(self.account.db._playable_characters, + 'Playable character list is not empty! %s' % self.account.db._playable_characters) + + +class TestDefaultAccountEv(EvenniaTest): + """ + Testing using the EvenniaTest parent + + """ + def test_characters_property(self): + "test existence of None in _playable_characters Attr" + self.account.db._playable_characters = [self.char1, None] + chars = self.account.characters + self.assertEqual(chars, [self.char1]) + self.assertEqual(self.account.db._playable_characters, [self.char1]) + + def test_puppet_success(self): + self.account.msg = MagicMock() + with patch("evennia.accounts.accounts._MULTISESSION_MODE", 2): + self.account.puppet_object(self.session, self.char1) + self.account.msg.assert_called_with("You are already puppeting this object.") + + @patch("evennia.accounts.accounts.time.time", return_value=10000) + def test_idle_time(self, mock_time): + self.session.cmd_last_visible = 10000 - 10 + idle = self.account.idle_time + self.assertEqual(idle, 10) + + # test no sessions + with patch("evennia.accounts.accounts._SESSIONS.sessions_from_account", return_value=[]) as mock_sessh: + idle = self.account.idle_time + self.assertEqual(idle, None) + + @patch("evennia.accounts.accounts.time.time", return_value=10000) + def test_connection_time(self, mock_time): + self.session.conn_time = 10000 - 10 + conn = self.account.connection_time + self.assertEqual(conn, 10) + + # test no sessions + with patch("evennia.accounts.accounts._SESSIONS.sessions_from_account", return_value=[]) as mock_sessh: + idle = self.account.connection_time + self.assertEqual(idle, None) + + def test_create_account(self): + acct = create.account( + "TestAccount3", "test@test.com", "testpassword123", + locks="test:all()", + tags=[("tag1", "category1"), ("tag2", "category2", "data1"), ("tag3", None)], + attributes=[("key1", "value1", "category1", + "edit:false()", True), + ("key2", "value2")]) + acct.save() + self.assertTrue(acct.pk) + + def test_at_look(self): + ret = self.account.at_look() + self.assertTrue("Out-of-Character" in ret) + ret = self.account.at_look(target=self.obj1) + self.assertTrue("Obj" in ret) + ret = self.account.at_look(session=self.session) + self.assertTrue("*" in ret) # * marks session is active in list + ret = self.account.at_look(target=self.obj1, session=self.session) + self.assertTrue("Obj" in ret) + ret = self.account.at_look(target="Invalid", session=self.session) + self.assertEqual(ret, 'Invalid has no in-game appearance.') + + def test_msg(self): + self.account.msg diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index 44304b4ea1..a8c23a97ff 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -46,7 +46,7 @@ from django.conf import settings from evennia.commands.command import InterruptCommand from evennia.comms.channelhandler import CHANNELHANDLER from evennia.utils import logger, utils -from evennia.utils.utils import string_suggestions, to_unicode +from evennia.utils.utils import string_suggestions from django.utils.translation import ugettext as _ @@ -190,7 +190,7 @@ def _progressive_cmd_run(cmd, generator, response=None): try: if response is None: - value = generator.next() + value = next(generator) else: value = generator.send(response) except StopIteration: @@ -198,7 +198,7 @@ def _progressive_cmd_run(cmd, generator, response=None): else: if isinstance(value, (int, float)): utils.delay(value, _progressive_cmd_run, cmd, generator) - elif isinstance(value, basestring): + elif isinstance(value, str): _GET_INPUT(cmd.caller, value, _process_input, cmd=cmd, generator=generator) else: raise ValueError("unknown type for a yielded value in command: {}".format(type(value))) @@ -211,8 +211,8 @@ def _process_input(caller, prompt, result, cmd, generator): Args: caller (Character, Account or Session): the caller. - prompt (basestring): The sent prompt. - result (basestring): The unprocessed answer. + prompt (str): The sent prompt. + result (str): The unprocessed answer. cmd (Command): The command itself. generator (GeneratorType): The generator. @@ -443,7 +443,7 @@ 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(tempmergers.values(), key=lambda x: x.priority) + 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] @@ -618,8 +618,6 @@ def cmdhandler(called_by, raw_string, _testing=False, callertype="session", sess finally: _COMMAND_NESTING[called_by] -= 1 - raw_string = to_unicode(raw_string, force_string=True) - session, account, obj = session, None, None if callertype == "session": session = called_by @@ -649,6 +647,7 @@ def cmdhandler(called_by, raw_string, _testing=False, callertype="session", sess args = raw_string unformatted_raw_string = "%s%s" % (cmdname, args) cmdset = None + raw_cmdname = cmdname # session = session # account = account diff --git a/evennia/commands/cmdparser.py b/evennia/commands/cmdparser.py index e104233e49..2c78a247ab 100644 --- a/evennia/commands/cmdparser.py +++ b/evennia/commands/cmdparser.py @@ -5,7 +5,7 @@ replacing cmdparser function. The replacement parser must accept the same inputs as the default one. """ -from __future__ import division + import re from django.conf import settings @@ -15,6 +15,107 @@ _MULTIMATCH_REGEX = re.compile(settings.SEARCH_MULTIMATCH_REGEX, re.I + re.U) _CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES +def create_match(cmdname, string, cmdobj, raw_cmdname): + """ + Builds a command match by splitting the incoming string and + evaluating the quality of the match. + + Args: + cmdname (str): Name of command to check for. + string (str): The string to match against. + cmdobj (str): The full Command instance. + raw_cmdname (str, optional): If CMD_IGNORE_PREFIX is set and the cmdname starts with + one of the prefixes to ignore, this contains the raw, unstripped cmdname, + otherwise it is None. + + Returns: + match (tuple): This is on the form (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname), + where `cmdname` is the command's name and `args` is the rest of the incoming + string, without said command name. `cmdobj` is + the Command instance, the cmdlen is the same as len(cmdname) and mratio + is a measure of how big a part of the full input string the cmdname + takes up - an exact match would be 1.0. Finally, the `raw_cmdname` is + the cmdname unmodified by eventual prefix-stripping. + + """ + cmdlen, strlen = len(str(cmdname)), len(str(string)) + mratio = 1 - (strlen - cmdlen) / (1.0 * strlen) + args = string[cmdlen:] + return (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname) + + +def build_matches(raw_string, cmdset, include_prefixes=False): + """ + Build match tuples by matching raw_string against available commands. + + Args: + raw_string (str): Input string that can look in any way; the only assumption is + that the sought command's name/alias must be *first* in the string. + cmdset (CmdSet): The current cmdset to pick Commands from. + include_prefixes (bool): If set, include prefixes like @, ! etc (specified in settings) + in the match, otherwise strip them before matching. + + Returns: + matches (list) A list of match tuples created by `cmdparser.create_match`. + + """ + l_raw_string = raw_string.lower() + matches = [] + try: + if include_prefixes: + # use the cmdname as-is + for cmd in cmdset: + matches.extend([create_match(cmdname, raw_string, cmd, cmdname) + for cmdname in [cmd.key] + cmd.aliases + if cmdname and l_raw_string.startswith(cmdname.lower()) and + (not cmd.arg_regex or + cmd.arg_regex.match(l_raw_string[len(cmdname):]))]) + else: + # strip prefixes set in settings + for cmd in cmdset: + for raw_cmdname in [cmd.key] + cmd.aliases: + cmdname = raw_cmdname.lstrip(_CMD_IGNORE_PREFIXES) if len(raw_cmdname) > 1 else raw_cmdname + if cmdname and l_raw_string.startswith(cmdname.lower()) and \ + (not cmd.arg_regex or cmd.arg_regex.match(l_raw_string[len(cmdname):])): + matches.append(create_match(cmdname, raw_string, cmd, raw_cmdname)) + except Exception: + log_trace("cmdhandler error. raw_input:%s" % raw_string) + return matches + + +def try_num_prefixes(raw_string): + """ + Test if user tried to separate multi-matches with a number separator + (default 1-name, 2-name etc). This is usually called last, if no other + match was found. + + Args: + raw_string (str): The user input to parse. + + Returns: + mindex, new_raw_string (tuple): If a multimatch-separator was detected, + this is stripped out as an integer to separate between the matches. The + new_raw_string is the result of stripping out that identifier. If no + such form was found, returns (None, None). + + Example: + In the default configuration, entering 2-ball (e.g. in a room will more + than one 'ball' object), will lead to a multimatch and this function + will parse `"2-ball"` and return `(2, "ball")`. + + """ + # no matches found + num_ref_match = _MULTIMATCH_REGEX.match(raw_string) + if num_ref_match: + # the user might be trying to identify the command + # with a #num-command style syntax. We expect the regex to + # contain the groups "number" and "name". + mindex, new_raw_string = num_ref_match.group("number"), num_ref_match.group("name") + return mindex, new_raw_string + else: + return None, None + + def cmdparser(raw_string, cmdset, caller, match_index=None): """ This function is called by the cmdhandler once it has @@ -30,6 +131,10 @@ def cmdparser(raw_string, cmdset, caller, match_index=None): the first run resulted in a multimatch, and the index is given to select between the results for the second run. + Returns: + matches (list): This is a list of match-tuples as returned by `create_match`. + If no matches were found, this is an empty list. + Notes: The cmdparser understand the following command combinations (where [] marks optional parts. @@ -47,75 +152,11 @@ def cmdparser(raw_string, cmdset, caller, match_index=None): the remaining arguments, and the matched cmdobject from the cmdset. """ - - def create_match(cmdname, string, cmdobj, raw_cmdname): - """ - Builds a command match by splitting the incoming string and - evaluating the quality of the match. - - Args: - cmdname (str): Name of command to check for. - string (str): The string to match against. - cmdobj (str): The full Command instance. - raw_cmdname (str, optional): If CMD_IGNORE_PREFIX is set and the cmdname starts with - one of the prefixes to ignore, this contains the raw, unstripped cmdname, - otherwise it is None. - - Returns: - match (tuple): This is on the form (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname), where - `cmdname` is the command's name and `args` the rest of the incoming string, - without said command name. `cmdobj` is the Command instance, the cmdlen is - the same as len(cmdname) and mratio is a measure of how big a part of the - full input string the cmdname takes up - an exact match would be 1.0. Finally, - the `raw_cmdname` is the cmdname unmodified by eventual prefix-stripping. - - """ - cmdlen, strlen = len(unicode(cmdname)), len(unicode(string)) - mratio = 1 - (strlen - cmdlen) / (1.0 * strlen) - args = string[cmdlen:] - return (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname) - - def build_matches(raw_string, include_prefixes=False): - l_raw_string = raw_string.lower() - matches = [] - try: - if include_prefixes: - # use the cmdname as-is - for cmd in cmdset: - matches.extend([create_match(cmdname, raw_string, cmd, cmdname) - for cmdname in [cmd.key] + cmd.aliases - if cmdname and l_raw_string.startswith(cmdname.lower()) and - (not cmd.arg_regex or - cmd.arg_regex.match(l_raw_string[len(cmdname):]))]) - else: - # strip prefixes set in settings - for cmd in cmdset: - for raw_cmdname in [cmd.key] + cmd.aliases: - cmdname = raw_cmdname.lstrip(_CMD_IGNORE_PREFIXES) if len(raw_cmdname) > 1 else raw_cmdname - if cmdname and l_raw_string.startswith(cmdname.lower()) and \ - (not cmd.arg_regex or cmd.arg_regex.match(l_raw_string[len(cmdname):])): - matches.append(create_match(cmdname, raw_string, cmd, raw_cmdname)) - except Exception: - log_trace("cmdhandler error. raw_input:%s" % raw_string) - return matches - - def try_num_prefixes(raw_string): - if not matches: - # no matches found - num_ref_match = _MULTIMATCH_REGEX.match(raw_string) - if num_ref_match: - # the user might be trying to identify the command - # with a #num-command style syntax. We expect the regex to - # contain the groups "number" and "name". - mindex, new_raw_string = num_ref_match.group("number"), num_ref_match.group("name") - return mindex, new_raw_string - return None, None - if not raw_string: return [] # find mathces, first using the full name - matches = build_matches(raw_string, include_prefixes=True) + matches = build_matches(raw_string, cmdset, include_prefixes=True) if not matches: # try to match a number 1-cmdname, 2-cmdname etc mindex, new_raw_string = try_num_prefixes(raw_string) @@ -124,7 +165,7 @@ def cmdparser(raw_string, cmdset, caller, match_index=None): if _CMD_IGNORE_PREFIXES: # still no match. Try to strip prefixes raw_string = raw_string.lstrip(_CMD_IGNORE_PREFIXES) if len(raw_string) > 1 else raw_string - matches = build_matches(raw_string, include_prefixes=False) + matches = build_matches(raw_string, cmdset, include_prefixes=False) # only select command matches we are actually allowed to call. matches = [match for match in matches if match[2].access(caller, 'cmd')] diff --git a/evennia/commands/cmdset.py b/evennia/commands/cmdset.py index 6f127da1c2..95bbf9da22 100644 --- a/evennia/commands/cmdset.py +++ b/evennia/commands/cmdset.py @@ -54,7 +54,7 @@ class _CmdSetMeta(type): if not isinstance(cls.key_mergetypes, dict): cls.key_mergetypes = {} - super(_CmdSetMeta, cls).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) class CmdSet(with_metaclass(_CmdSetMeta, object)): diff --git a/evennia/commands/cmdsethandler.py b/evennia/commands/cmdsethandler.py index 14d195599c..d332c7fcd2 100644 --- a/evennia/commands/cmdsethandler.py +++ b/evennia/commands/cmdsethandler.py @@ -422,13 +422,13 @@ class CmdSetHandler(object): it's a 'quirk' that has to be documented. """ - if not (isinstance(cmdset, basestring) or utils.inherits_from(cmdset, CmdSet)): + if not (isinstance(cmdset, str) or utils.inherits_from(cmdset, CmdSet)): string = _("Only CmdSets can be added to the cmdsethandler!") raise Exception(string) if callable(cmdset): cmdset = cmdset(self.obj) - elif isinstance(cmdset, basestring): + elif isinstance(cmdset, str): # this is (maybe) a python path. Try to import from cache. cmdset = self._import_cmdset(cmdset) if cmdset and cmdset.key != '_CMDSET_ERROR': diff --git a/evennia/commands/command.py b/evennia/commands/command.py index 17902b3602..28d5fcb1d7 100644 --- a/evennia/commands/command.py +++ b/evennia/commands/command.py @@ -7,11 +7,15 @@ All commands in Evennia inherit from the 'Command' class in this module. from builtins import range import re +import math from django.conf import settings from evennia.locks.lockhandler import LockHandler from evennia.utils.utils import is_iter, fill, lazy_property, make_iter +from evennia.utils.evtable import EvTable +from evennia.utils.ansi import ANSIString + from future.utils import with_metaclass @@ -24,7 +28,10 @@ def _init_command(cls, **kwargs): and (optionally) at instantiation time. If kwargs are given, these are set as instance-specific properties - on the command. + on the command - but note that the Command instance is *re-used* on a given + host object, so a kwarg value set on the instance will *remain* on the instance + for subsequent uses of that Command on that particular object. + """ for i in range(len(kwargs)): # used for dynamic creation of commands @@ -65,7 +72,7 @@ def _init_command(cls, **kwargs): temp.append(lockstring) cls.lock_storage = ";".join(temp) - if hasattr(cls, 'arg_regex') and isinstance(cls.arg_regex, basestring): + if hasattr(cls, 'arg_regex') and isinstance(cls.arg_regex, str): cls.arg_regex = re.compile(r"%s" % cls.arg_regex, re.I + re.UNICODE) if not hasattr(cls, "auto_help"): cls.auto_help = True @@ -82,7 +89,7 @@ class CommandMeta(type): """ def __init__(cls, *args, **kwargs): _init_command(cls, **kwargs) - super(CommandMeta, cls).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # The Command class is the basic unit of an Evennia command; when # defining new commands, the admin subclass this class and @@ -201,6 +208,19 @@ class Command(with_metaclass(CommandMeta, object)): # probably got a string return cmd in self._matchset + def __hash__(self): + """ + Python 3 requires that any class which implements __eq__ must also + implement __hash__ and that the corresponding hashes for equivalent + instances are themselves equivalent. + + Technically, the following implementation is only valid for comparison + against other Commands, as our __eq__ supports comparison against + str, too. + + """ + return hash('\n'.join(self._matchset)) + def __ne__(self, cmd): """ The logical negation of __eq__. Since this is one of the most @@ -266,7 +286,7 @@ class Command(with_metaclass(CommandMeta, object)): caches are properly updated as well. """ - if isinstance(new_aliases, basestring): + if isinstance(new_aliases, str): new_aliases = new_aliases.split(';') aliases = (str(alias).strip().lower() for alias in make_iter(new_aliases)) self.aliases = list(set(alias for alias in aliases if alias != self.key)) @@ -394,6 +414,14 @@ class Command(with_metaclass(CommandMeta, object)): set in self.parse()) """ + variables = '\n'.join(" |w{}|n ({}): {}".format(key, type(val), val) for key, val in self.__dict__.items()) + string = f""" +Command {self} has no defined `func()` - showing on-command variables: +{variables} + """ + self.caller.msg(string) + return + # a simple test command to show the available properties string = "-" * 50 string += "\n|w%s|n - Command variables from evennia:\n" % self.key @@ -452,6 +480,149 @@ class Command(with_metaclass(CommandMeta, object)): """ return self.__doc__ + def client_width(self): + """ + Get the client screenwidth for the session using this command. + + Returns: + client width (int or None): The width (in characters) of the client window. None + if this command is run without a Session (such as by an NPC). + + """ + if self.session: + return self.session.protocol_flags['SCREENWIDTH'][0] + + def styled_table(self, *args, **kwargs): + """ + Create an EvTable styled by on user preferences. + + Args: + *args (str): Column headers. If not colored explicitly, these will get colors + from user options. + Kwargs: + any (str, int or dict): EvTable options, including, optionally a `table` dict + detailing the contents of the table. + Returns: + table (EvTable): An initialized evtable entity, either complete (if using `table` kwarg) + or incomplete and ready for use with `.add_row` or `.add_collumn`. + + """ + border_color = self.account.options.get('border_color') + column_color = self.account.options.get('column_names_color') + + colornames = ['|%s%s|n' % (column_color, col) for col in args] + + h_line_char = kwargs.pop('header_line_char', '~') + header_line_char = ANSIString(f'|{border_color}{h_line_char}|n') + c_char = kwargs.pop('corner_char', '+') + corner_char = ANSIString(f'|{border_color}{c_char}|n') + + b_left_char = kwargs.pop('border_left_char', '||') + border_left_char = ANSIString(f'|{border_color}{b_left_char}|n') + + b_right_char = kwargs.pop('border_right_char', '||') + border_right_char = ANSIString(f'|{border_color}{b_right_char}|n') + + b_bottom_char = kwargs.pop('border_bottom_char', '-') + border_bottom_char = ANSIString(f'|{border_color}{b_bottom_char}|n') + + b_top_char = kwargs.pop('border_top_char', '-') + border_top_char = ANSIString(f'|{border_color}{b_top_char}|n') + + table = EvTable(*colornames, header_line_char=header_line_char, corner_char=corner_char, + border_left_char=border_left_char, border_right_char=border_right_char, + border_top_char=border_top_char, **kwargs) + return table + + def _render_decoration(self, header_text=None, fill_character=None, edge_character=None, + mode='header', color_header=True, width=None): + """ + Helper for formatting a string into a pretty display, for a header, separator or footer. + + Kwargs: + header_text (str): Text to include in header. + fill_character (str): This single character will be used to fill the width of the + display. + edge_character (str): This character caps the edges of the display. + mode(str): One of 'header', 'separator' or 'footer'. + color_header (bool): If the header should be colorized based on user options. + width (int): If not given, the client's width will be used if available. + + Returns: + string (str): The decorated and formatted text. + + """ + + colors = dict() + colors['border'] = self.account.options.get('border_color') + colors['headertext'] = self.account.options.get('%s_text_color' % mode) + colors['headerstar'] = self.account.options.get('%s_star_color' % mode) + + width = width or self.width() + if edge_character: + width -= 2 + + if header_text: + if color_header: + header_text = ANSIString(header_text).clean() + header_text = ANSIString('|n|%s%s|n' % (colors['headertext'], header_text)) + if mode == 'header': + begin_center = ANSIString("|n|%s<|%s* |n" % (colors['border'], colors['headerstar'])) + end_center = ANSIString("|n |%s*|%s>|n" % (colors['headerstar'], colors['border'])) + center_string = ANSIString(begin_center + header_text + end_center) + else: + center_string = ANSIString('|n |%s%s |n' % (colors['headertext'], header_text)) + else: + center_string = '' + + fill_character = self.account.options.get('%s_fill' % mode) + + remain_fill = width - len(center_string) + if remain_fill % 2 == 0: + right_width = remain_fill / 2 + left_width = remain_fill / 2 + else: + right_width = math.floor(remain_fill / 2) + left_width = math.ceil(remain_fill / 2) + + right_fill = ANSIString('|n|%s%s|n' % (colors['border'], fill_character * int(right_width))) + left_fill = ANSIString('|n|%s%s|n' % (colors['border'], fill_character * int(left_width))) + + if edge_character: + edge_fill = ANSIString('|n|%s%s|n' % (colors['border'], edge_character)) + main_string = ANSIString(center_string) + final_send = ANSIString(edge_fill) + left_fill + main_string + right_fill + ANSIString(edge_fill) + else: + final_send = left_fill + ANSIString(center_string) + right_fill + return final_send + + def styled_header(self, *args, **kwargs): + """ + Create a pretty header. + """ + + if 'mode' not in kwargs: + kwargs['mode'] = 'header' + return self._render_decoration(*args, **kwargs) + + def styled_separator(self, *args, **kwargs): + """ + Create a separator. + + """ + if 'mode' not in kwargs: + kwargs['mode'] = 'separator' + return self._render_decoration(*args, **kwargs) + + def styled_footer(self, *args, **kwargs): + """ + Create a pretty footer. + + """ + if 'mode' not in kwargs: + kwargs['mode'] = 'footer' + return self._render_decoration(*args, **kwargs) + class InterruptCommand(Exception): diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index 71e7470a20..1a54bbc131 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -11,7 +11,7 @@ self.account to make sure to always use the account object rather than self.caller (which change depending on the level you are calling from) The property self.character can be used to access the character when these commands are triggered with a connected character (such as the -case of the @ooc command), it is None if we are OOC. +case of the `ooc` command), it is None if we are OOC. Note that under MULTISESSION_MODE > 2, Account commands should use self.msg() and similar methods to reroute returns to the correct @@ -21,9 +21,10 @@ method. Otherwise all text will be returned to all connected sessions. from builtins import range import time +from codecs import lookup as codecs_lookup from django.conf import settings from evennia.server.sessionhandler import SESSIONS -from evennia.utils import utils, create, search, evtable +from evennia.utils import utils, create, logger, search COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -46,7 +47,7 @@ class MuxAccountLookCommand(COMMAND_DEFAULT_CLASS): def parse(self): """Custom parsing""" - super(MuxAccountLookCommand, self).parse() + super().parse() if _MULTISESSION_MODE < 2: # only one character allowed - not used in this mode @@ -101,7 +102,7 @@ class CmdOOCLook(MuxAccountLookCommand): if _MULTISESSION_MODE < 2: # only one character allowed - self.msg("You are out-of-character (OOC).\nUse |w@ic|n to get back into the game.") + self.msg("You are out-of-character (OOC).\nUse |wic|n to get back into the game.") return # call on-account look helper method @@ -113,14 +114,14 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): create a new character Usage: - @charcreate [= desc] + charcreate [= desc] Create a new character, optionally giving it a description. You may use upper-case letters in the name - you will nevertheless always be able to access your character using lower-case letters if you want. """ - key = "@charcreate" + key = "charcreate" locks = "cmd:pperm(Player)" help_category = "General" @@ -131,7 +132,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): """create the new character""" account = self.account if not self.args: - self.msg("Usage: @charcreate [= description]") + self.msg("Usage: charcreate [= description]") return key = self.lhs desc = self.rhs @@ -162,15 +163,16 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): home=default_home, permissions=permissions) # only allow creator (and developers) to puppet this char - new_character.locks.add("puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer)" % - (new_character.id, account.id)) + new_character.locks.add("puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer);delete:id(%i) or perm(Admin)" % + (new_character.id, account.id, account.id)) account.db._playable_characters.append(new_character) if desc: new_character.db.desc = desc elif not new_character.db.desc: new_character.db.desc = "This is a character." - self.msg("Created new character %s. Use |w@ic %s|n to enter the game as this character." + self.msg("Created new character %s. Use |wic %s|n to enter the game as this character." % (new_character.key, new_character.key)) + logger.log_sec('Character Created: %s (Caller: %s, IP: %s).' % (new_character, account, self.session.address)) class CmdCharDelete(COMMAND_DEFAULT_CLASS): @@ -178,11 +180,11 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS): delete a character - this cannot be undone! Usage: - @chardelete + chardelete Permanently deletes one of your characters. """ - key = "@chardelete" + key = "chardelete" locks = "cmd:pperm(Player)" help_category = "General" @@ -191,7 +193,7 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS): account = self.account if not self.args: - self.msg("Usage: @chardelete ") + self.msg("Usage: chardelete ") return # use the playable_characters list to search @@ -214,12 +216,19 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS): caller.db._playable_characters = [pc for pc in caller.db._playable_characters if pc != delobj] delobj.delete() self.msg("Character '%s' was permanently deleted." % key) + logger.log_sec('Character Deleted: %s (Caller: %s, IP: %s).' % (key, account, self.session.address)) else: self.msg("Deletion was aborted.") del caller.ndb._char_to_delete match = match[0] account.ndb._char_to_delete = match + + # Return if caller has no permission to delete this + if not match.access(account, 'delete'): + self.msg("You do not have permission to delete this character.") + return + prompt = "|rThis will permanently destroy '%s'. This cannot be undone.|n Continue yes/[no]?" get_input(account, prompt % match.key, _callback) @@ -229,7 +238,7 @@ class CmdIC(COMMAND_DEFAULT_CLASS): control an object you have permission to puppet Usage: - @ic + ic Go in-character (IC) as a given Character. @@ -242,10 +251,10 @@ class CmdIC(COMMAND_DEFAULT_CLASS): as you the account have access right to puppet it. """ - key = "@ic" + key = "ic" # lock must be all() for different puppeted objects to access it. locks = "cmd:all()" - aliases = "@puppet" + aliases = "puppet" help_category = "General" # this is used by the parent @@ -262,7 +271,7 @@ class CmdIC(COMMAND_DEFAULT_CLASS): if not self.args: new_character = account.db._last_puppet if not new_character: - self.msg("Usage: @ic ") + self.msg("Usage: ic ") return if not new_character: # search for a matching character @@ -279,8 +288,10 @@ class CmdIC(COMMAND_DEFAULT_CLASS): try: account.puppet_object(session, new_character) account.db._last_puppet = new_character + logger.log_sec('Puppet Success: (Caller: %s, Target: %s, IP: %s).' % (account, new_character, self.session.address)) except RuntimeError as exc: self.msg("|rYou cannot become |C%s|n: %s" % (new_character.name, exc)) + logger.log_sec('Puppet Failed: %s (Caller: %s, Target: %s, IP: %s).' % (exc, account, new_character, self.session.address)) # note that this is inheriting from MuxAccountLookCommand, @@ -290,16 +301,16 @@ class CmdOOC(MuxAccountLookCommand): stop puppeting and go ooc Usage: - @ooc + ooc Go out-of-character (OOC). This will leave your current character and put you in a incorporeal OOC state. """ - key = "@ooc" + key = "ooc" locks = "cmd:pperm(Player)" - aliases = "@unpuppet" + aliases = "unpuppet" help_category = "General" # this is used by the parent @@ -326,7 +337,7 @@ class CmdOOC(MuxAccountLookCommand): if _MULTISESSION_MODE < 2: # only one character allowed - self.msg("You are out-of-character (OOC).\nUse |w@ic|n to get back into the game.") + self.msg("You are out-of-character (OOC).\nUse |wic|n to get back into the game.") return self.msg(account.at_look(target=self.playable, session=session)) @@ -340,12 +351,12 @@ class CmdSessions(COMMAND_DEFAULT_CLASS): check your connected session(s) Usage: - @sessions + sessions Lists the sessions currently connected to your account. """ - key = "@sessions" + key = "sessions" locks = "cmd:all()" help_category = "General" @@ -356,11 +367,11 @@ class CmdSessions(COMMAND_DEFAULT_CLASS): """Implement function""" account = self.account sessions = account.sessions.all() - table = evtable.EvTable("|wsessid", - "|wprotocol", - "|whost", - "|wpuppet/character", - "|wlocation") + table = self.styled_table("|wsessid", + "|wprotocol", + "|whost", + "|wpuppet/character", + "|wlocation") for sess in sorted(sessions, key=lambda x: x.sessid): char = account.get_puppet(sess) table.add_row(str(sess.sessid), str(sess.protocol_key), @@ -404,10 +415,10 @@ class CmdWho(COMMAND_DEFAULT_CLASS): else: show_session_data = account.check_permstring("Developer") or account.check_permstring("Admins") - naccounts = (SESSIONS.account_count()) + naccounts = SESSIONS.account_count() if show_session_data: # privileged info - table = evtable.EvTable("|wAccount Name", + table = self.styled_table("|wAccount Name", "|wOn for", "|wIdle", "|wPuppeting", @@ -433,7 +444,7 @@ class CmdWho(COMMAND_DEFAULT_CLASS): isinstance(session.address, tuple) and session.address[0] or session.address) else: # unprivileged - table = evtable.EvTable("|wAccount name", "|wOn for", "|wIdle") + table = self.styled_table("|wAccount name", "|wOn for", "|wIdle") for session in session_list: if not session.logged_in: continue @@ -453,7 +464,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): Set an account option Usage: - @option[/save] [name = value] + option[/save] [name = value] Switches: save - Save the current option settings for future logins. @@ -465,8 +476,8 @@ class CmdOption(COMMAND_DEFAULT_CLASS): """ - key = "@option" - aliases = "@options" + key = "option" + aliases = "options" switch_options = ("save", "clear") locks = "cmd:all()" @@ -489,7 +500,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): if "save" in self.switches: # save all options self.caller.db._saved_protocol_flags = flags - self.msg("|gSaved all options. Use @option/clear to remove.|n") + self.msg("|gSaved all options. Use option/clear to remove.|n") if "clear" in self.switches: # clear all saves self.caller.db._saved_protocol_flags = {} @@ -503,17 +514,17 @@ class CmdOption(COMMAND_DEFAULT_CLASS): options["SCREENWIDTH"] = options["SCREENWIDTH"][0] else: options["SCREENWIDTH"] = " \n".join("%s : %s" % (screenid, size) - for screenid, size in options["SCREENWIDTH"].iteritems()) + for screenid, size in options["SCREENWIDTH"].items()) if "SCREENHEIGHT" in options: if len(options["SCREENHEIGHT"]) == 1: options["SCREENHEIGHT"] = options["SCREENHEIGHT"][0] else: options["SCREENHEIGHT"] = " \n".join("%s : %s" % (screenid, size) - for screenid, size in options["SCREENHEIGHT"].iteritems()) + for screenid, size in options["SCREENHEIGHT"].items()) options.pop("TTYPE", None) header = ("Name", "Value", "Saved") if saved_options else ("Name", "Value") - table = evtable.EvTable(*header) + table = self.styled_table(*header) for key in sorted(options): row = [key, options[key]] if saved_options: @@ -526,7 +537,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): return if not self.rhs: - self.msg("Usage: @option [name = [value]]") + self.msg("Usage: option [name = [value]]") return # Try to assign new values @@ -534,7 +545,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): def validate_encoding(new_encoding): # helper: change encoding try: - utils.to_str(utils.to_unicode("test-string"), encoding=new_encoding) + codecs_lookup(new_encoding) except LookupError: raise RuntimeError("The encoding '|w%s|n' is invalid. " % new_encoding) return val @@ -608,11 +619,11 @@ class CmdPassword(COMMAND_DEFAULT_CLASS): change your password Usage: - @password = + password = Changes your password. Make sure to pick a safe one. """ - key = "@password" + key = "password" locks = "cmd:pperm(Player)" # this is used by the parent @@ -623,7 +634,7 @@ class CmdPassword(COMMAND_DEFAULT_CLASS): account = self.account if not self.rhs: - self.msg("Usage: @password = ") + self.msg("Usage: password = ") return oldpass = self.lhslist[0] # Both of these are newpass = self.rhslist[0] # already stripped by parse() @@ -641,6 +652,7 @@ class CmdPassword(COMMAND_DEFAULT_CLASS): account.set_password(newpass) account.save() self.msg("Password changed.") + logger.log_sec('Password Changed: %s (Caller: %s, IP: %s).' % (account, account, self.session.address)) class CmdQuit(COMMAND_DEFAULT_CLASS): @@ -648,7 +660,7 @@ class CmdQuit(COMMAND_DEFAULT_CLASS): quit the game Usage: - @quit + quit Switch: all - disconnect all connected sessions @@ -656,7 +668,7 @@ class CmdQuit(COMMAND_DEFAULT_CLASS): Gracefully disconnect your current session from the game. Use the /all switch to disconnect from all sessions. """ - key = "@quit" + key = "quit" switch_options = ("all",) locks = "cmd:all()" @@ -690,7 +702,7 @@ class CmdColorTest(COMMAND_DEFAULT_CLASS): testing which colors your client support Usage: - @color ansi||xterm256 + color ansi||xterm256 Prints a color map along with in-mud color codes to use to produce them. It also tests what is supported in your client. Choices are @@ -698,7 +710,7 @@ class CmdColorTest(COMMAND_DEFAULT_CLASS): standard. No checking is done to determine your client supports color - if not you will see rubbish appear. """ - key = "@color" + key = "color" locks = "cmd:all()" help_category = "General" @@ -793,7 +805,7 @@ class CmdColorTest(COMMAND_DEFAULT_CLASS): self.msg(string) else: # malformed input - self.msg("Usage: @color ansi||xterm256") + self.msg("Usage: color ansi||xterm256") class CmdQuell(COMMAND_DEFAULT_CLASS): @@ -813,8 +825,8 @@ class CmdQuell(COMMAND_DEFAULT_CLASS): Use the unquell command to revert back to normal operation. """ - key = "@quell" - aliases = ["@unquell"] + key = "quell" + aliases = ["unquell"] locks = "cmd:pperm(Player)" help_category = "General" @@ -836,7 +848,7 @@ class CmdQuell(COMMAND_DEFAULT_CLASS): """Perform the command""" account = self.account permstr = account.is_superuser and " (superuser)" or "(%s)" % (", ".join(account.permissions.all())) - if self.cmdstring in ('unquell', '@unquell'): + if self.cmdstring in ('unquell', 'unquell'): if not account.attributes.get('_quell'): self.msg("Already using normal Account permissions %s." % permstr) else: @@ -853,8 +865,47 @@ class CmdQuell(COMMAND_DEFAULT_CLASS): cpermstr = "Quelling to current puppet's permissions %s." % cpermstr cpermstr += "\n(Note: If this is higher than Account permissions %s," \ " the lowest of the two will be used.)" % permstr - cpermstr += "\nUse @unquell to return to normal permission usage." + cpermstr += "\nUse unquell to return to normal permission usage." self.msg(cpermstr) else: - self.msg("Quelling Account permissions%s. Use @unquell to get them back." % permstr) + self.msg("Quelling Account permissions%s. Use unquell to get them back." % permstr) self._recache_locks(account) + + +class CmdStyle(COMMAND_DEFAULT_CLASS): + """ + In-game style options + + Usage: + style + style