Make scripts/objects lists use EvMore. Change EvMore to not justify by default.

This commit is contained in:
Griatch 2020-01-11 15:49:12 +01:00
parent b5aee2c41e
commit 69d85bd184
221 changed files with 2190 additions and 6810 deletions

View file

@ -26,13 +26,7 @@ from evennia.commands import cmdhandler
from evennia.server.models import ServerConfig
from evennia.server.throttle import Throttle
from evennia.utils import class_from_module, create, logger
from evennia.utils.utils import (
lazy_property,
to_str,
make_iter,
is_iter,
variable_from_module,
)
from evennia.utils.utils import lazy_property, to_str, make_iter, is_iter, variable_from_module
from evennia.server.signals import (
SIGNAL_ACCOUNT_POST_CREATE,
SIGNAL_OBJECT_POST_PUPPET,
@ -57,10 +51,12 @@ _CMDSET_ACCOUNT = settings.CMDSET_ACCOUNT
_MUDINFO_CHANNEL = None
# Create throttles for too many account-creations and login attempts
CREATION_THROTTLE = Throttle(limit=settings.CREATION_THROTTLE_LIMIT,
timeout=settings.CREATION_THROTTLE_TIMEOUT)
LOGIN_THROTTLE = Throttle(limit=settings.LOGIN_THROTTLE_LIMIT,
timeout=settings.LOGIN_THROTTLE_TIMEOUT)
CREATION_THROTTLE = Throttle(
limit=settings.CREATION_THROTTLE_LIMIT, timeout=settings.CREATION_THROTTLE_TIMEOUT
)
LOGIN_THROTTLE = Throttle(
limit=settings.LOGIN_THROTTLE_LIMIT, timeout=settings.LOGIN_THROTTLE_TIMEOUT
)
class AccountSessionHandler(object):
@ -297,9 +293,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
self.msg(txt1, session=session)
self.msg(txt2, session=obj.sessions.all())
else:
txt1 = (
f"Taking over |c{obj.name}|n from another of your sessions."
)
txt1 = f"Taking over |c{obj.name}|n from another of your sessions."
txt2 = f"|c{obj.name}|n|R is now acted from another of your sessions.|n"
self.msg(txt1, session=session)
self.msg(txt2, session=obj.sessions.all())
@ -354,9 +348,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
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
)
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
@ -392,9 +384,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
by this Account.
"""
return list(
set(session.puppet for session in self.sessions.all() if session.puppet)
)
return list(set(session.puppet for session in self.sessions.all() if session.puppet))
def __get_single_puppet(self):
"""
@ -737,11 +727,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
try:
try:
account = create.create_account(
username,
email,
password,
permissions=permissions,
typeclass=typeclass,
username, email, password, permissions=permissions, typeclass=typeclass
)
logger.log_sec(f"Account Created: {account} (IP: {ip}).")
@ -762,13 +748,9 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
account.db.creator_ip = ip
# join the new account to the public channel
pchannel = ChannelDB.objects.get_channel(
settings.DEFAULT_CHANNELS[0]["key"]
)
pchannel = ChannelDB.objects.get_channel(settings.DEFAULT_CHANNELS[0]["key"])
if not pchannel or not pchannel.connect(account):
string = (
f"New account '{account.key}' could not connect to public channel!"
)
string = f"New account '{account.key}' could not connect to public channel!"
errors.append(string)
logger.log_err(string)
@ -803,9 +785,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
# 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."
)
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
@ -832,9 +812,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
except RuntimeError:
# no puppet to disconnect from
pass
session.sessionhandler.disconnect(
session, reason=_("Account being deleted.")
)
session.sessionhandler.disconnect(session, reason=_("Account being deleted."))
self.scripts.stop()
self.attributes.clear()
self.nicks.clear()
@ -994,12 +972,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
return matches
def access(
self,
accessing_obj,
access_type="read",
default=False,
no_superuser_bypass=False,
**kwargs,
self, accessing_obj, access_type="read", default=False, no_superuser_bypass=False, **kwargs
):
"""
Determines if another object has permission to access this
@ -1079,9 +1052,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
"""
# set an (empty) attribute holding the characters this account has
lockstring = (
"attrread:perm(Admins);attredit:perm(Admins);" "attrcreate:perm(Admins);"
)
lockstring = "attrread:perm(Admins);attredit:perm(Admins);" "attrcreate:perm(Admins);"
self.attributes.add("_playable_characters", [], lockstring=lockstring)
self.attributes.add("_saved_protocol_flags", {}, lockstring=lockstring)
@ -1236,19 +1207,13 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
global _MUDINFO_CHANNEL
if not _MUDINFO_CHANNEL:
try:
_MUDINFO_CHANNEL = ChannelDB.objects.filter(
db_key=settings.CHANNEL_MUDINFO["key"]
)[0]
_MUDINFO_CHANNEL = ChannelDB.objects.filter(db_key=settings.CHANNEL_MUDINFO["key"])[
0
]
except Exception:
logger.log_trace()
now = timezone.now()
now = "%02i-%02i-%02i(%02i:%02i)" % (
now.year,
now.month,
now.day,
now.hour,
now.minute,
)
now = "%02i-%02i-%02i(%02i:%02i)" % (now.year, now.month, now.day, now.hour, now.minute)
if _MUDINFO_CHANNEL:
_MUDINFO_CHANNEL.tempmsg(f"[{_MUDINFO_CHANNEL.key}, {now}]: {message}")
else:
@ -1300,12 +1265,9 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
# screen. We execute look on the account.
# we make sure to clean up the _playable_characters list in case
# any was deleted in the interim.
self.db._playable_characters = [
char for char in self.db._playable_characters if char
]
self.db._playable_characters = [char for char in self.db._playable_characters if char]
self.msg(
self.at_look(target=self.db._playable_characters, session=session),
session=session,
self.at_look(target=self.db._playable_characters, session=session), session=session
)
def at_failed_login(self, session, **kwargs):
@ -1463,9 +1425,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
csessid = sess.sessid
addr = "%s (%s)" % (
sess.protocol_key,
isinstance(sess.address, tuple)
and str(sess.address[0])
or str(sess.address),
isinstance(sess.address, tuple) and str(sess.address[0]) or str(sess.address),
)
result.append(
"\n %s %s"
@ -1488,18 +1448,14 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
"\n\n You don't have any characters yet. See |whelp @charcreate|n for creating one."
)
else:
result.append(
"\n |w@charcreate <name> [=description]|n - create new character"
)
result.append("\n |w@charcreate <name> [=description]|n - create new character")
result.append(
"\n |w@chardelete <name>|n - delete a character (cannot be undone!)"
)
if characters:
string_s_ending = len(characters) > 1 and "s" or ""
result.append(
"\n |w@ic <character>|n - enter the game (|w@ooc|n to get back here)"
)
result.append("\n |w@ic <character>|n - enter the game (|w@ooc|n to get back here)")
if is_su:
result.append(
f"\n\nAvailable character{string_s_ending} ({len(characters)}/unlimited):"
@ -1509,9 +1465,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
"\n\nAvailable character%s%s:"
% (
string_s_ending,
charmax > 1
and " (%i/%i)" % (len(characters), charmax)
or "",
charmax > 1 and " (%i/%i)" % (len(characters), charmax) or "",
)
)
@ -1531,9 +1485,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
)
else:
# character is "free to puppet"
result.append(
f"\n - {char.key} [{', '.join(char.permissions.all())}]"
)
result.append(f"\n - {char.key} [{', '.join(char.permissions.all())}]")
look_string = ("-" * 68) + "\n" + "".join(result) + "\n" + ("-" * 68)
return look_string
@ -1611,9 +1563,7 @@ class DefaultGuest(DefaultAccount):
# 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."
)
errors.append("An error occurred. Please e-mail an admin if the problem persists.")
logger.log_trace()
return None, errors

View file

@ -32,8 +32,7 @@ class AccountDBChangeForm(UserChangeForm):
"invalid": "This value may contain only letters, spaces, numbers "
"and @/./+/-/_ characters."
},
help_text="30 characters or fewer. Letters, spaces, digits and "
"@/./+/-/_ only.",
help_text="30 characters or fewer. Letters, spaces, digits and " "@/./+/-/_ only.",
)
def clean_username(self):
@ -67,8 +66,7 @@ class AccountDBCreationForm(UserCreationForm):
"invalid": "This value may contain only letters, spaces, numbers "
"and @/./+/-/_ characters."
},
help_text="30 characters or fewer. Letters, spaces, digits and "
"@/./+/-/_ only.",
help_text="30 characters or fewer. Letters, spaces, digits and " "@/./+/-/_ only.",
)
def clean_username(self):
@ -230,13 +228,7 @@ class AccountDBAdmin(BaseUserAdmin):
(
"Website Permissions",
{
"fields": (
"is_active",
"is_staff",
"is_superuser",
"user_permissions",
"groups",
),
"fields": ("is_active", "is_staff", "is_superuser", "user_permissions", "groups"),
"description": "<i>These are permissions/permission groups for "
"accessing the admin site. They are unrelated to "
"in-game access rights.</i>",
@ -246,8 +238,7 @@ class AccountDBAdmin(BaseUserAdmin):
"Game Options",
{
"fields": ("db_typeclass_path", "db_cmdset_storage", "db_lock_storage"),
"description": "<i>These are attributes that are more relevant "
"to gameplay.</i>",
"description": "<i>These are attributes that are more relevant " "to gameplay.</i>",
},
),
)
@ -290,9 +281,7 @@ class AccountDBAdmin(BaseUserAdmin):
from django.http import HttpResponseRedirect
from django.urls import reverse
return HttpResponseRedirect(
reverse("admin:accounts_accountdb_change", args=[obj.id])
)
return HttpResponseRedirect(reverse("admin:accounts_accountdb_change", args=[obj.id]))
admin.site.register(AccountDB, AccountDBAdmin)

