Skip to content

Commit 8199161

Browse files
[Fixes #38]: "Feature: implement /api/v2/groups/ "
2 parents 18b5b6b + 0f71f01 commit 8199161

File tree

3 files changed

+320
-0
lines changed

3 files changed

+320
-0
lines changed

src/geonoderest/geonodectl.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from geonoderest.documents import GeonodeDocumentsHandler
2020
from geonoderest.maps import GeonodeMapsHandler
2121
from geonoderest.users import GeonodeUsersHandler
22+
from geonoderest.groups import GeonodeGroupsHandler
2223
from geonoderest.geoapps import GeonodeGeoappsHandler
2324
from geonoderest.uploads import GeonodeUploadsHandler
2425
from geonoderest.executionrequest import GeonodeExecutionRequestHandler
@@ -846,6 +847,111 @@ def geonodectl():
846847
"is_staff": true, "is_superuser": true}\' ... (mutually exclusive [c])',
847848
)
848849

850+
###########################
851+
# GROUPS ARGUMENT PARSING #
852+
###########################
853+
groups = subparsers.add_parser(
854+
"groups", help="group | groups commands", aliases=("group",)
855+
)
856+
groups_subparsers = groups.add_subparsers(
857+
help="geonodectl groups commands", dest="subcommand", required=True
858+
)
859+
860+
# LIST
861+
groups_list = groups_subparsers.add_parser("list", help="list groups")
862+
groups_list.add_argument(
863+
"--filter",
864+
nargs="*",
865+
action=kwargs_append_action,
866+
dest="filter",
867+
type=str,
868+
help="filter groups by key value pairs. E.g. --filter title=mygroup",
869+
)
870+
groups_list.add_argument(
871+
"--ordering",
872+
dest="ordering",
873+
default="pk",
874+
type=str,
875+
help="Which field to use when ordering the results. --ordering title (default: pk)",
876+
)
877+
groups_list.add_argument(
878+
"--search",
879+
dest="search",
880+
type=str,
881+
required=False,
882+
help="A search term to filter the results by. --search mygroup",
883+
)
884+
885+
# DESCRIBE
886+
groups_describe = groups_subparsers.add_parser("describe", help="get group details")
887+
groups_describe.add_argument(
888+
type=int, dest="pk", help="pk of group to describe ..."
889+
)
890+
891+
# PATCH
892+
groups_patch = groups_subparsers.add_parser("patch", help="patch group metadata")
893+
groups_patch.add_argument(type=int, dest="pk", help="pk of group to patch")
894+
groups_patch_mutually_exclusive_group = groups_patch.add_mutually_exclusive_group()
895+
groups_patch_mutually_exclusive_group.add_argument(
896+
"--set",
897+
dest="fields",
898+
type=str,
899+
help='patch metadata by providing a json string like: \'{"title": "new title"}\' ',
900+
)
901+
groups_patch_mutually_exclusive_group.add_argument(
902+
"--json_path",
903+
dest="json_path",
904+
type=str,
905+
help="patch metadata by providing a path to a json file",
906+
)
907+
908+
# CREATE
909+
groups_create = groups_subparsers.add_parser("create", help="create a new group")
910+
groups_create_mutually_exclusive_group = (
911+
groups_create.add_mutually_exclusive_group()
912+
)
913+
groups_create_mutually_exclusive_group.add_argument(
914+
"--title",
915+
type=str,
916+
dest="title",
917+
help="title of the new group ... (mutually exclusive [a])",
918+
)
919+
groups_create.add_argument(
920+
"--name",
921+
type=str,
922+
required=False,
923+
dest="name",
924+
help="slug name of the new group (only with --title) ...",
925+
)
926+
groups_create.add_argument(
927+
"--description",
928+
type=str,
929+
required=False,
930+
dest="description",
931+
default="",
932+
help="description of the new group (only with --title) ...",
933+
)
934+
groups_create_mutually_exclusive_group.add_argument(
935+
"--json_path",
936+
dest="json_path",
937+
type=str,
938+
help="create group by providing a path to a json file ... (mutually exclusive [b])",
939+
)
940+
groups_create_mutually_exclusive_group.add_argument(
941+
"--set",
942+
dest="fields",
943+
type=str,
944+
help='create group by providing a json string like: \'{"title": "mygroup", "description": "my desc"}\' ... (mutually exclusive [c])',
945+
)
946+
947+
# DELETE
948+
groups_delete = groups_subparsers.add_parser("delete", help="delete existing group")
949+
groups_delete.add_argument(
950+
type=str,
951+
dest="pk",
952+
help="pk of group(s) to delete (range '1-5', list '1,2,3', single '1') ...",
953+
)
954+
849955
###########################
850956
# UPLOAD ARGUMENT PARSING #
851957
###########################
@@ -1101,6 +1207,8 @@ def geonodectl():
11011207
g_obj = GeonodeMapsHandler(env=geonode_env)
11021208
case "users" | "user":
11031209
g_obj = GeonodeUsersHandler(env=geonode_env)
1210+
case "groups" | "group":
1211+
g_obj = GeonodeGroupsHandler(env=geonode_env)
11041212
case "geoapps" | "apps":
11051213
g_obj = GeonodeGeoappsHandler(env=geonode_env)
11061214
case "uploads":

