From e871329f30a0a4795c81dbefc7d1fb7668a7792e Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Tue, 22 Aug 2023 18:51:20 +0300 Subject: [PATCH] Refactoring + Talk api(first small part) (#95) More Talk API will come tomorrow. --------- Signed-off-by: Alexander Piskun --- .github/workflows/analysis-coverage.yml | 10 +- CHANGELOG.md | 11 +- docs/reference/Apps.rst | 12 +- docs/reference/Talk.rst | 50 ++ docs/reference/Users/Notifications.rst | 6 +- docs/reference/Users/Users.rst | 2 +- docs/reference/Users/UsersGroups.rst | 4 +- docs/reference/Users/UsersStatus.rst | 11 +- docs/reference/Users/WeatherStatus.rst | 6 +- docs/reference/index.rst | 1 + nc_py_api/_misc.py | 19 +- .../{apps/preferences.py => _preferences.py} | 4 +- .../preferences_ex.py => _preferences_ex.py} | 18 +- nc_py_api/_version.py | 2 +- nc_py_api/{apps => }/apps.py | 14 +- nc_py_api/apps/__init__.py | 1 - nc_py_api/ex_app/defs.py | 2 + nc_py_api/nextcloud.py | 46 +- nc_py_api/{users => }/notifications.py | 28 +- nc_py_api/talk.py | 528 ++++++++++++++++++ nc_py_api/{users/status.py => user_status.py} | 109 ++-- nc_py_api/{users => }/users.py | 25 +- nc_py_api/users/__init__.py | 1 - .../{users/groups.py => users_groups.py} | 78 +-- .../{users/weather.py => weather_status.py} | 21 +- pyproject.toml | 2 +- scripts/dev_register.sh | 2 +- tests/nc_app_test.py | 4 +- tests/notifications_test.py | 83 +++ tests/talk_test.py | 100 ++++ ...ers_status_test.py => user_status_test.py} | 86 +-- tests/users_groups_test.py | 68 +-- tests/users_notifications_test.py | 83 --- tests/users_test.py | 2 +- ...weather_test.py => weather_status_test.py} | 48 +- 35 files changed, 1112 insertions(+), 375 deletions(-) create mode 100644 docs/reference/Talk.rst rename nc_py_api/{apps/preferences.py => _preferences.py} (92%) rename nc_py_api/{apps/preferences_ex.py => _preferences_ex.py} (88%) rename nc_py_api/{apps => }/apps.py (95%) delete mode 100644 nc_py_api/apps/__init__.py rename nc_py_api/{users => }/notifications.py (89%) create mode 100644 nc_py_api/talk.py rename nc_py_api/{users/status.py => user_status.py} (68%) rename nc_py_api/{users => }/users.py (86%) delete mode 100644 nc_py_api/users/__init__.py rename nc_py_api/{users/groups.py => users_groups.py} (63%) rename nc_py_api/{users/weather.py => weather_status.py} (90%) create mode 100644 tests/notifications_test.py create mode 100644 tests/talk_test.py rename tests/{users_status_test.py => user_status_test.py} (57%) delete mode 100644 tests/users_notifications_test.py rename tests/{users_weather_test.py => weather_status_test.py} (56%) diff --git a/.github/workflows/analysis-coverage.yml b/.github/workflows/analysis-coverage.yml index d9d2a2a9..a8fa35ac 100644 --- a/.github/workflows/analysis-coverage.yml +++ b/.github/workflows/analysis-coverage.yml @@ -136,7 +136,7 @@ jobs: cd .. php occ app_ecosystem_v2:daemon:register manual_install "Manual Install" manual-install 0 0 0 php occ app_ecosystem_v2:app:register $APP_ID manual_install --json-info \ - "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[2, 10, 11],\"optional\":[30, 31, 32, 33]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[2, 10, 11],\"optional\":[30, 31, 32, 33, 50]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ -e --force-scopes kill -15 $(cat /tmp/_install.pid) timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null @@ -290,7 +290,7 @@ jobs: cd .. php occ app_ecosystem_v2:daemon:register manual_install "Manual Install" manual-install 0 0 0 php occ app_ecosystem_v2:app:register $APP_ID manual_install --json-info \ - "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[2, 10, 11],\"optional\":[30, 31, 32, 33]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[2, 10, 11],\"optional\":[30, 31, 32, 33, 50]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ -e --force-scopes kill -15 $(cat /tmp/_install.pid) timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null @@ -429,7 +429,7 @@ jobs: cd .. php occ app_ecosystem_v2:daemon:register manual_install "Manual Install" manual-install 0 0 0 php occ app_ecosystem_v2:app:register $APP_ID manual_install --json-info \ - "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[2, 10, 11],\"optional\":[30, 31, 32, 33]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[2, 10, 11],\"optional\":[30, 31, 32, 33, 50]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ -e --force-scopes kill -15 $(cat /tmp/_install.pid) timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null @@ -549,7 +549,7 @@ jobs: cd .. php occ app_ecosystem_v2:daemon:register manual_install "Manual Install" manual-install 0 0 0 php occ app_ecosystem_v2:app:register $APP_ID manual_install --json-info \ - "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[2, 10, 11],\"optional\":[30, 31, 32, 33]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[2, 10, 11],\"optional\":[30, 31, 32, 33, 50]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ -e --force-scopes kill -15 $(cat /tmp/_install.pid) timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null @@ -795,7 +795,7 @@ jobs: cd .. php occ app_ecosystem_v2:daemon:register manual_install "Manual Install" manual-install 0 0 0 php occ app_ecosystem_v2:app:register $APP_ID manual_install --json-info \ - "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[2, 10, 11],\"optional\":[30, 31, 32, 33]},\"protocol\":\"http\",\"port\":$APP_PORT,\"system_app\":1}" \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[2, 10, 11],\"optional\":[30, 31, 32, 33, 50]},\"protocol\":\"http\",\"port\":$APP_PORT,\"system_app\":1}" \ -e --force-scopes kill -15 $(cat /tmp/_install.pid) timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null diff --git a/CHANGELOG.md b/CHANGELOG.md index cb28b2fa..c371a9af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,20 @@ All notable changes to this project will be documented in this file. -## [0.0.32 - 2023-08-2x] +## [0.0.40 - 2023-08-22] + +### Added + +- Basic APIs for Nextcloud Talk(Part 1) ### Changed - `require_capabilities`/`check_capabilities` can accept value with `dot`: like `files_sharing.api_enabled` and check for sub-values. +- Refactored all API(except `Files`) again. + +### Fixed + +- `options.NPA_NC_CERT` bug, when setting throw `.env` file. ## [0.0.31 - 2023-08-17] diff --git a/docs/reference/Apps.rst b/docs/reference/Apps.rst index 2acb9f53..d8c440ae 100644 --- a/docs/reference/Apps.rst +++ b/docs/reference/Apps.rst @@ -1,32 +1,32 @@ Applications Management ----------------------- -.. autoclass:: nc_py_api.apps.apps.AppsAPI +.. autoclass:: nc_py_api.apps._AppsAPI :members: -.. autoclass:: nc_py_api.apps.apps.ExAppInfo +.. autoclass:: nc_py_api.apps.ExAppInfo :members: Preferences ^^^^^^^^^^^ -.. autoclass:: nc_py_api.apps.preferences_ex.CfgRecord +.. autoclass:: nc_py_api._preferences_ex.CfgRecord :members: :undoc-members: User specific """"""""""""" -.. autoclass:: nc_py_api.apps.preferences.PreferencesAPI +.. autoclass:: nc_py_api._preferences.PreferencesAPI :members: -.. autoclass:: nc_py_api.apps.preferences_ex.PreferencesExAPI +.. autoclass:: nc_py_api._preferences_ex.PreferencesExAPI :members: :inherited-members: Non-user specific """"""""""""""""" -.. autoclass:: nc_py_api.apps.preferences_ex.AppConfigExAPI +.. autoclass:: nc_py_api._preferences_ex.AppConfigExAPI :members: :inherited-members: diff --git a/docs/reference/Talk.rst b/docs/reference/Talk.rst new file mode 100644 index 00000000..a3fa4604 --- /dev/null +++ b/docs/reference/Talk.rst @@ -0,0 +1,50 @@ +Talk API +-------- + +.. autoclass:: nc_py_api.talk.Conversation + :members: + :inherited-members: + +.. autoclass:: nc_py_api.talk._TalkAPI + :members: + +.. autoclass:: nc_py_api.talk.ConversationType + :members: + +.. autoclass:: nc_py_api.talk.ParticipantType + :members: + +.. autoclass:: nc_py_api.talk.AttendeePermissions + :members: + +.. autoclass:: nc_py_api.talk.InCallFlags + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.ListableScope + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.NotificationLevel + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.WebinarLobbyStates + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.SipEnabledStatus + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.CallRecordingStatus + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.BreakoutRoomMode + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.BreakoutRoomStatus + :members: + :undoc-members: diff --git a/docs/reference/Users/Notifications.rst b/docs/reference/Users/Notifications.rst index 93b6ca61..4b72e797 100644 --- a/docs/reference/Users/Notifications.rst +++ b/docs/reference/Users/Notifications.rst @@ -1,11 +1,11 @@ Notifications ------------- -.. autoclass:: nc_py_api.users.notifications._NotificationsAPI +.. autoclass:: nc_py_api.notifications._NotificationsAPI :members: -.. autoclass:: nc_py_api.users.notifications.Notification +.. autoclass:: nc_py_api.notifications.Notification :members: -.. autoclass:: nc_py_api.users.notifications.NotificationInfo +.. autoclass:: nc_py_api.notifications.NotificationInfo :members: diff --git a/docs/reference/Users/Users.rst b/docs/reference/Users/Users.rst index 2c9a5ccf..3663b2ce 100644 --- a/docs/reference/Users/Users.rst +++ b/docs/reference/Users/Users.rst @@ -1,5 +1,5 @@ User Management --------------- -.. autoclass:: nc_py_api.users.users.UsersAPI +.. autoclass:: nc_py_api.users._UsersAPI :members: diff --git a/docs/reference/Users/UsersGroups.rst b/docs/reference/Users/UsersGroups.rst index bfa063ce..976270a7 100644 --- a/docs/reference/Users/UsersGroups.rst +++ b/docs/reference/Users/UsersGroups.rst @@ -1,8 +1,8 @@ User Groups Management ---------------------- -.. autoclass:: nc_py_api.users.groups._UserGroupsAPI +.. autoclass:: nc_py_api.users_groups._UsersGroupsAPI :members: -.. autoclass:: nc_py_api.users.groups.GroupDetails +.. autoclass:: nc_py_api.users_groups.GroupDetails :members: diff --git a/docs/reference/Users/UsersStatus.rst b/docs/reference/Users/UsersStatus.rst index 550b91ca..c6cd8ef2 100644 --- a/docs/reference/Users/UsersStatus.rst +++ b/docs/reference/Users/UsersStatus.rst @@ -1,17 +1,18 @@ User Status ----------- -.. autoclass:: nc_py_api.users.status._UserStatusAPI +.. autoclass:: nc_py_api.user_status._UserStatusAPI :members: -.. autoclass:: nc_py_api.users.status.CurrentUserStatus +.. autoclass:: nc_py_api.user_status.CurrentUserStatus :members: -.. autoclass:: nc_py_api.users.status.UserStatus +.. autoclass:: nc_py_api.user_status.UserStatus :members: + :inherited-members: -.. autoclass:: nc_py_api.users.status.PredefinedStatus +.. autoclass:: nc_py_api.user_status.PredefinedStatus :members: -.. autoclass:: nc_py_api.users.status.ClearAt +.. autoclass:: nc_py_api.user_status.ClearAt :members: diff --git a/docs/reference/Users/WeatherStatus.rst b/docs/reference/Users/WeatherStatus.rst index cd0a7eb2..155b9a40 100644 --- a/docs/reference/Users/WeatherStatus.rst +++ b/docs/reference/Users/WeatherStatus.rst @@ -1,11 +1,11 @@ Weather Status -------------- -.. autoclass:: nc_py_api.users.weather._WeatherStatusAPI +.. autoclass:: nc_py_api.weather_status._WeatherStatusAPI :members: -.. autoclass:: nc_py_api.users.weather.WeatherLocation +.. autoclass:: nc_py_api.weather_status.WeatherLocation :members: -.. autoclass:: nc_py_api.users.weather.WeatherLocationMode +.. autoclass:: nc_py_api.weather_status.WeatherLocationMode :members: diff --git a/docs/reference/index.rst b/docs/reference/index.rst index dea8768d..44510f11 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -10,4 +10,5 @@ Reference Users/index.rst ExApp Exceptions + Talk Session diff --git a/nc_py_api/_misc.py b/nc_py_api/_misc.py index ce9df8e2..e5fd5f86 100644 --- a/nc_py_api/_misc.py +++ b/nc_py_api/_misc.py @@ -5,14 +5,25 @@ from random import choice from string import ascii_lowercase, ascii_uppercase, digits -from typing import Union +from typing import Callable, Union from ._exceptions import NextcloudMissingCapabilities -def kwargs_to_dict(keys: list[str], **kwargs) -> dict: - """Creates dictionary from ``kwargs`` by keys, the value of each is not ``None``.""" - return {k: kwargs[k] for k in keys if kwargs[k] is not None} +def __check_for_none(v): + return v is not None + + +def kwargs_to_params(keys: list[str], filter_func: Callable = __check_for_none, **kwargs) -> dict: + """Returns dictionary from ``kwargs`` by keys. By default, only pairs with ``not None`` values returned.""" + return {k: kwargs[k] for k in keys if filter_func(kwargs[k])} + + +def clear_from_params_empty(keys: list[str], params: dict) -> None: + """Removes key:values pairs from ``params`` which values are empty.""" + for key in keys: + if key in params and not params[key]: + params.pop(key) def require_capabilities(capabilities: Union[str, list[str]], srv_capabilities: dict) -> None: diff --git a/nc_py_api/apps/preferences.py b/nc_py_api/_preferences.py similarity index 92% rename from nc_py_api/apps/preferences.py rename to nc_py_api/_preferences.py index 7d230156..a0777e7c 100644 --- a/nc_py_api/apps/preferences.py +++ b/nc_py_api/_preferences.py @@ -1,7 +1,7 @@ """Nextcloud API for working with classics app's storage with user's context (table oc_preferences).""" -from .._misc import check_capabilities, require_capabilities -from .._session import NcSessionBasic +from ._misc import check_capabilities, require_capabilities +from ._session import NcSessionBasic class PreferencesAPI: diff --git a/nc_py_api/apps/preferences_ex.py b/nc_py_api/_preferences_ex.py similarity index 88% rename from nc_py_api/apps/preferences_ex.py rename to nc_py_api/_preferences_ex.py index ff0843dc..087673ac 100644 --- a/nc_py_api/apps/preferences_ex.py +++ b/nc_py_api/_preferences_ex.py @@ -1,13 +1,13 @@ """Nextcloud API for working with apps V2's storage w/wo user context(table oc_appconfig_ex/oc_preferences_ex).""" -from dataclasses import dataclass -from typing import Optional, Union +import dataclasses +import typing -from .._exceptions import NextcloudExceptionNotFound -from .._misc import require_capabilities -from .._session import NcSessionBasic +from ._exceptions import NextcloudExceptionNotFound +from ._misc import require_capabilities +from ._session import NcSessionBasic -@dataclass +@dataclasses.dataclass class CfgRecord: """A representation of a single key-value pair returned from the **get_values** method.""" @@ -25,7 +25,7 @@ class _BasicAppCfgPref: def __init__(self, session: NcSessionBasic): self._session = session - def get_value(self, key: str, default=None) -> Optional[str]: + def get_value(self, key: str, default=None) -> typing.Optional[str]: """Returns the value of the key, if found, or the specified default value.""" if not key: raise ValueError("`key` parameter can not be empty") @@ -48,7 +48,7 @@ def get_values(self, keys: list[str]) -> list[CfgRecord]: ) return [CfgRecord(i) for i in results] - def delete(self, keys: Union[str, list[str]], not_fail=True) -> None: + def delete(self, keys: typing.Union[str, list[str]], not_fail=True) -> None: """Deletes config/preference entries by the provided keys.""" if isinstance(keys, str): keys = [keys] @@ -85,7 +85,7 @@ class AppConfigExAPI(_BasicAppCfgPref): _url_suffix = "ex-app/config" - def set_value(self, key: str, value: str, sensitive: Optional[bool] = None) -> None: + def set_value(self, key: str, value: str, sensitive: typing.Optional[bool] = None) -> None: """Sets a value and if specified the sensitive flag for a key. .. note:: A sensitive flag ensures key values are truncated in Nextcloud logs. diff --git a/nc_py_api/_version.py b/nc_py_api/_version.py index 8a2d88f0..538537fb 100644 --- a/nc_py_api/_version.py +++ b/nc_py_api/_version.py @@ -1,3 +1,3 @@ """Version of nc_py_api.""" -__version__ = "0.0.32.dev0" +__version__ = "0.0.40.dev0" diff --git a/nc_py_api/apps/apps.py b/nc_py_api/apps.py similarity index 95% rename from nc_py_api/apps/apps.py rename to nc_py_api/apps.py index 2926c59b..4a64c434 100644 --- a/nc_py_api/apps/apps.py +++ b/nc_py_api/apps.py @@ -1,13 +1,13 @@ """Nextcloud API for working with applications.""" -from dataclasses import dataclass -from typing import Optional +import dataclasses +import typing -from .._misc import require_capabilities -from .._session import NcSessionBasic +from ._misc import require_capabilities +from ._session import NcSessionBasic -@dataclass +@dataclasses.dataclass class ExAppInfo: """Information about the External Application.""" @@ -33,7 +33,7 @@ def __init__(self, raw_data: dict): self.system = raw_data["system"] -class AppsAPI: +class _AppsAPI: """The class provides the application management API on the Nextcloud server.""" _ep_base: str = "/ocs/v1.php/cloud/apps" @@ -59,7 +59,7 @@ def enable(self, app_id: str) -> None: raise ValueError("`app_id` parameter can not be empty") self._session.ocs(method="POST", path=f"{self._ep_base}/{app_id}") - def get_list(self, enabled: Optional[bool] = None) -> list[str]: + def get_list(self, enabled: typing.Optional[bool] = None) -> list[str]: """Get the list of installed applications. :param enabled: filter to list all/only enabled/only disabled applications. diff --git a/nc_py_api/apps/__init__.py b/nc_py_api/apps/__init__.py deleted file mode 100644 index c70dd33e..00000000 --- a/nc_py_api/apps/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""APIs for managing applications and their settings.""" diff --git a/nc_py_api/ex_app/defs.py b/nc_py_api/ex_app/defs.py index 534ea069..b94cb220 100644 --- a/nc_py_api/ex_app/defs.py +++ b/nc_py_api/ex_app/defs.py @@ -35,3 +35,5 @@ class ApiScope(enum.IntEnum): """Allows access to APIs that provide Notifications.""" WEATHER_STATUS = 33 """Allows access to APIs that provide Weather status.""" + TALK = 50 + """Allows access to Talk API endpoints.""" diff --git a/nc_py_api/nextcloud.py b/nc_py_api/nextcloud.py index 0aa03be8..ee96649b 100644 --- a/nc_py_api/nextcloud.py +++ b/nc_py_api/nextcloud.py @@ -6,33 +6,53 @@ from httpx import Headers as HttpxHeaders from ._misc import check_capabilities +from ._preferences import PreferencesAPI +from ._preferences_ex import AppConfigExAPI, PreferencesExAPI from ._session import AppConfig, NcSession, NcSessionApp, NcSessionBasic, ServerVersion from ._theming import ThemingInfo, get_parsed_theme -from .apps.apps import AppsAPI -from .apps.preferences import PreferencesAPI -from .apps.preferences_ex import AppConfigExAPI, PreferencesExAPI +from .apps import _AppsAPI from .ex_app.defs import ApiScope, LogLvl from .ex_app.ui.ui import UiApi from .files.files import FilesAPI -from .users.users import UsersAPI +from .notifications import _NotificationsAPI +from .talk import _TalkAPI +from .user_status import _UserStatusAPI +from .users import _UsersAPI +from .users_groups import _UsersGroupsAPI +from .weather_status import _WeatherStatusAPI -class _NextcloudBasic(ABC): - apps: AppsAPI +class _NextcloudBasic(ABC): # pylint: disable=too-many-instance-attributes + apps: _AppsAPI """Nextcloud API for App management""" files: FilesAPI """Nextcloud API for File System and Files Sharing""" preferences: PreferencesAPI """Nextcloud User Preferences API""" - users: UsersAPI - """Nextcloud API for managing users, user groups, user status, user weather status""" + notifications: _NotificationsAPI + """Nextcloud API for managing user notifications""" + talk: _TalkAPI + """Nextcloud Talk Api""" + users: _UsersAPI + """Nextcloud API for managing users.""" + users_groups: _UsersGroupsAPI + """Nextcloud API for managing user groups.""" + user_status: _UserStatusAPI + """Nextcloud API for managing users statuses""" + weather_status: _WeatherStatusAPI + """Nextcloud API for managing user weather statuses""" _session: NcSessionBasic def _init_api(self, session: NcSessionBasic): - self.apps = AppsAPI(session) + self.apps = _AppsAPI(session) self.files = FilesAPI(session) self.preferences = PreferencesAPI(session) - self.users = UsersAPI(session) + self.notifications = _NotificationsAPI(session) + self.talk = _TalkAPI(session) + self.users = _UsersAPI(session) + self.users_groups = _UsersGroupsAPI(session) + self.user_status = _UserStatusAPI(session) + self.weather_status = _WeatherStatusAPI(session) @property def capabilities(self) -> dict: @@ -106,10 +126,10 @@ class NextcloudApp(_NextcloudBasic): _session: NcSessionApp appconfig_ex: AppConfigExAPI """Nextcloud App Preferences API for ExApps""" - ui: UiApi - """Nextcloud UI API for ExApps""" preferences_ex: PreferencesExAPI """Nextcloud User Preferences API for ExApps""" + ui: UiApi + """Nextcloud UI API for ExApps""" def __init__(self, **kwargs): """The parameters will be taken from the environment. @@ -161,6 +181,8 @@ def user(self) -> str: def user(self, value: str): if self._session.user != value: self._session.user = value + self.talk.config_sha = "" + self.talk.modified_since = 0 self._session.update_server_info() @property diff --git a/nc_py_api/users/notifications.py b/nc_py_api/notifications.py similarity index 89% rename from nc_py_api/users/notifications.py rename to nc_py_api/notifications.py index c37ca8bd..9a33bfb4 100644 --- a/nc_py_api/users/notifications.py +++ b/nc_py_api/notifications.py @@ -1,15 +1,15 @@ """Nextcloud API for working with Notifications.""" -from dataclasses import dataclass -from datetime import datetime -from email.utils import parsedate_to_datetime -from typing import Optional +import dataclasses +import datetime +import email.utils +import typing -from .._misc import check_capabilities, random_string, require_capabilities -from .._session import NcSessionApp, NcSessionBasic +from ._misc import check_capabilities, random_string, require_capabilities +from ._session import NcSessionApp, NcSessionBasic -@dataclass +@dataclasses.dataclass class NotificationInfo: """Extra Notification attributes from Nextcloud.""" @@ -17,7 +17,7 @@ class NotificationInfo: """Application name that generated notification.""" user_id: str """User name for which this notification is.""" - time: datetime + time: datetime.datetime """Time when the notification was created.""" subject: str """Subject of the notification.""" @@ -32,16 +32,16 @@ def __init__(self, raw_info: dict): self.app_name = raw_info["app"] self.user_id = raw_info["user"] try: - self.time = parsedate_to_datetime(raw_info["datetime"]) + self.time = email.utils.parsedate_to_datetime(raw_info["datetime"]) except (ValueError, TypeError): - self.time = datetime(1970, 1, 1) + self.time = datetime.datetime(1970, 1, 1) self.subject = raw_info["subject"] self.message = raw_info["message"] self.link = raw_info.get("link", "") self.icon = raw_info.get("icon", "") -@dataclass +@dataclasses.dataclass class Notification: """Class representing information about Nextcloud notification.""" @@ -78,8 +78,8 @@ def create( self, subject: str, message: str = "", - subject_params: Optional[dict] = None, - message_params: Optional[dict] = None, + subject_params: typing.Optional[dict] = None, + message_params: typing.Optional[dict] = None, link: str = "", ) -> str: """Create a Notification for the current user and returns it's ObjectID. @@ -122,7 +122,7 @@ def get_one(self, notification_id: int) -> Notification: require_capabilities("notifications", self._session.capabilities) return Notification(self._session.ocs(method="GET", path=f"{self._ep_base}/{notification_id}")) - def by_object_id(self, object_id: str) -> Optional[Notification]: + def by_object_id(self, object_id: str) -> typing.Optional[Notification]: """Returns Notification if any by its object ID. .. note:: this method is a temporary workaround until `create` can return `notification_id`. diff --git a/nc_py_api/talk.py b/nc_py_api/talk.py new file mode 100644 index 00000000..0989b1fa --- /dev/null +++ b/nc_py_api/talk.py @@ -0,0 +1,528 @@ +"""Nextcloud Talk API.""" + +import dataclasses +import enum +import typing + +from ._misc import check_capabilities, clear_from_params_empty +from ._session import NcSessionBasic +from .user_status import _UserStatus + + +class ConversationType(enum.IntEnum): + """Talk conversation types.""" + + ONE_TO_ONE = 1 + """Direct One to One""" + GROUP = 2 + """Group conversation(group chat)""" + PUBLIC = 3 + """Group conversation opened to all""" + CHANGELOG = 4 + """Conversation that some App start to inform about new features, changes, e.g. changelog.""" + FORMER = 5 + """Former "One to one" + (When a user is deleted from the server or removed from all their conversations, + "One to one" rooms are converted to this type)""" + + +class ParticipantType(enum.IntEnum): + """Permissions level of the current user.""" + + OWNER = 1 + """Creator of the conversation""" + MODERATOR = 2 + """Moderator of the conversation""" + USER = 3 + """Conversation participant""" + GUEST = 4 + """Conversation participant, with no account on NC instance""" + USER_SELF_JOINED = 5 + """User following a public link""" + GUEST_MODERATOR = 6 + """Conversation moderator, with no account on NC instance""" + + +class AttendeePermissions(enum.IntFlag): + """Final permissions for the current participant. + + .. note:: Permissions are picked in order of attendee then call, then default, + and the first which is ``Custom`` will apply. + """ + + DEFAULT = 0 + """Default permissions (will pick the one from the next level of: ``user``, ``call``, ``conversation``)""" + CUSTOM = 1 + """Custom permissions (this is required to be able to remove all other permissions)""" + START_CALL = 2 + """Start call""" + JOIN_CALL = 4 + """Join call""" + IGNORE = 8 + """Can ignore lobby""" + AUDIO = 16 + """Can publish audio stream""" + VIDEO = 32 + """Can publish video stream""" + SHARE_SCREEN = 64 + """Can publish screen sharing stream""" + OTHER = 128 + """Can post chat message, share items and do reactions""" + + +class InCallFlags(enum.IntFlag): + """Participant in-call flags.""" + + DISCONNECTED = 0 + IN_CALL = 1 + PROVIDES_AUDIO = 2 + PROVIDES_VIDEO = 4 + USES_SIP_DIAL_IN = 8 + + +class ListableScope(enum.IntEnum): + """Listable scope for the room.""" + + PARTICIPANTS_ONLY = 0 + ONLY_REGULAR_USERS = 1 + EVERYONE = 2 + + +class NotificationLevel(enum.IntEnum): + """The notification level for the user. + + .. note:: Default: ``1`` for one-to-one conversations, ``2`` for other conversations. + """ + + DEFAULT = 0 + ALWAYS_NOTIFY = 1 + NOTIFY_ON_MENTION = 2 + NEVER_NOTIFY = 3 + + +class WebinarLobbyStates(enum.IntEnum): + """Webinar lobby restriction (0-1), if the participant is a moderator, they can always join the conversation.""" + + NO_LOBBY = 0 + NON_MODERATORS = 1 + + +class SipEnabledStatus(enum.IntEnum): + """SIP enable status.""" + + DISABLED = 0 + ENABLED = 1 + """Each participant needs a unique PIN.""" + ENABLED_NO_PIN = 2 + """Only the conversation token is required.""" + + +class CallRecordingStatus(enum.IntEnum): + """Type of call recording.""" + + NO_RECORDING = 0 + VIDEO = 1 + AUDIO = 2 + STARTING_VIDEO = 3 + STARTING_AUDIO = 4 + RECORDING_FAILED = 5 + + +class BreakoutRoomMode(enum.IntEnum): + """Breakout room modes.""" + + NOT_CONFIGURED = 0 + AUTOMATIC = 1 + """ Attendees are unsorted and then distributed over the rooms, so they all have the same participant count.""" + MANUAL = 2 + """A map with attendee to room number specifies the participants.""" + FREE = 3 + """Each attendee picks their own breakout room.""" + + +class BreakoutRoomStatus(enum.IntEnum): + """Breakout room status.""" + + STOPPED = 0 + """Breakout rooms lobbies are disabled.""" + STARTED = 1 + """Breakout rooms lobbies are enabled.""" + + +@dataclasses.dataclass(init=False) +class Conversation(_UserStatus): + """Talk conversation.""" + + @property + def conversation_id(self) -> int: + """Numeric identifier of the conversation. Most methods that require this should accept this class itself.""" + return self._raw_data["id"] + + @property + def token(self) -> str: + """Token identifier of the conversation which is used for further interaction.""" + return self._raw_data["token"] + + @property + def conversation_type(self) -> ConversationType: + """Type of the conversation, see: :py:class:`~nc_py_api.talk.ConversationType`.""" + return ConversationType(self._raw_data["type"]) + + @property + def name(self) -> str: + """Name of the conversation (can also be empty).""" + return self._raw_data.get("name", "") + + @property + def display_name(self) -> str: + """``name`` if non-empty, otherwise it falls back to a list of participants.""" + return self._raw_data["displayName"] + + @property + def description(self) -> str: + """Description of the conversation (can also be empty) (only available with ``room-description`` capability).""" + return self._raw_data.get("description", "") + + @property + def participant_type(self) -> ParticipantType: + """Permissions level of the current user, see: :py:class:`~nc_py_api.talk.ParticipantType`.""" + return ParticipantType(self._raw_data["participantType"]) + + @property + def attendee_id(self) -> int: + """Unique attendee id.""" + return self._raw_data["attendeeId"] + + @property + def attendee_pin(self) -> str: + """Unique dial-in authentication code for this user when the conversation has SIP enabled.""" + return self._raw_data["attendeePin"] + + @property + def actor_type(self) -> str: + """Actor types of chat messages: **users**, **guests**, **bots**, **bridged**.""" + return self._raw_data["actorType"] + + @property + def actor_id(self) -> str: + """The unique identifier for the given actor type.""" + return self._raw_data["actorId"] + + @property + def permissions(self) -> AttendeePermissions: + """Final permissions, combined :py:class:`~nc_py_api.talk.AttendeePermissions` values.""" + return AttendeePermissions(self._raw_data["permissions"]) + + @property + def attendee_permissions(self) -> AttendeePermissions: + """Dedicated permissions for the current participant, if not ``Custom``, they are not the resulting ones.""" + return AttendeePermissions(self._raw_data["attendeePermissions"]) + + @property + def call_permissions(self) -> AttendeePermissions: + """Call permissions, if not ``Custom``, these are not the resulting permissions. + + .. note:: If set, they will reset after the end of the call. + """ + return AttendeePermissions(self._raw_data["callPermissions"]) + + @property + def default_permissions(self) -> AttendeePermissions: + """Default permissions for new participants.""" + return AttendeePermissions(self._raw_data["defaultPermissions"]) + + @property + def participant_flags(self) -> InCallFlags: + """``In call`` flags of the user's session making the request. + + .. note:: Available with ``in-call-flags`` capability. + """ + return InCallFlags(self._raw_data.get("participantFlags", InCallFlags.DISCONNECTED)) + + @property + def read_only(self) -> bool: + """Read-only state for the current user (only available with ``read-only-rooms`` capability).""" + return bool(self._raw_data.get("readOnly", False)) + + @property + def listable(self) -> ListableScope: + """Listable scope for the room (only available with ``listable-rooms`` capability).""" + return ListableScope(self._raw_data.get("listable", ListableScope.PARTICIPANTS_ONLY)) + + @property + def message_expiration(self) -> int: + """The message expiration time in seconds in this chat. Zero if disabled. + + .. note:: Only available with ``message-expiration`` capability. + """ + return self._raw_data.get("messageExpiration", 0) + + @property + def has_password(self) -> bool: + """Flag if the conversation has a password.""" + return bool(self._raw_data["hasPassword"]) + + @property + def has_call(self) -> bool: + """Flag if the conversation has call.""" + return bool(self._raw_data["hasCall"]) + + @property + def call_flag(self) -> InCallFlags: + """Combined flag of all participants in the current call. + + .. note:: Only available with ``conversation-call-flags`` capability. + """ + return InCallFlags(self._raw_data.get("callFlag", InCallFlags.DISCONNECTED)) + + @property + def can_start_call(self) -> bool: + """Flag if the user can start a new call in this conversation (joining is always possible). + + .. note:: Only available with start-call-flag capability. + """ + return bool(self._raw_data.get("canStartCall", False)) + + @property + def can_delete_conversation(self) -> bool: + """Flag if the user can delete the conversation for everyone. + + .. note: Not possible without moderator permissions or in one-to-one conversations. + """ + return bool(self._raw_data.get("canDeleteConversation", False)) + + @property + def can_leave_conversation(self) -> bool: + """Flag if the user can leave the conversation (not possible for the last user with moderator permissions).""" + return bool(self._raw_data.get("canLeaveConversation", False)) + + @property + def last_activity(self) -> int: + """Timestamp of the last activity in the conversation, in seconds and UTC time zone.""" + return self._raw_data["lastActivity"] + + @property + def is_favorite(self) -> bool: + """Flag if the conversation is favorite for the user.""" + return self._raw_data["isFavorite"] + + @property + def notification_level(self) -> NotificationLevel: + """The notification level for the user.""" + return NotificationLevel(self._raw_data["notificationLevel"]) + + @property + def lobby_state(self) -> WebinarLobbyStates: + """Webinar lobby restriction (0-1). + + .. note:: Only available with ``webinary-lobby`` capability. + """ + return WebinarLobbyStates(self._raw_data["lobbyState"]) + + @property + def lobby_timer(self) -> int: + """Timestamp when the lobby will be automatically disabled. + + .. note:: Only available with ``webinary-lobby`` capability. + """ + return self._raw_data["lobbyTimer"] + + @property + def sip_enabled(self) -> SipEnabledStatus: + """Status of the SIP for the conversation.""" + return SipEnabledStatus(self._raw_data["sipEnabled"]) + + @property + def can_enable_sip(self) -> bool: + """Whether the given user can enable SIP for this conversation. + + .. note:: When the token is not-numeric only, SIP can not be enabled even + if the user is permitted and a moderator of the conversation. + """ + return bool(self._raw_data["canEnableSIP"]) + + @property + def unread_messages_count(self) -> int: + """Number of unread chat messages in the conversation. + + .. note: Only available with chat-v2 capability. + """ + return self._raw_data["unreadMessages"] + + @property + def unread_mention(self) -> bool: + """Flag if the user was mentioned since their last visit.""" + return self._raw_data["unreadMention"] + + @property + def unread_mention_direct(self) -> bool: + """Flag if the user was mentioned directly (ignoring **@all** mentions) since their last visit. + + .. note:: Only available with ``direct-mention-flag`` capability. + """ + return self._raw_data["unreadMentionDirect"] + + @property + def last_read_message(self) -> int: + """ID of the last read message in a room. + + .. note:: only available with ``chat-read-marker`` capability. + """ + return self._raw_data["lastReadMessage"] + + @property + def breakout_room_mode(self) -> BreakoutRoomMode: + """Breakout room configuration mode. + + .. note:: Only available with ``breakout-rooms-v1`` capability. + """ + return BreakoutRoomMode(self._raw_data.get("breakoutRoomMode", BreakoutRoomMode.NOT_CONFIGURED)) + + @property + def breakout_room_status(self) -> BreakoutRoomStatus: + """Breakout room status. + + .. note:: Only available with ``breakout-rooms-v1`` capability. + """ + return BreakoutRoomStatus(self._raw_data.get("breakoutRoomStatus", BreakoutRoomStatus.STOPPED)) + + @property + def avatar_version(self) -> str: + """Version of conversation avatar used to easier expiration of the avatar in case a moderator updates it. + + .. note:: Only available with ``avatar`` capability. + """ + return self._raw_data["avatarVersion"] + + @property + def is_custom_avatar(self) -> bool: + """Flag if the conversation has a custom avatar. + + .. note:: Only available with ``avatar`` capability. + """ + return self._raw_data.get("isCustomAvatar", False) + + @property + def call_start_time(self) -> int: + """Timestamp when the call was started. + + .. note:: Only available with ``recording-v1`` capability. + """ + return self._raw_data["callStartTime"] + + @property + def recording_status(self) -> CallRecordingStatus: + """Call recording status.. + + .. note:: Only available with ``recording-v1`` capability. + """ + return CallRecordingStatus(self._raw_data.get("callRecording", CallRecordingStatus.NO_RECORDING)) + + +class _TalkAPI: + """Class that implements work with Nextcloud Talk.""" + + _ep_base: str = "/ocs/v2.php/apps/spreed/api/v4" + config_sha: str + """Sha1 value over Talk config. After receiving a different value on subsequent requests, settings got refreshed.""" + modified_since: int + """Used by ``get_user_conversations``, when **modified_since** param is ``True``.""" + + def __init__(self, session: NcSessionBasic): + self._session = session + self.config_sha = "" + self.modified_since = 0 + + @property + def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("spreed", self._session.capabilities) + + def get_user_conversations( + self, no_status_update: bool = True, include_status: bool = False, modified_since: typing.Union[int, bool] = 0 + ) -> list[Conversation]: + """Returns the list of the user's conversations. + + :param no_status_update: When the user status should not be automatically set to the online. Default = **True** + :param include_status: Whether the user status information of all one-to-one conversations should be loaded. + :param modified_since: When provided only conversations with a newer **lastActivity** + (and one-to-one conversations when includeStatus is provided) are returned. + Can be set to ``True`` to automatically use last ``modified_since`` from previous calls. Default = **0**. + + .. note:: In rare cases, when a request arrives between seconds, it is possible that return data + will contain part of the conversations from the last call that was not modified( + their `last_activity` will be the same as ``talk.modified_since``). + """ + params: dict = {} + if no_status_update: + params["noStatusUpdate"] = True + if include_status: + params["includeStatus"] = True + if modified_since: + params["modifiedSince"] = self.modified_since if modified_since is True else modified_since + + result = self._session.ocs("GET", self._ep_base + "/room", params=params) + self.modified_since = int(self._session.response_headers["X-Nextcloud-Talk-Modified-Before"]) + config_sha = self._session.response_headers["X-Nextcloud-Talk-Hash"] + if self.config_sha != config_sha: + self._session.update_server_info() + self.config_sha = config_sha + return [Conversation(i) for i in result] + + def create_conversation( + self, + conversation_type: ConversationType, + invite: str = "", + source: str = "", + room_name: str = "", + object_type: str = "", + object_id: str = "", + ) -> Conversation: + """Creates a new conversation. + + .. note:: Creating a conversation as a child breakout room will automatically set the lobby when breakout + rooms are not started and will always overwrite the room type with the parent room type. + Also, moderators of the parent conversation will be automatically added as moderators. + + :param conversation_type: type of the conversation to create. + :param invite: User ID(roomType=ONE_TO_ONE), Group ID(roomType=GROUP - optional), + Circle ID(roomType=GROUP, source='circles', only available with the ``circles-support`` capability). + :param source: The source for the invite, only supported on roomType = GROUP for groups and circles. + :param room_name: Conversation name up to 255 characters(``not available for roomType=ONE_TO_ONE``). + :param object_type: Type of object this room references, currently only allowed + value is **"room"** to indicate the parent of a breakout room. + :param object_id: ID of an object this room references, room token is used for the parent of a breakout room. + """ + params: dict = { + "roomType": int(conversation_type), + "invite": invite, + "source": source, + "roomName": room_name, + "objectType": object_type, + "objectId": object_id, + } + clear_from_params_empty(["invite", "source", "roomName", "objectType", "objectId"], params) + return Conversation(self._session.ocs("POST", self._ep_base + "/room", json=params)) + + def delete_conversation(self, conversation: typing.Union[Conversation, str]) -> None: + """Deletes a conversation. + + .. note:: Deleting a conversation that is the parent of breakout rooms, will also delete them. + ``ONE_TO_ONE`` conversations can not be deleted for them + :py:class:`~nc_py_api.talk._TalkAPI.leave_conversation` should be used. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + self._session.ocs("DELETE", self._ep_base + f"/room/{token}") + + def leave_conversation(self, conversation: typing.Union[Conversation, str]) -> None: + """Removes yourself from the conversation. + + .. note:: When the participant is a moderator or owner and there are no other moderators or owners left, + participant can not leave conversation. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + self._session.ocs("DELETE", self._ep_base + f"/room/{token}/participants/self") diff --git a/nc_py_api/users/status.py b/nc_py_api/user_status.py similarity index 68% rename from nc_py_api/users/status.py rename to nc_py_api/user_status.py index 77a648cf..6467a1e1 100644 --- a/nc_py_api/users/status.py +++ b/nc_py_api/user_status.py @@ -1,20 +1,20 @@ """Nextcloud API for working with user statuses.""" -from dataclasses import dataclass -from typing import Literal, Optional, Union +import dataclasses +import typing -from .._exceptions import NextcloudExceptionNotFound -from .._misc import check_capabilities, kwargs_to_dict, require_capabilities -from .._session import NcSessionBasic +from ._exceptions import NextcloudExceptionNotFound +from ._misc import check_capabilities, kwargs_to_params, require_capabilities +from ._session import NcSessionBasic -@dataclass +@dataclasses.dataclass class ClearAt: """Determination when a user's predefined status will be cleared.""" clear_type: str """Possible values: ``period``, ``end-of``""" - time: Union[str, int] + time: typing.Union[str, int] """Depending of ``type`` it can be number of seconds relative to ``now`` or one of the next values: ``day``""" def __init__(self, raw_data: dict): @@ -22,7 +22,7 @@ def __init__(self, raw_data: dict): self.time = raw_data["time"] -@dataclass +@dataclasses.dataclass class PredefinedStatus: """Definition of the predefined status.""" @@ -32,7 +32,7 @@ class PredefinedStatus: """Icon in string(UTF) format""" message: str """The message defined for this status. It is translated, so it depends on the user's language setting.""" - clear_at: Optional[ClearAt] + clear_at: typing.Optional[ClearAt] """When the default, if not override, the predefined status will be cleared.""" def __init__(self, raw_status: dict): @@ -46,45 +46,62 @@ def __init__(self, raw_status: dict): self.clear_at = None -@dataclass -class UserStatus: +@dataclasses.dataclass +class _UserStatus: + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def status_message(self) -> str: + """Message of the status.""" + return self._raw_data.get("message", "") + + @property + def status_icon(self) -> str: + """The icon picked by the user (must be one emoji).""" + return self._raw_data.get("icon", "") + + @property + def status_clear_at(self) -> typing.Optional[int]: + """Unix Timestamp representing the time to clear the status.""" + return self._raw_data.get("clearAt", None) + + @property + def status_type(self) -> str: + """Status type, on of the: online, away, dnd, invisible, offline.""" + return self._raw_data.get("status", "") + + +@dataclasses.dataclass +class UserStatus(_UserStatus): """Information about user status.""" user_id: str """The ID of the user this status is for""" - message: str - """Message of the status""" - icon: Optional[str] - """The icon picked by the user (must be one emoji)""" - clear_at: Optional[int] - """Unix Timestamp representing the time to clear the status.""" - status_type: str - """Status type, on of the: online, away, dnd, invisible, offline""" - def __init__(self, raw_status: dict): - self.user_id = raw_status["userId"] - self.message = raw_status["message"] - self.icon = raw_status["icon"] - self.clear_at = raw_status["clearAt"] - self.status_type = raw_status["status"] + def __init__(self, raw_data: dict): + super().__init__(raw_data) + self.user_id = raw_data["userId"] -@dataclass +@dataclasses.dataclass(init=False) class CurrentUserStatus(UserStatus): """Information about current user status.""" - status_id: Optional[str] - """ID of the predefined status""" - predefined: bool - """*True* if status if predefined, *False* otherwise""" - status_type_defined: bool - """*True* if :py:attr:`UserStatus.status_type` is set by user, *False* otherwise""" + @property + def status_id(self) -> typing.Optional[str]: + """ID of the predefined status.""" + return self._raw_data["messageId"] - def __init__(self, raw_status: dict): - super().__init__(raw_status) - self.status_id = raw_status["messageId"] - self.predefined = raw_status["messageIsPredefined"] - self.status_type_defined = raw_status["statusIsUserDefined"] + @property + def message_predefined(self) -> bool: + """*True* if the status is predefined, *False* otherwise.""" + return self._raw_data["messageIsPredefined"] + + @property + def status_type_defined(self) -> bool: + """*True* if :py:attr:`UserStatus.status_type` is set by user, *False* otherwise.""" + return self._raw_data["statusIsUserDefined"] class _UserStatusAPI: @@ -100,14 +117,14 @@ def available(self) -> bool: """Returns True if the Nextcloud instance supports this feature, False otherwise.""" return not check_capabilities("user_status.enabled", self._session.capabilities) - def get_list(self, limit: Optional[int] = None, offset: Optional[int] = None) -> list[UserStatus]: + def get_list(self, limit: typing.Optional[int] = None, offset: typing.Optional[int] = None) -> list[UserStatus]: """Returns statuses for all users. :param limit: limits the number of results. :param offset: offset of results. """ require_capabilities("user_status.enabled", self._session.capabilities) - data = kwargs_to_dict(["limit", "offset"], limit=limit, offset=offset) + data = kwargs_to_params(["limit", "offset"], limit=limit, offset=offset) result = self._session.ocs(method="GET", path=f"{self._ep_base}/statuses", params=data) return [UserStatus(i) for i in result] @@ -116,7 +133,7 @@ def get_current(self) -> CurrentUserStatus: require_capabilities("user_status.enabled", self._session.capabilities) return CurrentUserStatus(self._session.ocs(method="GET", path=f"{self._ep_base}/user_status")) - def get(self, user_id: str) -> Optional[UserStatus]: + def get(self, user_id: str) -> typing.Optional[UserStatus]: """Returns the user status for the specified user. :param user_id: User ID for getting status. @@ -144,17 +161,17 @@ def set_predefined(self, status_id: str, clear_at: int = 0) -> None: if self._session.nc_version["major"] < 27: return require_capabilities("user_status.enabled", self._session.capabilities) - params: dict[str, Union[int, str]] = {"messageId": status_id} + params: dict[str, typing.Union[int, str]] = {"messageId": status_id} if clear_at: params["clearAt"] = clear_at self._session.ocs(method="PUT", path=f"{self._ep_base}/user_status/message/predefined", params=params) - def set_status_type(self, value: Literal["online", "away", "dnd", "invisible", "offline"]) -> None: + def set_status_type(self, value: typing.Literal["online", "away", "dnd", "invisible", "offline"]) -> None: """Sets the status type for the current user.""" require_capabilities("user_status.enabled", self._session.capabilities) self._session.ocs(method="PUT", path=f"{self._ep_base}/user_status/status", params={"statusType": value}) - def set_status(self, message: Optional[str] = None, clear_at: int = 0, status_icon: str = "") -> None: + def set_status(self, message: typing.Optional[str] = None, clear_at: int = 0, status_icon: str = "") -> None: """Sets current user status. :param message: Message text to set in the status. @@ -167,14 +184,14 @@ def set_status(self, message: Optional[str] = None, clear_at: int = 0, status_ic return if status_icon: require_capabilities("user_status.supports_emoji", self._session.capabilities) - params: dict[str, Union[int, str]] = {"message": message} + params: dict[str, typing.Union[int, str]] = {"message": message} if clear_at: params["clearAt"] = clear_at if status_icon: params["statusIcon"] = status_icon self._session.ocs(method="PUT", path=f"{self._ep_base}/user_status/message/custom", params=params) - def get_backup_status(self, user_id: str = "") -> Optional[UserStatus]: + def get_backup_status(self, user_id: str = "") -> typing.Optional[UserStatus]: """Get the backup status of the user if any. :param user_id: User ID for getting status. @@ -185,7 +202,7 @@ def get_backup_status(self, user_id: str = "") -> Optional[UserStatus]: raise ValueError("user_id can not be empty.") return self.get(f"_{user_id}") - def restore_backup_status(self, status_id: str) -> Optional[CurrentUserStatus]: + def restore_backup_status(self, status_id: str) -> typing.Optional[CurrentUserStatus]: """Restores the backup state as current for the current user. :param status_id: backup status ID. diff --git a/nc_py_api/users/users.py b/nc_py_api/users.py similarity index 86% rename from nc_py_api/users/users.py rename to nc_py_api/users.py index 082b903c..bc022cce 100644 --- a/nc_py_api/users/users.py +++ b/nc_py_api/users.py @@ -2,37 +2,20 @@ import typing -from .._misc import kwargs_to_dict -from .._session import NcSessionBasic -from .groups import _UserGroupsAPI -from .notifications import _NotificationsAPI -from .status import _UserStatusAPI -from .weather import _WeatherStatusAPI +from ._misc import kwargs_to_params +from ._session import NcSessionBasic -class UsersAPI: +class _UsersAPI: """The class provides the user, user groups, user status API on the Nextcloud server. .. note:: In NextcloudApp mode, only ``get_list`` and ``get_details`` methods are available. """ - groups: _UserGroupsAPI - """API for managing user groups""" - status: _UserStatusAPI - """API for managing user statuses""" - notifications: _NotificationsAPI - """API for managing user notifications""" - weather: _WeatherStatusAPI - """API for managing user weather statuses""" - _ep_base: str = "/ocs/v1.php/cloud/users" def __init__(self, session: NcSessionBasic): self._session = session - self.groups = _UserGroupsAPI(session) - self.status = _UserStatusAPI(session) - self.notifications = _NotificationsAPI(session) - self.weather = _WeatherStatusAPI(session) def get_list( self, mask: typing.Optional[str] = "", limit: typing.Optional[int] = None, offset: typing.Optional[int] = None @@ -43,7 +26,7 @@ def get_list( :param limit: limits the number of results. :param offset: offset of results. """ - data = kwargs_to_dict(["search", "limit", "offset"], search=mask, limit=limit, offset=offset) + data = kwargs_to_params(["search", "limit", "offset"], search=mask, limit=limit, offset=offset) response_data = self._session.ocs(method="GET", path=self._ep_base, params=data) return response_data["users"] if response_data else {} diff --git a/nc_py_api/users/__init__.py b/nc_py_api/users/__init__.py deleted file mode 100644 index 27cb03b7..00000000 --- a/nc_py_api/users/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""APIs related to Users.""" diff --git a/nc_py_api/users/groups.py b/nc_py_api/users_groups.py similarity index 63% rename from nc_py_api/users/groups.py rename to nc_py_api/users_groups.py index d3b3cc89..d87fff51 100644 --- a/nc_py_api/users/groups.py +++ b/nc_py_api/users_groups.py @@ -1,39 +1,51 @@ """Nextcloud API for working with user groups.""" -from dataclasses import dataclass -from typing import Optional +import dataclasses +import typing -from .._misc import kwargs_to_dict -from .._session import NcSessionBasic +from ._misc import kwargs_to_params +from ._session import NcSessionBasic -@dataclass +@dataclasses.dataclass class GroupDetails: """User Group information.""" - group_id: str - """ID of the group""" - display_name: str - """Display name of the group""" - user_count: int - """Number of users in the group""" - disabled: bool - """Flag indicating is group disabled""" - can_add: bool - """Flag showing the caller has enough rights to add users to this group""" - can_remove: bool - """Flag showing the caller has enough rights to remove users from this group""" - - def __init__(self, raw_group: dict): - self.group_id = raw_group["id"] - self.display_name = raw_group["displayname"] - self.user_count = raw_group["usercount"] - self.disabled = bool(raw_group["disabled"]) - self.can_add = bool(raw_group["canAdd"]) - self.can_remove = bool(raw_group["canRemove"]) - - -class _UserGroupsAPI: + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def group_id(self) -> str: + """ID of the group.""" + return self._raw_data["id"] + + @property + def display_name(self) -> str: + """A display name of the group.""" + return self._raw_data["displayname"] + + @property + def user_count(self) -> int: + """Number of users in the group.""" + return self._raw_data["usercount"] + + @property + def disabled(self) -> bool: + """Flag indicating is group disabled.""" + return bool(self._raw_data["disabled"]) + + @property + def can_add(self) -> bool: + """Flag indicating the caller has enough rights to add users to this group.""" + return bool(self._raw_data["canAdd"]) + + @property + def can_remove(self) -> bool: + """Flag indicating the caller has enough rights to remove users from this group.""" + return bool(self._raw_data["canRemove"]) + + +class _UsersGroupsAPI: """Class providing an API for managing user groups on the Nextcloud server. .. note:: In NextcloudApp mode, only ``get_list`` and ``get_details`` methods are available. @@ -45,7 +57,7 @@ def __init__(self, session: NcSessionBasic): self._session = session def get_list( - self, mask: Optional[str] = None, limit: Optional[int] = None, offset: Optional[int] = None + self, mask: typing.Optional[str] = None, limit: typing.Optional[int] = None, offset: typing.Optional[int] = None ) -> list[str]: """Returns a list of user groups IDs. @@ -53,12 +65,12 @@ def get_list( :param limit: limits the number of results. :param offset: offset of results. """ - data = kwargs_to_dict(["search", "limit", "offset"], search=mask, limit=limit, offset=offset) + data = kwargs_to_params(["search", "limit", "offset"], search=mask, limit=limit, offset=offset) response_data = self._session.ocs(method="GET", path=self._ep_base, params=data) return response_data["groups"] if response_data else [] def get_details( - self, mask: Optional[str] = None, limit: Optional[int] = None, offset: Optional[int] = None + self, mask: typing.Optional[str] = None, limit: typing.Optional[int] = None, offset: typing.Optional[int] = None ) -> list[GroupDetails]: """Returns a list of user groups with detailed information. @@ -66,11 +78,11 @@ def get_details( :param limit: limits the number of results. :param offset: offset of results. """ - data = kwargs_to_dict(["search", "limit", "offset"], search=mask, limit=limit, offset=offset) + data = kwargs_to_params(["search", "limit", "offset"], search=mask, limit=limit, offset=offset) response_data = self._session.ocs(method="GET", path=f"{self._ep_base}/details", params=data) return [GroupDetails(i) for i in response_data["groups"]] if response_data else [] - def create(self, group_id: str, display_name: Optional[str] = None) -> None: + def create(self, group_id: str, display_name: typing.Optional[str] = None) -> None: """Creates the users group. :param group_id: the ID of group to be created. diff --git a/nc_py_api/users/weather.py b/nc_py_api/weather_status.py similarity index 90% rename from nc_py_api/users/weather.py rename to nc_py_api/weather_status.py index 55c60d91..b5ee27dd 100644 --- a/nc_py_api/users/weather.py +++ b/nc_py_api/weather_status.py @@ -1,14 +1,14 @@ """Nextcloud API for working with weather statuses.""" -from dataclasses import dataclass -from enum import IntEnum -from typing import Optional, Union +import dataclasses +import enum +import typing -from .._misc import check_capabilities, require_capabilities -from .._session import NcSessionBasic +from ._misc import check_capabilities, require_capabilities +from ._session import NcSessionBasic -class WeatherLocationMode(IntEnum): +class WeatherLocationMode(enum.IntEnum): """Source from where Nextcloud should determine user's location.""" UNKNOWN = 0 @@ -19,7 +19,7 @@ class WeatherLocationMode(IntEnum): """User has set their location manually""" -@dataclass +@dataclasses.dataclass class WeatherLocation: """Class representing information about the user's location.""" @@ -60,7 +60,10 @@ def get_location(self) -> WeatherLocation: return WeatherLocation(self._session.ocs(method="GET", path=f"{self._ep_base}/location")) def set_location( - self, latitude: Optional[float] = None, longitude: Optional[float] = None, address: Optional[str] = None + self, + latitude: typing.Optional[float] = None, + longitude: typing.Optional[float] = None, + address: typing.Optional[str] = None, ) -> bool: """Sets the user's location on the Nextcloud server. @@ -69,7 +72,7 @@ def set_location( :param address: city, index(*optional*) and country, e.g. "Paris, 75007, France" """ require_capabilities("weather_status.enabled", self._session.capabilities) - params: dict[str, Union[str, float]] = {} + params: dict[str, typing.Union[str, float]] = {} if latitude is not None and longitude is not None: params.update({"lat": latitude, "lon": longitude}) elif address: diff --git a/pyproject.toml b/pyproject.toml index f66177ad..e9efb2b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -169,7 +169,7 @@ design.max-attributes = 8 design.max-locals = 16 design.max-branches = 16 design.max-returns = 8 -design.max-args = 6 +design.max-args = 7 basic.good-names = [ "a", "b", "c", "d", "e", "f", "i", "j", "k", "r", "v", "ex", "_", "fp", "im", "nc", "ui", diff --git a/scripts/dev_register.sh b/scripts/dev_register.sh index e63cba1f..2124f77b 100644 --- a/scripts/dev_register.sh +++ b/scripts/dev_register.sh @@ -13,7 +13,7 @@ NEXTCLOUD_URL="http://$2" APP_PORT=9009 APP_ID="nc_py_api" APP_SECRET="12345" AP echo $! > /tmp/_install.pid python3 tests/_install_wait.py "http://localhost:9009/heartbeat" "\"status\":\"ok\"" 15 0.5 docker exec "$1" sudo -u www-data php occ app_ecosystem_v2:app:register nc_py_api manual_install --json-info \ - "{\"appid\":\"nc_py_api\",\"name\":\"nc_py_api\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"host\":\"host.docker.internal\",\"scopes\":{\"required\":[2, 10, 11],\"optional\":[30, 31, 32, 33]},\"port\":9009,\"protocol\":\"http\",\"system_app\":1}" \ + "{\"appid\":\"nc_py_api\",\"name\":\"nc_py_api\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"host\":\"host.docker.internal\",\"scopes\":{\"required\":[2, 10, 11],\"optional\":[30, 31, 32, 33, 50]},\"port\":9009,\"protocol\":\"http\",\"system_app\":1}" \ -e --force-scopes cat /tmp/_install.pid kill -15 "$(cat /tmp/_install.pid)" diff --git a/tests/nc_app_test.py b/tests/nc_app_test.py index 282cd4e6..333542c0 100644 --- a/tests/nc_app_test.py +++ b/tests/nc_app_test.py @@ -45,9 +45,9 @@ def test_change_user(): orig_user = NC_APP.user try: orig_capabilities = NC_APP.capabilities - assert NC_APP.users.status.available + assert NC_APP.user_status.available NC_APP.user = "" - assert not NC_APP.users.status.available + assert not NC_APP.user_status.available assert orig_capabilities != NC_APP.capabilities finally: NC_APP.user = orig_user diff --git a/tests/notifications_test.py b/tests/notifications_test.py new file mode 100644 index 00000000..561064c6 --- /dev/null +++ b/tests/notifications_test.py @@ -0,0 +1,83 @@ +import pytest +from gfixture import NC, NC_APP + +from nc_py_api.notifications import Notification, NotificationInfo + +if NC_APP is None or "app_ecosystem_v2" not in NC_APP.capabilities: + pytest.skip("app_ecosystem_v2 is not installed.", allow_module_level=True) + + +def test_available(): + assert NC_APP.notifications.available + + +@pytest.mark.skipif(NC is None, reason="Not available for NextcloudApp.") +def test_create_as_client(): + with pytest.raises(NotImplementedError): + NC.notifications.create("caption") + + +def test_create(): + obj_id = NC_APP.notifications.create("subject0123", "message456") + new_notification = NC_APP.notifications.by_object_id(obj_id) + assert isinstance(new_notification, Notification) + assert isinstance(new_notification.info, NotificationInfo) + assert new_notification.info.subject == "subject0123" + assert new_notification.info.message == "message456" + assert new_notification.info.icon + assert not new_notification.info.link + + +def test_create_link_icon(): + obj_id = NC_APP.notifications.create("1", "", link="https://some.link/gg") + new_notification = NC_APP.notifications.by_object_id(obj_id) + assert isinstance(new_notification, Notification) + assert isinstance(new_notification.info, NotificationInfo) + assert new_notification.info.subject == "1" + assert not new_notification.info.message + assert new_notification.info.icon + assert new_notification.info.link == "https://some.link/gg" + + +def test_delete_all(): + NC_APP.notifications.create("subject0123", "message456") + obj_id1 = NC_APP.notifications.create("subject0123", "message456") + ntf1 = NC_APP.notifications.by_object_id(obj_id1) + assert ntf1 + obj_id2 = NC_APP.notifications.create("subject0123", "message456") + ntf2 = NC_APP.notifications.by_object_id(obj_id2) + assert ntf2 + NC_APP.notifications.delete_all() + assert NC_APP.notifications.by_object_id(obj_id1) is None + assert NC_APP.notifications.by_object_id(obj_id2) is None + assert not NC_APP.notifications.get_all() + assert not NC_APP.notifications.exists([ntf1.notification_id, ntf2.notification_id]) + + +def test_delete_one(): + obj_id1 = NC_APP.notifications.create("subject0123") + obj_id2 = NC_APP.notifications.create("subject0123") + ntf1 = NC_APP.notifications.by_object_id(obj_id1) + ntf2 = NC_APP.notifications.by_object_id(obj_id2) + NC_APP.notifications.delete(ntf1.notification_id) + assert NC_APP.notifications.by_object_id(obj_id1) is None + assert NC_APP.notifications.by_object_id(obj_id2) + assert NC_APP.notifications.exists([ntf1.notification_id, ntf2.notification_id]) == [ntf2.notification_id] + NC_APP.notifications.delete(ntf2.notification_id) + + +def test_create_invalid_args(): + with pytest.raises(ValueError): + NC_APP.notifications.create("") + + +def test_get_one(): + NC_APP.notifications.delete_all() + obj_id1 = NC_APP.notifications.create("subject0123") + obj_id2 = NC_APP.notifications.create("subject0123") + ntf1 = NC_APP.notifications.by_object_id(obj_id1) + ntf2 = NC_APP.notifications.by_object_id(obj_id2) + ntf1_2 = NC_APP.notifications.get_one(ntf1.notification_id) + ntf2_2 = NC_APP.notifications.get_one(ntf2.notification_id) + assert ntf1 == ntf1_2 + assert ntf2 == ntf2_2 diff --git a/tests/talk_test.py b/tests/talk_test.py new file mode 100644 index 00000000..80cb8499 --- /dev/null +++ b/tests/talk_test.py @@ -0,0 +1,100 @@ +import contextlib + +import pytest +from gfixture import NC, NC_TO_TEST +from users_test import TEST_USER_NAME, TEST_USER_PASSWORD + +from nc_py_api import Nextcloud, NextcloudException, talk + +if NC_TO_TEST and NC_TO_TEST[0].talk.available is False: + pytest.skip("Nextcloud Talk is not installed.", allow_module_level=True) + + +@pytest.mark.parametrize("nc", NC_TO_TEST) +def test_available(nc): + assert nc.talk.available + + +@pytest.mark.parametrize("nc", NC_TO_TEST) +def test_conversation_create_delete(nc): + conversation = nc.talk.create_conversation(talk.ConversationType.GROUP, "admin") + nc.talk.delete_conversation(conversation) + assert isinstance(conversation.conversation_id, int) + assert isinstance(conversation.token, str) and conversation.token + assert isinstance(conversation.conversation_type, talk.ConversationType) + assert isinstance(conversation.name, str) + assert isinstance(conversation.display_name, str) + assert isinstance(conversation.description, str) + assert isinstance(conversation.participant_type, talk.ParticipantType) + assert isinstance(conversation.attendee_id, int) + assert isinstance(conversation.attendee_pin, str) + assert isinstance(conversation.actor_type, str) + assert isinstance(conversation.actor_id, str) + assert isinstance(conversation.permissions, talk.AttendeePermissions) + assert isinstance(conversation.attendee_permissions, talk.AttendeePermissions) + assert isinstance(conversation.call_permissions, talk.AttendeePermissions) + assert isinstance(conversation.default_permissions, talk.AttendeePermissions) + assert isinstance(conversation.participant_flags, talk.InCallFlags) + assert isinstance(conversation.read_only, bool) + assert isinstance(conversation.listable, talk.ListableScope) + assert isinstance(conversation.message_expiration, int) + assert isinstance(conversation.has_password, bool) + assert isinstance(conversation.has_call, bool) + assert isinstance(conversation.call_flag, talk.InCallFlags) + assert isinstance(conversation.can_start_call, bool) + assert isinstance(conversation.can_delete_conversation, bool) + assert isinstance(conversation.can_leave_conversation, bool) + assert isinstance(conversation.last_activity, int) + assert isinstance(conversation.is_favorite, bool) + assert isinstance(conversation.notification_level, talk.NotificationLevel) + assert isinstance(conversation.lobby_state, talk.WebinarLobbyStates) + assert isinstance(conversation.lobby_timer, int) + assert isinstance(conversation.sip_enabled, talk.SipEnabledStatus) + assert isinstance(conversation.can_enable_sip, bool) + assert isinstance(conversation.unread_messages_count, int) + assert isinstance(conversation.unread_mention, bool) + assert isinstance(conversation.unread_mention_direct, bool) + assert isinstance(conversation.last_read_message, int) + assert isinstance(conversation.breakout_room_mode, talk.BreakoutRoomMode) + assert isinstance(conversation.breakout_room_status, talk.BreakoutRoomStatus) + assert isinstance(conversation.avatar_version, str) + assert isinstance(conversation.is_custom_avatar, bool) + assert isinstance(conversation.call_start_time, int) + assert isinstance(conversation.recording_status, talk.CallRecordingStatus) + + +@pytest.mark.parametrize("nc", NC_TO_TEST) +def test_get_conversations_modified_since(nc): + conversation = nc.talk.create_conversation(talk.ConversationType.GROUP, "admin") + try: + conversations = nc.talk.get_user_conversations() + assert conversations + nc.talk.modified_since += 1 # read notes for ``modified_since`` param in docs. + conversations = nc.talk.get_user_conversations(modified_since=True) + assert not conversations + conversations = nc.talk.get_user_conversations(modified_since=9992708529, no_status_update=False) + assert not conversations + finally: + nc.talk.delete_conversation(conversation.token) + + +@pytest.mark.parametrize("nc", NC_TO_TEST) +@pytest.mark.skipif(NC is None, reason="Usual Nextcloud mode required for the test") +def test_get_conversations_include_status(nc): + with contextlib.suppress(NextcloudException): + NC.users.create(TEST_USER_NAME, password=TEST_USER_PASSWORD) + nc_second_user = Nextcloud(nc_auth_user=TEST_USER_NAME, nc_auth_pass=TEST_USER_PASSWORD) + nc_second_user.user_status.set_status_type("away") + conversation = nc.talk.create_conversation(talk.ConversationType.ONE_TO_ONE, TEST_USER_NAME) + try: + conversations = nc.talk.get_user_conversations(include_status=False) + assert conversations + first_conv = [i for i in conversations if i.conversation_id == conversation.conversation_id][0] + assert not first_conv.status_type + conversations = nc.talk.get_user_conversations(include_status=True) + assert conversations + first_conv = [i for i in conversations if i.conversation_id == conversation.conversation_id][0] + assert first_conv.status_type == "away" + finally: + nc.talk.leave_conversation(conversation.token) + NC.users.delete(TEST_USER_NAME) diff --git a/tests/users_status_test.py b/tests/user_status_test.py similarity index 57% rename from tests/users_status_test.py rename to tests/user_status_test.py index 9b2cbcda..a5215a24 100644 --- a/tests/users_status_test.py +++ b/tests/user_status_test.py @@ -3,47 +3,47 @@ import pytest from gfixture import NC_TO_TEST, NC_VERSION -from nc_py_api.users.status import ClearAt, UserStatus +from nc_py_api.user_status import ClearAt, UserStatus @pytest.mark.parametrize("nc", NC_TO_TEST) def test_available(nc): - assert nc.users.status.available + assert nc.user_status.available def compare_user_statuses(p1: UserStatus, p2: UserStatus): assert p1.user_id == p2.user_id - assert p1.message == p2.message - assert p1.icon == p2.icon - assert p1.clear_at == p2.clear_at + assert p1.status_message == p2.status_message + assert p1.status_icon == p2.status_icon + assert p1.status_clear_at == p2.status_clear_at assert p1.status_type == p2.status_type @pytest.mark.parametrize("nc", NC_TO_TEST) @pytest.mark.parametrize("message", ("1 2 3", None, "")) def test_get_status(nc, message): - nc.users.status.set_status(message) - r1 = nc.users.status.get_current() - r2 = nc.users.status.get(nc.user) + nc.user_status.set_status(message) + r1 = nc.user_status.get_current() + r2 = nc.user_status.get(nc.user) compare_user_statuses(r1, r2) assert r1.user_id == "admin" - assert r1.icon is None - assert r1.clear_at is None + assert r1.status_icon is None + assert r1.status_clear_at is None if message == "": message = None - assert r1.message == message + assert r1.status_message == message assert r1.status_id is None - assert not r1.predefined + assert not r1.message_predefined @pytest.mark.parametrize("nc", NC_TO_TEST) def test_get_status_non_existent_user(nc): - assert nc.users.status.get("no such user") is None + assert nc.user_status.get("no such user") is None @pytest.mark.parametrize("nc", NC_TO_TEST) def test_get_predefined(nc): - r = nc.users.status.get_predefined() + r = nc.user_status.get_predefined() if nc.srv_version["major"] < 27: assert r == [] else: @@ -58,10 +58,10 @@ def test_get_predefined(nc): @pytest.mark.parametrize("nc", NC_TO_TEST) def test_get_list(nc): - r_all = nc.users.status.get_list() + r_all = nc.user_status.get_list() assert r_all assert isinstance(r_all, list) - r_current = nc.users.status.get_current() + r_current = nc.user_status.get_current() for i in r_all: if i.user_id == nc.user: compare_user_statuses(i, r_current) @@ -70,28 +70,28 @@ def test_get_list(nc): @pytest.mark.parametrize("nc", NC_TO_TEST) def test_set_status(nc): time_clear = int(time()) + 60 - nc.users.status.set_status("cool status", time_clear) - r = nc.users.status.get_current() - assert r.message == "cool status" - assert r.clear_at == time_clear - assert r.icon is None - nc.users.status.set_status("Sick!", status_icon="🤒") - r = nc.users.status.get_current() - assert r.message == "Sick!" - assert r.clear_at is None - assert r.icon == "🤒" - nc.users.status.set_status(None) - r = nc.users.status.get_current() - assert r.message is None - assert r.clear_at is None - assert r.icon is None + nc.user_status.set_status("cool status", time_clear) + r = nc.user_status.get_current() + assert r.status_message == "cool status" + assert r.status_clear_at == time_clear + assert r.status_icon is None + nc.user_status.set_status("Sick!", status_icon="🤒") + r = nc.user_status.get_current() + assert r.status_message == "Sick!" + assert r.status_clear_at is None + assert r.status_icon == "🤒" + nc.user_status.set_status(None) + r = nc.user_status.get_current() + assert r.status_message is None + assert r.status_clear_at is None + assert r.status_icon is None @pytest.mark.parametrize("nc", NC_TO_TEST) @pytest.mark.parametrize("value", ("online", "away", "dnd", "invisible", "offline")) def test_set_status_type(nc, value): - nc.users.status.set_status_type(value) - r = nc.users.status.get_current() + nc.user_status.set_status_type(value) + r = nc.user_status.get_current() assert r.status_type == value assert r.status_type_defined @@ -100,16 +100,16 @@ def test_set_status_type(nc, value): @pytest.mark.parametrize("clear_at", (None, int(time()) + 360)) def test_set_predefined(nc, clear_at): if nc.srv_version["major"] < 27: - nc.users.status.set_predefined("meeting") + nc.user_status.set_predefined("meeting") else: - predefined_statuses = nc.users.status.get_predefined() + predefined_statuses = nc.user_status.get_predefined() for i in predefined_statuses: - nc.users.status.set_predefined(i.status_id, clear_at) - r = nc.users.status.get_current() - assert r.message == i.message + nc.user_status.set_predefined(i.status_id, clear_at) + r = nc.user_status.get_current() + assert r.status_message == i.message assert r.status_id == i.status_id - assert r.predefined - assert r.clear_at == clear_at + assert r.message_predefined + assert r.status_clear_at == clear_at @pytest.mark.parametrize("nc", NC_TO_TEST) @@ -119,7 +119,7 @@ def test_get_back_status_from_from_empty_user(nc): nc._session.user = "" try: with pytest.raises(ValueError): - nc.users.status.get_backup_status("") + nc.user_status.get_backup_status("") finally: nc._session.user = orig_user @@ -127,10 +127,10 @@ def test_get_back_status_from_from_empty_user(nc): @pytest.mark.parametrize("nc", NC_TO_TEST) @pytest.mark.skipif(NC_VERSION["major"] < 27, reason="Run only on NC27+") def test_get_back_status_from_from_non_exist_user(nc): - assert nc.users.status.get_backup_status("mёm_m-m.l") is None + assert nc.user_status.get_backup_status("mёm_m-m.l") is None @pytest.mark.parametrize("nc", NC_TO_TEST) @pytest.mark.skipif(NC_VERSION["major"] < 27, reason="Run only on NC27+") def test_restore_from_non_existing_back_status(nc): - assert nc.users.status.restore_backup_status("no such backup status") is None + assert nc.user_status.restore_backup_status("no such backup status") is None diff --git a/tests/users_groups_test.py b/tests/users_groups_test.py index 7b425e4e..bdeffcb9 100644 --- a/tests/users_groups_test.py +++ b/tests/users_groups_test.py @@ -4,7 +4,7 @@ from gfixture import NC_APP, NC_TO_TEST from nc_py_api import Nextcloud, NextcloudException -from nc_py_api.users.groups import GroupDetails +from nc_py_api.users_groups import GroupDetails TEST_GROUP_NAME = "test_coverage_group1" TEST_GROUP_NAME2 = "test_coverage_group2" @@ -16,13 +16,13 @@ def test_create_delete_group(nc, params): test_group_name = params[0] with contextlib.suppress(NextcloudException): - nc.users.groups.delete(test_group_name) - nc.users.groups.create(*params) + nc.users_groups.delete(test_group_name) + nc.users_groups.create(*params) with pytest.raises(NextcloudException): - nc.users.groups.create(*params) - nc.users.groups.delete(test_group_name) + nc.users_groups.create(*params) + nc.users_groups.delete(test_group_name) with pytest.raises(NextcloudException): - nc.users.groups.delete(test_group_name) + nc.users_groups.delete(test_group_name) @pytest.mark.skipif(not isinstance(NC_TO_TEST[:1][0], Nextcloud), reason="Not available for NextcloudApp.") @@ -30,25 +30,25 @@ def test_create_delete_group(nc, params): def test_group_get_list(nc): for i in (TEST_GROUP_NAME, TEST_GROUP_NAME2): with contextlib.suppress(NextcloudException): - nc.users.groups.create(i) - groups = nc.users.groups.get_list() + nc.users_groups.create(i) + groups = nc.users_groups.get_list() assert isinstance(groups, list) assert len(groups) >= 2 assert TEST_GROUP_NAME in groups assert TEST_GROUP_NAME2 in groups - groups = nc.users.groups.get_list(mask=TEST_GROUP_NAME) + groups = nc.users_groups.get_list(mask=TEST_GROUP_NAME) assert len(groups) == 1 - groups = nc.users.groups.get_list(limit=1) + groups = nc.users_groups.get_list(limit=1) assert len(groups) == 1 - assert groups[0] != nc.users.groups.get_list(limit=1, offset=1)[0] - nc.users.groups.delete(TEST_GROUP_NAME) - nc.users.groups.delete(TEST_GROUP_NAME2) + assert groups[0] != nc.users_groups.get_list(limit=1, offset=1)[0] + nc.users_groups.delete(TEST_GROUP_NAME) + nc.users_groups.delete(TEST_GROUP_NAME2) @pytest.mark.skipif(not isinstance(NC_TO_TEST[:1][0], Nextcloud), reason="Not available for NextcloudApp.") @pytest.mark.parametrize("nc", NC_TO_TEST[:1]) def test_get_non_existing_group(nc): - groups = nc.users.groups.get_list(mask="Such group should not be present") + groups = nc.users_groups.get_list(mask="Such group should not be present") assert isinstance(groups, list) assert not groups @@ -57,10 +57,10 @@ def test_get_non_existing_group(nc): @pytest.mark.parametrize("nc", NC_TO_TEST[:1]) def test_group_get_details(nc): with contextlib.suppress(NextcloudException): - nc.users.groups.delete(TEST_GROUP_NAME) + nc.users_groups.delete(TEST_GROUP_NAME) with contextlib.suppress(NextcloudException): - nc.users.groups.create(TEST_GROUP_NAME) - groups = nc.users.groups.get_details(mask=TEST_GROUP_NAME) + nc.users_groups.create(TEST_GROUP_NAME) + groups = nc.users_groups.get_details(mask=TEST_GROUP_NAME) assert len(groups) == 1 group = groups[0] assert group.group_id == TEST_GROUP_NAME @@ -69,19 +69,19 @@ def test_group_get_details(nc): assert isinstance(group.user_count, int) assert isinstance(group.can_add, bool) assert isinstance(group.can_remove, bool) - nc.users.groups.delete(TEST_GROUP_NAME) + nc.users_groups.delete(TEST_GROUP_NAME) @pytest.mark.skipif(not isinstance(NC_TO_TEST[:1][0], Nextcloud), reason="Not available for NextcloudApp.") @pytest.mark.parametrize("nc", NC_TO_TEST[:1]) def test_group_edit(nc): with contextlib.suppress(NextcloudException): - nc.users.groups.create(TEST_GROUP_NAME) - nc.users.groups.edit(TEST_GROUP_NAME, display_name="earth people") - assert nc.users.groups.get_details(mask=TEST_GROUP_NAME)[0].display_name == "earth people" - nc.users.groups.delete(TEST_GROUP_NAME) + nc.users_groups.create(TEST_GROUP_NAME) + nc.users_groups.edit(TEST_GROUP_NAME, display_name="earth people") + assert nc.users_groups.get_details(mask=TEST_GROUP_NAME)[0].display_name == "earth people" + nc.users_groups.delete(TEST_GROUP_NAME) with pytest.raises(NextcloudException) as exc_info: - nc.users.groups.edit(TEST_GROUP_NAME, display_name="earth people") + nc.users_groups.edit(TEST_GROUP_NAME, display_name="earth people") # remove 996 in the future, PR was already accepted in Nextcloud Server assert exc_info.value.status_code in ( 404, @@ -93,43 +93,43 @@ def test_group_edit(nc): @pytest.mark.parametrize("nc", NC_TO_TEST[:1]) def test_group_members_promote_demote(nc): with contextlib.suppress(NextcloudException): - nc.users.groups.create(TEST_GROUP_NAME) - group_members = nc.users.groups.get_members(TEST_GROUP_NAME) + nc.users_groups.create(TEST_GROUP_NAME) + group_members = nc.users_groups.get_members(TEST_GROUP_NAME) assert not group_members assert isinstance(group_members, list) - group_subadmins = nc.users.groups.get_subadmins(TEST_GROUP_NAME) + group_subadmins = nc.users_groups.get_subadmins(TEST_GROUP_NAME) assert isinstance(group_subadmins, list) assert not group_subadmins try: with contextlib.suppress(NextcloudException): nc.users.create("test_group_user", password="test_group_user") nc.users.add_to_group("test_group_user", TEST_GROUP_NAME) - group_members = nc.users.groups.get_members(TEST_GROUP_NAME) + group_members = nc.users_groups.get_members(TEST_GROUP_NAME) assert group_members assert isinstance(group_members[0], str) - group_subadmins = nc.users.groups.get_subadmins(TEST_GROUP_NAME) + group_subadmins = nc.users_groups.get_subadmins(TEST_GROUP_NAME) assert not group_subadmins nc.users.promote_to_subadmin("test_group_user", TEST_GROUP_NAME) - group_subadmins = nc.users.groups.get_subadmins(TEST_GROUP_NAME) + group_subadmins = nc.users_groups.get_subadmins(TEST_GROUP_NAME) assert group_subadmins assert isinstance(group_subadmins[0], str) nc.users.demote_from_subadmin("test_group_user", TEST_GROUP_NAME) - group_subadmins = nc.users.groups.get_subadmins(TEST_GROUP_NAME) + group_subadmins = nc.users_groups.get_subadmins(TEST_GROUP_NAME) assert not group_subadmins nc.users.remove_from_group("test_group_user", TEST_GROUP_NAME) - group_members = nc.users.groups.get_members(TEST_GROUP_NAME) + group_members = nc.users_groups.get_members(TEST_GROUP_NAME) assert not group_members finally: - nc.users.groups.delete(TEST_GROUP_NAME) + nc.users_groups.delete(TEST_GROUP_NAME) with contextlib.suppress(NextcloudException): nc.users.delete("test_group_user") @pytest.mark.skipif(NC_APP is None, reason="Test only for NextcloudApp.") def test_app_mode(): - groups_list = NC_APP.users.groups.get_list() + groups_list = NC_APP.users_groups.get_list() assert isinstance(groups_list, list) - groups_detailed_list = NC_APP.users.groups.get_details() + groups_detailed_list = NC_APP.users_groups.get_details() assert isinstance(groups_detailed_list, list) for i in groups_detailed_list: assert isinstance(i, GroupDetails) diff --git a/tests/users_notifications_test.py b/tests/users_notifications_test.py deleted file mode 100644 index 494acf2b..00000000 --- a/tests/users_notifications_test.py +++ /dev/null @@ -1,83 +0,0 @@ -import pytest -from gfixture import NC, NC_APP - -from nc_py_api.users.notifications import Notification, NotificationInfo - -if NC_APP is None or "app_ecosystem_v2" not in NC_APP.capabilities: - pytest.skip("app_ecosystem_v2 is not installed.", allow_module_level=True) - - -def test_available(): - assert NC_APP.users.notifications.available - - -@pytest.mark.skipif(NC is None, reason="Not available for NextcloudApp.") -def test_create_as_client(): - with pytest.raises(NotImplementedError): - NC.users.notifications.create("caption") - - -def test_create(): - obj_id = NC_APP.users.notifications.create("subject0123", "message456") - new_notification = NC_APP.users.notifications.by_object_id(obj_id) - assert isinstance(new_notification, Notification) - assert isinstance(new_notification.info, NotificationInfo) - assert new_notification.info.subject == "subject0123" - assert new_notification.info.message == "message456" - assert new_notification.info.icon - assert not new_notification.info.link - - -def test_create_link_icon(): - obj_id = NC_APP.users.notifications.create("1", "", link="https://some.link/gg") - new_notification = NC_APP.users.notifications.by_object_id(obj_id) - assert isinstance(new_notification, Notification) - assert isinstance(new_notification.info, NotificationInfo) - assert new_notification.info.subject == "1" - assert not new_notification.info.message - assert new_notification.info.icon - assert new_notification.info.link == "https://some.link/gg" - - -def test_delete_all(): - NC_APP.users.notifications.create("subject0123", "message456") - obj_id1 = NC_APP.users.notifications.create("subject0123", "message456") - ntf1 = NC_APP.users.notifications.by_object_id(obj_id1) - assert ntf1 - obj_id2 = NC_APP.users.notifications.create("subject0123", "message456") - ntf2 = NC_APP.users.notifications.by_object_id(obj_id2) - assert ntf2 - NC_APP.users.notifications.delete_all() - assert NC_APP.users.notifications.by_object_id(obj_id1) is None - assert NC_APP.users.notifications.by_object_id(obj_id2) is None - assert not NC_APP.users.notifications.get_all() - assert not NC_APP.users.notifications.exists([ntf1.notification_id, ntf2.notification_id]) - - -def test_delete_one(): - obj_id1 = NC_APP.users.notifications.create("subject0123") - obj_id2 = NC_APP.users.notifications.create("subject0123") - ntf1 = NC_APP.users.notifications.by_object_id(obj_id1) - ntf2 = NC_APP.users.notifications.by_object_id(obj_id2) - NC_APP.users.notifications.delete(ntf1.notification_id) - assert NC_APP.users.notifications.by_object_id(obj_id1) is None - assert NC_APP.users.notifications.by_object_id(obj_id2) - assert NC_APP.users.notifications.exists([ntf1.notification_id, ntf2.notification_id]) == [ntf2.notification_id] - NC_APP.users.notifications.delete(ntf2.notification_id) - - -def test_create_invalid_args(): - with pytest.raises(ValueError): - NC_APP.users.notifications.create("") - - -def test_get_one(): - NC_APP.users.notifications.delete_all() - obj_id1 = NC_APP.users.notifications.create("subject0123") - obj_id2 = NC_APP.users.notifications.create("subject0123") - ntf1 = NC_APP.users.notifications.by_object_id(obj_id1) - ntf2 = NC_APP.users.notifications.by_object_id(obj_id2) - ntf1_2 = NC_APP.users.notifications.get_one(ntf1.notification_id) - ntf2_2 = NC_APP.users.notifications.get_one(ntf2.notification_id) - assert ntf1 == ntf1_2 - assert ntf2 == ntf2_2 diff --git a/tests/users_test.py b/tests/users_test.py index 8ffd2c45..c4d56ce9 100644 --- a/tests/users_test.py +++ b/tests/users_test.py @@ -54,7 +54,7 @@ def test_create_user_with_groups(nc): with contextlib.suppress(NextcloudException): nc.users.delete(TEST_USER_NAME) nc.users.create(TEST_USER_NAME, password=TEST_USER_PASSWORD, groups=["admin"]) - admin_group = nc.users.groups.get_members("admin") + admin_group = nc.users_groups.get_members("admin") assert TEST_USER_NAME in admin_group nc.users.delete(TEST_USER_NAME) diff --git a/tests/users_weather_test.py b/tests/weather_status_test.py similarity index 56% rename from tests/users_weather_test.py rename to tests/weather_status_test.py index 7a3ac37d..de93bd33 100644 --- a/tests/users_weather_test.py +++ b/tests/weather_status_test.py @@ -2,43 +2,43 @@ from gfixture import NC_TO_TEST from nc_py_api import NextcloudException -from nc_py_api.users.weather import WeatherLocationMode +from nc_py_api.weather_status import WeatherLocationMode @pytest.mark.parametrize("nc", NC_TO_TEST) def test_available(nc): - assert nc.users.weather.available + assert nc.weather_status.available @pytest.mark.parametrize("nc", NC_TO_TEST) def test_get_set_location(nc): - nc.users.weather.set_location(longitude=0.0, latitude=0.0) - loc = nc.users.weather.get_location() + nc.weather_status.set_location(longitude=0.0, latitude=0.0) + loc = nc.weather_status.get_location() assert loc.latitude == 0.0 assert loc.longitude == 0.0 assert isinstance(loc.address, str) assert isinstance(loc.mode, int) try: - assert nc.users.weather.set_location(address="Paris, 75007, France") + assert nc.weather_status.set_location(address="Paris, 75007, France") except NextcloudException as e: if e.status_code in (500, 996): pytest.skip("Some network problem on the host") raise e from None - loc = nc.users.weather.get_location() + loc = nc.weather_status.get_location() assert loc.latitude assert loc.longitude if loc.address.find("Unknown") != -1: pytest.skip("Some network problem on the host") assert loc.address.find("Paris") != -1 - assert nc.users.weather.set_location(latitude=41.896655, longitude=12.488776) - loc = nc.users.weather.get_location() + assert nc.weather_status.set_location(latitude=41.896655, longitude=12.488776) + loc = nc.weather_status.get_location() assert loc.latitude == 41.896655 assert loc.longitude == 12.488776 if loc.address.find("Unknown") != -1: pytest.skip("Some network problem on the host") assert loc.address.find("Rom") != -1 - assert nc.users.weather.set_location(latitude=41.896655, longitude=12.488776, address="Paris, France") - loc = nc.users.weather.get_location() + assert nc.weather_status.set_location(latitude=41.896655, longitude=12.488776, address="Paris, France") + loc = nc.weather_status.get_location() assert loc.latitude == 41.896655 assert loc.longitude == 12.488776 if loc.address.find("Unknown") != -1: @@ -49,15 +49,15 @@ def test_get_set_location(nc): @pytest.mark.parametrize("nc", NC_TO_TEST) def test_get_set_location_no_lat_lon_address(nc): with pytest.raises(ValueError): - nc.users.weather.set_location() + nc.weather_status.set_location() @pytest.mark.parametrize("nc", NC_TO_TEST) def test_get_forecast(nc): - nc.users.weather.set_location(latitude=41.896655, longitude=12.488776) - if nc.users.weather.get_location().address.find("Unknown") != -1: + nc.weather_status.set_location(latitude=41.896655, longitude=12.488776) + if nc.weather_status.get_location().address.find("Unknown") != -1: pytest.skip("Some network problem on the host") - forecast = nc.users.weather.get_forecast() + forecast = nc.weather_status.get_forecast() assert isinstance(forecast, list) assert forecast assert isinstance(forecast[0], dict) @@ -65,27 +65,27 @@ def test_get_forecast(nc): @pytest.mark.parametrize("nc", NC_TO_TEST) def test_get_set_favorites(nc): - nc.users.weather.set_favorites([]) - r = nc.users.weather.get_favorites() + nc.weather_status.set_favorites([]) + r = nc.weather_status.get_favorites() assert isinstance(r, list) assert not r - nc.users.weather.set_favorites(["Paris, France", "Madrid, Spain"]) - r = nc.users.weather.get_favorites() + nc.weather_status.set_favorites(["Paris, France", "Madrid, Spain"]) + r = nc.weather_status.get_favorites() assert any("Paris" in x for x in r) assert any("Madrid" in x for x in r) @pytest.mark.parametrize("nc", NC_TO_TEST) def test_set_mode(nc): - nc.users.weather.set_mode(WeatherLocationMode.MODE_BROWSER_LOCATION) - assert nc.users.weather.get_location().mode == WeatherLocationMode.MODE_BROWSER_LOCATION.value - nc.users.weather.set_mode(WeatherLocationMode.MODE_MANUAL_LOCATION) - assert nc.users.weather.get_location().mode == WeatherLocationMode.MODE_MANUAL_LOCATION.value + nc.weather_status.set_mode(WeatherLocationMode.MODE_BROWSER_LOCATION) + assert nc.weather_status.get_location().mode == WeatherLocationMode.MODE_BROWSER_LOCATION.value + nc.weather_status.set_mode(WeatherLocationMode.MODE_MANUAL_LOCATION) + assert nc.weather_status.get_location().mode == WeatherLocationMode.MODE_MANUAL_LOCATION.value @pytest.mark.parametrize("nc", NC_TO_TEST) def test_set_mode_invalid(nc): with pytest.raises(ValueError): - nc.users.weather.set_mode(WeatherLocationMode.UNKNOWN) + nc.weather_status.set_mode(WeatherLocationMode.UNKNOWN) with pytest.raises(ValueError): - nc.users.weather.set_mode(0) + nc.weather_status.set_mode(0)