View file

@ -124,9 +124,7 @@ class Bot(DefaultAccount):
Evennia -> outgoing protocol
"""
super().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):
"""
@ -327,12 +325,8 @@ class IRCBot(Bot):
if kwargs["type"] == "nicklist":
# the return of a nicklist request
if hasattr(self, "_nicklist_callers") and self._nicklist_callers:
chstr = (
f"{self.db.irc_channel} ({self.db.irc_network}:{self.db.irc_port})"
)
nicklist = ", ".join(
sorted(kwargs["nicklist"], key=lambda n: n.lower())
)
chstr = f"{self.db.irc_channel} ({self.db.irc_network}:{self.db.irc_port})"
nicklist = ", ".join(sorted(kwargs["nicklist"], key=lambda n: n.lower()))
for obj in self._nicklist_callers:
obj.msg(f"Nicks at {chstr}:\n {nicklist}")
self._nicklist_callers = []
@ -341,9 +335,7 @@ class IRCBot(Bot):
elif kwargs["type"] == "ping":
# the return of a ping
if hasattr(self, "_ping_callers") and self._ping_callers:
chstr = (
f"{self.db.irc_channel} ({self.db.irc_network}:{self.db.irc_port})"
)
chstr = f"{self.db.irc_channel} ({self.db.irc_network}:{self.db.irc_port})"
for obj in self._ping_callers:
obj.msg(f"IRC ping return from {chstr} took {kwargs['timing']}s.")
self._ping_callers = []
@ -442,14 +434,8 @@ class RSSBot(Bot):
self.db.rss_rate = rss_rate
# instruct the server and portal to create a new session with
# the stored configuration
configdict = {
"uid": self.dbid,
"url": self.db.rss_url,
"rate": self.db.rss_rate,
}
_SESSIONS.start_bot_session(
"evennia.server.portal.rss.RSSBotFactory", configdict
)
configdict = {"uid": self.dbid, "url": self.db.rss_url, "rate": self.db.rss_rate}
_SESSIONS.start_bot_session("evennia.server.portal.rss.RSSBotFactory", configdict)
def execute_cmd(self, txt=None, session=None, **kwargs):
"""

View file

@ -92,9 +92,7 @@ class AccountDBManager(TypedObjectManager, UserManager):
end_date = timezone.now()
tdelta = datetime.timedelta(days)
start_date = end_date - tdelta
return self.filter(last_login__range=(start_date, end_date)).order_by(
"-last_login"
)
return self.filter(last_login__range=(start_date, end_date)).order_by("-last_login")
def get_account_from_email(self, uemail):
"""
@ -179,11 +177,7 @@ class AccountDBManager(TypedObjectManager, UserManager):
# try alias match
matches = self.filter(
db_tags__db_tagtype__iexact="alias",
**{
"db_tags__db_key__iexact"
if exact
else "db_tags__db_key__icontains": ostring
},
**{"db_tags__db_key__iexact" if exact else "db_tags__db_key__icontains": ostring},
)
return matches