src/geonoderest/groups.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import json
2+
import sys
3+
import logging
4+
from typing import Dict, List, Optional
5+
6+
from geonoderest.geonodeobject import GeonodeObjectHandler
7+
from geonoderest.geonodetypes import GeonodeCmdOutListKey
8+
from geonoderest.cmdprint import (
9+
print_json,
10+
json_decode_error_handler,
11+
)
12+
13+
14+
class GeonodeGroupsHandler(GeonodeObjectHandler):
15+
ENDPOINT_NAME = "groups"
16+
JSON_OBJECT_NAME = "groups"
17+
SINGULAR_RESOURCE_NAME = "group"
18+
19+
LIST_CMDOUT_HEADER: List[GeonodeCmdOutListKey] = [
20+
GeonodeCmdOutListKey(key="pk"),
21+
GeonodeCmdOutListKey(key="title"),
22+
GeonodeCmdOutListKey(key="name"),
23+
GeonodeCmdOutListKey(key="description"),
24+
]
25+
26+
def get(self, pk: int, **kwargs) -> Optional[Dict]:
27+
"""Get details for a given group pk.
28+
29+
Args:
30+
pk (int): pk of the group
31+
32+
Returns:
33+
Dict: group details
34+
"""
35+
r = self.http_get(endpoint=f"{self.ENDPOINT_NAME}/{pk}")
36+
if r is None:
37+
return None
38+
return r[self.SINGULAR_RESOURCE_NAME]
39+
40+
def cmd_create(
41+
self,
42+
title: Optional[str] = None,
43+
name: Optional[str] = None,
44+
description: str = "",
45+
fields: Optional[str] = None,
46+
json_path: Optional[str] = None,
47+
**kwargs,
48+
):
49+
"""Create a new group and print the result.
50+
51+
Args:
52+
title (Optional[str]): Title of the group.
53+
name (Optional[str]): Slug/name identifier for the group.
54+
description (str): Description of the group.
55+
fields (Optional[str]): JSON string with group data.
56+
json_path (Optional[str]): Path to a JSON file with group data.
57+
"""
58+
json_content = None
59+
if json_path:
60+
with open(json_path, "r") as file:
61+
try:
62+
json_content = json.load(file)
63+
except json.decoder.JSONDecodeError as E:
64+
json_decode_error_handler(str(file), E)
65+
elif fields:
66+
try:
67+
json_content = json.loads(fields)
68+
except json.decoder.JSONDecodeError as E:
69+
json_decode_error_handler(fields, E)
70+
71+
obj = self.create(
72+
title=title,
73+
name=name,
74+
description=description,
75+
json_content=json_content,
76+
**kwargs,
77+
)
78+
print_json(obj)
79+
80+
def create(
81+
self,
82+
title: Optional[str] = None,
83+
name: Optional[str] = None,
84+
description: str = "",
85+
json_content: Optional[Dict] = None,
86+
**kwargs,
87+
) -> Optional[Dict]:
88+
"""Create a new group.
89+
90+
Args:
91+
title (Optional[str]): Title of the group.
92+
name (Optional[str]): Slug/name identifier for the group.
93+
description (str): Description of the group.
94+
json_content (Optional[Dict]): Full JSON payload (overrides individual fields).
95+
96+
Returns:
97+
Dict: created group details
98+
"""
99+
if json_content is None:
100+
if title is None:
101+
logging.error("missing title for group creation ...")
102+
sys.exit(1)
103+
json_content = {
104+
"title": title,
105+
"description": description,
106+
}
107+
if name is not None:
108+
json_content["name"] = name
109+
110+
return self.http_post(
111+
endpoint=self.ENDPOINT_NAME,
112+
json=json_content,
113+
)

