Repository: fractal-analytics-platform/fractal-client (language: python)

Issue:
Support basic user-group features

Align CI with fractal-server 2.4.0



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c6cc1a38..72c93a8a9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 # Future release
 
+* Align with [`fractal-server` 2.4.0](https://fractal-analytics-platform.github.io/fractal-server/changelog/#240) (\#695).
+* Add `fractal group` command (\#695).
 * Testing
     * Update GitHub actions for upload/download/coverage (\#690, \#691).
 
diff --git a/fractal_client/cmd/__init__.py b/fractal_client/cmd/__init__.py
index 1c40025cb..b16690504 100644
--- a/fractal_client/cmd/__init__.py
+++ b/fractal_client/cmd/__init__.py
@@ -7,6 +7,10 @@
 from ._dataset import get_dataset
 from ._dataset import patch_dataset
 from ._dataset import post_dataset
+from ._group import group_get
+from ._group import group_list
+from ._group import group_new
+from ._group import group_update
 from ._job import get_job
 from ._job import get_job_list
 from ._job import get_job_logs
@@ -83,7 +87,7 @@ def project(
         function_kwargs = get_kwargs(parameters, kwargs)
         iface = delete_project(client, **function_kwargs)
     else:
-        raise NoCommandError(f"Command project {subcmd} not found")
+        raise NoCommandError(f"Command 'project {subcmd}' not found")
 
     return iface
 
@@ -112,7 +116,7 @@ def dataset(
         function_kwargs = get_kwargs(parameters, kwargs)
         iface = delete_dataset(client, **function_kwargs)
     else:
-        raise NoCommandError(f"Command dataset {subcmd} not found")
+        raise NoCommandError(f"Command 'dataset {subcmd}' not found")
     return iface
 
 
@@ -184,7 +188,7 @@ def task(
         function_kwargs = get_kwargs(parameters, kwargs)
         iface = delete_task(client, **function_kwargs)
     else:
-        raise NoCommandError(f"Command task {subcmd} not found")
+        raise NoCommandError(f"Command 'task {subcmd}' not found")
     return iface
 
 
@@ -256,7 +260,7 @@ def workflow(
         function_kwargs = get_kwargs(parameters, kwargs)
         iface = workflow_export(client, **function_kwargs)
     else:
-        raise NoCommandError(f"Command workflow {subcmd} not found")
+        raise NoCommandError(f"Command 'workflow {subcmd}' not found")
     return iface
 
 
@@ -294,7 +298,7 @@ def job(
         function_kwargs = get_kwargs(parameters, kwargs)
         iface = job_submit(client, batch=batch, **function_kwargs)
     else:
-        raise NoCommandError(f"Command job {subcmd} not found")
+        raise NoCommandError(f"Command 'job {subcmd}' not found")
     return iface
 
 
@@ -351,6 +355,31 @@ def user(
     elif subcmd == "whoami":
         iface = user_whoami(client, batch=batch)
     else:
-        raise NoCommandError(f"Command user {subcmd} not found")
+        raise NoCommandError(f"Command 'user {subcmd}' not found")
+
+    return iface
+
+
+def group(
+    client: AuthClient, subcmd: str, batch: bool = False, **kwargs
+) -> Interface:
+    if subcmd == "list":
+        parameters = ["user_ids"]
+        function_kwargs = get_kwargs(parameters, kwargs)
+        iface = group_list(client, batch=batch, **function_kwargs)
+    elif subcmd == "get":
+        parameters = ["group_id"]
+        function_kwargs = get_kwargs(parameters, kwargs)
+        iface = group_get(client, **function_kwargs)
+    elif subcmd == "new":
+        parameters = ["name"]
+        function_kwargs = get_kwargs(parameters, kwargs)
+        iface = group_new(client, batch=batch, **function_kwargs)
+    elif subcmd == "update":
+        parameters = ["group_id", "new_user_ids"]
+        function_kwargs = get_kwargs(parameters, kwargs)
+        iface = group_update(client, **function_kwargs)
+    else:
+        raise NoCommandError(f"Command 'group {subcmd}' not found")
 
     return iface
diff --git a/fractal_client/cmd/_group.py b/fractal_client/cmd/_group.py
new file mode 100644
index 000000000..29681e4db
--- /dev/null
+++ b/fractal_client/cmd/_group.py
@@ -0,0 +1,49 @@
+from ..authclient import AuthClient
+from ..config import settings
+from ..interface import Interface
+from ..response import check_response
+
+
+def group_list(
+    client: AuthClient, *, user_ids: bool = False, batch: bool = False
+):
+    query_params = "?user_ids=true" if user_ids else ""
+    res = client.get(f"{settings.FRACTAL_SERVER}/auth/group/{query_params}")
+    data = check_response(res, expected_status_code=200)
+    if batch:
+        return Interface(
+            retcode=0, data=" ".join([str(d["id"]) for d in data])
+        )
+    else:
+        return Interface(retcode=0, data=data)
+
+
+def group_get(client: AuthClient, *, group_id: int):
+    res = client.get(f"{settings.FRACTAL_SERVER}/auth/group/{group_id}/")
+    data = check_response(res, expected_status_code=200)
+    return Interface(retcode=0, data=data)
+
+
+def group_new(client: AuthClient, *, name: str, batch: bool = False):
+    res = client.post(
+        f"{settings.FRACTAL_SERVER}/auth/group/", json=dict(name=name)
+    )
+    data = check_response(res, expected_status_code=201)
+    if batch:
+        return Interface(retcode=0, data=data["id"])
+    else:
+        return Interface(retcode=0, data=data)
+
+
+def group_update(
+    client: AuthClient,
+    *,
+    group_id: int,
+    new_user_ids: list[int],
+):
+    res = client.patch(
+        f"{settings.FRACTAL_SERVER}/auth/group/{group_id}/",
+        json=dict(new_user_ids=new_user_ids),
+    )
+    data = check_response(res, expected_status_code=200)
+    return Interface(retcode=0, data=data)
diff --git a/fractal_client/parser.py b/fractal_client/parser.py
index 1c16e442e..d41944fc4 100644
--- a/fractal_client/parser.py
+++ b/fractal_client/parser.py
@@ -875,3 +875,59 @@
     action="store_true",
     required=False,
 )
+
+
+# (USER)GROUP GROUP
+
+group_parser = subparsers_main.add_parser(
+    "group",
+    description="UserGroup commands.",
+    allow_abbrev=False,
+)
+group_subparsers = group_parser.add_subparsers(
+    title="Valid sub-commands", dest="subcmd", required=True
+)
+
+
+# group list
+group_list_parser = group_subparsers.add_parser(
+    "list", description="Get all groups.", allow_abbrev=False
+)
+group_list_parser.add_argument(
+    "--user-ids",
+    help="Also return the `user_ids` lists together with the groups",
+    action="store_true",
+    required=False,
+)
+
+# group get
+group_get_parser = group_subparsers.add_parser(
+    "get", description="Get single group.", allow_abbrev=False
+)
+group_get_parser.add_argument(
+    "group_id", help="ID of the group to get.", type=int
+)
+
+# group new
+group_new_parser = group_subparsers.add_parser(
+    "new", description="Create new group.", allow_abbrev=False
+)
+group_new_parser.add_argument("name", help="Name of the new group.", type=str)
+
+# group update
+group_update_parser = group_subparsers.add_parser(
+    "update", description="Update single group.", allow_abbrev=False
+)
+group_update_parser.add_argument(
+    "group_id", help="ID of the group to update.", type=int
+)
+group_update_parser.add_argument(
+    "--new-user-ids",
+    help=(
+        "New list of user in the group "
+        "(e.g. `--new-user-ids 4 8 15 16 23 42`)",
+    ),
+    required=True,
+    type=int,
+    nargs="+",
+)
diff --git a/poetry.lock b/poetry.lock
index f5e398d0e..4629cd2e5 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -696,7 +696,7 @@ typing = ["typing-extensions (>=4.8)"]
 
 [[package]]
 name = "fractal-server"
-version = "2.3.7"
+version = "2.4.0"
 description = "Server component of the Fractal analytics platform"
 optional = false
 python-versions = "^3.9"
@@ -730,7 +730,7 @@ postgres-psycopg-binary = ["psycopg[binary] (>=3.1.0,<4.0.0)"]
 type = "git"
 url = "https://github.com/fractal-analytics-platform/fractal-server.git"
 reference = "main"
-resolved_reference = "c5b0768e5e8024437056fac5aaca013eb59d813c"
+resolved_reference = "db54dd98ff294cda3334b03d0e353d4d01c139eb"
 
 [[package]]
 name = "ghp-import"


Test patch:
diff --git a/tests/fixtures_testserver.py b/tests/fixtures_testserver.py
index 9440f257b..f552b8ade 100644
--- a/tests/fixtures_testserver.py
+++ b/tests/fixtures_testserver.py
@@ -1,9 +1,13 @@
 import logging
+import time
+from multiprocessing import Process
 from os import environ
 from typing import Optional
 
 import httpx
 import pytest
+import uvicorn
+from sqlmodel import select
 
 
 logger = logging.getLogger("fractal-client")
@@ -46,20 +50,48 @@ def _get_settings():
 
 @pytest.fixture(scope="function", autouse=True)
 def testserver(override_server_settings):
-    import uvicorn
-    from multiprocessing import Process
     from fractal_server.app.db import DB
-    import time
     from fractal_server.app.models.security import SQLModel
+    from fractal_server.app.models.security import UserOAuth
+    from fractal_server.app.models.security import UserGroup
+    from fractal_server.app.models.linkusergroup import LinkUserGroup
+    from fractal_server.app.security import _create_first_group
 
     # INIT DB
     DB.set_sync_db()
     logger.debug(DB.engine_sync().url)
     SQLModel.metadata.create_all(DB.engine_sync())
 
+    # Create default group and first superuser
+    # NOTE: we have to do it here, because we are not calling the `set_db` function
+    # from fractal-server. This would change with
+    # https://github.com/fractal-analytics-platform/fractal-client/issues/697
+    # NOTE: `hashed_password` is the bcrypt hash of "1234", see
+    # https://github.com/fractal-analytics-platform/fractal-server/issues/1750
+    _create_first_group()
+    with next(DB.get_sync_db()) as db:
+        user = UserOAuth(
+            email="admin@fractal.xy",
+            hashed_password=(
+                "$2b$12$K0C4t7XILgpcQx35V3QE3enOODQ1IH9pzW49nqjHbrx2uQTMVYsQC"
+            ),
+            username=environ["FRACTAL_USERNAME"],
+            is_superuser=True,
+            is_verified=True,
+            is_active=True,
+        )
+        db.add(user)
+        db.commit()
+
+        first_group = db.execute(select(UserGroup)).scalar()
+        first_user = db.execute(select(UserOAuth)).scalar()
+
+        link = LinkUserGroup(group_id=first_group.id, user_id=first_user.id)
+        db.add(link)
+        db.commit()
+
     # Run testserver in a separate process
     # cf. https://stackoverflow.com/a/57816608/283972
-
     def run_server():
         uvicorn.run(
             "fractal_server.main:app",
diff --git a/tests/test_group.py b/tests/test_group.py
new file mode 100644
index 000000000..075d0c008
--- /dev/null
+++ b/tests/test_group.py
@@ -0,0 +1,152 @@
+import pytest
+from fractal_server.app.security import FRACTAL_DEFAULT_GROUP_NAME
+
+
+def test_group_commands_auth(register_user, invoke, caplog):
+    """
+    Assert 'group' commands are not accessible to standard users
+    """
+
+    def _assert_403(cmd):
+        caplog.clear()
+        with pytest.raises(SystemExit):
+            invoke(cmd)
+        assert "403" in caplog.text
+
+    _assert_403(cmd="group list")
+    _assert_403(cmd="group get 1")
+    _assert_403(cmd="group new foo")
+    _assert_403(cmd="group update 1 --new-user-ids 1")
+
+
+def test_group_commands(user_factory, invoke_as_superuser):
+
+    # get default group id and superuser id
+    res = invoke_as_superuser("group list --user-ids")
+    assert res.retcode == 0
+    assert len(res.data) == 1  # Only one group (default)
+    assert res.data[0]["name"] == FRACTAL_DEFAULT_GROUP_NAME
+    assert len(res.data[0]["user_ids"]) == 1  # Only one user (superuser)
+
+    default_group_id = res.data[0]["id"]
+    superuser_id = res.data[0]["user_ids"][0]
+
+    # create 3 standard users (by default in default group)
+    user1 = user_factory(email="user1@fractal.xy", password="psw1")
+    user1_id = user1["id"]
+    assert user1["group_ids"] == [default_group_id]
+    user2 = user_factory(email="user2@fractal.xy", password="psw2")
+    user2_id = user2["id"]
+    assert user2["group_ids"] == [default_group_id]
+    user3 = user_factory(email="user3@fractal.xy", password="psw3")
+    user3_id = user3["id"]
+    assert user3["group_ids"] == [default_group_id]
+
+    res = invoke_as_superuser("group list --user-ids")
+    assert len(res.data) == 1
+    assert res.data[0]["user_ids"] == [
+        superuser_id,
+        user1_id,
+        user2_id,
+        user3_id,
+    ]
+
+    # Create 2 new empty groups (`group new`)
+
+    with pytest.raises(SystemExit):
+        # missing 'name'
+        invoke_as_superuser("group new")
+
+    res = invoke_as_superuser("group new foo")
+    assert res.retcode == 0
+    assert res.data["name"] == "foo"
+    assert res.data["user_ids"] == []
+    group1_id = res.data["id"]
+
+    res = invoke_as_superuser("group new bar")
+    group2_id = res.data["id"]
+
+    # Add users to groups (`group update`)
+
+    with pytest.raises(SystemExit):
+        # missing 'group_id' and 'new_user_ids'
+        invoke_as_superuser("group update")
+    with pytest.raises(SystemExit):
+        # missing 'new_user_ids'
+        invoke_as_superuser(f"group update {default_group_id}")
+    with pytest.raises(SystemExit):
+        # missing 'group_id'
+        invoke_as_superuser(f"group update --new-user-ids {superuser_id}")
+    with pytest.raises(SystemExit):
+        # user already in group
+        invoke_as_superuser(
+            f"group update {default_group_id} --new-user-ids {superuser_id}"
+        )
+    with pytest.raises(SystemExit):
+        # non existing user
+        invoke_as_superuser(
+            f"group update {default_group_id} --new-user-ids 9999"
+        )
+
+    # add `user1` and `user2` to `group1`
+    res = invoke_as_superuser(
+        f"group update {group1_id} --new-user-ids {user1_id} {user2_id}"
+    )
+    assert res.retcode == 0
+    assert res.data["id"] == group1_id
+    assert res.data["user_ids"] == [user1_id, user2_id]
+
+    # add `user3` and `user2` to `group2`
+    res = invoke_as_superuser(
+        f"group update {group2_id} --new-user-ids {user3_id} {user2_id}"
+    )
+    assert res.retcode == 0
+    assert res.data["id"] == group2_id
+    assert res.data["user_ids"] == [user2_id, user3_id]  # order
+    # add also `superuser` to `group2`
+    res = invoke_as_superuser(
+        f"group update {group2_id} --new-user-ids {superuser_id}"
+    )
+    assert res.data["user_ids"] == [superuser_id, user2_id, user3_id]  # order
+
+    # Check groups are updated
+
+    res = invoke_as_superuser("group list --user-ids")
+    assert len(res.data) == 3
+    assert res.data[0]["id"] == default_group_id
+    assert res.data[0]["user_ids"] == [
+        superuser_id,
+        user1_id,
+        user2_id,
+        user3_id,
+    ]
+    assert res.data[1]["id"] == group1_id
+    assert res.data[1]["user_ids"] == [user1_id, user2_id]
+    assert res.data[2]["id"] == group2_id
+    # sorted in order of insertion
+    assert res.data[2]["user_ids"] == [user3_id, user2_id, superuser_id]
+
+    # Test `group get` command
+
+    with pytest.raises(SystemExit):
+        # missing 'group_id'
+        invoke_as_superuser("group get")
+
+    res = invoke_as_superuser(f"group get {default_group_id}")
+    assert res.retcode == 0
+    assert res.data["name"] == FRACTAL_DEFAULT_GROUP_NAME
+    assert len(res.data["user_ids"]) == 4
+
+    # Test `list` without `--user-ids`
+
+    res = invoke_as_superuser("group list")
+    for group in res.data:
+        assert group["user_ids"] is None
+
+    # Test `--batch`
+
+    res = invoke_as_superuser("--batch group list")
+    assert res.data == f"{default_group_id} {group1_id} {group2_id}"
+
+    res = invoke_as_superuser("--batch group new xyz")
+    assert isinstance(res.data, int)
diff --git a/tests/test_invalid_commands.py b/tests/test_invalid_commands.py
index 5643cffc3..0abc3121a 100644
--- a/tests/test_invalid_commands.py
+++ b/tests/test_invalid_commands.py
@@ -1,6 +1,7 @@
 import pytest
 
 from fractal_client.cmd import dataset
+from fractal_client.cmd import group
 from fractal_client.cmd import job
 from fractal_client.cmd import NoCommandError
 from fractal_client.cmd import project
@@ -17,13 +18,15 @@ def test_invalid_commands(invoke):
             "project",
             "job",
             "workflow",
-            "task" "user",
+            "task",
+            "user",
+            "group",
         ]:
             with pytest.raises(SystemExit):
                 invoke(f"{command}{arg}")
 
 
 def test_unit_invalid_subcommand():
-    for _function in [project, dataset, task, workflow, job, user]:
+    for _function in [project, dataset, task, workflow, job, user, group]:
         with pytest.raises(NoCommandError):
             _function(client=None, subcmd="invalid")