From 654d206033c3e372fd65ce34fbe7a1d3878e1072 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Mon, 25 Nov 2019 09:45:53 +0100
Subject: [PATCH] Server CLI: user management

---
 api/funkwhale_api/cli/__init__.py      |   0
 api/funkwhale_api/cli/base.py          |  65 +++++++
 api/funkwhale_api/cli/main.py          |  19 ++
 api/funkwhale_api/cli/users.py         | 234 +++++++++++++++++++++++++
 api/funkwhale_api/cli/utils.py         |   3 +
 api/manage.py                          |   9 +-
 api/requirements/base.txt              |   2 +
 api/tests/cli/__init__.py              |   0
 api/tests/cli/test_main.py             | 118 +++++++++++++
 api/tests/cli/test_users.py            | 147 ++++++++++++++++
 changes/changelog.d/server-cli.feature |   1 +
 changes/notes.rst                      |  15 ++
 docs/admin/commands.rst                |  88 ++++++++++
 13 files changed, 700 insertions(+), 1 deletion(-)
 create mode 100644 api/funkwhale_api/cli/__init__.py
 create mode 100644 api/funkwhale_api/cli/base.py
 create mode 100644 api/funkwhale_api/cli/main.py
 create mode 100644 api/funkwhale_api/cli/users.py
 create mode 100644 api/funkwhale_api/cli/utils.py
 create mode 100644 api/tests/cli/__init__.py
 create mode 100644 api/tests/cli/test_main.py
 create mode 100644 api/tests/cli/test_users.py
 create mode 100644 changes/changelog.d/server-cli.feature

diff --git a/api/funkwhale_api/cli/__init__.py b/api/funkwhale_api/cli/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/funkwhale_api/cli/base.py b/api/funkwhale_api/cli/base.py
new file mode 100644
index 0000000000..439d91ff6f
--- /dev/null
+++ b/api/funkwhale_api/cli/base.py
@@ -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
diff --git a/api/funkwhale_api/cli/main.py b/api/funkwhale_api/cli/main.py
new file mode 100644
index 0000000000..a51cca1fd0
--- /dev/null
+++ b/api/funkwhale_api/cli/main.py
@@ -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)
diff --git a/api/funkwhale_api/cli/users.py b/api/funkwhale_api/cli/users.py
new file mode 100644
index 0000000000..678b19f810
--- /dev/null
+++ b/api/funkwhale_api/cli/users.py
@@ -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)
diff --git a/api/funkwhale_api/cli/utils.py b/api/funkwhale_api/cli/utils.py
new file mode 100644
index 0000000000..08dd1a6fa6
--- /dev/null
+++ b/api/funkwhale_api/cli/utils.py
@@ -0,0 +1,3 @@
+import logging
+
+logger = logging.getLogger("funkwhale_api.cli")
diff --git a/api/manage.py b/api/manage.py
index c8db95ede2..8331a33ece 100755
--- a/api/manage.py
+++ b/api/manage.py
@@ -17,4 +17,11 @@ if __name__ == "__main__":
 
     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)
diff --git a/api/requirements/base.txt b/api/requirements/base.txt
index cc24b10f62..b651d9c4d7 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -73,3 +73,5 @@ django-storages==1.7.1
 boto3<3
 unicode-slugify
 django-cacheops==4.2
+
+click>=7,<8
diff --git a/api/tests/cli/__init__.py b/api/tests/cli/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/tests/cli/test_main.py b/api/tests/cli/test_main.py
new file mode 100644
index 0000000000..4cea9e414c
--- /dev/null
+++ b/api/tests/cli/test_main.py
@@ -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)
diff --git a/api/tests/cli/test_users.py b/api/tests/cli/test_users.py
new file mode 100644
index 0000000000..5f0c63bdb0
--- /dev/null
+++ b/api/tests/cli/test_users.py
@@ -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
diff --git a/changes/changelog.d/server-cli.feature b/changes/changelog.d/server-cli.feature
new file mode 100644
index 0000000000..11eb9ec252
--- /dev/null
+++ b/changes/changelog.d/server-cli.feature
@@ -0,0 +1 @@
+User management through the server CLI
diff --git a/changes/notes.rst b/changes/notes.rst
index 96ac3d7651..7a84fce9ac 100644
--- a/changes/notes.rst
+++ b/changes/notes.rst
@@ -5,3 +5,18 @@ Next release notes
 
     Those release notes refer to the current development branch and are reset
     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.
diff --git a/docs/admin/commands.rst b/docs/admin/commands.rst
index c30a67a999..b771e4fe9e 100644
--- a/docs/admin/commands.rst
+++ b/docs/admin/commands.rst
@@ -1,6 +1,94 @@
 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
 ---------------
 
-- 
GitLab