tests/test_groups.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import unittest
2+
from unittest.mock import patch
3+
4+
from geonoderest.groups import GeonodeGroupsHandler
5+
6+
7+
class TestGeonodeGroupsHandler(unittest.TestCase):
8+
9+
@patch.object(GeonodeGroupsHandler, "http_get")
10+
def test_list(self, mock_http_get):
11+
"""Ensure list calls the groups endpoint and returns groups list."""
12+
mock_http_get.return_value = {
13+
"groups": [
14+
{"pk": 1, "title": "Group A", "name": "group-a", "description": ""},
15+
{"pk": 2, "title": "Group B", "name": "group-b", "description": ""},
16+
]
17+
}
18+
handler = GeonodeGroupsHandler(env={})
19+
result = handler.list()
20+
mock_http_get.assert_called_once_with(endpoint="groups/", params={})
21+
self.assertEqual(len(result), 2)
22+
self.assertEqual(result[0]["title"], "Group A")
23+
24+
@patch.object(GeonodeGroupsHandler, "http_get")
25+
def test_get(self, mock_http_get):
26+
"""Ensure get returns the 'group' dict from the API response."""
27+
mock_http_get.return_value = {
28+
"group": {"pk": 1, "title": "Group A", "name": "group-a", "description": ""}
29+
}
30+
handler = GeonodeGroupsHandler(env={})
31+
result = handler.get(1)
32+
mock_http_get.assert_called_once_with(endpoint="groups/1")
33+
self.assertEqual(result["title"], "Group A")
34+
self.assertEqual(result["pk"], 1)
35+
36+
@patch.object(GeonodeGroupsHandler, "http_get")
37+
def test_get_returns_none_on_missing(self, mock_http_get):
38+
"""Ensure get returns None if http_get returns None."""
39+
mock_http_get.return_value = None
40+
handler = GeonodeGroupsHandler(env={})
41+
result = handler.get(999)
42+
self.assertIsNone(result)
43+
44+
@patch.object(GeonodeGroupsHandler, "http_patch")
45+
def test_patch(self, mock_http_patch):
46+
"""Ensure patch calls the correct endpoint with JSON content."""
47+
mock_http_patch.return_value = {"pk": 1, "title": "Updated Group"}
48+
handler = GeonodeGroupsHandler(env={})
49+
result = handler.patch(1, json_content={"title": "Updated Group"})
50+
mock_http_patch.assert_called_once_with(
51+
endpoint="groups/1/", json_content={"title": "Updated Group"}
52+
)
53+
self.assertEqual(result["title"], "Updated Group")
54+
55+
@patch.object(GeonodeGroupsHandler, "http_post")
56+
def test_create_with_title(self, mock_http_post):
57+
"""Ensure create with title sends the correct JSON payload."""
58+
mock_http_post.return_value = {"pk": 5, "title": "New Group"}
59+
handler = GeonodeGroupsHandler(env={})
60+
result = handler.create(
61+
title="New Group", name="new-group", description="A desc"
62+
)
63+
mock_http_post.assert_called_once_with(
64+
endpoint="groups",
65+
json={"title": "New Group", "description": "A desc", "name": "new-group"},
66+
)
67+
self.assertEqual(result["pk"], 5)
68+
69+
@patch.object(GeonodeGroupsHandler, "http_post")
70+
def test_create_with_json_content(self, mock_http_post):
71+
"""Ensure create with json_content bypasses individual fields."""
72+
mock_http_post.return_value = {"pk": 6, "title": "JSON Group"}
73+
handler = GeonodeGroupsHandler(env={})
74+
json_content = {"title": "JSON Group", "description": "from json"}
75+
result = handler.create(json_content=json_content)
76+
mock_http_post.assert_called_once_with(endpoint="groups", json=json_content)
77+
self.assertEqual(result["pk"], 6)
78+
79+
@patch.object(GeonodeGroupsHandler, "http_delete")
80+
def test_delete(self, mock_http_delete):
81+
"""Ensure delete calls the correct endpoint."""
82+
mock_http_delete.return_value = {}
83+
handler = GeonodeGroupsHandler(env={})
84+
handler.delete(pk=3)
85+
mock_http_delete.assert_called_once_with(endpoint="groups/3/")
86+
87+
@patch.object(GeonodeGroupsHandler, "http_delete")
88+
def test_delete_range(self, mock_http_delete):
89+
"""Ensure range delete only calls group endpoint for each pk in range."""
90+
mock_http_delete.return_value = {}
91+
handler = GeonodeGroupsHandler(env={})
92+
for pk in range(1, 4):
93+
handler.delete(pk=pk)
94+
calls = [c.kwargs["endpoint"] for c in mock_http_delete.call_args_list]
95+
self.assertEqual(calls, ["groups/1/", "groups/2/", "groups/3/"])
96+
97+
98+
if __name__ == "__main__":
99+
unittest.main()

0 commit comments

Comments
 (0)