View file

@ -17,10 +17,7 @@ class Migration(migrations.Migration):
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
verbose_name="ID", serialize=False, auto_created=True, primary_key=True
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
@ -54,21 +51,15 @@ class Migration(migrations.Migration):
),
(
"first_name",
models.CharField(
max_length=30, verbose_name="first name", blank=True
),
models.CharField(max_length=30, verbose_name="first name", blank=True),
),
(
"last_name",
models.CharField(
max_length=30, verbose_name="last name", blank=True
),
models.CharField(max_length=30, verbose_name="last name", blank=True),
),
(
"email",
models.EmailField(
max_length=75, verbose_name="email address", blank=True
),
models.EmailField(max_length=75, verbose_name="email address", blank=True),
),
(
"is_staff",
@ -92,10 +83,7 @@ class Migration(migrations.Migration):
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"db_key",
models.CharField(max_length=255, verbose_name="key", db_index=True),
),
("db_key", models.CharField(max_length=255, verbose_name="key", db_index=True)),
(
"db_typeclass_path",
models.CharField(
@ -107,9 +95,7 @@ class Migration(migrations.Migration):
),
(
"db_date_created",
models.DateTimeField(
auto_now_add=True, verbose_name="creation date"
),
models.DateTimeField(auto_now_add=True, verbose_name="creation date"),
),
(
"db_lock_storage",

View file

@ -6,9 +6,7 @@ from django.db import models, migrations
def convert_defaults(apps, schema_editor):
AccountDB = apps.get_model("accounts", "AccountDB")
for account in AccountDB.objects.filter(
db_typeclass_path="src.accounts.account.Account"
):
for account in AccountDB.objects.filter(db_typeclass_path="src.accounts.account.Account"):
account.db_typeclass_path = "typeclasses.accounts.Account"
account.save()

View file

@ -10,10 +10,7 @@ class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
name="DefaultAccount",
fields=[],
options={"proxy": True},
bases=("accounts.accountdb",),
name="DefaultAccount", fields=[], options={"proxy": True}, bases=("accounts.accountdb",)
),
migrations.CreateModel(
name="DefaultGuest",
@ -21,7 +18,5 @@ class Migration(migrations.Migration):
options={"proxy": True},
bases=("accounts.defaultaccount",),
),
migrations.AlterModelOptions(
name="accountdb", options={"verbose_name": "Account"}
),
migrations.AlterModelOptions(name="accountdb", options={"verbose_name": "Account"}),
]

View file

@ -14,15 +14,12 @@ class Migration(migrations.Migration):
migrations.DeleteModel(name="DefaultGuest"),
migrations.DeleteModel(name="DefaultAccount"),
migrations.AlterModelManagers(
name="accountdb",
managers=[("objects", evennia.accounts.manager.AccountDBManager())],
name="accountdb", managers=[("objects", evennia.accounts.manager.AccountDBManager())]
),
migrations.AlterField(
model_name="accountdb",
name="email",
field=models.EmailField(
max_length=254, verbose_name="email address", blank=True
),
field=models.EmailField(max_length=254, verbose_name="email address", blank=True),
),
migrations.AlterField(
model_name="accountdb",
@ -39,9 +36,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="accountdb",
name="last_login",
field=models.DateTimeField(
null=True, verbose_name="last login", blank=True
),
field=models.DateTimeField(null=True, verbose_name="last login", blank=True),
),
migrations.AlterField(
model_name="accountdb",

View file

@ -12,8 +12,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelManagers(
name="accountdb",
managers=[("objects", evennia.accounts.manager.AccountDBManager())],
name="accountdb", managers=[("objects", evennia.accounts.manager.AccountDBManager())]
),
migrations.AlterField(
model_name="accountdb",
@ -42,9 +41,7 @@ class Migration(migrations.Migration):
model_name="accountdb",
name="db_is_bot",
field=models.BooleanField(
default=False,
help_text="Used to identify irc/rss bots",
verbose_name="is_bot",
default=False, help_text="Used to identify irc/rss bots", verbose_name="is_bot"
),
),
migrations.AlterField(

View file

@ -22,8 +22,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="accountdb",
name="last_name",
field=models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
field=models.CharField(blank=True, max_length=150, verbose_name="last name"),
),
]

View file

