diff --git a/integration/tests/posit/connect/test_content_item_permissions.py b/integration/tests/posit/connect/test_content_item_permissions.py index 489bf543..518178d5 100644 --- a/integration/tests/posit/connect/test_content_item_permissions.py +++ b/integration/tests/posit/connect/test_content_item_permissions.py @@ -1,5 +1,14 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + from posit import connect -from posit.connect.content import ContentItem + +if TYPE_CHECKING: + from posit.connect.content import ContentItem + from posit.connect.permissions import Permission class TestContentPermissions: @@ -48,7 +57,7 @@ def test_permissions_add_destroy(self): role="owner", ) - def assert_permissions_match_guids(permissions, objs_with_guid): + def assert_permissions_match_guids(permissions: list[Permission], objs_with_guid): for permission, obj_with_guid in zip(permissions, objs_with_guid): assert permission["principal_guid"] == obj_with_guid["guid"] @@ -59,11 +68,9 @@ def assert_permissions_match_guids(permissions, objs_with_guid): ) # Remove permissions (and from some that isn't an owner) - destroyed_permissions = self.content.permissions.destroy(self.user_aron, self.user_bill) - assert_permissions_match_guids( - destroyed_permissions, - [self.user_aron], - ) + self.content.permissions.destroy(self.user_aron) + with pytest.raises(ValueError): + self.content.permissions.destroy(self.user_bill) # Prove they have been removed assert_permissions_match_guids( @@ -72,11 +79,7 @@ def assert_permissions_match_guids(permissions, objs_with_guid): ) # Remove the last permission - destroyed_permissions = self.content.permissions.destroy(self.group_friends) - assert_permissions_match_guids( - destroyed_permissions, - [self.group_friends], - ) + self.content.permissions.destroy(self.group_friends) # Prove they have been removed assert self.content.permissions.find() == [] diff --git a/integration/tests/posit/connect/test_users.py b/integration/tests/posit/connect/test_users.py index ed4efbad..efb3c72c 100644 --- a/integration/tests/posit/connect/test_users.py +++ b/integration/tests/posit/connect/test_users.py @@ -51,6 +51,48 @@ def test_get(self): assert self.client.users.get(self.bill["guid"]) == self.bill assert self.client.users.get(self.cole["guid"]) == self.cole + # Also tests Groups.members + def test_user_group_interactions(self): + try: + test_group = self.client.groups.create(name="UnitFriends") + + # `Group.members.count()` + assert test_group.members.count() == 0 + + # `Group.members.add()` + test_group.members.add(self.bill) + # `User.groups.add()` + assert test_group.members.count() == 1 + self.cole.groups.add(test_group) + assert test_group.members.count() == 2 + + # `Group.members.find()` + group_users = test_group.members.find() + assert len(group_users) == 2 + assert group_users[0]["guid"] == self.bill["guid"] + assert group_users[1]["guid"] == self.cole["guid"] + + # `User.group.find()` + bill_groups = self.bill.groups.find() + assert len(bill_groups) == 1 + assert bill_groups[0]["guid"] == test_group["guid"] + + # `Group.members.delete()` + test_group.members.delete(self.bill) + assert test_group.members.count() == 1 + + # `User.groups.delete()` + self.cole.groups.delete(test_group) + assert test_group.members.count() == 0 + + finally: + groups = self.client.groups.find(prefix="UnitFriends") + if len(groups) > 0: + test_group = groups[0] + test_group.delete() + + assert len(self.client.groups.find(prefix="UnitFriends")) == 0 + class TestUserContent: """Checks behavior of the content attribute.""" diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index e1ba808c..42cff275 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -156,7 +156,7 @@ def __init__(self, *args, **kwargs) -> None: session.hooks["response"].append(hooks.handle_errors) self.session = session self.resource_params = ResourceParameters(session, self.cfg.url) - self._ctx = Context(self.session, self.cfg.url) + self._ctx = Context(self) @property def version(self) -> str | None: @@ -180,7 +180,7 @@ def me(self) -> User: User The currently authenticated user. """ - return me.get(self.resource_params) + return me.get(self._ctx) @property def groups(self) -> Groups: @@ -191,7 +191,7 @@ def groups(self) -> Groups: Groups The groups resource interface. """ - return Groups(self.resource_params) + return Groups(self._ctx) @property def tasks(self) -> Tasks: @@ -215,7 +215,7 @@ def users(self) -> Users: Users The users resource instance. """ - return Users(self.resource_params) + return Users(self._ctx) @property def content(self) -> Content: @@ -227,7 +227,7 @@ def content(self) -> Content: Content The content resource instance. """ - return Content(self.resource_params) + return Content(self._ctx) @property def metrics(self) -> Metrics: diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 404867b7..00407049 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -20,7 +20,6 @@ from . import tasks from ._api import ApiDictEndpoint, JsonifiableDict from .bundles import Bundles -from .context import Context from .env import EnvVars from .errors import ClientError from .jobs import JobsMixin @@ -32,6 +31,7 @@ from .variants import Variants if TYPE_CHECKING: + from .context import Context from .tasks import Task @@ -221,30 +221,32 @@ class _AttrsCreate(_AttrsBase): @overload def __init__( self, + ctx: Context, /, - params: ResourceParameters, + *, guid: str, ) -> None: ... @overload def __init__( self, + ctx: Context, /, - params: ResourceParameters, + *, guid: str, **kwargs: Unpack[ContentItem._Attrs], ) -> None: ... def __init__( self, + ctx: Context, /, - params: ResourceParameters, + *, guid: str, **kwargs: Unpack[ContentItem._AttrsNotRequired], ) -> None: _assert_guid(guid) - ctx = Context(params.session, params.url) path = f"v1/content/{guid}" super().__init__(ctx, path, guid=guid, **kwargs) @@ -291,8 +293,8 @@ def create_repository( def delete(self) -> None: """Delete the content item.""" path = f"v1/content/{self['guid']}" - url = self.params.url + path - self.params.session.delete(url) + url = self._ctx.url + path + self._ctx.session.delete(url) def deploy(self) -> tasks.Task: """Deploy the content. @@ -311,8 +313,8 @@ def deploy(self) -> tasks.Task: None """ path = f"v1/content/{self['guid']}/deploy" - url = self.params.url + path - response = self.params.session.post(url, json={"bundle_id": None}) + url = self._ctx.url + path + response = self._ctx.session.post(url, json={"bundle_id": None}) result = response.json() ts = tasks.Tasks(self.params) return ts.get(result["task_id"]) @@ -367,8 +369,8 @@ def restart(self) -> None: self.environment_variables.create(key, unix_epoch_in_seconds) self.environment_variables.delete(key) # GET via the base Connect URL to force create a new worker thread. - url = posixpath.join(dirname(self.params.url), f"content/{self['guid']}") - self.params.session.get(url) + url = posixpath.join(dirname(self._ctx.url), f"content/{self['guid']}") + self._ctx.session.get(url) return None else: raise ValueError( @@ -438,8 +440,8 @@ def update( ------- None """ - url = self.params.url + f"v1/content/{self['guid']}" - response = self.params.session.patch(url, json=attrs) + url = self._ctx.url + f"v1/content/{self['guid']}" + response = self._ctx.session.patch(url, json=attrs) super().update(**response.json()) # Relationships @@ -464,7 +466,9 @@ def owner(self) -> dict: # If it's not included, we can retrieve the information by `owner_guid` from .users import Users - self["owner"] = Users(self.params).get(self["owner_guid"]) + self["owner"] = Users( + self._ctx, + ).get(self["owner_guid"]) return self["owner"] @property @@ -512,12 +516,13 @@ class Content(Resources): def __init__( self, - params: ResourceParameters, + ctx: Context, *, owner_guid: str | None = None, ) -> None: - super().__init__(params) + super().__init__(ctx.client.resource_params) self.owner_guid = owner_guid + self._ctx = ctx def count(self) -> int: """Count the number of content items. @@ -592,9 +597,9 @@ def create( ContentItem """ path = "v1/content" - url = self.params.url + path - response = self.params.session.post(url, json=attrs) - return ContentItem(self.params, **response.json()) + url = self._ctx.url + path + response = self._ctx.session.post(url, json=attrs) + return ContentItem(self._ctx, **response.json()) @overload def find( @@ -680,11 +685,11 @@ def find(self, include: Optional[str | list[Any]] = None, **conditions) -> List[ conditions["owner_guid"] = self.owner_guid path = "v1/content" - url = self.params.url + path - response = self.params.session.get(url, params=conditions) + url = self._ctx.url + path + response = self._ctx.session.get(url, params=conditions) return [ ContentItem( - self.params, + self._ctx, **result, ) for result in response.json() @@ -853,6 +858,6 @@ def get(self, guid: str) -> ContentItem: ContentItem """ path = f"v1/content/{guid}" - url = self.params.url + path - response = self.params.session.get(url) - return ContentItem(self.params, **response.json()) + url = self._ctx.url + path + response = self._ctx.session.get(url) + return ContentItem(self._ctx, **response.json()) diff --git a/src/posit/connect/context.py b/src/posit/connect/context.py index 1e275838..6faaf545 100644 --- a/src/posit/connect/context.py +++ b/src/posit/connect/context.py @@ -1,6 +1,7 @@ from __future__ import annotations import functools +import weakref from typing import TYPE_CHECKING, Protocol from packaging.version import Version @@ -8,6 +9,7 @@ if TYPE_CHECKING: import requests + from .client import Client from .urls import Url @@ -28,9 +30,12 @@ def wrapper(instance: ContextManager, *args, **kwargs): class Context: - def __init__(self, session: requests.Session, url: Url): - self.session = session - self.url = url + def __init__(self, client: Client): + self.session: requests.Session = client.session + self.url: Url = client.cfg.url + # Since this is a child object of the client, we use a weak reference to avoid circular + # references (which would prevent garbage collection) + self.client: Client = weakref.proxy(client) @property def version(self) -> str | None: diff --git a/src/posit/connect/groups.py b/src/posit/connect/groups.py index 217f47ee..2a909c5f 100644 --- a/src/posit/connect/groups.py +++ b/src/posit/connect/groups.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, overload +from typing import TYPE_CHECKING, List, Optional, overload from .paginator import Paginator from .resources import Resource, Resources @@ -10,18 +10,264 @@ if TYPE_CHECKING: import requests + from posit.connect.context import Context + + from .users import User + class Group(Resource): + def __init__(self, ctx: Context, **kwargs) -> None: + super().__init__(ctx.client.resource_params, **kwargs) + self._ctx: Context = ctx + + @property + def members(self) -> GroupMembers: + """Get the group members. + + Returns + ------- + GroupMembers + All the users in the group. + + Examples + -------- + ```python + from posit.connect import Client + + client = Client("https://posit.example.com", "API_KEY") + + group = client.groups.get("GROUP_GUID_HERE") + group_users = group.members.find() + + # Get count of group members + group_user_count = group.members.count() + ``` + + """ + return GroupMembers(self._ctx, group_guid=self["guid"]) + def delete(self) -> None: - """Delete the group.""" + """Delete the group. + + Examples + -------- + ```python + from posit.connect import Client + + client = Client("https://posit.example.com", "API_KEY") + + group = client.groups.get("GROUP_GUID_HERE") + + # Delete the group + group.delete() + ``` + """ path = f"v1/groups/{self['guid']}" - url = self.params.url + path - self.params.session.delete(url) + url = self._ctx.url + path + self._ctx.session.delete(url) + + +class GroupMembers(Resources): + def __init__(self, ctx: Context, group_guid: str) -> None: + super().__init__(ctx.client.resource_params) + self._group_guid = group_guid + self._ctx: Context = ctx + + @overload + def add(self, user: User, /) -> None: ... + @overload + def add(self, /, *, user_guid: str) -> None: ... + + def add(self, user: Optional[User] = None, /, *, user_guid: Optional[str] = None) -> None: + """Add a user to the group. + + Parameters + ---------- + user : User + User object to add to the group. Only one of `user=` or `user_guid=` can be provided. + user_guid : str + The user GUID. + + Examples + -------- + ```python + from posit.connect import Client + + client = Client("https://posit.example.com", "API_KEY") + + group = client.groups.get("GROUP_GUID_HERE") + user = client.users.get("USER_GUID_HERE") + + # Add a user to the group + group.members.add(user) + + # Add multiple users to the group + users = client.users.find() + for user in users: + group.members.add(user) + + # Add a user to the group by GUID + group.members.add(user_guid="USER_GUID_HERE") + ``` + + See Also + -------- + * https://docs.posit.co/connect/api/#post-/v1/groups/-group_guid-/members + """ + if user is not None: + from .users import User + + if user_guid: + raise ValueError("Only one of `user=` or `user_guid=` should be provided.") + if not isinstance(user, User): + raise TypeError(f"`user=` is not a `User` object. Received {user}") + + user_guid = user["guid"] + + if not isinstance(user_guid, str): + raise TypeError(f"`user_guid=` should be a string. Received {user_guid}") + if not user_guid: + raise ValueError("`user_guid=` should not be empty.") + + path = f"v1/groups/{self._group_guid}/members" + url = self._ctx.url + path + self._ctx.session.post(url, json={"user_guid": user_guid}) + + @overload + def delete(self, user: User, /) -> None: ... + @overload + def delete(self, /, *, user_guid: str) -> None: ... + + def delete(self, user: Optional[User] = None, /, *, user_guid: Optional[str] = None) -> None: + """Remove a user from the group. + + Parameters + ---------- + user : User + User object to add to the group. Only one of `user=` or `user_guid=` can be provided. + user_guid : str + The user GUID. + + Examples + -------- + ```python + from posit.connect import Client + + client = Client("https://posit.example.com", "API_KEY") + + group = client.groups.get("GROUP_GUID_HERE") + + # Remove a user from the group + first_user = group.members.find()[0] + group.members.delete(first_user) + + # Remove multiple users from the group + group_users = group.members.find()[:2] + for group_user in group_users: + group.members.delete(group_user) + + # Remove a user from the group by GUID + group.members.delete(user_guid="USER_GUID_HERE") + ``` + + See Also + -------- + * https://docs.posit.co/connect/api/#delete-/v1/groups/-group_guid-/members/-user_guid- + """ + if user is not None: + from .users import User + + if user_guid: + raise ValueError("Only one of `user=` or `user_guid=` should be provided.") + if not isinstance(user, User): + raise TypeError(f"`user=` is not a `User` object. Received {user}") + + user_guid = user["guid"] + + if not isinstance(user_guid, str): + raise TypeError(f"`user_guid=` should be a string. Received {user_guid}") + if not user_guid: + raise ValueError("`user_guid=` should not be empty.") + + path = f"v1/groups/{self._group_guid}/members/{user_guid}" + url = self._ctx.url + path + self._ctx.session.delete(url) + + def find(self) -> list[User]: + """Find group members. + + Returns + ------- + list[User] + All the users in the group. + + Examples + -------- + ```python + from posit.connect import Client + + client = Client("https://posit.example.com", "API_KEY") + + group = client.groups.get("GROUP_GUID_HERE") + + # Find all users in the group + group_users = group.members.find() + ``` + + See Also + -------- + * https://docs.posit.co/connect/api/#get-/v1/groups/-group_guid-/members + """ + # Avoid circular import + from .users import User + + path = f"v1/groups/{self._group_guid}/members" + url = self._ctx.url + path + paginator = Paginator(self._ctx.session, url) + member_dicts = paginator.fetch_results() + + # For each member in the group + users = [User(self._ctx, **member_dict) for member_dict in member_dicts] + return users + + def count(self) -> int: + """Count the number of group members. + + Returns + ------- + int + + Examples + -------- + ```python + from posit.connect import Client + + client = Client("https://posit.example.com", "API_KEY") + + group = client.groups.get("GROUP_GUID_HERE") + + # Get count of group members + group_user_count = group.members.count() + ``` + + See Also + -------- + * https://docs.posit.co/connect/api/#get-/v1/groups/-group_guid-/members + """ + path = f"v1/groups/{self._group_guid}/members" + url = self._ctx.url + path + response = self._ctx.session.get(url, params={"page_size": 1}) + result = response.json() + return result["total"] class Groups(Resources): """Groups resource.""" + def __init__(self, ctx: Context) -> None: + super().__init__(ctx.client.resource_params) + self._ctx: Context = ctx + @overload def create(self, *, name: str, unique_id: str | None) -> Group: """Create a group. @@ -34,6 +280,10 @@ def create(self, *, name: str, unique_id: str | None) -> Group: Returns ------- Group + + See Also + -------- + * https://docs.posit.co/connect/api/#post-/v1/groups """ @overload @@ -58,9 +308,9 @@ def create(self, **kwargs) -> Group: Group """ path = "v1/groups" - url = self.params.url + path - response = self.params.session.post(url, json=kwargs) - return Group(self.params, **response.json()) + url = self._ctx.url + path + response = self._ctx.session.post(url, json=kwargs) + return Group(self._ctx, **response.json()) @overload def find( @@ -83,14 +333,18 @@ def find(self, **kwargs): Returns ------- List[Group] + + See Also + -------- + * https://docs.posit.co/connect/api/#get-/v1/groups """ path = "v1/groups" - url = self.params.url + path - paginator = Paginator(self.params.session, url, params=kwargs) + url = self._ctx.url + path + paginator = Paginator(self._ctx.session, url, params=kwargs) results = paginator.fetch_results() return [ Group( - self.params, + self._ctx, **result, ) for result in results @@ -117,15 +371,19 @@ def find_one(self, **kwargs) -> Group | None: Returns ------- Group | None + + See Also + -------- + * https://docs.posit.co/connect/api/#get-/v1/groups """ path = "v1/groups" - url = self.params.url + path - paginator = Paginator(self.params.session, url, params=kwargs) + url = self._ctx.url + path + paginator = Paginator(self._ctx.session, url, params=kwargs) pages = paginator.fetch_pages() results = (result for page in pages for result in page.results) groups = ( Group( - self.params, + self._ctx, **result, ) for result in results @@ -142,11 +400,15 @@ def get(self, guid: str) -> Group: Returns ------- Group + + See Also + -------- + * https://docs.posit.co/connect/api/#get-/v1/groups """ - url = self.params.url + f"v1/groups/{guid}" - response = self.params.session.get(url) + url = self._ctx.url + f"v1/groups/{guid}" + response = self._ctx.session.get(url) return Group( - self.params, + self._ctx, **response.json(), ) @@ -156,9 +418,13 @@ def count(self) -> int: Returns ------- int + + See Also + -------- + * https://docs.posit.co/connect/api/#get-/v1/groups """ path = "v1/groups" - url = self.params.url + path - response: requests.Response = self.params.session.get(url, params={"page_size": 1}) + url = self._ctx.url + path + response: requests.Response = self._ctx.session.get(url, params={"page_size": 1}) result: dict = response.json() return result["total"] diff --git a/src/posit/connect/me.py b/src/posit/connect/me.py index a32d7c63..ee795724 100644 --- a/src/posit/connect/me.py +++ b/src/posit/connect/me.py @@ -1,9 +1,8 @@ -from posit.connect.resources import ResourceParameters - +from .context import Context from .users import User -def get(params: ResourceParameters) -> User: +def get(ctx: Context) -> User: """ Gets the current user. @@ -15,6 +14,6 @@ def get(params: ResourceParameters) -> User: ------- User: The current user. """ - url = params.url + "v1/user" - response = params.session.get(url) - return User(params, **response.json()) + url = ctx.url + "v1/user" + response = ctx.session.get(url) + return User(ctx, **response.json()) diff --git a/src/posit/connect/permissions.py b/src/posit/connect/permissions.py index 5806e019..a20b6f3f 100644 --- a/src/posit/connect/permissions.py +++ b/src/posit/connect/permissions.py @@ -111,8 +111,15 @@ def find(self, **kwargs) -> List[Permission]: """ path = f"v1/content/{self.content_guid}/permissions" url = self.params.url + path - response = self.params.session.get(url, json=kwargs) + response = self.params.session.get(url) + kwargs_items = kwargs.items() results = response.json() + if len(kwargs_items) > 0: + results = [ + result + for result in results + if isinstance(result, dict) and (result.items() >= kwargs_items) + ] return [Permission(self.params, **result) for result in results] def find_one(self, **kwargs) -> Permission | None: @@ -142,25 +149,18 @@ def get(self, uid: str) -> Permission: response = self.params.session.get(url) return Permission(self.params, **response.json()) - def destroy(self, *permissions: str | Group | User | Permission) -> list[Permission]: - """Remove supplied content item permissions. + def destroy(self, permission: str | Group | User | Permission, /) -> None: + """Remove supplied content item permission. - Removes all provided permissions from the content item's permissions. If a permission isn't - found, it is silently ignored. + Removes provided permission from the content item's permissions. Parameters ---------- - *permissions : str | Group | User | Permission - The content item permissions to remove. If a `str` is received, it is compared against + permission : str | Group | User | Permission + The content item permission to remove. If a `str` is received, it is compared against the `Permissions`'s `principal_guid`. If a `Group` or `User` is received, the associated `Permission` will be removed. - Returns - ------- - list[Permission] - The removed permissions. If a permission is not found, there is nothing to remove and - it is not included in the returned list. - Examples -------- ```python @@ -175,51 +175,46 @@ def destroy(self, *permissions: str | Group | User | Permission) -> list[Permiss ############################ client = connect.Client() + content_item = client.content.get(content_guid) # Remove a single permission by principal_guid - client.content.get(content_guid).permissions.destroy(principal_guid) + content_item.permissions.destroy(principal_guid) # Remove by user (if principal_guid is a user) user = client.users.get(principal_guid) - client.content.get(content_guid).permissions.destroy(user) + content_item.permissions.destroy(user) # Remove by group (if principal_guid is a group) group = client.groups.get(principal_guid) - client.content.get(content_guid).permissions.destroy(group) + content_item.permissions.destroy(group) # Remove all groups with a matching prefix name groups = client.groups.find(prefix=group_name_prefix) - client.content.get(content_guid).permissions.destroy(*groups) + for group in groups: + content_item.permissions.destroy(group) # Confirm new permissions - client.content.get(content_guid).permissions.find() + content_item.permissions.find() ``` """ from .groups import Group from .users import User - if len(permissions) == 0: - raise ValueError("Expected at least one `permission` to remove") - - principal_guids: set[str] = set() - - for arg in permissions: - if isinstance(arg, str): - principal_guid = arg - elif isinstance(arg, (Group, User)): - principal_guid: str = arg["guid"] - elif isinstance(arg, Permission): - principal_guid: str = arg["principal_guid"] - else: - raise TypeError( - f"destroy() expected argument type 'str', 'User', 'Group', or 'Permission' but got '{type(arg).__name__}'", - ) - principal_guids.add(principal_guid) - - destroyed_permissions: list[Permission] = [] - for permission in self.find(): - if permission["principal_guid"] in principal_guids: - permission.destroy() - destroyed_permissions.append(permission) - - return destroyed_permissions + if isinstance(permission, str): + permission_obj = self.get(permission) + elif isinstance(permission, (Group, User)): + principal_guid: str = permission["guid"] + permission_obj = self.find_one( + principal_guid=principal_guid, + ) + print("Barret!", permission, principal_guid, permission_obj) + if permission_obj is None: + raise ValueError(f"Permission with principal_guid '{principal_guid}' not found.") + elif isinstance(permission, Permission): + permission_obj = permission + else: + raise TypeError( + f"destroy() expected `permission=` to have type `str | User | Group | Permission`. Received `{permission}` of type `{type(permission)}`.", + ) + + permission_obj.destroy() diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index dd9c6833..ca99ced0 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -2,20 +2,29 @@ from __future__ import annotations -from typing import List, Literal +from typing import TYPE_CHECKING, List, Literal from typing_extensions import NotRequired, Required, TypedDict, Unpack from . import me from .content import Content from .paginator import Paginator -from .resources import Resource, ResourceParameters, Resources +from .resources import Resource, Resources + +if TYPE_CHECKING: + from posit.connect.context import Context + + from .groups import Group class User(Resource): + def __init__(self, ctx: Context, /, **attributes) -> None: + super().__init__(ctx.client.resource_params, **attributes) + self._ctx: Context = ctx + @property def content(self) -> Content: - return Content(self.params, owner_guid=self["guid"]) + return Content(self._ctx, owner_guid=self["guid"]) def lock(self, *, force: bool = False): """ @@ -41,15 +50,19 @@ def lock(self, *, force: bool = False): Attempt to lock your own account (will raise `RuntimeError` unless `force` is set to `True`): >>> user.lock(force=True) + + See Also + -------- + * https://docs.posit.co/connect/api/#post-/v1/users/-guid-/lock """ - _me = me.get(self.params) + _me = me.get(self._ctx) if _me["guid"] == self["guid"] and not force: raise RuntimeError( "You cannot lock your own account. Set force=True to override this behavior.", ) - url = self.params.url + f"v1/users/{self['guid']}/lock" + url = self._ctx.url + f"v1/users/{self['guid']}/lock" body = {"locked": True} - self.params.session.post(url, json=body) + self._ctx.session.post(url, json=body) super().update(locked=True) def unlock(self): @@ -67,10 +80,14 @@ def unlock(self): Unlock a user's account: >>> user.unlock() + + See Also + -------- + * https://docs.posit.co/connect/api/#post-/v1/users/-guid-/lock """ - url = self.params.url + f"v1/users/{self['guid']}/lock" + url = self._ctx.url + f"v1/users/{self['guid']}/lock" body = {"locked": False} - self.params.session.post(url, json=body) + self._ctx.session.post(url, json=body) super().update(locked=False) class UpdateUser(TypedDict): @@ -115,17 +132,187 @@ def update( Update the user's first and last name: >>> user.update(first_name="Jane", last_name="Smith") + + See Also + -------- + * https://docs.posit.co/connect/api/#put-/v1/users/-guid- """ - url = self.params.url + f"v1/users/{self['guid']}" - response = self.params.session.put(url, json=kwargs) + url = self._ctx.url + f"v1/users/{self['guid']}" + response = self._ctx.session.put(url, json=kwargs) super().update(**response.json()) + @property + def groups(self) -> UserGroups: + """ + Retrieve the groups to which the user belongs. + + Returns + ------- + UserGroups + Helper class that returns the groups of which the user is a member. + + Examples + -------- + Retrieve the groups to which the user belongs: + + ```python + user = client.users.get("USER_GUID_HERE") + groups = user.groups.find() + ``` + """ + return UserGroups(self._ctx, self["guid"]) + + +class UserGroups(Resources): + def __init__(self, ctx: Context, user_guid: str) -> None: + super().__init__(ctx.client.resource_params) + self._ctx: Context = ctx + self._user_guid: str = user_guid + + def add(self, group: str | Group) -> None: + """ + Add the user to the specified group. + + Parameters + ---------- + group : str | Group + The group guid or `Group` object to which the user will be added. + + Examples + -------- + ```python + from posit.connect import Client + + client = Client("https://posit.example.com", "API_KEY") + + group = client.groups.get("GROUP_GUID_HERE") + user = client.users.get("USER_GUID_HERE") + + # Add the user to the group + user.groups.add(group) + + # Add the user to multiple groups + groups = [ + client.groups.get("GROUP_GUID_1"), + client.groups.get("GROUP_GUID_2"), + ] + for group in groups: + user.groups.add(group) + + # Add the user to a group by GUID + user.groups.add("GROUP_GUID_HERE") + ``` + + See Also + -------- + * https://docs.posit.co/connect/api/#post-/v1/groups/-group_guid-/members + """ + from .groups import Group + + if isinstance(group, Group): + group.members.add(user_guid=self._user_guid) + return + + if not isinstance(group, str): + raise TypeError(f"`group=` must be a `str | Group`. Received {group}") + if not group: + raise ValueError("`group=` must not be empty.") + + group_obj = self._ctx.client.groups.get(group) + group_obj.members.add(user_guid=self._user_guid) + + def delete(self, group: str | Group) -> None: + """ + Remove the user from the specified group. + + Parameters + ---------- + group : str | Group + The group to which the user will be added. + + Examples + -------- + ```python + from posit.connect import Client + + client = Client("https://posit.example.com", "API_KEY") + + group = client.groups.get("GROUP_GUID_HERE") + user = client.users.get("USER_GUID_HERE") + + # Remove the user from the group + user.groups.delete(group) + + # Remove the user from multiple groups + groups = [ + client.groups.get("GROUP_GUID_1"), + client.groups.get("GROUP_GUID_2"), + ] + for group in groups: + user.groups.delete(group) + + # Remove the user from a group by GUID + user.groups.delete("GROUP_GUID_HERE") + ``` + + See Also + -------- + * https://docs.posit.co/connect/api/#delete-/v1/groups/-group_guid-/members/-user_guid- + """ + from .groups import Group + + if isinstance(group, Group): + group.members.delete(user_guid=self._user_guid) + return + + if not isinstance(group, str): + raise TypeError(f"`group=` must be a `str | Group`. Received {group}") + if not group: + raise ValueError("`group=` must not be empty.") + + group_obj = self._ctx.client.groups.get(group) + group_obj.members.delete(user_guid=self._user_guid) + + def find(self) -> List[Group]: + """ + Retrieve the groups to which the user belongs. + + Returns + ------- + List[Group] + A list of groups to which the user belongs. + + Examples + -------- + ```python + from posit.connect import Client + + client = Client("https://posit.example.com", "API_KEY") + + user = client.users.get("USER_GUID_HERE") + groups = user.groups.find() + ``` + + See Also + -------- + * https://docs.posit.co/connect/api/#get-/v1/groups/-group_guid-/members + """ + self_groups: list[Group] = [] + for group in self._ctx.client.groups.find(): + group_users = group.members.find() + for group_user in group_users: + if group_user["guid"] == self._user_guid: + self_groups.append(group) + + return self_groups + class Users(Resources): """Users resource.""" - def __init__(self, params: ResourceParameters) -> None: - super().__init__(params) + def __init__(self, ctx: Context) -> None: + super().__init__(ctx.client.resource_params) + self._ctx: Context = ctx class CreateUser(TypedDict): """Create user request.""" @@ -195,11 +382,15 @@ def create(self, **attributes: Unpack[CreateUser]) -> User: ... user_must_set_password=True, ... user_role="viewer", ... ) + + See Also + -------- + * https://docs.posit.co/connect/api/#post-/v1/users """ # todo - use the 'context' module to inspect the 'authentication' object and route to POST (local) or PUT (remote). - url = self.params.url + "v1/users" - response = self.params.session.post(url, json=attributes) - return User(self.params, **response.json()) + url = self._ctx.url + "v1/users" + response = self._ctx.session.post(url, json=attributes) + return User(self._ctx, **response.json()) class FindUser(TypedDict): """Find user request.""" @@ -239,13 +430,17 @@ def find(self, **conditions: Unpack[FindUser]) -> List[User]: Find all users who are locked or licensed: >>> users = client.find(account_status="locked|licensed") + + See Also + -------- + * https://docs.posit.co/connect/api/#get-/v1/users """ - url = self.params.url + "v1/users" - paginator = Paginator(self.params.session, url, params={**conditions}) + url = self._ctx.url + "v1/users" + paginator = Paginator(self._ctx.session, url, params={**conditions}) results = paginator.fetch_results() return [ User( - self.params, + self._ctx, **user, ) for user in results @@ -282,14 +477,18 @@ def find_one(self, **conditions: Unpack[FindUser]) -> User | None: Find a user who is locked or licensed: >>> user = client.find_one(account_status="locked|licensed") + + See Also + -------- + * https://docs.posit.co/connect/api/#get-/v1/users """ - url = self.params.url + "v1/users" - paginator = Paginator(self.params.session, url, params={**conditions}) + url = self._ctx.url + "v1/users" + paginator = Paginator(self._ctx.session, url, params={**conditions}) pages = paginator.fetch_pages() results = (result for page in pages for result in page.results) users = ( User( - self.params, + self._ctx, **result, ) for result in results @@ -312,11 +511,15 @@ def get(self, uid: str) -> User: Examples -------- >>> user = client.get("123e4567-e89b-12d3-a456-426614174000") + + See Also + -------- + * https://docs.posit.co/connect/api/#get-/v1/users """ - url = self.params.url + f"v1/users/{uid}" - response = self.params.session.get(url) + url = self._ctx.url + f"v1/users/{uid}" + response = self._ctx.session.get(url) return User( - self.params, + self._ctx, **response.json(), ) @@ -327,8 +530,12 @@ def count(self) -> int: Returns ------- int + + See Also + -------- + * https://docs.posit.co/connect/api/#get-/v1/users """ - url = self.params.url + "v1/users" - response = self.params.session.get(url, params={"page_size": 1}) + url = self._ctx.url + "v1/users" + response = self._ctx.session.get(url, params={"page_size": 1}) result: dict = response.json() return result["total"] diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions.json index 9db6f6bf..67a27f92 100644 --- a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions.json +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions.json @@ -1,16 +1,16 @@ [ - { - "id": 94, - "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", - "principal_guid": "78974391-d89f-4f11-916a-ba50cfe993db", - "principal_type": "user", - "role": "owner" - }, - { - "id": 59, - "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", - "principal_guid": "75b95fc0-ae02-4d85-8732-79a845143eed", - "principal_type": "group", - "role": "viewer" - } + { + "id": 94, + "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "principal_guid": "20a79ce3-6e87-4522-9faf-be24228800a4", + "principal_type": "user", + "role": "owner" + }, + { + "id": 59, + "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "principal_guid": "6f300623-1e0c-48e6-a473-ddf630c0c0c3", + "principal_type": "group", + "role": "viewer" + } ] diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/59.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/59.json new file mode 100644 index 00000000..2802e5a7 --- /dev/null +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/59.json @@ -0,0 +1,7 @@ +{ + "id": 59, + "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "principal_guid": "6f300623-1e0c-48e6-a473-ddf630c0c0c3", + "principal_type": "group", + "role": "viewer" +} diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/94.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/94.json index 491db40c..f8d667d2 100644 --- a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/94.json +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/94.json @@ -1,7 +1,7 @@ { - "id": 94, - "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", - "principal_guid": "78974391-d89f-4f11-916a-ba50cfe993db", - "principal_type": "user", - "role": "owner" + "id": "94", + "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "principal_guid": "20a79ce3-6e87-4522-9faf-be24228800a4", + "principal_type": "user", + "role": "owner" } diff --git a/tests/posit/connect/__api__/v1/groups.json b/tests/posit/connect/__api__/v1/groups.json new file mode 100644 index 00000000..601d27ef --- /dev/null +++ b/tests/posit/connect/__api__/v1/groups.json @@ -0,0 +1,16 @@ +{ + "results": [ + { + "guid": "empty-group-guid", + "name": "Empty Friends", + "owner_guid": "empty-owner-guid" + }, + { + "guid": "6f300623-1e0c-48e6-a473-ddf630c0c0c3", + "name": "Friends", + "owner_guid": "20a79ce3-6e87-4522-9faf-be24228800a4" + } + ], + "current_page": 1, + "total": 2 +} diff --git a/tests/posit/connect/__api__/v1/groups/6f300623-1e0c-48e6-a473-ddf630c0c0c3.json b/tests/posit/connect/__api__/v1/groups/6f300623-1e0c-48e6-a473-ddf630c0c0c3.json index bcd0c7a2..44ee4a0d 100644 --- a/tests/posit/connect/__api__/v1/groups/6f300623-1e0c-48e6-a473-ddf630c0c0c3.json +++ b/tests/posit/connect/__api__/v1/groups/6f300623-1e0c-48e6-a473-ddf630c0c0c3.json @@ -1,5 +1,5 @@ { - "guid": "6f300623-1e0c-48e6-a473-ddf630c0c0c3", - "name": "Friends", - "owner_guid": "20a79ce3-6e87-4522-9faf-be24228800a4" - } + "guid": "6f300623-1e0c-48e6-a473-ddf630c0c0c3", + "name": "Friends", + "owner_guid": "20a79ce3-6e87-4522-9faf-be24228800a4" +} diff --git a/tests/posit/connect/__api__/v1/groups/6f300623-1e0c-48e6-a473-ddf630c0c0c3/members.json b/tests/posit/connect/__api__/v1/groups/6f300623-1e0c-48e6-a473-ddf630c0c0c3/members.json new file mode 100644 index 00000000..93841f3f --- /dev/null +++ b/tests/posit/connect/__api__/v1/groups/6f300623-1e0c-48e6-a473-ddf630c0c0c3/members.json @@ -0,0 +1,32 @@ +{ + "results": [ + { + "email": "alice@connect.example", + "username": "al", + "first_name": "Alice", + "last_name": "User", + "user_role": "publisher", + "created_time": "2017-08-08T15:24:32Z", + "updated_time": "2023-03-02T20:25:06Z", + "active_time": "2018-05-09T16:58:45Z", + "confirmed": true, + "locked": false, + "guid": "a01792e3-2e67-402e-99af-be04a48da074" + }, + { + "email": "bob@connect.example", + "username": "robert", + "first_name": "Bob", + "last_name": "Loblaw", + "user_role": "publisher", + "created_time": "2023-01-06T19:47:29Z", + "updated_time": "2023-05-05T19:08:45Z", + "active_time": "2023-05-05T20:29:11Z", + "confirmed": true, + "locked": false, + "guid": "87c12c08-11cd-4de1-8da3-12a7579c4998" + } + ], + "current_page": 1, + "total": 2 +} diff --git a/tests/posit/connect/__api__/v1/groups/empty-group-guid/members.json b/tests/posit/connect/__api__/v1/groups/empty-group-guid/members.json new file mode 100644 index 00000000..ee21cbe5 --- /dev/null +++ b/tests/posit/connect/__api__/v1/groups/empty-group-guid/members.json @@ -0,0 +1,5 @@ +{ + "results": [], + "current_page": 1, + "total": 0 +} diff --git a/tests/posit/connect/__api__/v1/groups/groups.json b/tests/posit/connect/__api__/v1/groups/groups.json new file mode 100644 index 00000000..601d27ef --- /dev/null +++ b/tests/posit/connect/__api__/v1/groups/groups.json @@ -0,0 +1,16 @@ +{ + "results": [ + { + "guid": "empty-group-guid", + "name": "Empty Friends", + "owner_guid": "empty-owner-guid" + }, + { + "guid": "6f300623-1e0c-48e6-a473-ddf630c0c0c3", + "name": "Friends", + "owner_guid": "20a79ce3-6e87-4522-9faf-be24228800a4" + } + ], + "current_page": 1, + "total": 2 +} diff --git a/tests/posit/connect/test_client.py b/tests/posit/connect/test_client.py index 5a8d1b42..3f7e8284 100644 --- a/tests/posit/connect/test_client.py +++ b/tests/posit/connect/test_client.py @@ -84,7 +84,12 @@ def test_init( MockConfig.assert_called_once_with(api_key=api_key, url=url) MockSession.assert_called_once() - def test__del__(self, MockAuth, MockConfig, MockSession): + def test__del__( + self, + MockAuth: MagicMock, + MockConfig: MagicMock, + MockSession: MagicMock, + ): api_key = "12345" url = "https://connect.example.com" client = Client(api_key=api_key, url=url) diff --git a/tests/posit/connect/test_content.py b/tests/posit/connect/test_content.py index 926df753..f18b46f5 100644 --- a/tests/posit/connect/test_content.py +++ b/tests/posit/connect/test_content.py @@ -1,5 +1,4 @@ import pytest -import requests import responses from responses import matchers @@ -7,7 +6,6 @@ from posit.connect.content import ContentItem, ContentItemRepository from posit.connect.context import Context from posit.connect.resources import ResourceParameters -from posit.connect.urls import Url from .api import load_mock, load_mock_dict @@ -562,15 +560,19 @@ def content_guid(self): @property def content_item(self): - return ContentItem(self.params, guid=self.content_guid) + return ContentItem(self.ctx, guid=self.content_guid) @property def endpoint(self): return f"{self.base_url}/__api__/v1/content/{self.content_guid}/repository" + @property + def client(self): + return Client(self.base_url, "12345") + @property def ctx(self): - return Context(requests.Session(), Url(self.base_url)) + return Context(self.client) @property def params(self): diff --git a/tests/posit/connect/test_context.py b/tests/posit/connect/test_context.py index be0330e4..c764effe 100644 --- a/tests/posit/connect/test_context.py +++ b/tests/posit/connect/test_context.py @@ -2,11 +2,10 @@ from unittest.mock import MagicMock, Mock import pytest -import requests import responses +from posit.connect.client import Client from posit.connect.context import Context, requires -from posit.connect.urls import Url class TestRequires: @@ -65,9 +64,7 @@ def test_unknown(self): json={}, ) - session = requests.Session() - url = Url("http://connect.example") - ctx = Context(session, url) + ctx = Context(Client("http://connect.example", "12345")) assert ctx.version is None @@ -78,13 +75,11 @@ def test_known(self): json={"version": "2024.09.24"}, ) - session = requests.Session() - url = Url("http://connect.example") - ctx = Context(session, url) + ctx = Context(Client("http://connect.example", "12345")) assert ctx.version == "2024.09.24" def test_setter(self): - ctx = Context(Mock(), Mock()) + ctx = Context(Mock()) ctx.version = "2024.09.24" assert ctx.version == "2024.09.24" diff --git a/tests/posit/connect/test_groups.py b/tests/posit/connect/test_groups.py index 71954e56..5a3b0b5b 100644 --- a/tests/posit/connect/test_groups.py +++ b/tests/posit/connect/test_groups.py @@ -1,7 +1,13 @@ from unittest import mock from unittest.mock import Mock +import pytest +import responses + +from posit.connect.client import Client +from posit.connect.context import Context from posit.connect.groups import Group +from posit.connect.users import User from .api import load_mock_dict @@ -24,3 +30,87 @@ def test_name(self): def test_owner_guid(self): assert self.item["owner_guid"] == "20a79ce3-6e87-4522-9faf-be24228800a4" + + +class TestGroupMembers: + @classmethod + def setup_class(cls): + cls.client = Client("https://connect.example", "12345") + guid = "6f300623-1e0c-48e6-a473-ddf630c0c0c3" + fake_item = load_mock_dict(f"v1/groups/{guid}.json") + ctx = Context(cls.client) + cls.group = Group(ctx, **fake_item) + + @responses.activate + def test_members_count(self): + responses.get( + f"https://connect.example/__api__/v1/groups/{self.group['guid']}/members", + json=load_mock_dict(f"v1/groups/{self.group['guid']}/members.json"), + ) + group_members = self.group.members + + assert group_members.count() == 2 + + @responses.activate + def test_members_find(self): + responses.get( + f"https://connect.example/__api__/v1/groups/{self.group['guid']}/members", + json=load_mock_dict(f"v1/groups/{self.group['guid']}/members.json"), + ) + + group_users = self.group.members.find() + assert len(group_users) == 2 + for user in group_users: + assert isinstance(user, User) + + @responses.activate + def test_members_add(self): + user_guid = "user-guid" + responses.post( + f"https://connect.example/__api__/v1/groups/{self.group['guid']}/members", + json=[], # No need to return anything + ) + + user = User(self.client._ctx, guid=user_guid) + self.group.members.add(user) + self.group.members.add(user_guid=user["guid"]) + + with pytest.raises(TypeError): + self.group.members.add( + "not-a-user", # pyright: ignore[reportArgumentType] + ) + with pytest.raises(TypeError): + self.group.members.add(group_guid=42) # pyright: ignore[reportCallIssue] + with pytest.raises(ValueError): + self.group.members.add(user, user_guid=user["guid"]) # pyright: ignore[reportCallIssue] + with pytest.raises(ValueError): + self.group.members.add(user_guid="") + + @responses.activate + def test_members_delete(self): + user_guid = "user-guid" + responses.get( + f"https://connect.example/__api__/v1/groups/{self.group['guid']}", + json=dict(self.group), + ) + responses.delete( + f"https://connect.example/__api__/v1/groups/{self.group['guid']}/members/{user_guid}", + json=[], # No need to return anything + ) + + user = User(self.client._ctx, guid=user_guid) + + self.group.members.delete(user) + self.group.members.delete(user_guid=user["guid"]) + + with pytest.raises(TypeError): + self.group.members.delete( + "not-a-user", # pyright: ignore[reportArgumentType] + ) + with pytest.raises(TypeError): + self.group.members.delete(group_guid=42) # pyright: ignore[reportCallIssue] + with pytest.raises(ValueError): + self.group.members.delete(user, user_guid=user["guid"]) # pyright: ignore[reportCallIssue] + + with pytest.raises(ValueError): + self.group.members.delete(user_guid="") diff --git a/tests/posit/connect/test_permissions.py b/tests/posit/connect/test_permissions.py index c8cc4b6a..dc0fa470 100644 --- a/tests/posit/connect/test_permissions.py +++ b/tests/posit/connect/test_permissions.py @@ -6,6 +6,8 @@ import responses from responses import matchers +from posit.connect.client import Client +from posit.connect.context import Context from posit.connect.groups import Group from posit.connect.permissions import Permission, Permissions from posit.connect.resources import ResourceParameters @@ -271,14 +273,19 @@ class TestPermissionsDestroy: @responses.activate def test_destroy(self): # data - permission_uid = "94" content_guid = "f2f37341-e21d-3d80-c698-a935ad614066" fake_permissions = load_mock_list(f"v1/content/{content_guid}/permissions.json") - fake_followup_permissions = fake_permissions.copy() - fake_followup_permissions.pop(0) - fake_permission = load_mock_dict( - f"v1/content/{content_guid}/permissions/{permission_uid}.json" + + assert fake_permissions[0]["principal_type"] == "user" + user_permission_id = fake_permissions[0]["id"] + assert fake_permissions[1]["principal_type"] == "group" + group_permission_id = fake_permissions[1]["id"] + assert user_permission_id != group_permission_id + + fake_manual_user_permission = load_mock_dict( + f"v1/content/{content_guid}/permissions/{user_permission_id}.json" ) + fake_user = load_mock_dict("v1/user.json") fake_group = load_mock_dict("v1/groups/6f300623-1e0c-48e6-a473-ddf630c0c0c3.json") @@ -286,59 +293,58 @@ def test_destroy(self): # Used in internal for-loop mock_permissions_get = [ + # Used to find all permissions when searching for user and group responses.get( f"https://connect.example/__api__/v1/content/{content_guid}/permissions", json=fake_permissions, ), + # Retrieve permissions object for permission id responses.get( - f"https://connect.example/__api__/v1/content/{content_guid}/permissions", - json=fake_followup_permissions, + f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{user_permission_id}", + json=fake_manual_user_permission, ), ] # permission delete - mock_permission_delete = responses.delete( - f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{permission_uid}", - ) + mock_permission_deletes = [ + responses.delete( + f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{user_permission_id}", + ), + responses.delete( + f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{group_permission_id}", + ), + ] # setup - params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) - permissions = Permissions(params, content_guid=content_guid) + c = Client(api_key="12345", url="https://connect.example/") + ctx = Context(c) + permissions = Permissions(ctx.client.resource_params, content_guid=content_guid) # (Doesn't match any permissions, but that's okay) - user_to_remove = User(params, **fake_user) - group_to_remove = Group(params, **fake_group) - permission_to_remove = Permission(params, **fake_permission) + user_to_remove = User(ctx, **fake_user) + group_to_remove = Group(ctx, **fake_group) + permission_to_remove = Permission( + ctx.client.resource_params, **fake_manual_user_permission + ) # invoke - destroyed_permission = permissions.destroy( - fake_permission["principal_guid"], - # Make sure duplicates are dropped - fake_permission["principal_guid"], - # Extract info from User, Group, Permission - user_to_remove, - group_to_remove, - permission_to_remove, - ) + permissions.destroy(permission_to_remove["id"]) + permissions.destroy(permission_to_remove) + permissions.destroy(user_to_remove) + + permissions.destroy(group_to_remove) + + # Assert values + assert mock_permissions_get[0].call_count == 2 + assert mock_permissions_get[1].call_count == 1 + + # permission_id, user -> permission_id, permission -> permission_id + assert mock_permission_deletes[0].call_count == 3 + # group -> permission_id + assert mock_permission_deletes[1].call_count == 1 + # assert mock_permission_deletes[2].call_count == 1 # Assert bad input value with pytest.raises(TypeError): permissions.destroy( 42 # pyright: ignore[reportArgumentType] ) - with pytest.raises(ValueError): - permissions.destroy() - - # Assert values - assert mock_permissions_get[0].call_count == 1 - assert mock_permissions_get[1].call_count == 0 - assert mock_permission_delete.call_count == 1 - assert len(destroyed_permission) == 1 - assert destroyed_permission[0] == fake_permission - - # Invoking again is a no-op - destroyed_permission = permissions.destroy(fake_permission["principal_guid"]) - - assert mock_permissions_get[0].call_count == 1 - assert mock_permissions_get[1].call_count == 1 - assert mock_permission_delete.call_count == 1 - assert len(destroyed_permission) == 0 diff --git a/tests/posit/connect/test_users.py b/tests/posit/connect/test_users.py index 68adbb3b..d101b8ab 100644 --- a/tests/posit/connect/test_users.py +++ b/tests/posit/connect/test_users.py @@ -6,8 +6,10 @@ from responses import matchers from posit.connect.client import Client +from posit.connect.groups import Group +from posit.connect.users import User -from .api import load_mock +from .api import load_mock, load_mock_dict session = Mock() url = Mock() @@ -134,6 +136,104 @@ def test_unlock(self): assert not user["locked"] +class TestUserGroups: + @responses.activate + def test_groups(self): + # Get user + carlos_guid = "20a79ce3-6e87-4522-9faf-be24228800a4" + carlos_empty_group_guid = "empty-group-guid" + responses.get( + f"https://connect.example/__api__/v1/users/{carlos_guid}", + json=load_mock_dict(f"v1/users/{carlos_guid}.json"), + ) + responses.get( + f"https://connect.example/__api__/v1/groups/{carlos_empty_group_guid}/members?page_number=1&page_size=500", + json=load_mock_dict(f"v1/groups/{carlos_empty_group_guid}/members.json"), + ) + responses.get( + "https://connect.example/__api__/v1/users", + json=load_mock_dict("v1/users?page_number=1&page_size=500.jsonc"), + ) + # Get groups + responses.get( + "https://connect.example/__api__/v1/groups", + json=load_mock_dict("v1/groups.json"), + ) + # Get group members + group_guid = "6f300623-1e0c-48e6-a473-ddf630c0c0c3" + responses.get( + f"https://connect.example/__api__/v1/groups/{group_guid}/members", + json=load_mock_dict(f"v1/groups/{group_guid}/members.json"), + ) + + client = Client("https://connect.example/", "12345") + + carlos = client.users.get(carlos_guid) + no_groups = carlos.groups.find() + assert len(no_groups) == 0 + + user = client.users.find()[1] + assert user["guid"] == "87c12c08-11cd-4de1-8da3-12a7579c4998" + + groups = user.groups.find() + assert isinstance(groups, list) + assert len(groups) == 1 + group = groups[0] + assert isinstance(group, Group) + assert group["name"] == "Friends" + + @responses.activate + def test_groups_add(self): + group_guid = "new-group-guid" + user_guid = "user-guid" + responses.get( + f"https://connect.example/__api__/v1/groups/{group_guid}", + json={"guid": group_guid}, + ) + responses.post( + f"https://connect.example/__api__/v1/groups/{group_guid}/members", + json=[], + ) + + client = Client("https://connect.example/", "12345") + + user = User(client._ctx, guid=user_guid) + new_group = Group(client._ctx, guid=group_guid) + # Add via Group + user.groups.add(new_group) + # Add via guid + user.groups.add(new_group["guid"]) + + with pytest.raises(ValueError): + user.groups.add("") + + @responses.activate + def test_groups_delete(self): + group_guid = "new-group-guid" + user_guid = "user-guid" + responses.get( + f"https://connect.example/__api__/v1/groups/{group_guid}", + json={"guid": group_guid}, + ) + responses.delete( + f"https://connect.example/__api__/v1/groups/{group_guid}/members/{user_guid}", + json=[], + ) + + client = Client("https://connect.example/", "12345") + + user = User(client._ctx, guid=user_guid) + group = Group(client._ctx, guid=group_guid) + + # Delete via Group + user.groups.delete(group) + # Delete via guid + user.groups.delete(group["guid"]) + + with pytest.raises(ValueError): + user.groups.delete("") + + class TestUsers: @responses.activate def test_users_get(self):