diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee241282c..4b2b0cebde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,11 @@ ## Main branch -Updated dependencies: Twisted >24 (<25). Python 3.10, 3.11, 3.12, 3.13. Will -drop 3.10 support as part of next major release. +Updated dependencies: Django >5.1 (<5,2), Twisted >24 (<25). +Python versions: 3.11, 3.12, 3.13. +- Feat (backwards incompatible): RUN MIGRATIONS (`evennia migrate`): Now requiring Django 5.1 (Griatch) +- Feat (backwards incompatible): Drop support and testing for Python 3.10 (Griatch) - [Feat][pull3719]: Support Python 3.13. (0xDEADFED5) - [Feat][pull3633]: Default object's default descs are now taken from a `default_description` class variable instead of the `desc` Attribute always being set (count-infinity) @@ -13,8 +15,7 @@ drop 3.10 support as part of next major release. strings instead of `None` if no name is provided, also enforce string type (InspectorCaracal) - [Fix][pull3682]: Allow in-game help searching for commands natively starting with `*` (which is the Lunr search wildcard) (count-infinity) -- [Fix][pull3684]: Web client stopped auto-focusing the input box after opening - settings (count-infinity) +- [Fix][pull3684]: Web client stopped auto-focusing the input box after opening settings (count-infinity) - [Fix][pull3689]: Partial matching fix in default search, makes sure e.g. `b sw` uniquely finds `big sword` even if another type of sword is around (InspectorCaracal) - [Fix][pull3690]: In searches, allow special 'here' and 'me' keywords only be valid queries @@ -37,7 +38,6 @@ drop 3.10 support as part of next major release. used as the task's category (Griatch) - [Docs]: Fixes from InspectorCaracal, Griatch, ChrisLR - [pull3633]: https://github.com/evennia/evennia/pull/3633 [pull3677]: https://github.com/evennia/evennia/pull/3677 [pull3682]: https://github.com/evennia/evennia/pull/3682 diff --git a/evennia/VERSION_REQS.txt b/evennia/VERSION_REQS.txt index eff063f934..a767c6b243 100644 --- a/evennia/VERSION_REQS.txt +++ b/evennia/VERSION_REQS.txt @@ -3,8 +3,8 @@ # when people upgrade outside regular channels). This file only supports lines of # `value = number` and only specific names supported by the handler. -PYTHON_MIN = 3.10 +PYTHON_MIN = 3.11 PYTHON_MAX_TESTED = 3.13.100 TWISTED_MIN = 24.11 DJANGO_MIN = 4.0.2 -DJANGO_MAX_TESTED = 4.2.100 +DJANGO_MAX_TESTED = 5.1.100 diff --git a/evennia/contrib/base_systems/awsstorage/tests.py b/evennia/contrib/base_systems/awsstorage/tests.py index 37736fcc59..7ea4f82832 100644 --- a/evennia/contrib/base_systems/awsstorage/tests.py +++ b/evennia/contrib/base_systems/awsstorage/tests.py @@ -8,7 +8,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.core.files.base import ContentFile from django.test import TestCase, override_settings -from django.utils.timezone import is_aware, utc +from django.utils.timezone import is_aware _SKIP = False try: @@ -533,7 +533,7 @@ class S3Boto3StorageTests(S3Boto3TestCase): def _test_storage_mtime(self, use_tz): obj = self.storage.bucket.Object.return_value - obj.last_modified = datetime.datetime.now(utc) + obj.last_modified = datetime.datetime.now(datetime.timezone.utc) name = "file.txt" self.assertFalse( diff --git a/evennia/game_template/server/logs/README.md b/evennia/game_template/server/logs/README.md index 35ad999cd5..16a4af70d7 100644 --- a/evennia/game_template/server/logs/README.md +++ b/evennia/game_template/server/logs/README.md @@ -1,15 +1,15 @@ This directory contains Evennia's log files. The existence of this README.md file is also necessary to correctly include the log directory in git (since log files are ignored by git and you can't -commit an empty directory). +commit an empty directory). -- `server.log` - log file from the game Server. -- `portal.log` - log file from Portal proxy (internet facing) +`server.log` - log file from the game Server. +`portal.log` - log file from Portal proxy (internet facing) Usually these logs are viewed together with `evennia -l`. They are also rotated every week so as not -to be too big. Older log names will have a name appended by `_month_date`. - -- `lockwarnings.log` - warnings from the lock system. -- `http_requests.log` - this will generally be empty unless turning on debugging inside the server. +to be too big. Older log names will have a name appended by `_month_date`. -- `channel_.log` - these are channel logs for the in-game channels They are also used - by the `/history` flag in-game to get the latest message history. +`lockwarnings.log` - warnings from the lock system. +`http_requests.log` - this will generally be empty unless turning on debugging inside the server. + +`channel_.log` - these are channel logs for the in-game channels They are also used +by the `/history` flag in-game to get the latest message history. diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 0fc94ca8d6..65869bc431 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -1484,69 +1484,78 @@ def create_superuser(): def check_database(always_return=False): """ - Check so the database exists. + Check if the database exists and has basic tables. This is only run by the launcher. Args: - always_return (bool, optional): If set, will always return True/False - also on critical errors. No output will be printed. + always_return (bool, optional): If True, will not raise exceptions on errors. + Returns: - exists (bool): `True` if the database exists, otherwise `False`. - - + exists (bool): `True` if database exists and seems set up, `False` otherwise. + If `always_return` is `False`, this will raise exceptions instead of + returning `False`. """ - # Check so a database exists and is accessible + # Check if database exists + from django.conf import settings from django.db import connection - tables = connection.introspection.get_table_list(connection.cursor()) - if not tables or not isinstance(tables[0], str): # django 1.8+ - tables = [tableinfo.name for tableinfo in tables] - if tables and "accounts_accountdb" in tables: - # database exists and seems set up. Initialize evennia. - evennia._init() - # Try to get Account#1 - from evennia.accounts.models import AccountDB + tables_to_check = [ + "accounts_accountdb", # base account table + "objects_objectdb", # base object table + "scripts_scriptdb", # base script table + "typeclasses_tag", # base tag table + ] try: - AccountDB.objects.get(id=1) - except (django.db.utils.OperationalError, ProgrammingError) as e: - if always_return: - return False - print(ERROR_DATABASE.format(traceback=e)) - sys.exit() - except AccountDB.DoesNotExist: - # no superuser yet. We need to create it. + with connection.cursor() as cursor: + # Get all table names in the database + if connection.vendor == "postgresql": + cursor.execute( + """ + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + """ + ) + elif connection.vendor == "mysql": + cursor.execute( + """ + SELECT table_name FROM information_schema.tables + WHERE table_schema = %s + """, + [settings.DATABASES["default"]["NAME"]], + ) + elif connection.vendor == "sqlite": + cursor.execute( + """ + SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + """ + ) + else: + if not always_return: + raise Exception(f"Unsupported database vendor: {connection.vendor}") + return False - other_superuser = AccountDB.objects.filter(is_superuser=True) - if other_superuser: - # Another superuser was found, but not with id=1. This may - # happen if using flush (the auto-id starts at a higher - # value). Wwe copy this superuser into id=1. To do - # this we must deepcopy it, delete it then save the copy - # with the new id. This allows us to avoid the UNIQUE - # constraint on usernames. - other = other_superuser[0] - other_id = other.id - other_key = other.username - print(WARNING_MOVING_SUPERUSER.format(other_key=other_key, other_id=other_id)) - res = "" - while res.upper() != "Y": - # ask for permission - res = eval(input("Continue [Y]/N: ")) - if res.upper() == "N": - sys.exit() - elif not res: - break - # continue with the - from copy import deepcopy + existing_tables = {row[0].lower() for row in cursor.fetchall()} - new = deepcopy(other) - other.delete() - new.id = 1 - new.save() - else: - create_superuser() - check_database(always_return=always_return) - return True + # Check if essential tables exist + missing_tables = [table for table in tables_to_check if table not in existing_tables] + + if missing_tables: + if always_return: + return False + raise Exception( + f"Database tables missing: {', '.join(missing_tables)}. " + "Did you remember to run migrations?" + ) + return True + + except Exception as exc: + if not always_return: + raise + import traceback + + traceback.print_exc() + return False def getenv(): diff --git a/evennia/typeclasses/migrations/0017_use_index_instead_of_index_together_in_tags.py b/evennia/typeclasses/migrations/0017_use_index_instead_of_index_together_in_tags.py new file mode 100644 index 0000000000..4cb2f273fb --- /dev/null +++ b/evennia/typeclasses/migrations/0017_use_index_instead_of_index_together_in_tags.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.6 on 2025-03-01 21:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("typeclasses", "0016_alter_attribute_id_alter_tag_id"), + ] + + operations = [ + # First create the index with the old name that Django expects + migrations.AddIndex( + model_name="tag", + index=models.Index( + fields=["db_key", "db_category", "db_tagtype", "db_model"], + name="typeclasses_tag_db_key_db_category_db_tagtype_db_model_idx", + ), + ), + # Then rename it to the new name + migrations.RenameIndex( + model_name="tag", + new_name="typeclasses_db_key_be0c81_idx", + old_fields=("db_key", "db_category", "db_tagtype", "db_model"), + ), + ] diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index b396419a4b..66cf60e1ce 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -14,7 +14,6 @@ from collections import defaultdict from django.conf import settings from django.db import models - from evennia.locks.lockfuncs import perm as perm_lockfunc from evennia.utils.utils import make_iter, to_str @@ -79,9 +78,10 @@ class Tag(models.Model): class Meta: "Define Django meta options" + verbose_name = "Tag" unique_together = (("db_key", "db_category", "db_tagtype", "db_model"),) - index_together = (("db_key", "db_category", "db_tagtype", "db_model"),) + indexes = [models.Index(fields=["db_key", "db_category", "db_tagtype", "db_model"])] def __lt__(self, other): return str(self) < str(other) diff --git a/evennia/utils/create.py b/evennia/utils/create.py index e90df490ad..326d1bab90 100644 --- a/evennia/utils/create.py +++ b/evennia/utils/create.py @@ -14,8 +14,7 @@ objects already existing in the database. """ -from django.contrib.contenttypes.models import ContentType -from django.db.utils import OperationalError, ProgrammingError +from django.utils.functional import SimpleLazyObject # limit symbol import from API __all__ = ( @@ -29,232 +28,254 @@ __all__ = ( _GA = object.__getattribute__ -# import objects this way to avoid circular import problems -try: - ObjectDB = ContentType.objects.get(app_label="objects", model="objectdb").model_class() - ScriptDB = ContentType.objects.get(app_label="scripts", model="scriptdb").model_class() - AccountDB = ContentType.objects.get(app_label="accounts", model="accountdb").model_class() - Msg = ContentType.objects.get(app_label="comms", model="msg").model_class() - ChannelDB = ContentType.objects.get(app_label="comms", model="channeldb").model_class() - HelpEntry = ContentType.objects.get(app_label="help", model="helpentry").model_class() - Tag = ContentType.objects.get(app_label="typeclasses", model="tag").model_class() -except (OperationalError, ProgrammingError): - # this is a fallback used during tests/doc building - print("Database not available yet - using temporary fallback for create managers.") - from evennia.accounts.models import AccountDB - from evennia.comms.models import ChannelDB, Msg - from evennia.help.models import HelpEntry - from evennia.objects.models import ObjectDB - from evennia.scripts.models import ScriptDB - from evennia.typeclasses.tags import Tag # noqa -# -# Game Object creation -# -# Create a new in-game object. -# -# Keyword Args: -# typeclass (class or str): Class or python path to a typeclass. -# key (str): Name of the new object. If not set, a name of -# `#dbref` will be set. -# location (Object or str): Obj or #dbref to use as the location of the new object. -# home (Object or str): Obj or #dbref to use as the object's home location. -# permissions (list): A list of permission strings or tuples (permstring, category). -# locks (str): one or more lockstrings, separated by semicolons. -# aliases (list): A list of alternative keys or tuples (aliasstring, category). -# tags (list): List of tag keys or tuples (tagkey, category) or (tagkey, category, data). -# destination (Object or str): Obj or #dbref to use as an Exit's target. -# report_to (Object): The object to return error messages to. -# nohome (bool): This allows the creation of objects without a -# default home location; only used when creating the default -# location itself or during unittests. -# attributes (list): Tuples on the form (key, value) or (key, value, category), -# (key, value, lockstring) or (key, value, lockstring, default_access). -# to set as Attributes on the new object. -# nattributes (list): Non-persistent tuples on the form (key, value). Note that -# adding this rarely makes sense since this data will not survive a reload. -# -# Returns: -# object (Object): A newly created object of the given typeclass. -# -# Raises: -# ObjectDB.DoesNotExist: If trying to create an Object with -# `location` or `home` that can't be found. -# +# Lazy-loaded model classes +def _get_objectdb(): + from django.contrib.contenttypes.models import ContentType -create_object = ObjectDB.objects.create_object -# alias for create_object + return ContentType.objects.get(app_label="objects", model="objectdb").model_class() + + +def _get_scriptdb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="scripts", model="scriptdb").model_class() + + +def _get_accountdb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="accounts", model="accountdb").model_class() + + +def _get_msg(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="comms", model="msg").model_class() + + +def _get_channeldb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="comms", model="channeldb").model_class() + + +def _get_helpentry(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="help", model="helpentry").model_class() + + +def _get_tag(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="typeclasses", model="tag").model_class() + + +# Lazy model instances +ObjectDB = SimpleLazyObject(_get_objectdb) +ScriptDB = SimpleLazyObject(_get_scriptdb) +AccountDB = SimpleLazyObject(_get_accountdb) +Msg = SimpleLazyObject(_get_msg) +ChannelDB = SimpleLazyObject(_get_channeldb) +HelpEntry = SimpleLazyObject(_get_helpentry) +Tag = SimpleLazyObject(_get_tag) + + +def create_object(*args, **kwargs): + """ + Create a new in-game object. + + Keyword Args: + typeclass (class or str): Class or python path to a typeclass. + key (str): Name of the new object. If not set, a name of + `#dbref` will be set. + location (Object or str): Obj or #dbref to use as the location of the new object. + home (Object or str): Obj or #dbref to use as the object's home location. + permissions (list): A list of permission strings or tuples (permstring, category). + locks (str): one or more lockstrings, separated by semicolons. + aliases (list): A list of alternative keys or tuples (aliasstring, category). + tags (list): List of tag keys or tuples (tagkey, category) or (tagkey, category, data). + destination (Object or str): Obj or #dbref to use as an Exit's target. + report_to (Object): The object to return error messages to. + nohome (bool): This allows the creation of objects without a + default home location; only used when creating the default + location itself or during unittests. + attributes (list): Tuples on the form (key, value) or (key, value, category), + (key, value, lockstring) or (key, value, lockstring, default_access). + to set as Attributes on the new object. + nattributes (list): Non-persistent tuples on the form (key, value). Note that + adding this rarely makes sense since this data will not survive a reload. + + Returns: + object (Object): A newly created object of the given typeclass. + + Raises: + ObjectDB.DoesNotExist: If trying to create an Object with + `location` or `home` that can't be found. + """ + return ObjectDB.objects.create_object(*args, **kwargs) + + +def create_script(*args, **kwargs): + """ + Create a new script. All scripts are a combination of a database + object that communicates with the database, and an typeclass that + 'decorates' the database object into being different types of + scripts. It's behaviour is similar to the game objects except + scripts has a time component and are more limited in scope. + + Keyword Args: + typeclass (class or str): Class or python path to a typeclass. + key (str): Name of the new object. If not set, a name of + #dbref will be set. + obj (Object): The entity on which this Script sits. If this + is `None`, we are creating a "global" script. + account (Account): The account on which this Script sits. It is + exclusiv to `obj`. + locks (str): one or more lockstrings, separated by semicolons. + interval (int): The triggering interval for this Script, in + seconds. If unset, the Script will not have a timing + component. + start_delay (bool): If `True`, will wait `interval` seconds + before triggering the first time. + repeats (int): The number of times to trigger before stopping. + If unset, will repeat indefinitely. + persistent (bool): If this Script survives a server shutdown + or not (all Scripts will survive a reload). + autostart (bool): If this Script will start immediately when + created or if the `start` method must be called explicitly. + report_to (Object): The object to return error messages to. + desc (str): Optional description of script + tags (list): List of tags or tuples (tag, category). + attributes (list): List if tuples (key, value) or (key, value, category) + (key, value, lockstring) or (key, value, lockstring, default_access). + + Returns: + script (obj): An instance of the script created + + See evennia.scripts.manager for methods to manipulate existing + scripts in the database. + """ + return ScriptDB.objects.create_script(*args, **kwargs) + + +def create_help_entry(*args, **kwargs): + """ + Create a static help entry in the help database. Note that Command + help entries are dynamic and directly taken from the __doc__ + entries of the command. The database-stored help entries are + intended for more general help on the game, more extensive info, + in-game setting information and so on. + + Args: + key (str): The name of the help entry. + entrytext (str): The body of te help entry + category (str, optional): The help category of the entry. + locks (str, optional): A lockstring to restrict access. + aliases (list of str, optional): List of alternative (likely shorter) keynames. + tags (lst, optional): List of tags or tuples `(tag, category)`. + + Returns: + help (HelpEntry): A newly created help entry. + """ + return HelpEntry.objects.create_help(*args, **kwargs) + + +def create_message(*args, **kwargs): + """ + Create a new communication Msg. Msgs represent a unit of + database-persistent communication between entites. + + Args: + senderobj (Object, Account, Script, str or list): The entity (or + entities) sending the Msg. If a `str`, this is the id-string + for an external sender type. + message (str): Text with the message. Eventual headers, titles + etc should all be included in this text string. Formatting + will be retained. + receivers (Object, Account, Script, str or list): An Account/Object to send + to, or a list of them. If a string, it's an identifier for an external + receiver. + locks (str): Lock definition string. + tags (list): A list of tags or tuples `(tag, category)`. + header (str): Mime-type or other optional information for the message + + Notes: + The Comm system is created to be very open-ended, so it's fully + possible to let a message both go several receivers at the same time, + it's up to the command definitions to limit this as desired. + """ + return Msg.objects.create_message(*args, **kwargs) + + +def create_channel(*args, **kwargs): + """ + Create A communication Channel. A Channel serves as a central hub + for distributing Msgs to groups of people without specifying the + receivers explicitly. Instead accounts may 'connect' to the channel + and follow the flow of messages. By default the channel allows + access to all old messages, but this can be turned off with the + keep_log switch. + + Args: + key (str): This must be unique. + + Keyword Args: + aliases (list of str): List of alternative (likely shorter) keynames. + desc (str): A description of the channel, for use in listings. + locks (str): Lockstring. + keep_log (bool): Log channel throughput. + typeclass (str or class): The typeclass of the Channel (not + often used). + tags (list): A list of tags or tuples `(tag[,category[,data]])`. + attrs (list): List of attributes on form `(name, value[,category[,lockstring]])` + + Returns: + channel (Channel): A newly created channel. + """ + return ChannelDB.objects.create_channel(*args, **kwargs) + + +def create_account(*args, **kwargs): + """ + This creates a new account. + + Args: + key (str): The account's name. This should be unique. + email (str or None): Email on valid addr@addr.domain form. If + the empty string, will be set to None. + password (str): Password in cleartext. + + Keyword Args: + typeclass (str): The typeclass to use for the account. + is_superuser (bool): Whether or not this account is to be a superuser + locks (str): Lockstring. + permission (list): List of permission strings. + tags (list): List of Tags on form `(key, category[, data])` + attributes (list): List of Attributes on form + `(key, value [, category, [,lockstring [, default_pass]]])` + report_to (Object): An object with a msg() method to report + errors to. If not given, errors will be logged. + + Returns: + Account: The newly created Account. + Raises: + ValueError: If `key` already exists in database. + + Notes: + Usually only the server admin should need to be superuser, all + other access levels can be handled with more fine-grained + permissions or groups. A superuser bypasses all lock checking + operations and is thus not suitable for play-testing the game. + """ + return AccountDB.objects.create_account(*args, **kwargs) + + +# Aliases for the creation functions object = create_object - - -# -# Script creation - -# Create a new script. All scripts are a combination of a database -# object that communicates with the database, and an typeclass that -# 'decorates' the database object into being different types of -# scripts. It's behaviour is similar to the game objects except -# scripts has a time component and are more limited in scope. -# -# Keyword Args: -# typeclass (class or str): Class or python path to a typeclass. -# key (str): Name of the new object. If not set, a name of -# #dbref will be set. -# obj (Object): The entity on which this Script sits. If this -# is `None`, we are creating a "global" script. -# account (Account): The account on which this Script sits. It is -# exclusiv to `obj`. -# locks (str): one or more lockstrings, separated by semicolons. -# interval (int): The triggering interval for this Script, in -# seconds. If unset, the Script will not have a timing -# component. -# start_delay (bool): If `True`, will wait `interval` seconds -# before triggering the first time. -# repeats (int): The number of times to trigger before stopping. -# If unset, will repeat indefinitely. -# persistent (bool): If this Script survives a server shutdown -# or not (all Scripts will survive a reload). -# autostart (bool): If this Script will start immediately when -# created or if the `start` method must be called explicitly. -# report_to (Object): The object to return error messages to. -# desc (str): Optional description of script -# tags (list): List of tags or tuples (tag, category). -# attributes (list): List if tuples (key, value) or (key, value, category) -# (key, value, lockstring) or (key, value, lockstring, default_access). -# -# Returns: -# script (obj): An instance of the script created -# -# See evennia.scripts.manager for methods to manipulate existing -# scripts in the database. - -create_script = ScriptDB.objects.create_script -# alias script = create_script - - -# -# Help entry creation -# - -# """ -# Create a static help entry in the help database. Note that Command -# help entries are dynamic and directly taken from the __doc__ -# entries of the command. The database-stored help entries are -# intended for more general help on the game, more extensive info, -# in-game setting information and so on. -# -# Args: -# key (str): The name of the help entry. -# entrytext (str): The body of te help entry -# category (str, optional): The help category of the entry. -# locks (str, optional): A lockstring to restrict access. -# aliases (list of str, optional): List of alternative (likely shorter) keynames. -# tags (lst, optional): List of tags or tuples `(tag, category)`. -# -# Returns: -# help (HelpEntry): A newly created help entry. -# - -create_help_entry = HelpEntry.objects.create_help -# alias help_entry = create_help_entry - - -# -# Comm system methods - -# -# Create a new communication Msg. Msgs represent a unit of -# database-persistent communication between entites. -# -# Args: -# senderobj (Object, Account, Script, str or list): The entity (or -# entities) sending the Msg. If a `str`, this is the id-string -# for an external sender type. -# message (str): Text with the message. Eventual headers, titles -# etc should all be included in this text string. Formatting -# will be retained. -# receivers (Object, Account, Script, str or list): An Account/Object to send -# to, or a list of them. If a string, it's an identifier for an external -# receiver. -# locks (str): Lock definition string. -# tags (list): A list of tags or tuples `(tag, category)`. -# header (str): Mime-type or other optional information for the message -# -# Notes: -# The Comm system is created to be very open-ended, so it's fully -# possible to let a message both go several receivers at the same time, -# it's up to the command definitions to limit this as desired. -# - -create_message = Msg.objects.create_message message = create_message -create_msg = create_message - - -# Create A communication Channel. A Channel serves as a central hub -# for distributing Msgs to groups of people without specifying the -# receivers explicitly. Instead accounts may 'connect' to the channel -# and follow the flow of messages. By default the channel allows -# access to all old messages, but this can be turned off with the -# keep_log switch. -# -# Args: -# key (str): This must be unique. -# -# Keyword Args: -# aliases (list of str): List of alternative (likely shorter) keynames. -# desc (str): A description of the channel, for use in listings. -# locks (str): Lockstring. -# keep_log (bool): Log channel throughput. -# typeclass (str or class): The typeclass of the Channel (not -# often used). -# tags (list): A list of tags or tuples `(tag, category)`. -# -# Returns: -# channel (Channel): A newly created channel. -# - -create_channel = ChannelDB.objects.create_channel channel = create_channel - - -# -# Account creation methods -# - -# This creates a new account. -# -# Args: -# key (str): The account's name. This should be unique. -# email (str or None): Email on valid addr@addr.domain form. If -# the empty string, will be set to None. -# password (str): Password in cleartext. -# -# Keyword Args: -# typeclass (str): The typeclass to use for the account. -# is_superuser (bool): Wether or not this account is to be a superuser -# locks (str): Lockstring. -# permission (list): List of permission strings. -# tags (list): List of Tags on form `(key, category[, data])` -# attributes (list): List of Attributes on form -# `(key, value [, category, [,lockstring [, default_pass]]])` -# report_to (Object): An object with a msg() method to report -# errors to. If not given, errors will be logged. -# -# Returns: -# Account: The newly created Account. -# Raises: -# ValueError: If `key` already exists in database. -# -# -# Notes: -# Usually only the server admin should need to be superuser, all -# other access levels can be handled with more fine-grained -# permissions or groups. A superuser bypasses all lock checking -# operations and is thus not suitable for play-testing the game. - -create_account = AccountDB.objects.create_account -# alias account = create_account diff --git a/evennia/utils/search.py b/evennia/utils/search.py index 7f94ed6378..26f4a384b1 100644 --- a/evennia/utils/search.py +++ b/evennia/utils/search.py @@ -21,13 +21,9 @@ Example: To reach the search method 'get_object_with_account' > from evennia.objects.models import ObjectDB > match = Object.objects.get_object_with_account(...) - """ -# Import the manager methods to be wrapped - -from django.contrib.contenttypes.models import ContentType -from django.db.utils import OperationalError, ProgrammingError +from django.utils.functional import SimpleLazyObject # limit symbol import from API __all__ = ( @@ -45,180 +41,265 @@ __all__ = ( ) -# import objects this way to avoid circular import problems -try: - ObjectDB = ContentType.objects.get(app_label="objects", model="objectdb").model_class() - AccountDB = ContentType.objects.get(app_label="accounts", model="accountdb").model_class() - ScriptDB = ContentType.objects.get(app_label="scripts", model="scriptdb").model_class() - Msg = ContentType.objects.get(app_label="comms", model="msg").model_class() - ChannelDB = ContentType.objects.get(app_label="comms", model="channeldb").model_class() - HelpEntry = ContentType.objects.get(app_label="help", model="helpentry").model_class() - Tag = ContentType.objects.get(app_label="typeclasses", model="tag").model_class() -except (OperationalError, ProgrammingError): - # this is a fallback used during tests/doc building - print("Database not available yet - using temporary fallback for search managers.") - from evennia.accounts.models import AccountDB - from evennia.comms.models import ChannelDB, Msg - from evennia.help.models import HelpEntry - from evennia.objects.models import ObjectDB - from evennia.scripts.models import ScriptDB - from evennia.typeclasses.tags import Tag # noqa +# Lazy-loaded model classes +def _get_objectdb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="objects", model="objectdb").model_class() + + +def _get_accountdb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="accounts", model="accountdb").model_class() + + +def _get_scriptdb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="scripts", model="scriptdb").model_class() + + +def _get_msg(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="comms", model="msg").model_class() + + +def _get_channeldb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="comms", model="channeldb").model_class() + + +def _get_helpentry(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="help", model="helpentry").model_class() + + +def _get_tag(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="typeclasses", model="tag").model_class() + + +# Lazy model instances +ObjectDB = SimpleLazyObject(_get_objectdb) +AccountDB = SimpleLazyObject(_get_accountdb) +ScriptDB = SimpleLazyObject(_get_scriptdb) +Msg = SimpleLazyObject(_get_msg) +ChannelDB = SimpleLazyObject(_get_channeldb) +HelpEntry = SimpleLazyObject(_get_helpentry) +Tag = SimpleLazyObject(_get_tag) + # ------------------------------------------------------------------- # Search manager-wrappers # ------------------------------------------------------------------- -# -# Search objects as a character -# -# NOTE: A more powerful wrapper of this method -# is reachable from within each command class -# by using self.caller.search()! -# -# def object_search(self, ostring=None, -# attribute_name=None, -# typeclass=None, -# candidates=None, -# exact=True): -# -# Search globally or in a list of candidates and return results. -# The result is always a list of Objects (or the empty list) -# -# Arguments: -# ostring: (str) The string to compare names against. By default (if -# not attribute_name is set), this will search object.key -# and object.aliases in order. Can also be on the form #dbref, -# which will, if exact=True be matched against primary key. -# attribute_name: (str): Use this named ObjectAttribute to match ostring -# against, instead of the defaults. -# typeclass (str or TypeClass): restrict matches to objects having -# this typeclass. This will help speed up global searches. -# candidates (list obj ObjectDBs): If supplied, search will only be -# performed among the candidates in this list. A common list -# of candidates is the contents of the current location. -# exact (bool): Match names/aliases exactly or partially. Partial -# matching matches the beginning of words in the names/aliases, -# using a matching routine to separate multiple matches in -# names with multiple components (so "bi sw" will match -# "Big sword"). Since this is more expensive than exact -# matching, it is recommended to be used together with -# the objlist keyword to limit the number of possibilities. -# This keyword has no meaning if attribute_name is set. -# -# Returns: -# A list of matching objects (or a list with one unique match) -# def object_search(self, ostring, caller=None, -# candidates=None, -# attribute_name=None): -# -search_object = ObjectDB.objects.search_object + +def search_object(*args, **kwargs): + """ + Search for objects in the database. + + Args: + key (str or int): Object key or dbref to search for. This can also + be a list of keys/dbrefs. `None` (default) returns all objects. + exact (bool): Only valid for string keys. If True, requires exact + key match, otherwise also match key with case-insensitive and + partial matching. Default is True. + candidates (list): Only search among these object candidates, + if given. Default is to search all objects. + attribute_name (str): If set, search by objects with this attribute_name + defined on them, with the value specified by `attribute_value`. + attribute_value (any): What value the given attribute_name must have. + location (Object): Filter by objects at this location. + typeclass (str or TypeClass): Filter by objects having this typeclass. + This can also be a list of typeclasses. + tags (str or list): Filter by objects having one or more Tags. + This can be a single tag key, a list of tag keys, or a list of + tuples (tag_key, tag_category). + nofetch (bool): Don't fetch typeclass and perms data from db. + This is faster but gives less info. + + Returns: + matches (list): List of Objects matching the search criteria. + """ + return ObjectDB.objects.search_object(*args, **kwargs) + + search_objects = search_object object_search = search_object objects = search_objects -# -# Search for accounts -# -# account_search(self, ostring) -# Searches for a particular account by name or -# database id. -# -# ostring = a string or database id. -# +def search_account(*args, **kwargs): + """ + Search for accounts in the database. + + Args: + key (str or int): Account key or dbref to search for. This can also + be a list of keys/dbrefs. `None` (default) returns all accounts. + exact (bool): Only valid for string keys. If True, requires exact + key match, otherwise also match key with case-insensitive and + partial matching. Default is True. + candidates (list): Only search among these account candidates, + if given. Default is to search all accounts. + attribute_name (str): If set, search by accounts with this attribute_name + defined on them, with the value specified by `attribute_value`. + attribute_value (any): What value the given attribute_name must have. + tags (str or list): Filter by accounts having one or more Tags. + This can be a single tag key, a list of tag keys, or a list of + tuples (tag_key, tag_category). + nofetch (bool): Don't fetch typeclass and perms data from db. + This is faster but gives less info. + + Returns: + matches (list): List of Accounts matching the search criteria. + """ + return AccountDB.objects.search_account(*args, **kwargs) + -search_account = AccountDB.objects.search_account search_accounts = search_account account_search = search_account accounts = search_accounts -# -# Searching for scripts -# -# script_search(self, ostring, obj=None, only_timed=False) -# -# Search for a particular script. -# -# ostring - search criterion - a script ID or key -# obj - limit search to scripts defined on this object -# only_timed - limit search only to scripts that run -# on a timer. -# -search_script = ScriptDB.objects.search_script +def search_script(*args, **kwargs): + """ + Search for scripts in the database. + + Args: + key (str or int): Script key or dbref to search for. This can also + be a list of keys/dbrefs. `None` (default) returns all scripts. + exact (bool): Only valid for string keys. If True, requires exact + key match, otherwise also match key with case-insensitive and + partial matching. Default is True. + candidates (list): Only search among these script candidates, + if given. Default is to search all scripts. + attribute_name (str): If set, search by scripts with this attribute_name + defined on them, with the value specified by `attribute_value`. + attribute_value (any): What value the given attribute_name must have. + obj (Object): Filter by scripts defined on this object. + account (Account): Filter by scripts defined on this account. + typeclass (str or TypeClass): Filter by scripts having this typeclass. + This can also be a list of typeclasses. + tags (str or list): Filter by scripts having one or more Tags. + This can be a single tag key, a list of tag keys, or a list of + tuples (tag_key, tag_category). + nofetch (bool): Don't fetch typeclass and perms data from db. + This is faster but gives less info. + + Returns: + matches (list): List of Scripts matching the search criteria. + """ + return ScriptDB.objects.search_script(*args, **kwargs) + + search_scripts = search_script script_search = search_script scripts = search_scripts -# -# Searching for communication messages -# -# -# message_search(self, sender=None, receiver=None, channel=None, freetext=None) -# -# Search the message database for particular messages. At least one -# of the arguments must be given to do a search. -# -# sender - get messages sent by a particular account -# receiver - get messages received by a certain account -# channel - get messages sent to a particular channel -# freetext - Search for a text string in a message. -# NOTE: This can potentially be slow, so make sure to supply -# one of the other arguments to limit the search. -# -search_message = Msg.objects.search_message + +def search_message(*args, **kwargs): + """ + Search for messages in the database. + + Args: + sender (Object, Account or str): Filter by messages sent by this entity. + If a string, this is an external sender name. + receiver (Object, Account or str): Filter by messages received by this entity. + If a string, this is an external receiver name. + channel (Channel): Filter by messages sent to this channel. + date (datetime): Filter by messages sent on this date. + type (str): Filter by messages of this type. + tags (str or list): Filter by messages having one or more Tags. + This can be a single tag key, a list of tag keys, or a list of + tuples (tag_key, tag_category). + exclude_tags (str or list): Exclude messages with these tags. + search_text (str): Search for text in message content. + exact (bool): If True, require exact text match. Default False. + + Returns: + matches (list): List of Messages matching the search criteria. + """ + return Msg.objects.search_message(*args, **kwargs) + + search_messages = search_message message_search = search_message messages = search_messages -# -# Search for Communication Channels -# -# channel_search(self, ostring) -# -# Search the channel database for a particular channel. -# -# ostring - the key or database id of the channel. -# exact - requires an exact ostring match (not case sensitive) -# -search_channel = ChannelDB.objects.search_channel +def search_channel(*args, **kwargs): + """ + Search for channels in the database. + + Args: + key (str or int): Channel key or dbref to search for. This can also + be a list of keys/dbrefs. `None` (default) returns all channels. + exact (bool): Only valid for string keys. If True, requires exact + key match, otherwise also match key with case-insensitive and + partial matching. Default is True. + candidates (list): Only search among these channel candidates, + if given. Default is to search all channels. + attribute_name (str): If set, search by channels with this attribute_name + defined on them, with the value specified by `attribute_value`. + attribute_value (any): What value the given attribute_name must have. + typeclass (str or TypeClass): Filter by channels having this typeclass. + This can also be a list of typeclasses. + tags (str or list): Filter by channels having one or more Tags. + This can be a single tag key, a list of tag keys, or a list of + tuples (tag_key, tag_category). + nofetch (bool): Don't fetch typeclass and perms data from db. + This is faster but gives less info. + + Returns: + matches (list): List of Channels matching the search criteria. + """ + return ChannelDB.objects.search_channel(*args, **kwargs) + + search_channels = search_channel channel_search = search_channel channels = search_channels -# -# Find help entry objects. -# -# search_help(self, ostring, help_category=None) -# -# Retrieve a search entry object. -# -# ostring - the help topic to look for -# category - limit the search to a particular help topic -# -search_help = HelpEntry.objects.search_help +def search_help(*args, **kwargs): + """ + Search for help entries in the database. + + Args: + key (str or int): Help entry key or dbref to search for. This can also + be a list of keys/dbrefs. `None` (default) returns all help entries. + exact (bool): Only valid for string keys. If True, requires exact + key match, otherwise also match key with case-insensitive and + partial matching. Default is True. + category (str): Filter by help entries in this category. + tags (str or list): Filter by help entries having one or more Tags. + This can be a single tag key, a list of tag keys, or a list of + tuples (tag_key, tag_category). + locks (str): Filter by help entries with these locks. + + Returns: + matches (list): List of HelpEntries matching the search criteria. + """ + return HelpEntry.objects.search_help(*args, **kwargs) + + search_help_entry = search_help search_help_entries = search_help help_entry_search = search_help help_entries = search_help -# Locate Attributes - -# search_object_attribute(key, category, value, strvalue) (also search_attribute works) -# search_account_attribute(key, category, value, strvalue) (also search_attribute works) -# search_script_attribute(key, category, value, strvalue) (also search_attribute works) -# search_channel_attribute(key, category, value, strvalue) (also search_attribute works) - -# Note that these return the object attached to the Attribute, -# not the attribute object itself (this is usually what you want) - - def search_object_attribute( key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs ): + """ + Search for objects by their attributes. + """ return ObjectDB.objects.get_by_attribute( key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs ) @@ -227,6 +308,9 @@ def search_object_attribute( def search_account_attribute( key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs ): + """ + Search for accounts by their attributes. + """ return AccountDB.objects.get_by_attribute( key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs ) @@ -235,6 +319,9 @@ def search_account_attribute( def search_script_attribute( key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs ): + """ + Search for scripts by their attributes. + """ return ScriptDB.objects.get_by_attribute( key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs ) @@ -243,23 +330,20 @@ def search_script_attribute( def search_channel_attribute( key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs ): + """ + Search for channels by their attributes. + """ return ChannelDB.objects.get_by_attribute( key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs ) -# search for attribute objects -search_attribute_object = ObjectDB.objects.get_attribute - -# Locate Tags - -# search_object_tag(key=None, category=None) (also search_tag works) -# search_account_tag(key=None, category=None) -# search_script_tag(key=None, category=None) -# search_channel_tag(key=None, category=None) - -# Note that this returns the object attached to the tag, not the tag -# object itself (this is usually what you want) +# Replace direct assignments with functions +def search_attribute_object(*args, **kwargs): + """ + Search for attribute objects. + """ + return ObjectDB.objects.get_attribute(*args, **kwargs) def search_object_by_tag(key=None, category=None, tagtype=None, **kwargs): @@ -281,7 +365,6 @@ def search_object_by_tag(key=None, category=None, tagtype=None, **kwargs): matches (list): List of Objects with tags matching the search criteria, or an empty list if no matches were found. - """ return ObjectDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs) @@ -292,23 +375,6 @@ search_tag = search_object_by_tag # this is the most common case def search_account_tag(key=None, category=None, tagtype=None, **kwargs): """ Find account based on tag or category. - - Args: - key (str, optional): The tag key to search for. - category (str, optional): The category of tag - to search for. If not set, uncategorized - tags will be searched. - tagtype (str, optional): 'type' of Tag, by default - this is either `None` (a normal Tag), `alias` or - `permission`. This always apply to all queried tags. - kwargs (any): Other optional parameter that may be supported - by the manager method. - - Returns: - matches (list): List of Accounts with tags matching - the search criteria, or an empty list if no - matches were found. - """ return AccountDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs) @@ -316,23 +382,6 @@ def search_account_tag(key=None, category=None, tagtype=None, **kwargs): def search_script_tag(key=None, category=None, tagtype=None, **kwargs): """ Find script based on tag or category. - - Args: - key (str, optional): The tag key to search for. - category (str, optional): The category of tag - to search for. If not set, uncategorized - tags will be searched. - tagtype (str, optional): 'type' of Tag, by default - this is either `None` (a normal Tag), `alias` or - `permission`. This always apply to all queried tags. - kwargs (any): Other optional parameter that may be supported - by the manager method. - - Returns: - matches (list): List of Scripts with tags matching - the search criteria, or an empty list if no - matches were found. - """ return ScriptDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs) @@ -340,35 +389,16 @@ def search_script_tag(key=None, category=None, tagtype=None, **kwargs): def search_channel_tag(key=None, category=None, tagtype=None, **kwargs): """ Find channel based on tag or category. - - Args: - key (str, optional): The tag key to search for. - category (str, optional): The category of tag - to search for. If not set, uncategorized - tags will be searched. - tagtype (str, optional): 'type' of Tag, by default - this is either `None` (a normal Tag), `alias` or - `permission`. This always apply to all queried tags. - kwargs (any): Other optional parameter that may be supported - by the manager method. - - Returns: - matches (list): List of Channels with tags matching - the search criteria, or an empty list if no - matches were found. - """ return ChannelDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs) -# search for tag objects (not the objects they are attached to -search_tag_object = ObjectDB.objects.get_tag - - -# Locate Objects by Typeclass - -# search_objects_by_typeclass(typeclass="", include_children=False, include_parents=False) (also search_typeclass works) -# This returns the objects of the given typeclass +# Replace direct assignment with function +def search_tag_object(*args, **kwargs): + """ + Search for tag objects. + """ + return ObjectDB.objects.get_tag(*args, **kwargs) def search_objects_by_typeclass(typeclass, include_children=False, include_parents=False): diff --git a/evennia/web/templates/website/_menu.html b/evennia/web/templates/website/_menu.html index 6bd1e0f704..f55837c018 100644 --- a/evennia/web/templates/website/_menu.html +++ b/evennia/web/templates/website/_menu.html @@ -5,86 +5,98 @@ folder and edit it to add/remove links to the menu. {% endcomment %} {% load static %} diff --git a/evennia/web/website/tests.py b/evennia/web/website/tests.py index 40e453c173..3725fb3da2 100644 --- a/evennia/web/website/tests.py +++ b/evennia/web/website/tests.py @@ -97,6 +97,20 @@ class LoginTest(EvenniaWebTest): class LogoutTest(EvenniaWebTest): url_name = "logout" + def test_get(self): + """Since Django 5.0, logout is no longer supported with GET requests""" + pass + + def test_post(self): + """Do the logout test with a POST request""" + response = self.client.post(reverse(self.url_name), follow=True) + self.assertEqual(response.status_code, 200) + + def test_get_authenticated(self): + """Do the logout test with a POST instead of GET""" + response = self.client.post(reverse(self.url_name), follow=True) + self.assertEqual(response.status_code, 200) + class PasswordResetTest(EvenniaWebTest): url_name = "password_change" diff --git a/pyproject.toml b/pyproject.toml index 07d5abb548..ac9df2c4e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ classifiers = [ dependencies = [ # core dependencies "legacy-cgi;python_version >= '3.13'", - "django >= 4.2, < 4.3", + "django >= 5.1, < 5.2", "twisted >= 24.11.0, < 25", "pytz >= 2022.6", "djangorestframework >= 3.14, < 3.15",