@ -129,11 +129,7 @@ class AccountDB(TypedObject, AbstractUser):
Setter. Allows for self.name = value. Stores as a comma-separated
string.
"""
_SA(
self,
"db_cmdset_storage",
",".join(str(val).strip() for val in make_iter(value)),
)
_SA(self, "db_cmdset_storage", ",".join(str(val).strip() for val in make_iter(value)))
_GA(self, "save")()
# @cmdset_storage.deleter
@ -142,9 +138,7 @@ class AccountDB(TypedObject, AbstractUser):
_SA(self, "db_cmdset_storage", None)
_GA(self, "save")()
cmdset_storage = property(
__cmdset_storage_get, __cmdset_storage_set, __cmdset_storage_del
)
cmdset_storage = property(__cmdset_storage_get, __cmdset_storage_set, __cmdset_storage_del)
#
# property/field access

View file

@ -54,9 +54,7 @@ class TestAccountSessionHandler(TestCase):
evennia.server.sessionhandler.SESSIONS[s3.uid] = s3
self.assertEqual([s.uid for s in self.handler.get()], [s1.uid])
self.assertEqual(
[s.uid for s in [self.handler.get(self.account.uid)]], [s1.uid]
)
self.assertEqual([s.uid for s in [self.handler.get(self.account.uid)]], [s1.uid])
self.assertEqual([s.uid for s in self.handler.get(self.account.uid + 1)], [])
def test_all(self):
@ -88,8 +86,7 @@ class TestDefaultGuest(EvenniaTest):
# 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!",
account, "Two guest accounts were created with a single entry on the guest list!"
)
@patch("evennia.accounts.accounts.ChannelDB.objects.get_channel")
@ -150,17 +147,13 @@ class TestDefaultAccountAuth(EvenniaTest):
# Try creating a duplicate account
account2, errors = DefaultAccount.create(username="Ziggy", password="starman11")
self.assertFalse(
account2, "Duplicate account name should not have been allowed."
)
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"
)
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!",
@ -183,9 +176,7 @@ class TestDefaultAccountAuth(EvenniaTest):
# Should not allow duplicate username
result, error = DefaultAccount.validate_username(self.account.name)
self.assertFalse(
result, "Duplicate username should not have passed validation."
)
self.assertFalse(result, "Duplicate username should not have passed validation.")
# Should not allow username too short
result, error = DefaultAccount.validate_username("xx")
@ -301,9 +292,7 @@ class TestDefaultAccount(TestCase):
account.puppet_object(self.s1, obj)
self.assertTrue(
self.s1.data_out.call_args[1]["text"].startswith(
"You don't have permission to puppet"
)
self.s1.data_out.call_args[1]["text"].startswith("You don't have permission to puppet")
)
self.assertIsNone(obj.at_post_puppet.call_args)
@ -334,9 +323,7 @@ class TestDefaultAccount(TestCase):
account.puppet_object(self.s1, obj)
# works because django.conf.settings.MULTISESSION_MODE is not in (1, 3)
self.assertTrue(
self.s1.data_out.call_args[1]["text"].endswith(
"from another of your sessions.|n"
)
self.s1.data_out.call_args[1]["text"].endswith("from another of your sessions.|n")
)
self.assertTrue(obj.at_post_puppet.call_args[1] == {})
@ -378,15 +365,12 @@ class TestAccountPuppetDeletion(EvenniaTest):
def test_puppet_deletion(self):
# Check for existing chars
self.assertFalse(
self.account.db._playable_characters,
"Account should not have any chars by default.",
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."
)
self.assertTrue(self.account.db._playable_characters, "Char was not added to account.")
# See what happens when we delete char1.
self.char1.delete()
@ -414,9 +398,7 @@ class TestDefaultAccountEv(EvenniaTest):
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."
)
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):
@ -450,15 +432,8 @@ class TestDefaultAccountEv(EvenniaTest):
"test@test.com",
"testpassword123",
locks="test:all()",
tags=[
("tag1", "category1"),
("tag2", "category2", "data1"),
("tag3", None),
],
attributes=[
("key1", "value1", "category1", "edit:false()", True),
("key2", "value2"),
],
tags=[("tag1", "category1"), ("tag2", "category2", "data1"), ("tag3", None)],
attributes=[("key1", "value1", "category1", "edit:false()", True), ("key2", "value2")],
)
acct.save()
self.assertTrue(acct.pk)