From ef05ea9cab5e50699dc751795d002e99ab91b5fa Mon Sep 17 00:00:00 2001 From: bigcat88 Date: Thu, 19 Feb 2026 14:37:41 +0200 Subject: [PATCH 1/6] feat: add Teams (Circles) async API with full CRUD and member management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #343 — adds async-only Teams/Circles API to nc_py_api. New module `nc_py_api/teams.py` provides: - Data classes: Circle, Member - Enums: MemberType, MemberLevel, CircleConfig - _AsyncTeamsAPI with methods: available, get_list, create, get_details, destroy, edit_name, edit_description, edit_config, get_members, add_member, add_members, remove_member, set_member_level, confirm_member, join, leave Registered on _AsyncNextcloudBasic (AsyncNextcloud + AsyncNextcloudApp). 17 tests covering both client and AppAPI modes. --- nc_py_api/nextcloud.py | 4 + nc_py_api/teams.py | 385 +++++++++++++++++++++++++++++++ tests/actual_tests/teams_test.py | 270 ++++++++++++++++++++++ tests/gfixture_set_env.py | 2 +- 4 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 nc_py_api/teams.py create mode 100644 tests/actual_tests/teams_test.py diff --git a/nc_py_api/nextcloud.py b/nc_py_api/nextcloud.py index e22b84f1..9873d74f 100644 --- a/nc_py_api/nextcloud.py +++ b/nc_py_api/nextcloud.py @@ -42,6 +42,7 @@ from .user_status import _AsyncUserStatusAPI from .users import _AsyncUsersAPI, _UsersAPI from .users_groups import _AsyncUsersGroupsAPI, _UsersGroupsAPI +from .teams import _AsyncTeamsAPI from .weather_status import _AsyncWeatherStatusAPI from .webhooks import _AsyncWebhooksAPI, _WebhooksAPI @@ -155,6 +156,8 @@ class _AsyncNextcloudBasic(ABC): # pylint: disable=too-many-instance-attributes """Nextcloud API for managing user notifications""" talk: _AsyncTalkAPI """Nextcloud Talk API""" + teams: _AsyncTeamsAPI + """Nextcloud API for managing Teams (Circles)""" users: _AsyncUsersAPI """Nextcloud API for managing users.""" users_groups: _AsyncUsersGroupsAPI @@ -176,6 +179,7 @@ def __init__(self, session: AsyncNcSessionBasic): self.notes = _AsyncNotesAPI(session) self.notifications = _AsyncNotificationsAPI(session) self.talk = _AsyncTalkAPI(session) + self.teams = _AsyncTeamsAPI(session) self.users = _AsyncUsersAPI(session) self.users_groups = _AsyncUsersGroupsAPI(session) self.user_status = _AsyncUserStatusAPI(session) diff --git a/nc_py_api/teams.py b/nc_py_api/teams.py new file mode 100644 index 00000000..6c6777f8 --- /dev/null +++ b/nc_py_api/teams.py @@ -0,0 +1,385 @@ +"""Nextcloud API for working with Teams (Circles).""" + +import dataclasses +import enum + +from ._misc import check_capabilities, require_capabilities +from ._session import AsyncNcSessionBasic + + +class MemberType(enum.IntEnum): + """Type of a Team member.""" + + SINGLE = 0 + """Single (personal circle owner)""" + USER = 1 + """Nextcloud user""" + GROUP = 2 + """Nextcloud group""" + MAIL = 4 + """Email address""" + CONTACT = 8 + """Contact""" + CIRCLE = 16 + """Another team/circle""" + APP = 10000 + """Application""" + + +class MemberLevel(enum.IntEnum): + """Permission level of a Team member.""" + + NONE = 0 + """No level (not a member)""" + MEMBER = 1 + """Regular member""" + MODERATOR = 4 + """Moderator""" + ADMIN = 8 + """Administrator""" + OWNER = 9 + """Owner""" + + +class CircleConfig(enum.IntFlag): + """Configuration flags for a Team (circle). Flags can be combined with bitwise OR.""" + + DEFAULT = 0 + """Default: locked circle, only moderator can add members""" + SINGLE = 1 + """Circle with only one single member""" + PERSONAL = 2 + """Personal circle, only the owner can see it""" + SYSTEM = 4 + """System circle (not managed by the official front-end)""" + VISIBLE = 8 + """Visible to everyone; if not set, people must know its name""" + OPEN = 16 + """Open circle, anyone can join""" + INVITE = 32 + """Adding a member generates an invitation that must be accepted""" + REQUEST = 64 + """Request to join needs moderator confirmation""" + FRIEND = 128 + """Members can invite their friends""" + PROTECTED = 256 + """Password protected to join/request""" + NO_OWNER = 512 + """No owner, only members""" + HIDDEN = 1024 + """Hidden from listing, but available as share entity""" + BACKEND = 2048 + """Fully hidden, only backend circles""" + LOCAL = 4096 + """Local even on GlobalScale""" + ROOT = 8192 + """Circle cannot be inside another circle""" + CIRCLE_INVITE = 16384 + """Circle must confirm when invited in another circle""" + FEDERATED = 32768 + """Federated""" + MOUNTPOINT = 65536 + """Generate a Files folder for this circle""" + APP = 131072 + """App-managed: some features unavailable to OCS API""" + + +@dataclasses.dataclass +class Member: + """Team member information.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def member_id(self) -> str: + """Unique ID of the member within the circle.""" + return self._raw_data.get("id", "") + + @property + def circle_id(self) -> str: + """ID of the circle this member belongs to.""" + return self._raw_data.get("circleId", "") + + @property + def single_id(self) -> str: + """Single ID of the member.""" + return self._raw_data.get("singleId", "") + + @property + def user_id(self) -> str: + """User ID of the member.""" + return self._raw_data.get("userId", "") + + @property + def user_type(self) -> MemberType: + """Type of the member.""" + return MemberType(self._raw_data.get("userType", 1)) + + @property + def level(self) -> MemberLevel: + """Permission level of the member.""" + return MemberLevel(self._raw_data.get("level", 0)) + + @property + def status(self) -> str: + """Status of the member (Member, Invited, Requesting, Blocked).""" + return self._raw_data.get("status", "") + + @property + def display_name(self) -> str: + """Display name of the member.""" + return self._raw_data.get("displayName", "") + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} user_id={self.user_id}, level={self.level.name}, status={self.status}>" + + +@dataclasses.dataclass +class Circle: + """Team (Circle) information.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def circle_id(self) -> str: + """Unique ID of the circle.""" + return self._raw_data.get("id", "") + + @property + def name(self) -> str: + """Name of the circle.""" + return self._raw_data.get("name", "") + + @property + def display_name(self) -> str: + """Display name of the circle.""" + return self._raw_data.get("displayName", "") + + @property + def sanitized_name(self) -> str: + """Sanitized name of the circle.""" + return self._raw_data.get("sanitizedName", "") + + @property + def config(self) -> CircleConfig: + """Configuration flags for the circle.""" + return CircleConfig(self._raw_data.get("config", 0)) + + @property + def description(self) -> str: + """Description of the circle.""" + return self._raw_data.get("description", "") + + @property + def population(self) -> int: + """Number of members in the circle.""" + return self._raw_data.get("population", 0) + + @property + def url(self) -> str: + """URL of the circle.""" + return self._raw_data.get("url", "") + + @property + def creation(self) -> int: + """Creation timestamp.""" + return self._raw_data.get("creation", 0) + + @property + def owner(self) -> Member | None: + """Owner of the circle.""" + owner_data = self._raw_data.get("owner") + return Member(owner_data) if owner_data else None + + @property + def initiator(self) -> Member | None: + """The requesting user's membership details in this circle.""" + initiator_data = self._raw_data.get("initiator") + return Member(initiator_data) if initiator_data else None + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} id={self.circle_id}, name={self.name}, population={self.population}>" + + +class _AsyncTeamsAPI: + """Class providing the async API for managing Teams (Circles) on the Nextcloud server.""" + + _ep_base: str = "/ocs/v2.php/apps/circles" + + def __init__(self, session: AsyncNcSessionBasic): + self._session = session + + @property + async def available(self) -> bool: + """Returns True if the Nextcloud instance supports Teams (Circles), False otherwise.""" + return not check_capabilities("circles", await self._session.capabilities) + + async def get_list(self) -> list[Circle]: + """Returns the list of all circles available to the current user.""" + require_capabilities("circles", await self._session.capabilities) + result = await self._session.ocs("GET", f"{self._ep_base}/circles") + return [Circle(c) for c in result] if result else [] + + async def create(self, name: str, personal: bool = False, local: bool = False) -> Circle: + """Creates a new circle (team). + + :param name: Name of the new circle. + :param personal: If True, creates a personal circle visible only to the owner. + :param local: If True, creates a circle limited to the local instance. + """ + require_capabilities("circles", await self._session.capabilities) + params: dict[str, str | int | bool] = {"name": name} + if personal: + params["personal"] = True + if local: + params["local"] = True + result = await self._session.ocs("POST", f"{self._ep_base}/circles", params=params) + return Circle(result) + + async def get_details(self, circle_id: str) -> Circle: + """Returns detailed information about a circle. + + :param circle_id: ID of the circle. + """ + require_capabilities("circles", await self._session.capabilities) + result = await self._session.ocs("GET", f"{self._ep_base}/circles/{circle_id}") + return Circle(result) + + async def destroy(self, circle_id: str) -> None: + """Destroys a circle. + + :param circle_id: ID of the circle to destroy. + """ + require_capabilities("circles", await self._session.capabilities) + await self._session.ocs("DELETE", f"{self._ep_base}/circles/{circle_id}") + + async def edit_name(self, circle_id: str, name: str) -> Circle: + """Changes the name of a circle. + + :param circle_id: ID of the circle. + :param name: New name for the circle. + """ + require_capabilities("circles", await self._session.capabilities) + result = await self._session.ocs( + "PUT", f"{self._ep_base}/circles/{circle_id}/name", params={"value": name} + ) + return Circle(result) + + async def edit_description(self, circle_id: str, description: str) -> Circle: + """Changes the description of a circle. + + :param circle_id: ID of the circle. + :param description: New description for the circle. + """ + require_capabilities("circles", await self._session.capabilities) + result = await self._session.ocs( + "PUT", f"{self._ep_base}/circles/{circle_id}/description", params={"value": description} + ) + return Circle(result) + + async def edit_config(self, circle_id: str, config: int) -> Circle: + """Changes the configuration flags of a circle. + + :param circle_id: ID of the circle. + :param config: New configuration bitmask (combination of CircleConfig flags). + """ + require_capabilities("circles", await self._session.capabilities) + result = await self._session.ocs( + "PUT", f"{self._ep_base}/circles/{circle_id}/config", params={"value": config} + ) + return Circle(result) + + async def get_members(self, circle_id: str) -> list[Member]: + """Returns the list of members in a circle. + + :param circle_id: ID of the circle. + """ + require_capabilities("circles", await self._session.capabilities) + result = await self._session.ocs("GET", f"{self._ep_base}/circles/{circle_id}/members") + return [Member(m) for m in result] if result else [] + + async def add_member( + self, circle_id: str, user_id: str, member_type: MemberType = MemberType.USER + ) -> list[Member]: + """Adds a single member to a circle. + + :param circle_id: ID of the circle. + :param user_id: ID of the user to add. + :param member_type: Type of the member to add. + """ + require_capabilities("circles", await self._session.capabilities) + params: dict[str, str | int] = {"userId": user_id, "type": int(member_type)} + result = await self._session.ocs("POST", f"{self._ep_base}/circles/{circle_id}/members", params=params) + return [Member(m) for m in result] if result else [] + + async def add_members(self, circle_id: str, members: list[dict[str, str | int]]) -> list[Member]: + """Adds multiple members to a circle at once. + + :param circle_id: ID of the circle. + :param members: List of dicts with ``id`` and ``type`` keys. + Example: ``[{"id": "user1", "type": 1}, {"id": "user2", "type": 1}]`` + """ + require_capabilities("circles", await self._session.capabilities) + result = await self._session.ocs( + "POST", f"{self._ep_base}/circles/{circle_id}/members/multi", json={"members": members} + ) + return [Member(m) for m in result] if result else [] + + async def remove_member(self, circle_id: str, member_id: str) -> list[Member]: + """Removes a member from a circle. + + :param circle_id: ID of the circle. + :param member_id: ID of the member to remove. + """ + require_capabilities("circles", await self._session.capabilities) + result = await self._session.ocs( + "DELETE", f"{self._ep_base}/circles/{circle_id}/members/{member_id}" + ) + return [Member(m) for m in result] if result else [] + + async def set_member_level(self, circle_id: str, member_id: str, level: MemberLevel) -> Member: + """Changes the permission level of a member. + + :param circle_id: ID of the circle. + :param member_id: ID of the member. + :param level: New permission level for the member. + """ + require_capabilities("circles", await self._session.capabilities) + result = await self._session.ocs( + "PUT", + f"{self._ep_base}/circles/{circle_id}/members/{member_id}/level", + json={"level": int(level)}, + ) + return Member(result) + + async def confirm_member(self, circle_id: str, member_id: str) -> list[Member]: + """Confirms a pending member request. + + :param circle_id: ID of the circle. + :param member_id: ID of the member to confirm. + """ + require_capabilities("circles", await self._session.capabilities) + result = await self._session.ocs( + "PUT", f"{self._ep_base}/circles/{circle_id}/members/{member_id}" + ) + return [Member(m) for m in result] if result else [] + + async def join(self, circle_id: str) -> Circle: + """Joins an open circle. + + :param circle_id: ID of the circle to join. + """ + require_capabilities("circles", await self._session.capabilities) + result = await self._session.ocs("PUT", f"{self._ep_base}/circles/{circle_id}/join") + return Circle(result) + + async def leave(self, circle_id: str) -> Circle: + """Leaves a circle. + + :param circle_id: ID of the circle to leave. + """ + require_capabilities("circles", await self._session.capabilities) + result = await self._session.ocs("PUT", f"{self._ep_base}/circles/{circle_id}/leave") + return Circle(result) diff --git a/tests/actual_tests/teams_test.py b/tests/actual_tests/teams_test.py new file mode 100644 index 00000000..d7e5eef5 --- /dev/null +++ b/tests/actual_tests/teams_test.py @@ -0,0 +1,270 @@ +import contextlib +from os import environ + +import pytest + +from nc_py_api import NextcloudException +from nc_py_api.teams import Circle, CircleConfig, Member, MemberLevel, MemberType + + +@pytest.mark.asyncio(scope="session") +async def test_teams_available(anc): + assert await anc.teams.available + + +@pytest.mark.asyncio(scope="session") +async def test_teams_create_destroy(anc): + circle = await anc.teams.create("test_nc_py_api_team_cd") + try: + assert isinstance(circle, Circle) + assert circle.circle_id + assert circle.name == "test_nc_py_api_team_cd" + assert circle.display_name + assert isinstance(circle.config, CircleConfig) + assert isinstance(circle.population, int) + assert isinstance(circle.description, str) + assert isinstance(circle.url, str) + assert isinstance(circle.creation, int) + assert repr(circle).startswith("= 2 + for m in added: + assert isinstance(m, Member) + + members = await anc_any.teams.get_members(circle.circle_id) + user_ids = [m.user_id for m in members] + assert test_user_id in user_ids + assert test_admin_id in user_ids + finally: + with contextlib.suppress(NextcloudException): + await anc_any.teams.destroy(circle.circle_id) + + +@pytest.mark.asyncio(scope="session") +async def test_teams_member_level(anc_any): + test_user_id = environ.get("TEST_USER_ID", "") + if not test_user_id: + pytest.skip("No test user available") + circle = await anc_any.teams.create("test_nc_py_api_team_ml") + try: + await anc_any.teams.add_member(circle.circle_id, test_user_id, MemberType.USER) + members = await anc_any.teams.get_members(circle.circle_id) + user_member = next(m for m in members if m.user_id == test_user_id) + + result = await anc_any.teams.set_member_level( + circle.circle_id, user_member.member_id, MemberLevel.MODERATOR + ) + assert isinstance(result, Member) + assert result.level == MemberLevel.MODERATOR + + members = await anc_any.teams.get_members(circle.circle_id) + user_member = next(m for m in members if m.user_id == test_user_id) + assert user_member.level == MemberLevel.MODERATOR + + result = await anc_any.teams.set_member_level( + circle.circle_id, user_member.member_id, MemberLevel.MEMBER + ) + assert result.level == MemberLevel.MEMBER + finally: + with contextlib.suppress(NextcloudException): + await anc_any.teams.destroy(circle.circle_id) + + +@pytest.mark.asyncio(scope="session") +async def test_teams_join_leave(anc_any): + circle = await anc_any.teams.create("test_nc_py_api_team_jl") + try: + new_config = CircleConfig.VISIBLE | CircleConfig.OPEN + await anc_any.teams.edit_config(circle.circle_id, int(new_config)) + + test_user_id = environ.get("TEST_USER_ID", "") + test_user_pass = environ.get("TEST_USER_PASS", "") + if not test_user_id or not test_user_pass: + pytest.skip("No test user available") + + from nc_py_api import AsyncNextcloud + + anc_user = AsyncNextcloud( + nextcloud_url=environ.get("NEXTCLOUD_URL", "http://nextcloud.ncpyapi:13080"), + nc_auth_user=test_user_id, + nc_auth_pass=test_user_pass, + ) + joined = await anc_user.teams.join(circle.circle_id) + assert isinstance(joined, Circle) + + members = await anc_any.teams.get_members(circle.circle_id) + user_ids = [m.user_id for m in members] + assert test_user_id in user_ids + + left = await anc_user.teams.leave(circle.circle_id) + assert isinstance(left, Circle) + + members = await anc_any.teams.get_members(circle.circle_id) + user_ids = [m.user_id for m in members] + assert test_user_id not in user_ids + finally: + with contextlib.suppress(NextcloudException): + await anc_any.teams.destroy(circle.circle_id) + + +@pytest.mark.asyncio(scope="session") +async def test_teams_destroy_nonexistent(anc_any): + with pytest.raises(NextcloudException): + await anc_any.teams.destroy("nonexistent_circle_id_12345") + + +@pytest.mark.asyncio(scope="session") +async def test_teams_personal_circle(anc_any): + circle = await anc_any.teams.create("test_nc_py_api_team_pc", personal=True) + try: + assert isinstance(circle, Circle) + assert circle.config & CircleConfig.PERSONAL + finally: + await anc_any.teams.destroy(circle.circle_id) + + +@pytest.mark.asyncio(scope="session") +async def test_teams_local_circle(anc_any): + circle = await anc_any.teams.create("test_nc_py_api_team_lc", local=True) + try: + assert isinstance(circle, Circle) + assert circle.config & CircleConfig.LOCAL + finally: + await anc_any.teams.destroy(circle.circle_id) diff --git a/tests/gfixture_set_env.py b/tests/gfixture_set_env.py index bc6f5542..143c7a70 100644 --- a/tests/gfixture_set_env.py +++ b/tests/gfixture_set_env.py @@ -3,7 +3,7 @@ if not environ.get("CI", False): # For local tests environ["NC_AUTH_USER"] = "admin" environ["NC_AUTH_PASS"] = "admin" # "MrtGY-KfY24-iiDyg-cr4n4-GLsNZ" - environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://nextcloud.local") + environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://nextcloud.ncpyapi:13080") environ["APP_ID"] = "nc_py_api" environ["APP_VERSION"] = "1.0.0" environ["APP_SECRET"] = "12345" From 2461d7db33cb3cf7447d0c040d91baab08de5240 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:38:11 +0000 Subject: [PATCH 2/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- nc_py_api/nextcloud.py | 2 +- nc_py_api/teams.py | 20 +++++--------------- tests/actual_tests/teams_test.py | 8 ++------ 3 files changed, 8 insertions(+), 22 deletions(-) diff --git a/nc_py_api/nextcloud.py b/nc_py_api/nextcloud.py index 9873d74f..b052134a 100644 --- a/nc_py_api/nextcloud.py +++ b/nc_py_api/nextcloud.py @@ -39,10 +39,10 @@ from .loginflow_v2 import _AsyncLoginFlowV2API, _LoginFlowV2API from .notes import _AsyncNotesAPI from .notifications import _AsyncNotificationsAPI, _NotificationsAPI +from .teams import _AsyncTeamsAPI from .user_status import _AsyncUserStatusAPI from .users import _AsyncUsersAPI, _UsersAPI from .users_groups import _AsyncUsersGroupsAPI, _UsersGroupsAPI -from .teams import _AsyncTeamsAPI from .weather_status import _AsyncWeatherStatusAPI from .webhooks import _AsyncWebhooksAPI, _WebhooksAPI diff --git a/nc_py_api/teams.py b/nc_py_api/teams.py index 6c6777f8..3541739a 100644 --- a/nc_py_api/teams.py +++ b/nc_py_api/teams.py @@ -262,9 +262,7 @@ async def edit_name(self, circle_id: str, name: str) -> Circle: :param name: New name for the circle. """ require_capabilities("circles", await self._session.capabilities) - result = await self._session.ocs( - "PUT", f"{self._ep_base}/circles/{circle_id}/name", params={"value": name} - ) + result = await self._session.ocs("PUT", f"{self._ep_base}/circles/{circle_id}/name", params={"value": name}) return Circle(result) async def edit_description(self, circle_id: str, description: str) -> Circle: @@ -286,9 +284,7 @@ async def edit_config(self, circle_id: str, config: int) -> Circle: :param config: New configuration bitmask (combination of CircleConfig flags). """ require_capabilities("circles", await self._session.capabilities) - result = await self._session.ocs( - "PUT", f"{self._ep_base}/circles/{circle_id}/config", params={"value": config} - ) + result = await self._session.ocs("PUT", f"{self._ep_base}/circles/{circle_id}/config", params={"value": config}) return Circle(result) async def get_members(self, circle_id: str) -> list[Member]: @@ -300,9 +296,7 @@ async def get_members(self, circle_id: str) -> list[Member]: result = await self._session.ocs("GET", f"{self._ep_base}/circles/{circle_id}/members") return [Member(m) for m in result] if result else [] - async def add_member( - self, circle_id: str, user_id: str, member_type: MemberType = MemberType.USER - ) -> list[Member]: + async def add_member(self, circle_id: str, user_id: str, member_type: MemberType = MemberType.USER) -> list[Member]: """Adds a single member to a circle. :param circle_id: ID of the circle. @@ -334,9 +328,7 @@ async def remove_member(self, circle_id: str, member_id: str) -> list[Member]: :param member_id: ID of the member to remove. """ require_capabilities("circles", await self._session.capabilities) - result = await self._session.ocs( - "DELETE", f"{self._ep_base}/circles/{circle_id}/members/{member_id}" - ) + result = await self._session.ocs("DELETE", f"{self._ep_base}/circles/{circle_id}/members/{member_id}") return [Member(m) for m in result] if result else [] async def set_member_level(self, circle_id: str, member_id: str, level: MemberLevel) -> Member: @@ -361,9 +353,7 @@ async def confirm_member(self, circle_id: str, member_id: str) -> list[Member]: :param member_id: ID of the member to confirm. """ require_capabilities("circles", await self._session.capabilities) - result = await self._session.ocs( - "PUT", f"{self._ep_base}/circles/{circle_id}/members/{member_id}" - ) + result = await self._session.ocs("PUT", f"{self._ep_base}/circles/{circle_id}/members/{member_id}") return [Member(m) for m in result] if result else [] async def join(self, circle_id: str) -> Circle: diff --git a/tests/actual_tests/teams_test.py b/tests/actual_tests/teams_test.py index d7e5eef5..85cfff38 100644 --- a/tests/actual_tests/teams_test.py +++ b/tests/actual_tests/teams_test.py @@ -188,9 +188,7 @@ async def test_teams_member_level(anc_any): members = await anc_any.teams.get_members(circle.circle_id) user_member = next(m for m in members if m.user_id == test_user_id) - result = await anc_any.teams.set_member_level( - circle.circle_id, user_member.member_id, MemberLevel.MODERATOR - ) + result = await anc_any.teams.set_member_level(circle.circle_id, user_member.member_id, MemberLevel.MODERATOR) assert isinstance(result, Member) assert result.level == MemberLevel.MODERATOR @@ -198,9 +196,7 @@ async def test_teams_member_level(anc_any): user_member = next(m for m in members if m.user_id == test_user_id) assert user_member.level == MemberLevel.MODERATOR - result = await anc_any.teams.set_member_level( - circle.circle_id, user_member.member_id, MemberLevel.MEMBER - ) + result = await anc_any.teams.set_member_level(circle.circle_id, user_member.member_id, MemberLevel.MEMBER) assert result.level == MemberLevel.MEMBER finally: with contextlib.suppress(NextcloudException): From cf167e6bed04b91aa341d2d29735e3daa2c90759 Mon Sep 17 00:00:00 2001 From: Review Date: Tue, 24 Mar 2026 06:46:26 +0000 Subject: [PATCH 3/6] fix: correct Teams API return types and add missing exports Fix add_member, confirm_member to return Member (API returns single dict, not list). Fix remove_member to return None (API returns empty array). Accept CircleConfig | int in edit_config. Export public classes from __init__.py. Fix hardcoded URL in tests, revert unrelated gfixture_set_env change. --- nc_py_api/__init__.py | 1 + nc_py_api/teams.py | 19 ++++++++++--------- tests/actual_tests/teams_test.py | 17 ++++++++--------- tests/gfixture_set_env.py | 2 +- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/nc_py_api/__init__.py b/nc_py_api/__init__.py index e20f647f..7416bcd5 100644 --- a/nc_py_api/__init__.py +++ b/nc_py_api/__init__.py @@ -11,3 +11,4 @@ from .files import FilePermissions, FsNode, LockType, SystemTag from .files.sharing import ShareType from .nextcloud import AsyncNextcloud, AsyncNextcloudApp, Nextcloud, NextcloudApp +from .teams import Circle, CircleConfig, Member, MemberLevel, MemberType diff --git a/nc_py_api/teams.py b/nc_py_api/teams.py index 3541739a..df4d428b 100644 --- a/nc_py_api/teams.py +++ b/nc_py_api/teams.py @@ -277,14 +277,16 @@ async def edit_description(self, circle_id: str, description: str) -> Circle: ) return Circle(result) - async def edit_config(self, circle_id: str, config: int) -> Circle: + async def edit_config(self, circle_id: str, config: CircleConfig | int) -> Circle: """Changes the configuration flags of a circle. :param circle_id: ID of the circle. :param config: New configuration bitmask (combination of CircleConfig flags). """ require_capabilities("circles", await self._session.capabilities) - result = await self._session.ocs("PUT", f"{self._ep_base}/circles/{circle_id}/config", params={"value": config}) + result = await self._session.ocs( + "PUT", f"{self._ep_base}/circles/{circle_id}/config", params={"value": int(config)} + ) return Circle(result) async def get_members(self, circle_id: str) -> list[Member]: @@ -296,7 +298,7 @@ async def get_members(self, circle_id: str) -> list[Member]: result = await self._session.ocs("GET", f"{self._ep_base}/circles/{circle_id}/members") return [Member(m) for m in result] if result else [] - async def add_member(self, circle_id: str, user_id: str, member_type: MemberType = MemberType.USER) -> list[Member]: + async def add_member(self, circle_id: str, user_id: str, member_type: MemberType = MemberType.USER) -> Member: """Adds a single member to a circle. :param circle_id: ID of the circle. @@ -306,7 +308,7 @@ async def add_member(self, circle_id: str, user_id: str, member_type: MemberType require_capabilities("circles", await self._session.capabilities) params: dict[str, str | int] = {"userId": user_id, "type": int(member_type)} result = await self._session.ocs("POST", f"{self._ep_base}/circles/{circle_id}/members", params=params) - return [Member(m) for m in result] if result else [] + return Member(result) async def add_members(self, circle_id: str, members: list[dict[str, str | int]]) -> list[Member]: """Adds multiple members to a circle at once. @@ -321,15 +323,14 @@ async def add_members(self, circle_id: str, members: list[dict[str, str | int]]) ) return [Member(m) for m in result] if result else [] - async def remove_member(self, circle_id: str, member_id: str) -> list[Member]: + async def remove_member(self, circle_id: str, member_id: str) -> None: """Removes a member from a circle. :param circle_id: ID of the circle. :param member_id: ID of the member to remove. """ require_capabilities("circles", await self._session.capabilities) - result = await self._session.ocs("DELETE", f"{self._ep_base}/circles/{circle_id}/members/{member_id}") - return [Member(m) for m in result] if result else [] + await self._session.ocs("DELETE", f"{self._ep_base}/circles/{circle_id}/members/{member_id}") async def set_member_level(self, circle_id: str, member_id: str, level: MemberLevel) -> Member: """Changes the permission level of a member. @@ -346,7 +347,7 @@ async def set_member_level(self, circle_id: str, member_id: str, level: MemberLe ) return Member(result) - async def confirm_member(self, circle_id: str, member_id: str) -> list[Member]: + async def confirm_member(self, circle_id: str, member_id: str) -> Member: """Confirms a pending member request. :param circle_id: ID of the circle. @@ -354,7 +355,7 @@ async def confirm_member(self, circle_id: str, member_id: str) -> list[Member]: """ require_capabilities("circles", await self._session.capabilities) result = await self._session.ocs("PUT", f"{self._ep_base}/circles/{circle_id}/members/{member_id}") - return [Member(m) for m in result] if result else [] + return Member(result) async def join(self, circle_id: str) -> Circle: """Joins an open circle. diff --git a/tests/actual_tests/teams_test.py b/tests/actual_tests/teams_test.py index 85cfff38..6b1890ff 100644 --- a/tests/actual_tests/teams_test.py +++ b/tests/actual_tests/teams_test.py @@ -87,7 +87,7 @@ async def test_teams_edit_config(anc_any): circle = await anc_any.teams.create("test_nc_py_api_team_ec") try: new_config = CircleConfig.VISIBLE | CircleConfig.OPEN - updated = await anc_any.teams.edit_config(circle.circle_id, int(new_config)) + updated = await anc_any.teams.edit_config(circle.circle_id, new_config) assert isinstance(updated, Circle) assert updated.config & CircleConfig.VISIBLE assert updated.config & CircleConfig.OPEN @@ -121,9 +121,8 @@ async def test_teams_members_add_remove(anc_any): assert isinstance(members, list) added = await anc_any.teams.add_member(circle.circle_id, test_user_id, MemberType.USER) - assert isinstance(added, list) - for m in added: - assert isinstance(m, Member) + assert isinstance(added, Member) + assert added.user_id == test_user_id members = await anc_any.teams.get_members(circle.circle_id) user_ids = [m.user_id for m in members] @@ -140,9 +139,9 @@ async def test_teams_members_add_remove(anc_any): assert isinstance(member.circle_id, str) assert repr(member).startswith(" Date: Tue, 24 Mar 2026 07:04:53 +0000 Subject: [PATCH 4/6] ci: add circles app to CI and skip guards to teams tests Add pytest.skip("Teams (Circles) is not installed") guard to every teams test, matching the pattern used by notes/activity/talk tests. Install circles app in tests-pgsql and tests-maria CI jobs so tests actually run on those pipelines, while gracefully skipping on others. --- .github/workflows/analysis-coverage.yml | 18 +++++++++++++++ tests/actual_tests/teams_test.py | 30 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/.github/workflows/analysis-coverage.yml b/.github/workflows/analysis-coverage.yml index 9c603081..19083eea 100644 --- a/.github/workflows/analysis-coverage.yml +++ b/.github/workflows/analysis-coverage.yml @@ -213,6 +213,13 @@ jobs: ref: ${{ matrix.nextcloud }} path: apps/files_lock + - name: Checkout Circles + uses: actions/checkout@v6 + with: + repository: nextcloud/circles + ref: ${{ matrix.nextcloud }} + path: apps/circles + - name: Set up & run Nextcloud env: DB_PORT: 4444 @@ -233,6 +240,9 @@ jobs: - name: Enable Notes run: ./occ app:enable notes + - name: Enable Circles + run: ./occ app:enable circles + - name: Checkout NcPyApi uses: actions/checkout@v6 with: @@ -379,6 +389,13 @@ jobs: ref: ${{ matrix.nextcloud }} path: apps/activity + - name: Checkout Circles + uses: actions/checkout@v6 + with: + repository: nextcloud/circles + ref: ${{ matrix.nextcloud }} + path: apps/circles + - name: Set up & run Nextcloud env: DB_PORT: 4444 @@ -392,6 +409,7 @@ jobs: ./occ config:system:set ratelimit.protection.enabled --value=false --type=boolean ./occ app:enable notifications ./occ app:enable activity + ./occ app:enable circles PHP_CLI_SERVER_WORKERS=2 php -S localhost:8080 & - name: Checkout NcPyApi diff --git a/tests/actual_tests/teams_test.py b/tests/actual_tests/teams_test.py index 6b1890ff..fb6f9551 100644 --- a/tests/actual_tests/teams_test.py +++ b/tests/actual_tests/teams_test.py @@ -9,11 +9,15 @@ @pytest.mark.asyncio(scope="session") async def test_teams_available(anc): + if await anc.teams.available is False: + pytest.skip("Teams (Circles) is not installed") assert await anc.teams.available @pytest.mark.asyncio(scope="session") async def test_teams_create_destroy(anc): + if await anc.teams.available is False: + pytest.skip("Teams (Circles) is not installed") circle = await anc.teams.create("test_nc_py_api_team_cd") try: assert isinstance(circle, Circle) @@ -32,6 +36,8 @@ async def test_teams_create_destroy(anc): @pytest.mark.asyncio(scope="session") async def test_teams_get_list(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") circle = await anc_any.teams.create("test_nc_py_api_team_list") try: circles = await anc_any.teams.get_list() @@ -45,6 +51,8 @@ async def test_teams_get_list(anc_any): @pytest.mark.asyncio(scope="session") async def test_teams_get_details(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") circle = await anc_any.teams.create("test_nc_py_api_team_det") try: details = await anc_any.teams.get_details(circle.circle_id) @@ -58,6 +66,8 @@ async def test_teams_get_details(anc_any): @pytest.mark.asyncio(scope="session") async def test_teams_edit_name(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") circle = await anc_any.teams.create("test_nc_py_api_team_en") try: updated = await anc_any.teams.edit_name(circle.circle_id, "test_nc_py_api_team_en_new") @@ -71,6 +81,8 @@ async def test_teams_edit_name(anc_any): @pytest.mark.asyncio(scope="session") async def test_teams_edit_description(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") circle = await anc_any.teams.create("test_nc_py_api_team_ed") try: updated = await anc_any.teams.edit_description(circle.circle_id, "Test description") @@ -84,6 +96,8 @@ async def test_teams_edit_description(anc_any): @pytest.mark.asyncio(scope="session") async def test_teams_edit_config(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") circle = await anc_any.teams.create("test_nc_py_api_team_ec") try: new_config = CircleConfig.VISIBLE | CircleConfig.OPEN @@ -97,6 +111,8 @@ async def test_teams_edit_config(anc_any): @pytest.mark.asyncio(scope="session") async def test_teams_owner(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") circle = await anc_any.teams.create("test_nc_py_api_team_ow") try: details = await anc_any.teams.get_details(circle.circle_id) @@ -112,6 +128,8 @@ async def test_teams_owner(anc_any): @pytest.mark.asyncio(scope="session") async def test_teams_members_add_remove(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") test_user_id = environ.get("TEST_USER_ID", "") if not test_user_id: pytest.skip("No test user available") @@ -149,6 +167,8 @@ async def test_teams_members_add_remove(anc_any): @pytest.mark.asyncio(scope="session") async def test_teams_add_members_multi(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") test_user_id = environ.get("TEST_USER_ID", "") test_admin_id = environ.get("TEST_ADMIN_ID", "") if not test_user_id or not test_admin_id: @@ -178,6 +198,8 @@ async def test_teams_add_members_multi(anc_any): @pytest.mark.asyncio(scope="session") async def test_teams_member_level(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") test_user_id = environ.get("TEST_USER_ID", "") if not test_user_id: pytest.skip("No test user available") @@ -204,6 +226,8 @@ async def test_teams_member_level(anc_any): @pytest.mark.asyncio(scope="session") async def test_teams_join_leave(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") circle = await anc_any.teams.create("test_nc_py_api_team_jl") try: new_config = CircleConfig.VISIBLE | CircleConfig.OPEN @@ -241,12 +265,16 @@ async def test_teams_join_leave(anc_any): @pytest.mark.asyncio(scope="session") async def test_teams_destroy_nonexistent(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") with pytest.raises(NextcloudException): await anc_any.teams.destroy("nonexistent_circle_id_12345") @pytest.mark.asyncio(scope="session") async def test_teams_personal_circle(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") circle = await anc_any.teams.create("test_nc_py_api_team_pc", personal=True) try: assert isinstance(circle, Circle) @@ -257,6 +285,8 @@ async def test_teams_personal_circle(anc_any): @pytest.mark.asyncio(scope="session") async def test_teams_local_circle(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") circle = await anc_any.teams.create("test_nc_py_api_team_lc", local=True) try: assert isinstance(circle, Circle) From 4b6050b4cab87b3b6e1d9bdc453d73e7c0e709fc Mon Sep 17 00:00:00 2001 From: Review Date: Tue, 24 Mar 2026 07:22:26 +0000 Subject: [PATCH 5/6] ci: fix circles async processing by setting overwrite.cli.url The Circles app processes member removal and join operations asynchronously via HTTP loopback. Without overwrite.cli.url configured, the loopback fails silently and these operations never complete. Also enable circles after the PHP server starts so the app can verify its loopback connectivity at initialization time. --- .github/workflows/analysis-coverage.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/analysis-coverage.yml b/.github/workflows/analysis-coverage.yml index 19083eea..175ebcbc 100644 --- a/.github/workflows/analysis-coverage.yml +++ b/.github/workflows/analysis-coverage.yml @@ -231,6 +231,7 @@ jobs: ./occ config:system:set loglevel --value=0 --type=integer ./occ config:system:set debug --value=true --type=boolean ./occ config:system:set ratelimit.protection.enabled --value=false --type=boolean + ./occ config:system:set overwrite.cli.url --value="http://localhost:8080" ./occ app:enable notifications PHP_CLI_SERVER_WORKERS=2 php -S localhost:8080 & @@ -241,7 +242,7 @@ jobs: run: ./occ app:enable notes - name: Enable Circles - run: ./occ app:enable circles + run: php occ app:enable circles - name: Checkout NcPyApi uses: actions/checkout@v6 @@ -407,11 +408,14 @@ jobs: ./occ config:system:set loglevel --value=0 --type=integer ./occ config:system:set debug --value=true --type=boolean ./occ config:system:set ratelimit.protection.enabled --value=false --type=boolean + ./occ config:system:set overwrite.cli.url --value="http://localhost:8080" ./occ app:enable notifications ./occ app:enable activity - ./occ app:enable circles PHP_CLI_SERVER_WORKERS=2 php -S localhost:8080 & + - name: Enable Circles + run: php occ app:enable circles + - name: Checkout NcPyApi uses: actions/checkout@v6 with: From 1e0f246ca8c45807807403a9f42621b4ff13cbbe Mon Sep 17 00:00:00 2001 From: Review Date: Tue, 24 Mar 2026 07:50:59 +0000 Subject: [PATCH 6/6] test: add confirm_member test for Teams API Tests the full request-to-join flow: create circle with REQUEST config, user requests to join (status=Requesting), admin confirms the member (status=Member). --- tests/actual_tests/teams_test.py | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/actual_tests/teams_test.py b/tests/actual_tests/teams_test.py index fb6f9551..6a6f4a19 100644 --- a/tests/actual_tests/teams_test.py +++ b/tests/actual_tests/teams_test.py @@ -263,6 +263,44 @@ async def test_teams_join_leave(anc_any): await anc_any.teams.destroy(circle.circle_id) +@pytest.mark.asyncio(scope="session") +async def test_teams_confirm_member(anc_any): + if await anc_any.teams.available is False: + pytest.skip("Teams (Circles) is not installed") + test_user_id = environ.get("TEST_USER_ID", "") + test_user_pass = environ.get("TEST_USER_PASS", "") + if not test_user_id or not test_user_pass: + pytest.skip("No test user available") + circle = await anc_any.teams.create("test_nc_py_api_team_cm") + try: + await anc_any.teams.edit_config(circle.circle_id, CircleConfig.VISIBLE | CircleConfig.REQUEST) + + from nc_py_api import AsyncNextcloud + + anc_user = AsyncNextcloud( + nextcloud_url=environ["NEXTCLOUD_URL"], + nc_auth_user=test_user_id, + nc_auth_pass=test_user_pass, + ) + await anc_user.teams.join(circle.circle_id) + + members = await anc_any.teams.get_members(circle.circle_id) + requesting = next(m for m in members if m.user_id == test_user_id) + assert requesting.status == "Requesting" + + confirmed = await anc_any.teams.confirm_member(circle.circle_id, requesting.member_id) + assert isinstance(confirmed, Member) + assert confirmed.user_id == test_user_id + + members = await anc_any.teams.get_members(circle.circle_id) + member = next(m for m in members if m.user_id == test_user_id) + assert member.status == "Member" + assert member.level == MemberLevel.MEMBER + finally: + with contextlib.suppress(NextcloudException): + await anc_any.teams.destroy(circle.circle_id) + + @pytest.mark.asyncio(scope="session") async def test_teams_destroy_nonexistent(anc_any): if await anc_any.teams.available is False: