Merge pull request #315 from ihabunek/account-update

Account update
This commit is contained in:
Ivan Habunek 2023-02-22 08:41:08 +01:00 committed by GitHub
commit e370d76913
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 314 additions and 4 deletions

View File

@ -35,6 +35,9 @@ HOSTNAME = os.getenv("TOOT_TEST_HOSTNAME")
# Mastodon database name, used to confirm user registration without having to click the link
DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")
# Toot logo used for testing image upload
TRUMPET = path.join(path.dirname(path.dirname(path.realpath(__file__))), "trumpet.png")
if not HOSTNAME or not DATABASE_DSN:
pytest.skip("Skipping integration tests", allow_module_level=True)
@ -496,6 +499,125 @@ def test_tags(run):
assert out == "* #bar\thttp://localhost:3000/tags/bar"
def test_update_account_no_options(run):
with pytest.raises(ConsoleError) as exc:
run("update_account")
assert str(exc.value) == "Please specify at least one option to update the account"
def test_update_account_display_name(run, app, user):
out = run("update_account", "--display-name", "elwood")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["display_name"] == "elwood"
def test_update_account_note(run, app, user):
note = ("It's 106 miles to Chicago, we got a full tank of gas, half a pack "
"of cigarettes, it's dark... and we're wearing sunglasses.")
out = run("update_account", "--note", note)
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert get_text(account["note"]) == note
def test_update_account_language(run, app, user):
out = run("update_account", "--language", "hr")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["source"]["language"] == "hr"
def test_update_account_privacy(run, app, user):
out = run("update_account", "--privacy", "private")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["source"]["privacy"] == "private"
def test_update_account_avatar(run, app, user):
account = api.verify_credentials(app, user)
old_value = account["avatar"]
out = run("update_account", "--avatar", TRUMPET)
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["avatar"] != old_value
def test_update_account_header(run, app, user):
account = api.verify_credentials(app, user)
old_value = account["header"]
out = run("update_account", "--header", TRUMPET)
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["header"] != old_value
def test_update_account_locked(run, app, user):
out = run("update_account", "--locked")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["locked"] is True
out = run("update_account", "--no-locked")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["locked"] is False
def test_update_account_bot(run, app, user):
out = run("update_account", "--bot")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["bot"] is True
out = run("update_account", "--no-bot")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["bot"] is False
def test_update_account_discoverable(run, app, user):
out = run("update_account", "--discoverable")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["discoverable"] is True
out = run("update_account", "--no-discoverable")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["discoverable"] is False
def test_update_account_sensitive(run, app, user):
out = run("update_account", "--sensitive")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["source"]["sensitive"] is True
out = run("update_account", "--no-sensitive")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["source"]["sensitive"] is False
# ------------------------------------------------------------------------------
# Utils
# ------------------------------------------------------------------------------

View File

@ -1,12 +1,12 @@
import re
from typing import List
import uuid
from typing import List
from urllib.parse import urlparse, urlencode, quote
from toot import http, CLIENT_NAME, CLIENT_WEBSITE
from toot.exceptions import AuthenticationError
from toot.utils import str_bool
from toot.utils import str_bool, str_bool_nullable
SCOPES = 'read write follow'
@ -67,6 +67,42 @@ def register_account(app, username, email, password, locale="en", agreement=True
return http.anon_post(url, json=json, headers=headers).json()
def update_account(
app,
user,
display_name=None,
note=None,
avatar=None,
header=None,
bot=None,
discoverable=None,
locked=None,
privacy=None,
sensitive=None,
language=None
):
"""
Update account credentials
https://docs.joinmastodon.org/methods/accounts/#update_credentials
"""
files = {"avatar": avatar, "header": header}
files = {k: v for k, v in files.items() if v is not None}
data = {
"bot": str_bool_nullable(bot),
"discoverable": str_bool_nullable(discoverable),
"display_name": display_name,
"locked": str_bool_nullable(locked),
"note": note,
"source[language]": language,
"source[privacy]": privacy,
"source[sensitive]": str_bool_nullable(sensitive),
}
data = {k: v for k, v in data.items() if v is not None}
return http.patch(app, user, "/api/v1/accounts/update_credentials", files=files, data=data)
def fetch_app_token(app):
json = {
"client_id": app.client_id,

View File

@ -233,6 +233,41 @@ def env(app, user, args):
print_out(platform.platform())
def update_account(app, user, args):
options = [
args.avatar,
args.bot,
args.discoverable,
args.display_name,
args.header,
args.language,
args.locked,
args.note,
args.privacy,
args.sensitive,
]
if all(option is None for option in options):
raise ConsoleError("Please specify at least one option to update the account")
api.update_account(
app,
user,
avatar=args.avatar,
bot=args.bot,
discoverable=args.discoverable,
display_name=args.display_name,
header=args.header,
language=args.language,
locked=args.locked,
note=args.note,
privacy=args.privacy,
sensitive=args.sensitive,
)
print_out("<green>✓ Account updated</green>")
def login_cli(app, user, args):
app = create_app_interactive(instance=args.instance, scheme=args.scheme)
login_interactive(app, args.email)

View File

@ -4,16 +4,61 @@ import re
import shutil
import sys
from argparse import ArgumentParser, FileType, ArgumentTypeError
from argparse import ArgumentParser, FileType, ArgumentTypeError, Action
from collections import namedtuple
from itertools import chain
from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out, print_err
VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct']
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES)
PRIVACY_CHOICES = ["public", "unlisted", "private"]
PRIVACY_CHOICES_STR = ", ".join(f"'{v}'" for v in PRIVACY_CHOICES)
class BooleanOptionalAction(Action):
"""
Backported from argparse. This action is available since Python 3.9.
https://github.com/python/cpython/blob/3.11/Lib/argparse.py
"""
def __init__(self,
option_strings,
dest,
default=None,
type=None,
choices=None,
required=False,
help=None,
metavar=None):
_option_strings = []
for option_string in option_strings:
_option_strings.append(option_string)
if option_string.startswith('--'):
option_string = '--no-' + option_string[2:]
_option_strings.append(option_string)
super().__init__(
option_strings=_option_strings,
dest=dest,
nargs=0,
default=default,
type=type,
choices=choices,
required=required,
help=help,
metavar=metavar)
def __call__(self, parser, namespace, values, option_string=None):
if option_string in self.option_strings:
setattr(namespace, self.dest, not option_string.startswith('--no-'))
def format_usage(self):
return ' | '.join(self.option_strings)
def get_default_visibility():
return os.getenv("TOOT_POST_VISIBILITY", "public")
@ -38,6 +83,14 @@ def visibility(value):
return value
def privacy(value):
"""Validates the privacy parameter"""
if value not in PRIVACY_CHOICES:
raise ValueError(f"Invalid privacy value. Expected one of {PRIVACY_CHOICES_STR}.")
return value
def timeline_count(value):
n = int(value)
if not 0 < n <= 20:
@ -253,6 +306,53 @@ AUTH_COMMANDS = [
arguments=[],
require_auth=False,
),
Command(
name="update_account",
description="Update your account details",
arguments=[
(["--display-name"], {
"type": str,
"help": "The display name to use for the profile.",
}),
(["--note"], {
"type": str,
"help": "The account bio.",
}),
(["--avatar"], {
"type": FileType("rb"),
"help": "Path to the avatar image to set.",
}),
(["--header"], {
"type": FileType("rb"),
"help": "Path to the header image to set.",
}),
(["--bot"], {
"action": BooleanOptionalAction,
"help": "Whether the account has a bot flag.",
}),
(["--discoverable"], {
"action": BooleanOptionalAction,
"help": "Whether the account should be shown in the profile directory.",
}),
(["--locked"], {
"action": BooleanOptionalAction,
"help": "Whether manual approval of follow requests is required.",
}),
(["--privacy"], {
"type": privacy,
"help": f"Default post privacy for authored statuses. One of: {PRIVACY_CHOICES_STR}."
}),
(["--sensitive"], {
"action": BooleanOptionalAction,
"help": "Whether to mark authored statuses as sensitive by default."
}),
(["--language"], {
"type": language,
"help": "Default language to use for authored statuses (ISO 6391)."
}),
],
require_auth=True,
),
]
TUI_COMMANDS = [

View File

@ -80,6 +80,18 @@ def post(app, user, path, headers=None, files=None, data=None, json=None, allow_
return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
def patch(app, user, path, headers=None, files=None, data=None, json=None):
url = app.base_url + path
headers = headers or {}
headers["Authorization"] = f"Bearer {user.access_token}"
request = Request('PATCH', url, headers=headers, files=files, data=data, json=json)
response = send_request(request)
return process_response(response)
def delete(app, user, path, data=None, headers=None):
url = app.base_url + path

View File

@ -16,6 +16,11 @@ def str_bool(b):
return "true" if b else "false"
def str_bool_nullable(b):
"""Similar to str_bool, but leave None as None"""
return None if b is None else str_bool(b)
def get_text(html):
"""Converts html to text, strips all tags."""