Server CLI: user management
This commit is contained in:
parent
900fabae79
commit
654d206033
|
@ -0,0 +1,65 @@
|
||||||
|
import click
|
||||||
|
import functools
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_action(f, id_var, message_template="Do you want to proceed?"):
|
||||||
|
@functools.wraps(f)
|
||||||
|
def action(*args, **kwargs):
|
||||||
|
if id_var:
|
||||||
|
id_value = kwargs[id_var]
|
||||||
|
message = message_template.format(len(id_value))
|
||||||
|
else:
|
||||||
|
message = message_template
|
||||||
|
if not kwargs.pop("no_input", False) and not click.confirm(message, abort=True):
|
||||||
|
return
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return action
|
||||||
|
|
||||||
|
|
||||||
|
def delete_command(
|
||||||
|
group,
|
||||||
|
id_var="id",
|
||||||
|
name="rm",
|
||||||
|
message_template="Do you want to delete {} objects? This action is irreversible.",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Wrap a command to ensure it asks for confirmation before deletion, unless the --no-input
|
||||||
|
flag is provided
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(f):
|
||||||
|
decorated = click.option("--no-input", is_flag=True)(f)
|
||||||
|
decorated = confirm_action(
|
||||||
|
decorated, id_var=id_var, message_template=message_template
|
||||||
|
)
|
||||||
|
return group.command(name)(decorated)
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def update_command(
|
||||||
|
group,
|
||||||
|
id_var="id",
|
||||||
|
name="set",
|
||||||
|
message_template="Do you want to update {} objects? This action may have irreversible consequnces.",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Wrap a command to ensure it asks for confirmation before deletion, unless the --no-input
|
||||||
|
flag is provided
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(f):
|
||||||
|
decorated = click.option("--no-input", is_flag=True)(f)
|
||||||
|
decorated = confirm_action(
|
||||||
|
decorated, id_var=id_var, message_template=message_template
|
||||||
|
)
|
||||||
|
return group.command(name)(decorated)
|
||||||
|
|
||||||
|
return decorator
|
|
@ -0,0 +1,19 @@
|
||||||
|
import click
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import base
|
||||||
|
from . import users # noqa
|
||||||
|
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
def invoke():
|
||||||
|
try:
|
||||||
|
return base.cli()
|
||||||
|
except ValidationError as e:
|
||||||
|
click.secho("Invalid data:", fg="red")
|
||||||
|
for field, errors in e.detail.items():
|
||||||
|
click.secho(" {}:".format(field), fg="red")
|
||||||
|
for error in errors:
|
||||||
|
click.secho(" - {}".format(error), fg="red")
|
||||||
|
sys.exit(1)
|
|
@ -0,0 +1,234 @@
|
||||||
|
import click
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from funkwhale_api.federation import models as federation_models
|
||||||
|
from funkwhale_api.users import models
|
||||||
|
from funkwhale_api.users import serializers
|
||||||
|
from funkwhale_api.users import tasks
|
||||||
|
|
||||||
|
from . import base
|
||||||
|
from . import utils
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRequest(object):
|
||||||
|
def __init__(self, session={}):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handler_create_user(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
email,
|
||||||
|
is_superuser=False,
|
||||||
|
is_staff=False,
|
||||||
|
permissions=[],
|
||||||
|
upload_quota=None,
|
||||||
|
):
|
||||||
|
serializer = serializers.RS(
|
||||||
|
data={
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"password1": password,
|
||||||
|
"password2": password,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
utils.logger.debug("Validating user data…")
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# Override email validation, we assume accounts created from CLI have a valid email
|
||||||
|
request = FakeRequest(session={"account_verified_email": email})
|
||||||
|
utils.logger.debug("Creating user…")
|
||||||
|
user = serializer.save(request=request)
|
||||||
|
utils.logger.debug("Setting permissions and other attributes…")
|
||||||
|
user.is_staff = is_staff
|
||||||
|
user.upload_quota = upload_quota
|
||||||
|
user.is_superuser = is_superuser
|
||||||
|
for permission in permissions:
|
||||||
|
if permission in models.PERMISSIONS:
|
||||||
|
utils.logger.debug("Setting %s permission to True", permission)
|
||||||
|
setattr(user, "permission_{}".format(permission), True)
|
||||||
|
else:
|
||||||
|
utils.logger.warn("Unknown permission %s", permission)
|
||||||
|
utils.logger.debug("Creating actor…")
|
||||||
|
user.actor = models.create_actor(user)
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handler_delete_user(usernames, soft=True):
|
||||||
|
for username in usernames:
|
||||||
|
click.echo("Deleting {}…".format(username))
|
||||||
|
actor = None
|
||||||
|
user = None
|
||||||
|
try:
|
||||||
|
user = models.User.objects.get(username=username)
|
||||||
|
except models.User.DoesNotExist:
|
||||||
|
try:
|
||||||
|
actor = federation_models.Actor.objects.local().get(
|
||||||
|
preferred_username=username
|
||||||
|
)
|
||||||
|
except federation_models.Actor.DoesNotExist:
|
||||||
|
click.echo(" Not found, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
actor = actor or user.actor
|
||||||
|
if user:
|
||||||
|
tasks.delete_account(user_id=user.pk)
|
||||||
|
if not soft:
|
||||||
|
click.echo(" Hard delete, removing actor")
|
||||||
|
actor.delete()
|
||||||
|
click.echo(" Done")
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handler_update_user(usernames, kwargs):
|
||||||
|
users = models.User.objects.filter(username__in=usernames)
|
||||||
|
total = users.count()
|
||||||
|
if not total:
|
||||||
|
click.echo("No matching users")
|
||||||
|
return
|
||||||
|
|
||||||
|
final_kwargs = {}
|
||||||
|
supported_fields = [
|
||||||
|
"is_active",
|
||||||
|
"permission_moderation",
|
||||||
|
"permission_library",
|
||||||
|
"permission_settings",
|
||||||
|
"is_staff",
|
||||||
|
"is_superuser",
|
||||||
|
"upload_quota",
|
||||||
|
"password",
|
||||||
|
]
|
||||||
|
for field in supported_fields:
|
||||||
|
try:
|
||||||
|
value = kwargs[field]
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
final_kwargs[field] = value
|
||||||
|
|
||||||
|
click.echo(
|
||||||
|
"Updating {} on {} matching users…".format(
|
||||||
|
", ".join(final_kwargs.keys()), total
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "password" in final_kwargs:
|
||||||
|
new_password = final_kwargs.pop("password")
|
||||||
|
for user in users:
|
||||||
|
user.set_password(new_password)
|
||||||
|
models.User.objects.bulk_update(users, ["password"])
|
||||||
|
if final_kwargs:
|
||||||
|
users.update(**final_kwargs)
|
||||||
|
click.echo("Done!")
|
||||||
|
|
||||||
|
|
||||||
|
@base.cli.group()
|
||||||
|
def users():
|
||||||
|
"""Manage users"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@users.command()
|
||||||
|
@click.option("--username", "-u", prompt=True, required=True)
|
||||||
|
@click.option(
|
||||||
|
"-p",
|
||||||
|
"--password",
|
||||||
|
prompt="Password (leave empty to have a random one generated)",
|
||||||
|
hide_input=True,
|
||||||
|
envvar="FUNKWHALE_CLI_USER_PASSWORD",
|
||||||
|
default="",
|
||||||
|
help="If empty, a random password will be generated and displayed in console output",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-e",
|
||||||
|
"--email",
|
||||||
|
prompt=True,
|
||||||
|
help="Email address to associate with the account",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-q",
|
||||||
|
"--upload-quota",
|
||||||
|
help="Upload quota (leave empty to use default pod quota)",
|
||||||
|
required=False,
|
||||||
|
default=None,
|
||||||
|
type=click.INT,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--superuser/--no-superuser", default=False,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--staff/--no-staff", default=False,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--permission", multiple=True,
|
||||||
|
)
|
||||||
|
def create(username, password, email, superuser, staff, permission, upload_quota):
|
||||||
|
"""Create a new user"""
|
||||||
|
generated_password = None
|
||||||
|
if password == "":
|
||||||
|
generated_password = models.User.objects.make_random_password()
|
||||||
|
user = handler_create_user(
|
||||||
|
username=username,
|
||||||
|
password=password or generated_password,
|
||||||
|
email=email,
|
||||||
|
is_superuser=superuser,
|
||||||
|
is_staff=staff,
|
||||||
|
permissions=permission,
|
||||||
|
upload_quota=upload_quota,
|
||||||
|
)
|
||||||
|
click.echo("User {} created!".format(user.username))
|
||||||
|
if generated_password:
|
||||||
|
click.echo(" Generated password: {}".format(generated_password))
|
||||||
|
|
||||||
|
|
||||||
|
@base.delete_command(group=users, id_var="username")
|
||||||
|
@click.argument("username", nargs=-1)
|
||||||
|
@click.option(
|
||||||
|
"--hard/--no-hard",
|
||||||
|
default=False,
|
||||||
|
help="Purge all user-related info (allow recreating a user with the same username)",
|
||||||
|
)
|
||||||
|
def delete(username, hard):
|
||||||
|
"""Delete given users"""
|
||||||
|
handler_delete_user(usernames=username, soft=not hard)
|
||||||
|
|
||||||
|
|
||||||
|
@base.update_command(group=users, id_var="username")
|
||||||
|
@click.argument("username", nargs=-1)
|
||||||
|
@click.option(
|
||||||
|
"--active/--inactive",
|
||||||
|
help="Mark as active or inactive (inactive users cannot login or use the service)",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
@click.option("--superuser/--no-superuser", default=None)
|
||||||
|
@click.option("--staff/--no-staff", default=None)
|
||||||
|
@click.option("--permission-library/--no-permission-library", default=None)
|
||||||
|
@click.option("--permission-moderation/--no-permission-moderation", default=None)
|
||||||
|
@click.option("--permission-settings/--no-permission-settings", default=None)
|
||||||
|
@click.option("--password", default=None, envvar="FUNKWHALE_CLI_USER_UPDATE_PASSWORD")
|
||||||
|
@click.option(
|
||||||
|
"-q", "--upload-quota", type=click.INT,
|
||||||
|
)
|
||||||
|
def update(username, **kwargs):
|
||||||
|
"""Update attributes for given users"""
|
||||||
|
field_mapping = {
|
||||||
|
"active": "is_active",
|
||||||
|
"superuser": "is_superuser",
|
||||||
|
"staff": "is_staff",
|
||||||
|
}
|
||||||
|
final_kwargs = {}
|
||||||
|
for cli_field, value in kwargs.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
model_field = (
|
||||||
|
field_mapping[cli_field] if cli_field in field_mapping else cli_field
|
||||||
|
)
|
||||||
|
final_kwargs[model_field] = value
|
||||||
|
|
||||||
|
if not final_kwargs:
|
||||||
|
raise click.BadArgumentUsage("You need to update at least one attribute")
|
||||||
|
|
||||||
|
handler_update_user(usernames=username, kwargs=final_kwargs)
|
|
@ -0,0 +1,3 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("funkwhale_api.cli")
|
|
@ -17,4 +17,11 @@ if __name__ == "__main__":
|
||||||
|
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
execute_from_command_line(sys.argv)
|
if len(sys.argv) > 1 and sys.argv[1] in ["fw", "funkwhale"]:
|
||||||
|
# trigger our own click-based cli
|
||||||
|
from funkwhale_api.cli import main
|
||||||
|
|
||||||
|
sys.argv = sys.argv[1:]
|
||||||
|
main.invoke()
|
||||||
|
else:
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
|
@ -73,3 +73,5 @@ django-storages==1.7.1
|
||||||
boto3<3
|
boto3<3
|
||||||
unicode-slugify
|
unicode-slugify
|
||||||
django-cacheops==4.2
|
django-cacheops==4.2
|
||||||
|
|
||||||
|
click>=7,<8
|
||||||
|
|
|
@ -0,0 +1,118 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from funkwhale_api.cli import main
|
||||||
|
from funkwhale_api.cli import users
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cmd, args, handlers",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
("users", "create"),
|
||||||
|
(
|
||||||
|
"--username",
|
||||||
|
"testuser",
|
||||||
|
"--password",
|
||||||
|
"testpassword",
|
||||||
|
"--email",
|
||||||
|
"test@hello.com",
|
||||||
|
"--upload-quota",
|
||||||
|
"35",
|
||||||
|
"--permission",
|
||||||
|
"library",
|
||||||
|
"--permission",
|
||||||
|
"moderation",
|
||||||
|
"--staff",
|
||||||
|
"--superuser",
|
||||||
|
),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
users,
|
||||||
|
"handler_create_user",
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "testpassword",
|
||||||
|
"email": "test@hello.com",
|
||||||
|
"upload_quota": 35,
|
||||||
|
"permissions": ("library", "moderation"),
|
||||||
|
"is_staff": True,
|
||||||
|
"is_superuser": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
("users", "rm"),
|
||||||
|
("testuser1", "testuser2", "--no-input"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
users,
|
||||||
|
"handler_delete_user",
|
||||||
|
{"usernames": ("testuser1", "testuser2"), "soft": True},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
("users", "rm"),
|
||||||
|
("testuser1", "testuser2", "--no-input", "--hard",),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
users,
|
||||||
|
"handler_delete_user",
|
||||||
|
{"usernames": ("testuser1", "testuser2"), "soft": False},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
("users", "set"),
|
||||||
|
(
|
||||||
|
"testuser1",
|
||||||
|
"testuser2",
|
||||||
|
"--no-input",
|
||||||
|
"--inactive",
|
||||||
|
"--upload-quota",
|
||||||
|
"35",
|
||||||
|
"--no-staff",
|
||||||
|
"--superuser",
|
||||||
|
"--permission-library",
|
||||||
|
"--no-permission-moderation",
|
||||||
|
"--no-permission-settings",
|
||||||
|
"--password",
|
||||||
|
"newpassword",
|
||||||
|
),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
users,
|
||||||
|
"handler_update_user",
|
||||||
|
{
|
||||||
|
"usernames": ("testuser1", "testuser2"),
|
||||||
|
"kwargs": {
|
||||||
|
"is_active": False,
|
||||||
|
"upload_quota": 35,
|
||||||
|
"is_staff": False,
|
||||||
|
"is_superuser": True,
|
||||||
|
"permission_library": True,
|
||||||
|
"permission_moderation": False,
|
||||||
|
"permission_settings": False,
|
||||||
|
"password": "newpassword",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_cli(cmd, args, handlers, mocker):
|
||||||
|
patched_handlers = {}
|
||||||
|
for module, path, _ in handlers:
|
||||||
|
patched_handlers[(module, path)] = mocker.spy(module, path)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main.base.cli, cmd + args)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
|
||||||
|
for module, path, expected_call in handlers:
|
||||||
|
patched_handlers[(module, path)].assert_called_once_with(**expected_call)
|
|
@ -0,0 +1,147 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from funkwhale_api.cli import users
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_create_handler(factories, mocker, now):
|
||||||
|
kwargs = {
|
||||||
|
"username": "helloworld",
|
||||||
|
"password": "securepassword",
|
||||||
|
"is_superuser": False,
|
||||||
|
"is_staff": True,
|
||||||
|
"email": "hello@world.email",
|
||||||
|
"upload_quota": 35,
|
||||||
|
"permissions": ["moderation"],
|
||||||
|
}
|
||||||
|
set_password = mocker.spy(users.models.User, "set_password")
|
||||||
|
create_actor = mocker.spy(users.models, "create_actor")
|
||||||
|
user = users.handler_create_user(**kwargs)
|
||||||
|
|
||||||
|
assert user.username == kwargs["username"]
|
||||||
|
assert user.is_superuser == kwargs["is_superuser"]
|
||||||
|
assert user.is_staff == kwargs["is_staff"]
|
||||||
|
assert user.date_joined >= now
|
||||||
|
assert user.upload_quota == kwargs["upload_quota"]
|
||||||
|
set_password.assert_called_once_with(user, kwargs["password"])
|
||||||
|
create_actor.assert_called_once_with(user)
|
||||||
|
|
||||||
|
expected_permissions = {
|
||||||
|
p: p in kwargs["permissions"] for p in users.models.PERMISSIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
assert user.all_permissions == expected_permissions
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_delete_handler_soft(factories, mocker, now):
|
||||||
|
user1 = factories["federation.Actor"](local=True).user
|
||||||
|
actor1 = user1.actor
|
||||||
|
user2 = factories["federation.Actor"](local=True).user
|
||||||
|
actor2 = user2.actor
|
||||||
|
user3 = factories["federation.Actor"](local=True).user
|
||||||
|
delete_account = mocker.spy(users.tasks, "delete_account")
|
||||||
|
users.handler_delete_user([user1.username, user2.username, "unknown"])
|
||||||
|
|
||||||
|
assert delete_account.call_count == 2
|
||||||
|
delete_account.assert_any_call(user_id=user1.pk)
|
||||||
|
with pytest.raises(user1.DoesNotExist):
|
||||||
|
user1.refresh_from_db()
|
||||||
|
|
||||||
|
delete_account.assert_any_call(user_id=user2.pk)
|
||||||
|
with pytest.raises(user2.DoesNotExist):
|
||||||
|
user2.refresh_from_db()
|
||||||
|
|
||||||
|
# soft delete, actor shouldn't be deleted
|
||||||
|
actor1.refresh_from_db()
|
||||||
|
actor2.refresh_from_db()
|
||||||
|
|
||||||
|
# not deleted
|
||||||
|
user3.refresh_from_db()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_delete_handler_hard(factories, mocker, now):
|
||||||
|
user1 = factories["federation.Actor"](local=True).user
|
||||||
|
actor1 = user1.actor
|
||||||
|
user2 = factories["federation.Actor"](local=True).user
|
||||||
|
actor2 = user2.actor
|
||||||
|
user3 = factories["federation.Actor"](local=True).user
|
||||||
|
delete_account = mocker.spy(users.tasks, "delete_account")
|
||||||
|
users.handler_delete_user([user1.username, user2.username, "unknown"], soft=False)
|
||||||
|
|
||||||
|
assert delete_account.call_count == 2
|
||||||
|
delete_account.assert_any_call(user_id=user1.pk)
|
||||||
|
with pytest.raises(user1.DoesNotExist):
|
||||||
|
user1.refresh_from_db()
|
||||||
|
|
||||||
|
delete_account.assert_any_call(user_id=user2.pk)
|
||||||
|
with pytest.raises(user2.DoesNotExist):
|
||||||
|
user2.refresh_from_db()
|
||||||
|
|
||||||
|
# hard delete, actors are deleted as well
|
||||||
|
with pytest.raises(actor1.DoesNotExist):
|
||||||
|
actor1.refresh_from_db()
|
||||||
|
|
||||||
|
with pytest.raises(actor2.DoesNotExist):
|
||||||
|
actor2.refresh_from_db()
|
||||||
|
|
||||||
|
# not deleted
|
||||||
|
user3.refresh_from_db()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"params, expected",
|
||||||
|
[
|
||||||
|
({"is_active": False}, {"is_active": False}),
|
||||||
|
(
|
||||||
|
{"is_staff": True, "is_superuser": True},
|
||||||
|
{"is_staff": True, "is_superuser": True},
|
||||||
|
),
|
||||||
|
({"upload_quota": 35}, {"upload_quota": 35}),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"permission_library": True,
|
||||||
|
"permission_moderation": True,
|
||||||
|
"permission_settings": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"all_permissions": {
|
||||||
|
"library": True,
|
||||||
|
"moderation": True,
|
||||||
|
"settings": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_user_update_handler(params, expected, factories):
|
||||||
|
user1 = factories["federation.Actor"](local=True).user
|
||||||
|
user2 = factories["federation.Actor"](local=True).user
|
||||||
|
user3 = factories["federation.Actor"](local=True).user
|
||||||
|
|
||||||
|
def get_field_values(user):
|
||||||
|
return {f: getattr(user, f) for f, v in expected.items()}
|
||||||
|
|
||||||
|
unchanged = get_field_values(user3)
|
||||||
|
|
||||||
|
users.handler_update_user([user1.username, user2.username, "unknown"], params)
|
||||||
|
|
||||||
|
user1.refresh_from_db()
|
||||||
|
user2.refresh_from_db()
|
||||||
|
user3.refresh_from_db()
|
||||||
|
|
||||||
|
assert get_field_values(user1) == expected
|
||||||
|
assert get_field_values(user2) == expected
|
||||||
|
assert get_field_values(user3) == unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_update_handler_password(factories, mocker):
|
||||||
|
user = factories["federation.Actor"](local=True).user
|
||||||
|
current_password = user.password
|
||||||
|
|
||||||
|
set_password = mocker.spy(users.models.User, "set_password")
|
||||||
|
|
||||||
|
users.handler_update_user([user.username], {"password": "hello"})
|
||||||
|
|
||||||
|
user.refresh_from_db()
|
||||||
|
|
||||||
|
set_password.assert_called_once_with(user, "hello")
|
||||||
|
assert user.password != current_password
|
|
@ -0,0 +1 @@
|
||||||
|
User management through the server CLI
|
|
@ -5,3 +5,18 @@ Next release notes
|
||||||
|
|
||||||
Those release notes refer to the current development branch and are reset
|
Those release notes refer to the current development branch and are reset
|
||||||
after each release.
|
after each release.
|
||||||
|
|
||||||
|
User management through the server CLI
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
We now support user creation (incl. non-admin accounts), update and removal directly
|
||||||
|
from the server CLI. Typical use cases include:
|
||||||
|
|
||||||
|
- Changing a user password from the command line
|
||||||
|
- Creating or updating users from deployments scripts or playbooks
|
||||||
|
- Removing or granting permissions or upload quota to multiple users at once
|
||||||
|
- Marking multiple users as inactive
|
||||||
|
|
||||||
|
All user-related commands are available under the ``python manage.py fw users`` namespace.
|
||||||
|
Please refer to the `Admin documentation <https://docs.funkwhale.audio/admin/commands.html#user-management>`_ for
|
||||||
|
more information and instructions.
|
||||||
|
|
|
@ -1,6 +1,94 @@
|
||||||
Management commands
|
Management commands
|
||||||
===================
|
===================
|
||||||
|
|
||||||
|
User management
|
||||||
|
---------------
|
||||||
|
|
||||||
|
It's possible to create, remove and update users directly from the command line.
|
||||||
|
|
||||||
|
This feature is useful if you want to experiment, automate or perform batch actions that
|
||||||
|
would be too repetitive through the web UI.
|
||||||
|
|
||||||
|
All users-related commands are available under the ``python manage.py fw users`` namespace:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
# print subcommands and help
|
||||||
|
python manage.py fw users --help
|
||||||
|
|
||||||
|
|
||||||
|
Creation
|
||||||
|
^^^^^^^^
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
# print help
|
||||||
|
python manage.py fw users create --help
|
||||||
|
|
||||||
|
# create a user interactively
|
||||||
|
python manage.py fw users create
|
||||||
|
|
||||||
|
# create a user with a random password
|
||||||
|
python manage.py fw users create --username alice --email alice@email.host -p ""
|
||||||
|
|
||||||
|
# create a user with password set from an environment variable
|
||||||
|
export FUNKWHALE_CLI_USER_PASSWORD=securepassword
|
||||||
|
python manage.py fw users create --username bob --email bob@email.host
|
||||||
|
|
||||||
|
Additional options are available to further configure the user during creation, such as
|
||||||
|
setting permissions or user quota. Please refer to the command help.
|
||||||
|
|
||||||
|
|
||||||
|
Update
|
||||||
|
^^^^^^
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
# print help
|
||||||
|
python manage.py fw users set --help
|
||||||
|
|
||||||
|
# set upload quota to 500MB for alice
|
||||||
|
python manage.py fw users set --upload-quota 500 alice
|
||||||
|
|
||||||
|
# disable confirmation prompt with --no-input
|
||||||
|
python manage.py fw users set --no-input --upload-quota 500 alice
|
||||||
|
|
||||||
|
# make alice and bob staff members
|
||||||
|
python manage.py fw users set --staff --superuser alice bob
|
||||||
|
|
||||||
|
# remove staff privileges from bob
|
||||||
|
python manage.py fw users set --no-staff --no-superuser bob
|
||||||
|
|
||||||
|
# give bob moderation permission
|
||||||
|
python manage.py fw users set --permission-moderation bob
|
||||||
|
|
||||||
|
# reset alice's password
|
||||||
|
python manage.py fw users set --password "securepassword" alice
|
||||||
|
|
||||||
|
# reset bob's password through an environment variable
|
||||||
|
export FUNKWHALE_CLI_USER_UPDATE_PASSWORD=newsecurepassword
|
||||||
|
python manage.py fw users set bob
|
||||||
|
|
||||||
|
Deletion
|
||||||
|
^^^^^^^^
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
# print help
|
||||||
|
python manage.py fw users rm --help
|
||||||
|
|
||||||
|
# delete bob's account, but keep a reference to their account in the database
|
||||||
|
# to prevent future signup with the same username
|
||||||
|
python manage.py fw users rm bob
|
||||||
|
|
||||||
|
# delete alice's account, with no confirmation prompt
|
||||||
|
python manage.py fw users rm --no-input alice
|
||||||
|
|
||||||
|
# delete alice and bob accounts, including all reference to their account
|
||||||
|
# (people will be able to signup again with their usernames)
|
||||||
|
python manage.py fw users rm --hard alice bob
|
||||||
|
|
||||||
|
|
||||||
Pruning library
|
Pruning library
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue