├── tests ├── __init__.py ├── docker │ ├── Dockerfile │ ├── .env │ ├── docker-compose.yml │ └── README.md ├── base.py ├── helpers.py ├── constants.py ├── test_wipe.py ├── test_helpers.py ├── test_loginflow.py ├── test_maps.py ├── test_notifications.py ├── test_groups.py ├── test_ldap.py ├── test_shares.py ├── test_apps.py ├── test_status.py ├── test_ocs_general.py ├── test_groupfolders.py └── test_users.py ├── setup.py ├── .jenkins ├── Dockerfile-flake8 └── Dockerfile-pydocstyle ├── setup.cfg ├── CHANGELOG.md ├── pyproject.toml ├── Jenkinsfile ├── README.md ├── nextcloud_async ├── api │ ├── ocs │ │ ├── talk │ │ │ ├── exceptions.py │ │ │ ├── constants.py │ │ │ └── rich_objects.py │ │ ├── notifications.py │ │ ├── apps.py │ │ ├── ldap.py │ │ ├── groups.py │ │ ├── status.py │ │ ├── __init__.py │ │ ├── groupfolders.py │ │ ├── shares.py │ │ └── users.py │ ├── wipe.py │ ├── dav │ │ ├── __init__.py │ │ └── files.py │ ├── maps.py │ ├── __init__.py │ └── loginflow.py ├── helpers.py ├── exceptions.py └── __init__.py └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setuptools Dev Patch.""" 2 | import setuptools 3 | setuptools.setup() 4 | -------------------------------------------------------------------------------- /.jenkins/Dockerfile-flake8: -------------------------------------------------------------------------------- 1 | FROM python:3.10.5-bullseye 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y flake8 5 | 6 | COPY . /data 7 | 8 | ENTRYPOINT ["bash", "-c"] 9 | -------------------------------------------------------------------------------- /tests/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NEXTCLOUD_VERSION 2 | 3 | FROM nextcloud:$NEXTCLOUD_VERSION-apache 4 | 5 | ARG GROUPFOLDERS_URL 6 | ADD ${GROUPFOLDERS_URL} /tmp/ 7 | 8 | ARG GROUPFOLDERS_ARCHIVE_NAME 9 | RUN true \ 10 | && tar xf /tmp/$GROUPFOLDERS_ARCHIVE_NAME -C /tmp/ \ 11 | && true 12 | -------------------------------------------------------------------------------- /.jenkins/Dockerfile-pydocstyle: -------------------------------------------------------------------------------- 1 | FROM python:3.10.5-bullseye 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y pydocstyle python3-setuptools && \ 5 | python3.10 -mpip install docstr-coverage 6 | 7 | COPY . /data 8 | 9 | ENTRYPOINT ["bash", "-c"] 10 | CMD ["pydocstyle", "/data"] 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | max-line-length = 95 3 | 4 | [flake8] 5 | exclude = .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs,*.egg,node_modules,*venv*,temp.py 6 | max-line-length = 95 7 | count = true 8 | showpep8 = false 9 | show-source = false 10 | statistics = true 11 | ignore = E227 12 | 13 | [pydocstyle] 14 | ignore = D107,D203,D213,D105 15 | -------------------------------------------------------------------------------- /tests/docker/.env: -------------------------------------------------------------------------------- 1 | export NEXTCLOUD_VERSION="24" 2 | export GROUPFOLDERS_VERSION="11.1.2" 3 | export GROUPFOLDERS_ARCHIVE_NAME="groupfolders.tar.gz" 4 | export GROUPFOLDERS_URL="https://github.com/nextcloud/groupfolders/releases/download/v${GROUPFOLDERS_VERSION}/${GROUPFOLDERS_ARCHIVE_NAME}" 5 | export NEXTCLOUD_HOSTNAME="app" 6 | export NEXTCLOUD_ADMIN_USER="admin" 7 | export NEXTCLOUD_ADMIN_PASSWORD="admin" 8 | 9 | export NEXTCLOUD_HOST="app" 10 | export NEXTCLOUD_PORT="8181" 11 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | """Credit: https://github.com/luffah/nextcloud-API""" 2 | from .constants import ENDPOINT, USER, PASSWORD 3 | 4 | import httpx 5 | 6 | from unittest import TestCase 7 | 8 | from nextcloud_async import NextCloudAsync 9 | 10 | 11 | class BaseTestCase(TestCase): 12 | 13 | def setUp(self): 14 | self.ncc = NextCloudAsync( 15 | client=httpx.AsyncClient(), 16 | endpoint=ENDPOINT, 17 | user=USER, 18 | password=PASSWORD) 19 | -------------------------------------------------------------------------------- /tests/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | nextcloud: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | args: 9 | NEXTCLOUD_VERSION: ${NEXTCLOUD_VERSION} 10 | GROUPFOLDERS_URL: ${GROUPFOLDERS_URL} 11 | GROUPFOLDERS_ARCHIVE_NAME: ${GROUPFOLDERS_ARCHIVE_NAME} 12 | ports: 13 | - 8181:80 14 | environment: 15 | - SQLITE_DATABASE=nextcloud 16 | - NEXTCLOUD_TRUSTED_DOMAINS=${NEXTCLOUD_HOSTNAME} 17 | - NEXTCLOUD_ADMIN_USER 18 | - NEXTCLOUD_ADMIN_PASSWORD 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v0.0.4 -> v0.0.5 2 | * Talk: bug fixes (add asyncs) 3 | 4 | v0.0.3 -> v0.0.4 5 | * Talk: add missing awaits, returns and stubs 6 | * Talk.NextcloudRichObject: Remove link prop because it is not required (id, name and path are required) 7 | * Talk.NextcloudRichObject: Can initialize with other properties 8 | * Talk.NextcloudRichObject: Check for props if are on the list of allowed props 9 | * Talk.NextcloudRichObject: Avoid repeat metadata (already in dict) 10 | 11 | v0.0.2 -> v0.0.3 12 | * Fixes some talk functions 13 | * ocs_query defaults to GET method 14 | -------------------------------------------------------------------------------- /tests/docker/README.md: -------------------------------------------------------------------------------- 1 | # Docker things 2 | 3 | ## ---- Deprecated ---- 4 | 5 | Borrowed, hacked, and mangled from https://github.com/luffah/nextcloud-API 6 | 7 | These are here to facilitate quick setup of docker nextcloud containers. 8 | 9 | $ vi source .env 10 | 11 | Set your desired NEXTCLOUD_VERSION and GROUPFOLDERS_VERSION if you want 12 | to use Group Folders. Also make sure NEXTCLOUD_PORT is available for use. 13 | 14 | $ source .env 15 | $ docker-compose -d up 16 | 17 | You can now access your nextcloud on http://localhost:${NEXTCLOUD_PORT}/ 18 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from unittest import mock 4 | 5 | 6 | class AsyncContextManager(mock.MagicMock): 7 | async def __aenter__(self, *args, **kwargs): 8 | return self.__enter__(*args, **kwargs) 9 | 10 | async def __aexit__(self, *args, **kwargs): 11 | return self.__exit__(*args, **kwargs) 12 | 13 | 14 | class AsyncMock(mock.MagicMock): 15 | async def __call__(self, *args, **kwargs): 16 | return super().__call__(*args, **kwargs) 17 | 18 | 19 | def _run(coro): 20 | return asyncio.get_event_loop().run_until_complete(coro) 21 | -------------------------------------------------------------------------------- /tests/constants.py: -------------------------------------------------------------------------------- 1 | USER = 'dk' 2 | NAME = 'Darren King' 3 | PASSWORD = 'RIP MUTEMATH' 4 | EMAIL = 'IAmATree@GuidedByVoices.com' 5 | FILE = 'MatthewSweet.md' 6 | 7 | ENDPOINT = 'https://cloud.example.com' 8 | 9 | EMPTY_100 = bytes( 10 | '{"ocs":{"meta":{"status":"ok","statuscode":100,"message":"OK",' 11 | '"totalitems":"","itemsperpage":""},"data":[]}}', 'utf-8') 12 | 13 | EMPTY_200 = bytes( 14 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK",' 15 | '"totalitems":"","itemsperpage":""},"data":[]}}', 'utf-8') 16 | 17 | SIMPLE_100 = '{{"ocs":{{"meta":{{"status":"ok","statuscode":100,"message":"OK",'\ 18 | '"totalitems":"","itemsperpage":""}},"data": {0} }}}}' 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "nextcloud_async" 7 | version = "0.0.10" 8 | authors = [ 9 | { name="Aaron Segura" }, 10 | ] 11 | description = "Asynchronous client library for Nextcloud" 12 | readme = "README.md" 13 | license = { text="License :: OSI Approved :: GNU General Public License v3 (GPLv3)" } 14 | requires-python = ">=3.10" 15 | classifiers = [ 16 | "Development Status :: 3 - Alpha", 17 | "Framework :: Flake8", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 20 | "Natural Language :: English", 21 | "Programming Language :: Python :: 3.10" 22 | ] 23 | keywords = ["nextcloud", "asynchronous", "spreed"] 24 | dependencies = ["httpx", "xmltodict", "platformdirs", "PyNaCl"] 25 | 26 | [project.urls] 27 | "Homepage" = "https://github.com/aaronsegura/nextcloud-async" 28 | "Bug Tracker" = "https://github.com/aaronsegura/nextcloud-async/issues" 29 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent any 3 | 4 | stages { 5 | 6 | stage('Install Package') { 7 | steps { 8 | withPythonEnv('python3.10') { 9 | sh 'python3.10 -mpip install .' 10 | } 11 | } 12 | } 13 | 14 | stage('Run Source Code Tests') { 15 | parallel { 16 | stage('flake8 check') { 17 | agent { 18 | dockerfile { 19 | dir '.jenkins' 20 | filename 'Dockerfile-flake8' 21 | } 22 | } 23 | steps { 24 | withPythonEnv('python3.10') { 25 | sh 'python3.10 -mpip install --target ${WORKSPACE} flake8' 26 | sh 'flake8 --exclude .git,__pycache__,node_modules,.pyenv* --count --statistics --ignore E227 /data' 27 | } 28 | } 29 | } 30 | stage('pydocstyle check') { 31 | agent { 32 | dockerfile { 33 | dir '.jenkins' 34 | filename 'Dockerfile-pydocstyle' 35 | } 36 | } 37 | steps { 38 | sh 'pydocstyle --convention=pep257 --count nextcloud_async || true' 39 | sh 'docstr-coverage -P -m nextcloud_async' 40 | } 41 | } 42 | stage('Unit testing') { 43 | steps { 44 | withPythonEnv('python3.10') { 45 | sh 'python3.10 -m unittest' 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /tests/test_wipe.py: -------------------------------------------------------------------------------- 1 | """Test Nextcloud Remote Wipe API. 2 | 3 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/RemoteWipe/index.html 4 | """ 5 | 6 | from .base import BaseTestCase 7 | from .helpers import AsyncMock 8 | from .constants import USER, ENDPOINT, PASSWORD, EMPTY_200 9 | 10 | import asyncio 11 | import httpx 12 | 13 | from unittest.mock import patch 14 | 15 | 16 | class OCSRemoteWipeAPI(BaseTestCase): # noqa: D101 17 | 18 | def test_get_wipe_status(self): # noqa: D102 19 | with patch( 20 | 'httpx.AsyncClient.request', 21 | new_callable=AsyncMock, 22 | return_value=httpx.Response( 23 | status_code=200, 24 | content=bytes('[]', 'utf-8'))) as mock: 25 | asyncio.run(self.ncc.get_wipe_status()) 26 | mock.assert_called_with( 27 | method='POST', 28 | auth=(USER, PASSWORD), 29 | url=f'{ENDPOINT}/index.php/core/wipe/check', 30 | data={'token': PASSWORD}, 31 | headers={}) 32 | 33 | def test_notify_wipe_status(self): # noqa: D102 34 | with patch( 35 | 'httpx.AsyncClient.request', 36 | new_callable=AsyncMock, 37 | return_value=httpx.Response( 38 | status_code=200, 39 | content=EMPTY_200)) as mock: 40 | asyncio.run(self.ncc.notify_wipe_status()) 41 | mock.assert_called_with( 42 | method='POST', 43 | auth=(USER, PASSWORD), 44 | url=f'{ENDPOINT}/index.php/core/wipe/success', 45 | data={'token': PASSWORD}, 46 | headers={}) 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nextcloud-async 2 | ## Asynchronous Nextcloud Client 3 | 4 | This module provides an asyncio-friendly interface to public NextCloud APIs. 5 | 6 | ### Covered APIs 7 | * File Management API 8 | * User Management API 9 | * Group Management API 10 | * GroupFolders API 11 | * App Management API 12 | * LDAP Configuration API 13 | * Status API 14 | * Share API (except Federated shares) 15 | * Talk/spreed API 16 | * Notifications API 17 | * Login Flow v2 API 18 | * Remote Wipe API 19 | * Maps API 20 | 21 | ### APIs To Do 22 | * Sharee API 23 | * Reaction API 24 | * User Preferences API 25 | * Federated Shares API 26 | * Cookbook API 27 | * Passwords API 28 | * Notes API 29 | * Deck API 30 | * Calendar CalDAV API 31 | * Tasks CalDAV API 32 | * Contacts CardDAV API 33 | 34 | If you know of any APIs missing from this list, please open an issue at 35 | https://github.com/aaronsegura/nextcloud-async/issues with a link to 36 | the API documentation so it can be added. This project aims to eventually 37 | cover any API provided by NextCloud and commonly used NextCloud apps. 38 | 39 | ### Example Usage 40 | import httpx 41 | import asyncio 42 | from nextcloud_async import NextCloudAsync 43 | 44 | nca = NextCloudAsync( 45 | client=httpx.AsyncClient(), 46 | endpoint='http://localhost:8181', 47 | user='user', 48 | password='password') 49 | 50 | async def main(): 51 | users = await nca.get_users() 52 | tasks = [nca.get_user(user) for user in users] 53 | results = await asyncio.gather(*tasks) 54 | for user_info in results: 55 | print(user_info) 56 | 57 | if __name__ == "__main__": 58 | asyncio.run(main()) 59 | 60 | ---- 61 | This project is not endorsed or recognized in any way by the NextCloud 62 | project. 63 | -------------------------------------------------------------------------------- /nextcloud_async/api/ocs/talk/exceptions.py: -------------------------------------------------------------------------------- 1 | """Our own Exception classes.""" 2 | 3 | from nextcloud_async.exceptions import NextCloudException 4 | 5 | 6 | class NextCloudTalkException(NextCloudException): 7 | """Generic Exception.""" 8 | 9 | def __init__(self, status_code: int = None, reason: str = None): 10 | """Configure exception.""" 11 | super(NextCloudException, self).__init__(status_code=status_code, reason=reason) 12 | 13 | 14 | class NextCloudTalkBadRequest(NextCloudTalkException): 15 | """User made a bad request.""" 16 | 17 | status_code = 400 18 | reason = 'User made a bad request.' 19 | 20 | def __init__(self): 21 | """Configure exception.""" 22 | super(NextCloudTalkException, self).__init__() 23 | 24 | 25 | class NextCloudTalkConflict(NextCloudTalkException): 26 | """User has duplicate Talk sessions.""" 27 | 28 | status_code = 409 29 | reason = 'User has duplicate Talk sessions.' 30 | 31 | def __init__(self): 32 | """Configure exception.""" 33 | super(NextCloudTalkException, self).__init__() 34 | 35 | 36 | class NextCloudTalkPreconditionFailed(NextCloudTalkException): 37 | """User tried to join chat room without going to lobby.""" 38 | 39 | status_code = 412 40 | reason = 'User tried to join chat room without going to lobby.' 41 | 42 | def __init__(self): 43 | """Configure exception.""" 44 | super(NextCloudTalkException, self).__init__() 45 | 46 | 47 | class NextCloudTalkNotCapable(NextCloudTalkException): 48 | """Raised when server does not have required capability.""" 49 | 50 | status_code = 499 51 | reason = 'Server does not support required capability.' 52 | 53 | def __init__(self): 54 | """Configure exception.""" 55 | super(NextCloudTalkException, self).__init__() 56 | -------------------------------------------------------------------------------- /nextcloud_async/api/ocs/talk/constants.py: -------------------------------------------------------------------------------- 1 | """Nextcloud Talk Constants. 2 | 3 | https://nextcloud-talk.readthedocs.io/en/latest/constants/ 4 | """ 5 | 6 | from enum import IntFlag, Enum 7 | 8 | 9 | class ConversationType(Enum): 10 | """Conversation Types.""" 11 | 12 | one_to_one = 1 13 | group = 2 14 | public = 3 15 | changelog = 4 16 | 17 | 18 | class NotificationLevel(Enum): 19 | """Notification Levels.""" 20 | 21 | default = 0 22 | always_notify = 1 23 | notify_on_mention = 2 24 | never_notify = 3 25 | 26 | 27 | class CallNotificationLevel(Enum): 28 | """Call notification levels.""" 29 | 30 | off = 0 31 | on = 1 # Default 32 | 33 | 34 | class ReadStatusPrivacy(Enum): 35 | """Show user read status.""" 36 | 37 | public = 0 38 | private = 1 39 | 40 | 41 | class ListableScope(Enum): 42 | """Conversation Listing Scope.""" 43 | 44 | participants = 0 45 | users = 1 46 | everyone = 2 47 | 48 | 49 | class Permissions(IntFlag): 50 | """Participant permissions.""" 51 | 52 | default = 0 53 | custom = 1 54 | start_call = 2 55 | join_call = 4 56 | can_ignore_lobby = 8 57 | can_publish_audio = 16 58 | can_publish_video = 32 59 | can_publish_screen_sharing = 64 60 | 61 | 62 | class ParticipantType(Enum): 63 | """Participant Types.""" 64 | 65 | owner = 1 66 | moderator = 2 67 | user = 3 68 | guest = 4 69 | public_link_user = 5 70 | guest_moderator = 6 71 | 72 | 73 | class ParticipantInCallFlags(IntFlag): 74 | """Participant Call Status Flags.""" 75 | 76 | disconnected = 0 77 | in_call = 1 78 | provides_audio = 2 79 | provides_video = 4 80 | uses_sip_dial_in = 8 81 | 82 | 83 | class WebinarLobbyStates(Enum): 84 | """Webinar Lobby States.""" 85 | 86 | no_lobby = 0 87 | lobby = 1 88 | -------------------------------------------------------------------------------- /nextcloud_async/api/ocs/notifications.py: -------------------------------------------------------------------------------- 1 | # noqa: D400 D415 2 | """https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md""" 3 | 4 | 5 | class NotificationManager(object): 6 | """Manage user notifications on Nextcloud instance.""" 7 | 8 | async def get_notifications(self): 9 | """Get user's notifications. 10 | 11 | Returns 12 | ------- 13 | list: Notifications 14 | 15 | """ 16 | return await self.ocs_query( 17 | method='GET', 18 | sub=r'/ocs/v2.php/apps/notifications/api/v2/notifications') 19 | 20 | async def get_notification(self, not_id: int): 21 | """Get a single notification. 22 | 23 | Args 24 | ---- 25 | not_id (int): Notification ID 26 | 27 | Returns 28 | ------- 29 | dict: Notification description 30 | 31 | """ 32 | return await self.ocs_query( 33 | method='GET', 34 | sub=f'/ocs/v2.php/apps/notifications/api/v2/notifications/{not_id}') 35 | 36 | async def remove_notifications(self): 37 | """Clear all of user's notifications. 38 | 39 | Returns 40 | ------- 41 | Empty 200 Response 42 | 43 | """ 44 | return await self.ocs_query( 45 | method='DELETE', 46 | sub=r'/ocs/v2.php/apps/notifications/api/v2/notifications') 47 | 48 | async def remove_notification(self, not_id: int): 49 | """Remove a single notification. 50 | 51 | Args 52 | ---- 53 | not_id (int): Notification ID 54 | 55 | Returns 56 | ------- 57 | Empty 200 Response 58 | 59 | """ 60 | return await self.ocs_query( 61 | method='DELETE', 62 | sub=f'/ocs/v2.php/apps/notifications/api/v2/notifications/{not_id}') 63 | -------------------------------------------------------------------------------- /nextcloud_async/api/wipe.py: -------------------------------------------------------------------------------- 1 | """Implement Nextcloud Remote Wiping functionality. 2 | 3 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/RemoteWipe/index.html 4 | 5 | In order for this to work, you must be logged in using an app token. 6 | See api.loginflow.LoginFlowV2. 7 | """ 8 | 9 | import json 10 | 11 | 12 | class Wipe(object): 13 | """Interact with Nextcloud Remote Wipe API. 14 | 15 | Two simple functions: one to check if the user wants their data 16 | to be removed, one to notify the server upon removal of local 17 | user data. 18 | 19 | wipe_status = await get_wipe_status() 20 | if wipe_status: 21 | os.remove('.appdatata') # for example 22 | await notify_wipe_status() 23 | """ 24 | 25 | endpoint = None 26 | password = None 27 | 28 | async def get_wipe_status(self) -> bool: 29 | """Check for remote wipe flag. 30 | 31 | Returns 32 | ------- 33 | bool: Whether user has flagged this device for remote wiping. 34 | 35 | """ 36 | response = await self.request( 37 | method='POST', 38 | url=f'{self.endpoint}/index.php/core/wipe/check', 39 | data={'token': self.password}) 40 | 41 | try: 42 | result = response.json() 43 | except json.decoder.JSONDecodeError: 44 | return False 45 | 46 | if 'wipe' in result: 47 | return result['wipe'] 48 | return False 49 | 50 | async def notify_wipe_status(self): 51 | """Notify server that device has been wiped. 52 | 53 | Returns 54 | ------- 55 | Empty 200 Response 56 | 57 | """ 58 | return await self.request( 59 | method='POST', 60 | url=f'{self.endpoint}/index.php/core/wipe/success', 61 | data={'token': self.password}) 62 | -------------------------------------------------------------------------------- /nextcloud_async/api/dav/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/index.html 3 | """ 4 | 5 | import xmltodict 6 | import json 7 | 8 | from typing import Dict, Any 9 | 10 | from nextcloud_async.exceptions import NextCloudException 11 | 12 | from nextcloud_async.api import NextCloudBaseAPI 13 | 14 | 15 | class NextCloudDAVAPI(NextCloudBaseAPI): 16 | """Interace with Nextcloud DAV interface for file operations.""" 17 | 18 | async def dav_query( 19 | self, 20 | method: str, 21 | url: str = None, 22 | sub: str = '', 23 | data: Dict[str, Any] = {}, 24 | headers: Dict[str, Any] = {}) -> Dict: 25 | """Send a query to the Nextcloud DAV Endpoint. 26 | 27 | Args 28 | ---- 29 | method (str): HTTP Method to use 30 | 31 | url (str, optional): URL, if outside of provided cloud. Defaults to None. 32 | 33 | sub (str, optional): The part after the URL. Defaults to ''. 34 | 35 | data (dict, optional): Data to submit. Defaults to {}. 36 | 37 | headers (dict, optional): Headers for submission. Defaults to {}. 38 | 39 | Raises 40 | ------ 41 | NextCloudException: Server API Errors 42 | 43 | Returns 44 | ------- 45 | Dict: Response content 46 | 47 | """ 48 | response = await self.request(method, url=url, sub=sub, data=data, headers=headers) 49 | if response.content: 50 | response_data = json.loads(json.dumps(xmltodict.parse(response.content))) 51 | if 'd:error' in response_data: 52 | err = response_data['d:error'] 53 | 54 | raise NextCloudException( 55 | f'{err["s:exception"]}: {err["s:message"]}'.replace('\n', '')) 56 | 57 | return response_data['d:multistatus']['d:response'] 58 | else: 59 | return None 60 | -------------------------------------------------------------------------------- /nextcloud_async/api/ocs/apps.py: -------------------------------------------------------------------------------- 1 | """Nextcloud Application API. 2 | 3 | Reference: 4 | https://docs.nextcloud.com/server/22/admin_manual/\ 5 | configuration_user/instruction_set_for_apps.html 6 | """ 7 | 8 | from typing import Optional 9 | 10 | 11 | class AppManager(object): 12 | """Manage applications on a Nextcloud instance.""" 13 | 14 | async def get_app(self, app_id: str): 15 | """Get application information. 16 | 17 | Args 18 | ---- 19 | app_id (str): Application id 20 | 21 | Returns 22 | ------- 23 | dict: Application information 24 | 25 | """ 26 | return await self.ocs_query( 27 | method='GET', 28 | sub=f'/ocs/v1.php/cloud/apps/{app_id}') 29 | 30 | async def get_apps(self, filter: Optional[str] = None): 31 | """Get list of applications. 32 | 33 | Args 34 | ---- 35 | filter (str, optional): _description_. Defaults to None. 36 | 37 | Returns 38 | ------- 39 | list: List of application ids 40 | 41 | """ 42 | data = {} 43 | if filter: 44 | data = {'filter': filter} 45 | response = await self.ocs_query( 46 | method='GET', 47 | sub=r'/ocs/v1.php/cloud/apps', 48 | data=data) 49 | return response['apps'] 50 | 51 | async def enable_app(self, app_id: str): 52 | """Enable Application. 53 | 54 | Requires admin privileges. 55 | 56 | Args 57 | ---- 58 | app_id (str): Application ID 59 | 60 | Returns 61 | ------- 62 | Empty 100 Response 63 | 64 | """ 65 | return await self.ocs_query( 66 | method='POST', 67 | sub=f'/ocs/v1.php/cloud/apps/{app_id}') 68 | 69 | async def disable_app(self, app_id: str): 70 | """Disable Application. 71 | 72 | Requires admin privileges. 73 | 74 | Args 75 | ---- 76 | app_id (str): Application ID 77 | 78 | Returns 79 | ------- 80 | Empty 100 Response 81 | 82 | """ 83 | return await self.ocs_query( 84 | method='DELETE', 85 | sub=f'/ocs/v1.php/cloud/apps/{app_id}') 86 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | 2 | from unittest import TestCase 3 | 4 | from nextcloud_async.helpers import ( 5 | recursive_urlencode, 6 | resolve_element_list) 7 | 8 | 9 | class TestHelpers(TestCase): 10 | 11 | def test_recursive_urlencode_nested_dict(self): 12 | a = {'configData': {'key1': 'val1', 'key2': 'val2'}} 13 | r = recursive_urlencode(a) 14 | assert r == 'configData[key1]=val1&configData[key2]=val2' 15 | 16 | def test_resolve_element_list(self): 17 | KEY = 'apps' 18 | EMPTY_ANSWER = [] 19 | SINGLE_ANSWER = ['workflow_script'] 20 | MULTI_ANSWER = ['workflow_script', 'epubreader'] 21 | 22 | response_multi = { 23 | 'ocs': { 24 | 'meta': { 25 | 'status': 'ok', 26 | 'statuscode': '100', 27 | 'message': 'OK', 28 | 'totalitems': None, 29 | 'itemsperpage': None}, 30 | 'data': { 31 | KEY: { 32 | 'element': MULTI_ANSWER}}}} 33 | result_multi = resolve_element_list(response_multi, list_keys=[KEY]) 34 | assert result_multi['ocs']['data'][KEY] == MULTI_ANSWER 35 | 36 | response_single = { 37 | 'ocs': { 38 | 'meta': { 39 | 'status': 'ok', 40 | 'statuscode': '100', 41 | 'message': 'OK', 42 | 'totalitems': None, 43 | 'itemsperpage': None}, 44 | 'data': { 45 | KEY: { 46 | 'element': SINGLE_ANSWER}}}} 47 | result_single = resolve_element_list(response_single, list_keys=[KEY]) 48 | assert result_single['ocs']['data'][KEY] == SINGLE_ANSWER 49 | 50 | response_empty = { 51 | 'ocs': { 52 | 'meta': { 53 | 'status': 'ok', 54 | 'statuscode': '100', 55 | 'message': 'OK', 56 | 'totalitems': None, 57 | 'itemsperpage': None}, 58 | 'data': { 59 | KEY: { 60 | 'element': EMPTY_ANSWER}}}} 61 | result_empty = resolve_element_list(response_empty, list_keys=[KEY]) 62 | assert result_empty['ocs']['data'][KEY] == EMPTY_ANSWER 63 | -------------------------------------------------------------------------------- /nextcloud_async/api/ocs/ldap.py: -------------------------------------------------------------------------------- 1 | # noqa: D400 D415 2 | """https://docs.nextcloud.com/server/latest/admin_manual/\ 3 | configuration_user/user_auth_ldap_api.html""" 4 | 5 | from typing import Dict 6 | 7 | from nextcloud_async.helpers import recursive_urlencode 8 | 9 | 10 | class OCSLdapAPI(object): 11 | """Manage the LDAP configuration of a Nextcloud instance. 12 | 13 | Server must have LDAP user and group back-end enabled. 14 | """ 15 | 16 | async def create_ldap_config(self): 17 | """Create a new LDAP configuration. 18 | 19 | Returns 20 | ------- 21 | dict: New configuration ID, { "configID": ID } 22 | 23 | """ 24 | return await self.ocs_query( 25 | method='POST', 26 | sub='/ocs/v2.php/apps/user_ldap/api/v1/config') 27 | 28 | async def remove_ldap_config(self, id: str): 29 | """Remove the given LDAP configuration. 30 | 31 | Args 32 | ---- 33 | id (str): LDAP Configuration ID 34 | 35 | Returns 36 | ------- 37 | Empty 200 Response 38 | 39 | """ 40 | return await self.ocs_query( 41 | method='DELETE', 42 | sub=f'/ocs/v2.php/apps/user_ldap/api/v1/config/{id}') 43 | 44 | async def get_ldap_config(self, id: str): 45 | """Get an LDAP configuration. 46 | 47 | Args 48 | ---- 49 | id (str): LDAP Configuration ID 50 | 51 | Returns 52 | ------- 53 | dict: LDAP configuration description 54 | 55 | """ 56 | return await self.ocs_query( 57 | method='GET', 58 | sub=f'/ocs/v2.php/apps/user_ldap/api/v1/config/{id}') 59 | 60 | async def set_ldap_config(self, id: str, config_data: Dict): 61 | """Set the properties of a given LDAP configuration. 62 | 63 | Args 64 | ---- 65 | id (str): LDAP Configuration ID 66 | 67 | config_data (Dict): New values for configuration. 68 | 69 | Returns 70 | ------- 71 | Empty 200 Response 72 | 73 | """ 74 | if 'configData' not in config_data: 75 | # Attempt to fix improperly formatted dictionary 76 | config_data = {'configData': config_data} 77 | 78 | url_data = recursive_urlencode(config_data) 79 | return await self.ocs_query( 80 | method='PUT', 81 | sub=f'/ocs/v2.php/apps/user_ldap/api/v1/config/{id}?{url_data}') 82 | -------------------------------------------------------------------------------- /nextcloud_async/api/maps.py: -------------------------------------------------------------------------------- 1 | """Implement Nextcloud Maps API. 2 | 3 | https://github.com/nextcloud/maps/blob/master/openapi.yml 4 | 5 | """ 6 | 7 | import json 8 | 9 | 10 | class Maps(object): 11 | """Interact with Nextcloud Maps API. 12 | 13 | Add/remove/edit/delete map favorites. 14 | """ 15 | 16 | async def get_map_favorites(self) -> list: 17 | """Get a list of map favorites. 18 | 19 | Returns 20 | ------- 21 | list of favorites 22 | 23 | """ 24 | response = await self.request( 25 | method='GET', 26 | url=f'{self.endpoint}/index.php/apps/maps/api/1.0/favorites') 27 | return json.loads(response.content.decode('utf-8')) 28 | 29 | async def remove_map_favorite(self, id: int) -> str: 30 | """Remove a map favorite by Id. 31 | 32 | Args: 33 | ---- 34 | id (int): ID of favorite to remove 35 | 36 | """ 37 | return await self.request( 38 | method='DELETE', 39 | url=f'{self.endpoint}/index.php/apps/maps/api/1.0/favorites/{id}') 40 | 41 | async def update_map_favorite(self, id: int, data: dict) -> dict: 42 | """Update an existing map favorite. 43 | 44 | Args 45 | ---- 46 | id (int): ID of favorite to update 47 | 48 | data (dict): Dictionary describing new data to use 49 | Keys may be: ['name', 'lat', 'lng', 'category', 50 | 'comment', 'extensions'] 51 | 52 | Returns 53 | ------- 54 | dict: Result of update 55 | 56 | """ 57 | response = await self.request( 58 | method='PUT', 59 | url=f'{self.endpoint}/index.php/apps/maps/api/1.0/favorites/{id}', 60 | data=data) 61 | return json.loads(response.content.decode('utf-8')) 62 | 63 | async def create_map_favorite(self, data: dict) -> dict: 64 | """Update an existing map favorite. 65 | 66 | Args 67 | ---- 68 | data (dict): Dictionary describing new favorite 69 | Keys may be: ['name', 'lat', 'lng', 'category', 70 | 'comment', 'extensions'] 71 | 72 | Returns 73 | ------- 74 | dict: Result of update 75 | 76 | """ 77 | response = await self.request( 78 | method='POST', 79 | url=f'{self.endpoint}/index.php/apps/maps/api/1.0/favorites', 80 | data=data) 81 | return json.loads(response.content.decode('utf-8')) 82 | -------------------------------------------------------------------------------- /nextcloud_async/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions for NextCloudAsync.""" 2 | 3 | import urllib 4 | 5 | from typing import Dict 6 | 7 | 8 | def recursive_urlencode(d: Dict): 9 | """URL-encode a multidimensional dictionary PHP-style. 10 | 11 | https://stackoverflow.com/questions/4013838/urlencode-a-multidimensional-dictionary-in-python/4014164#4014164 12 | 13 | Updated for python3. 14 | 15 | >>> data = {'a': 'b&c', 'd': {'e': {'f&g': 'h*i'}}, 'j': 'k'} 16 | >>> recursive_urlencode(data) 17 | u'a=b%26c&j=k&d[e][f%26g]=h%2Ai' 18 | """ 19 | def _recursion(d, base=[]): 20 | pairs = [] 21 | 22 | for key, value in d.items(): 23 | new_base = base + [key] 24 | if hasattr(value, 'values'): 25 | pairs += _recursion(value, new_base) 26 | else: 27 | new_pair = None 28 | if len(new_base) > 1: 29 | first = urllib.parse.quote(new_base.pop(0)) 30 | rest = map(lambda x: urllib.parse.quote(x), new_base) 31 | new_pair = f'{first}[{"][".join(rest)}]={urllib.parse.quote(value)}' 32 | else: 33 | new_pair = f'{urllib.parse.quote(key)}={urllib.parse.quote(value)}' 34 | pairs.append(new_pair) 35 | return pairs 36 | 37 | return '&'.join(_recursion(d)) 38 | 39 | 40 | def resolve_element_list(data: Dict, list_keys=[]): 41 | """Resolve all 'element' items into a list. 42 | 43 | A side-effect of using xmltodict on nextcloud results is that lists of 44 | items come back differently depending on how many results there are, and 45 | there is always an unnecessary parent 'element' key wrapping the list: 46 | 47 | Zero results returns: 48 | { 49 | 'items': 'None' 50 | } 51 | 52 | One or more results returns a dict containing a list: 53 | { 54 | 'items': { 55 | 'element': [...] 56 | } 57 | } 58 | 59 | We want to get rid of the 'element' middle-man and turn all 60 | list items into their proper representation: 61 | 62 | Zero results: 63 | { 64 | 'items': [] 65 | } 66 | 67 | One or more results: 68 | { 69 | 'items': [ 70 | ..., 71 | ..., 72 | ] 73 | } 74 | """ 75 | ret = {} 76 | if isinstance(data, dict): 77 | for k, v in data.items(): 78 | if isinstance(v, dict): 79 | if k == 'element': 80 | ret = resolve_element_list(v, list_keys=list_keys) 81 | else: 82 | ret.setdefault(k, resolve_element_list(v, list_keys=list_keys)) 83 | elif isinstance(v, list) and k == 'element': 84 | ret = v 85 | elif k in list_keys and v is None: 86 | ret = {k: []} 87 | else: 88 | ret.setdefault(k, v) 89 | else: 90 | ret = resolve_element_list(data, list_keys=list_keys) 91 | 92 | return ret 93 | -------------------------------------------------------------------------------- /nextcloud_async/api/ocs/groups.py: -------------------------------------------------------------------------------- 1 | """Nextcloud Group Management API. 2 | 3 | https://docs.nextcloud.com/server/22/admin_manual/configuration_user/\ 4 | instruction_set_for_groups.html 5 | """ 6 | 7 | from typing import Optional 8 | 9 | 10 | class GroupManager(object): 11 | """Manage groups on a Nextcloud instance.""" 12 | 13 | async def search_groups( 14 | self, 15 | search: str, 16 | limit: Optional[int] = 100, 17 | offset: Optional[int] = 0): 18 | """Search groups. 19 | 20 | This is the way to 'get' a group. 21 | 22 | Args 23 | ---- 24 | search (str): Search string 25 | 26 | limit (int, optional): Results per page. Defaults to 100. 27 | 28 | offset (int, optional): Page offset. Defaults to 0. 29 | 30 | Returns 31 | ------- 32 | list: Group names. 33 | 34 | """ 35 | response = await self.ocs_query( 36 | method='GET', 37 | sub='/ocs/v1.php/cloud/groups', 38 | data={ 39 | 'limit': limit, 40 | 'offset': offset, 41 | 'search': search}) 42 | return response['groups'] 43 | 44 | async def create_group(self, group_id: str): 45 | """Create a new group. 46 | 47 | Args 48 | ---- 49 | group_id (str): Group name 50 | 51 | Returns 52 | ------- 53 | Empty 100 Response 54 | 55 | """ 56 | return await self.ocs_query( 57 | method='POST', 58 | sub=r'/ocs/v1.php/cloud/groups', 59 | data={'groupid': group_id}) 60 | 61 | async def get_group_members(self, group_id: str): 62 | """Get group members. 63 | 64 | Args 65 | ---- 66 | group_id (str): _description_ 67 | 68 | Returns 69 | ------- 70 | list: Users belonging to `group_id` 71 | 72 | """ 73 | response = await self.ocs_query( 74 | method='GET', 75 | sub=f'/ocs/v1.php/cloud/groups/{group_id}') 76 | return response['users'] 77 | 78 | async def get_group_subadmins(self, group_id: str): 79 | """Get `group_id` subadmins. 80 | 81 | Args 82 | ---- 83 | group_id (str): Group ID 84 | 85 | Returns 86 | ------- 87 | list: Users who are subadmins of this group. 88 | 89 | """ 90 | return await self.ocs_query( 91 | method='GET', 92 | sub=f'/ocs/v1.php/cloud/groups/{group_id}/subadmins') 93 | 94 | async def remove_group(self, group_id: str): 95 | """Remove `group_id`. 96 | 97 | Args 98 | ---- 99 | group_id (str): Group ID 100 | 101 | Returns 102 | ------- 103 | Empty 100 Response 104 | 105 | """ 106 | return await self.ocs_query( 107 | method='DELETE', 108 | sub=f'/ocs/v1.php/cloud/groups/{group_id}') 109 | -------------------------------------------------------------------------------- /tests/test_loginflow.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTestCase 2 | from .helpers import AsyncMock 3 | from .constants import USER, ENDPOINT, PASSWORD, EMPTY_200 4 | 5 | import asyncio 6 | import httpx 7 | 8 | from unittest.mock import patch 9 | from importlib.metadata import version 10 | 11 | VERSION = version('nextcloud_async') 12 | TOKEN = 'qWPKzgQoCeV4Cvgc8Sl9ENJ8kXrGmijwWgA0eCNgOnP2bt'\ 13 | 'sWturgzFkdLGySmzMiheh746voMs5lpOB57MRm66KDV40G4n7V03cUnwznKX95k1'\ 14 | 'taNobxuGCNthK3I5me' 15 | 16 | 17 | class LoginFlowV2(BaseTestCase): 18 | 19 | def test_login_flow_initiate(self): 20 | json_response = bytes( 21 | f'{{"poll":{{"token":"{TOKEN}","endpoint":"http:\\/\\/localhost:81' 22 | '81\\/login\\/v2\\/poll"},"login":"http:\\/\\/localhost:8181\\/l' 23 | 'ogin\\/v2\\/flow\\/TtnMLxXHbxzkvubprdlowN0QoS7k9UVtOLf977xxVXsf' 24 | '9oUUsXGXjU9vSRi3axFUEZCyF2nC6WD8NERUwaeewCZC99NgN6IjGlCMWEHrS08' 25 | 'I8GL1dChWpYqn78S1Zmk7"}', 'utf-8') 26 | with patch( 27 | 'httpx.AsyncClient.request', 28 | new_callable=AsyncMock, 29 | return_value=httpx.Response( 30 | status_code=200, 31 | content=json_response)) as mock: 32 | asyncio.run(self.ncc.login_flow_initiate()) 33 | mock.assert_called_with( 34 | method='POST', 35 | auth=(USER, PASSWORD), 36 | url=f'{ENDPOINT}/index.php/login/v2', 37 | data={}, 38 | headers={'user-agent': f'nextcloud_async/{VERSION}'}) 39 | 40 | def test_login_flow_confirm(self): 41 | json_response = bytes( 42 | f'{{"server":"http:\\/\\/localhost:8181","loginName":"{USER}",' 43 | '"appPassword":"aoXMDFSBmFQhsqvuKuFXhW4s4Uj1GUJ3OZttYid7jbAxL' 44 | 'XLZQDYOIywkW7kBLiroLyAik1Pf"}', 'utf-8') 45 | with patch( 46 | 'httpx.AsyncClient.request', 47 | new_callable=AsyncMock, 48 | return_value=httpx.Response( 49 | status_code=200, 50 | content=json_response)) as mock: 51 | asyncio.run(self.ncc.login_flow_wait_confirm(TOKEN, timeout=1)) 52 | mock.assert_called_with( 53 | method='POST', 54 | auth=(USER, PASSWORD), 55 | url=f'{ENDPOINT}/index.php/login/v2/poll', 56 | data={'token': TOKEN}, 57 | headers={}) 58 | 59 | def test_destroy_app_token(self): 60 | with patch( 61 | 'httpx.AsyncClient.request', 62 | new_callable=AsyncMock, 63 | return_value=httpx.Response( 64 | status_code=200, 65 | content=EMPTY_200)) as mock: 66 | asyncio.run(self.ncc.destroy_login_token()) 67 | mock.assert_called_with( 68 | method='DELETE', 69 | auth=(USER, PASSWORD), 70 | url=f'{ENDPOINT}/ocs/v2.php/core/apppassword', 71 | data={'format': 'json'}, 72 | headers={'OCS-APIRequest': 'true'}) 73 | -------------------------------------------------------------------------------- /nextcloud_async/exceptions.py: -------------------------------------------------------------------------------- 1 | """Our very own exception classes.""" 2 | 3 | 4 | class NextCloudException(Exception): 5 | """Generic Exception.""" 6 | 7 | status_code = None 8 | reason = None 9 | 10 | def __init__(self, status_code: int = None, reason: str = None): 11 | """Initialize our very own exception.""" 12 | super(BaseException, self).__init__() 13 | self.status_code = status_code 14 | self.reason = reason 15 | 16 | def __str__(self): 17 | if self.status_code: 18 | return f'[{self.status_code}] {self.reason}' 19 | else: 20 | return self.reason 21 | 22 | 23 | class NextCloudNotModified(NextCloudException): 24 | """304 - Content not modified.""" 25 | 26 | status_code = 304 27 | reason = 'Not modified.' 28 | 29 | def __init__(self): 30 | """Configure exception.""" 31 | super(NextCloudException, self).__init__() 32 | 33 | 34 | class NextCloudBadRequest(NextCloudException): 35 | """User made an invalid request.""" 36 | 37 | status_code = 400 38 | reason = 'Bad request.' 39 | 40 | def __init__(self): 41 | """Configure exception.""" 42 | super(NextCloudException, self).__init__() 43 | 44 | 45 | class NextCloudUnauthorized(NextCloudException): 46 | """User account is not authorized.""" 47 | 48 | status_code = 401 49 | reason = 'Invalid credentials.' 50 | 51 | def __init__(self): 52 | """Configure exception.""" 53 | super(NextCloudException, self).__init__() 54 | 55 | 56 | class NextCloudForbidden(NextCloudException): 57 | """Forbidden action due to permissions.""" 58 | 59 | status_code = 403 60 | reason = 'Forbidden action due to permissions.' 61 | 62 | def __init__(self): 63 | """Configure exception.""" 64 | super(NextCloudException, self).__init__() 65 | 66 | 67 | class NextCloudNotFound(NextCloudException): 68 | """Object not found.""" 69 | 70 | status_code = 404 71 | reason = 'Object not found.' 72 | 73 | def __init__(self): 74 | """Configure exception.""" 75 | super(NextCloudException, self).__init__() 76 | 77 | 78 | class NextCloudRequestTimeout(NextCloudException): 79 | """HTTP Request timed out.""" 80 | 81 | status_code = 408 82 | reason = "Request timed out." 83 | 84 | def __init__(self): 85 | """Configure exception.""" 86 | super(NextCloudException, self).__init__() 87 | 88 | 89 | class NextCloudLoginFlowTimeout(NextCloudException): 90 | """When the login flow times out.""" 91 | 92 | status_code = 408 93 | reason = "Login flow timed out. Try again." 94 | 95 | def __init__(self): 96 | """Configure exception.""" 97 | super(NextCloudException, self).__init__() 98 | 99 | class NextCloudTooManyRequests(NextCloudException): 100 | """Too many requests""" 101 | 102 | status_code = 429 103 | reason = "Too many requests. Try again later." 104 | 105 | def __init__(self): 106 | """Configure exception.""" 107 | super(NextCloudException, self).__init__() 108 | 109 | class NextCloudChunkedUploadException(NextCloudException): 110 | """When there is more than one chunk in the local cache directory.""" 111 | 112 | status_code = 999 113 | reason = "Unable to determine chunked upload state." 114 | 115 | def __init__(self): 116 | """Configure exception.""" 117 | super(NextCloudException, self).__init__() 118 | -------------------------------------------------------------------------------- /nextcloud_async/api/__init__.py: -------------------------------------------------------------------------------- 1 | """Nextcloud APIs. 2 | 3 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/ 4 | """ 5 | 6 | import httpx 7 | 8 | from urllib.parse import urlencode 9 | from typing import Dict, Optional 10 | 11 | from nextcloud_async.exceptions import ( 12 | NextCloudBadRequest, 13 | NextCloudForbidden, 14 | NextCloudNotModified, 15 | NextCloudUnauthorized, 16 | NextCloudNotFound, 17 | NextCloudRequestTimeout, 18 | NextCloudTooManyRequests) 19 | 20 | 21 | class NextCloudBaseAPI(object): 22 | """The Base API interface.""" 23 | 24 | def __init__( 25 | self, 26 | client: httpx.AsyncClient, 27 | endpoint: str, 28 | user: str = '', 29 | password: str = ''): # noqa: D416 30 | """Set up the basis for endpoint interaction. 31 | 32 | Args 33 | ---- 34 | client (httpx.AsyncClient): AsyncClient. Only httpx supported, but others may 35 | work. 36 | 37 | endpoint (str): The nextcloud endpoint URL 38 | 39 | user (str, optional): User login. Defaults to ''. 40 | 41 | password (str, optional): User password. Defaults to ''. 42 | 43 | """ 44 | self.user = user 45 | self.password = password 46 | self.endpoint = endpoint 47 | self.client = client 48 | 49 | async def request( 50 | self, 51 | method: str = 'GET', 52 | url: str = None, 53 | sub: str = '', 54 | data: Optional[Dict] = {}, 55 | headers: Optional[Dict] = {}) -> httpx.Response: 56 | """Send a request to the Nextcloud endpoint. 57 | 58 | Args 59 | ---- 60 | method (str, optional): HTTP Method. Defaults to 'GET'. 61 | 62 | url (str, optional): URL, if outside of cloud endpoint. Defaults to None. 63 | 64 | sub (str, optional): The part after the host. Defaults to ''. 65 | 66 | data (dict, optional): Data for submission. Defaults to {}. 67 | 68 | headers (dict, optional): Headers for submission. Defaults to {}. 69 | 70 | Raises 71 | ------ 72 | 304 - NextCloudNotModified 73 | 74 | 400 - NextCloudBadRequest 75 | 76 | 401 - NextCloudUnauthorized 77 | 78 | 403 - NextCloudForbidden 79 | 80 | 404 - NextCloudNotFound 81 | 82 | 429 - NextCloudTooManyRequests 83 | 84 | Returns 85 | ------- 86 | httpx.Response: An httpx Response Object 87 | 88 | """ 89 | if method.lower() == 'get': 90 | sub = f'{sub}?{urlencode(data, True)}' 91 | data = None 92 | 93 | try: 94 | response = await self.client.request( 95 | method=method, 96 | auth=(self.user, self.password), 97 | url=f'{url}{sub}' if url else f'{self.endpoint}{sub}', 98 | data=data, 99 | headers=headers) 100 | except httpx.ReadTimeout: 101 | raise NextCloudRequestTimeout() 102 | 103 | match response.status_code: 104 | case 304: 105 | raise NextCloudNotModified() 106 | case 400: 107 | raise NextCloudBadRequest() 108 | case 401: 109 | raise NextCloudUnauthorized() 110 | case 403: 111 | raise NextCloudForbidden() 112 | case 404: 113 | raise NextCloudNotFound() 114 | case 429: 115 | raise NextCloudTooManyRequests() 116 | 117 | return response 118 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Sublime Text project 7 | *.sublime-* 8 | 9 | # VSCode 10 | .vscode 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Temp files 16 | *.tmp 17 | temp.py 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | share/python-wheels/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | MANIFEST 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .nox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | *.py,cover 60 | .hypothesis/ 61 | .pytest_cache/ 62 | cover/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Django stuff: 69 | *.log 70 | local_settings.py 71 | db.sqlite3 72 | db.sqlite3-journal 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | .pybuilder/ 86 | target/ 87 | 88 | # Jupyter Notebook 89 | .ipynb_checkpoints 90 | 91 | # IPython 92 | profile_default/ 93 | ipython_config.py 94 | 95 | # pyenv 96 | # For a library or package, you might want to ignore these files since the code is 97 | # intended to run in multiple environments; otherwise, check them in: 98 | # .python-version 99 | 100 | # pipenv 101 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 102 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 103 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 104 | # install all needed dependencies. 105 | #Pipfile.lock 106 | 107 | # poetry 108 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 109 | # This is especially recommended for binary packages to ensure reproducibility, and is more 110 | # commonly ignored for libraries. 111 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 112 | #poetry.lock 113 | 114 | # pdm 115 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 116 | #pdm.lock 117 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 118 | # in version control. 119 | # https://pdm.fming.dev/#use-with-ide 120 | .pdm.toml 121 | 122 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 123 | __pypackages__/ 124 | 125 | # Celery stuff 126 | celerybeat-schedule 127 | celerybeat.pid 128 | 129 | # SageMath parsed files 130 | *.sage.py 131 | 132 | # Environments 133 | .venv 134 | env/ 135 | venv/ 136 | ENV/ 137 | env.bak/ 138 | venv.bak/ 139 | !tests/.env 140 | 141 | # Spyder project settings 142 | .spyderproject 143 | .spyproject 144 | 145 | # Rope project settings 146 | .ropeproject 147 | 148 | # mkdocs documentation 149 | /site 150 | 151 | # mypy 152 | .mypy_cache/ 153 | .dmypy.json 154 | dmypy.json 155 | 156 | # Pyre type checker 157 | .pyre/ 158 | 159 | # pytype static type analyzer 160 | .pytype/ 161 | 162 | # Cython debug symbols 163 | cython_debug/ 164 | 165 | # PyCharm 166 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 167 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 168 | # and can be added to the global gitignore or merged into this file. For a more nuclear 169 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 170 | #.idea/ 171 | -------------------------------------------------------------------------------- /tests/test_maps.py: -------------------------------------------------------------------------------- 1 | """Test Nextcloud Maps API.""" 2 | 3 | from .base import BaseTestCase 4 | from .helpers import AsyncMock 5 | from .constants import USER, ENDPOINT, PASSWORD 6 | 7 | import asyncio 8 | import httpx 9 | 10 | from unittest.mock import patch 11 | 12 | ID = 4 13 | LAT = 48.785526 14 | LNG = -95.035892 15 | NAME = "Blueberry Hill Campground" 16 | COMMENT = "ALL THE BLUEBERRIES" 17 | 18 | 19 | class MapsAPI(BaseTestCase): # noqa: D101 20 | 21 | def test_get_favorites(self): # noqa: D102 22 | with patch( 23 | 'httpx.AsyncClient.request', 24 | new_callable=AsyncMock, 25 | return_value=httpx.Response( 26 | status_code=200, 27 | content=bytes('[]', 'utf-8'))) as mock: 28 | asyncio.run(self.ncc.get_map_favorites()) 29 | mock.assert_called_with( 30 | method='GET', 31 | auth=(USER, PASSWORD), 32 | url=f'{ENDPOINT}/index.php/apps/maps/api/1.0/favorites?', 33 | data=None, 34 | headers={}) 35 | 36 | def test_create_favorite(self): # noqa: D102 37 | json_response = bytes( 38 | '{"id":4,"name":"Blueberry Hill Campground","date_modifie' 39 | 'd":1661549544,"date_created":1661549544,"lat":48.785526,"lng":-95.035' 40 | '892,"category":"Campgrounds","comment":"Blueberries!","extensions":null}', 41 | 'utf-8') 42 | 43 | with patch( 44 | 'httpx.AsyncClient.request', 45 | new_callable=AsyncMock, 46 | return_value=httpx.Response( 47 | status_code=200, 48 | content=json_response)) as mock: 49 | data = { 50 | 'name': NAME, 51 | 'lat': LAT, 52 | 'lng': LNG, 53 | 'comment': COMMENT} 54 | asyncio.run(self.ncc.create_map_favorite(data)) 55 | mock.assert_called_with( 56 | method='POST', 57 | auth=(USER, PASSWORD), 58 | url=f'{ENDPOINT}/index.php/apps/maps/api/1.0/favorites', 59 | data=data, 60 | headers={}) 61 | 62 | def test_update_favorite(self): # noqa: D102 63 | json_response = bytes( 64 | '{"id":4,"name":"Blueberry Hill Campground","date_modifie' 65 | 'd":1661549544,"date_created":1661549544,"lat":48.785526,"lng":-95.035' 66 | '892,"category":"Campgrounds","comment":"Blueberries!","extensions":null}', 67 | 'utf-8') 68 | 69 | with patch( 70 | 'httpx.AsyncClient.request', 71 | new_callable=AsyncMock, 72 | return_value=httpx.Response( 73 | status_code=200, 74 | content=json_response)) as mock: 75 | data = { 76 | 'name': NAME, 77 | 'lat': LAT, 78 | 'lng': LNG, 79 | 'comment': COMMENT} 80 | asyncio.run(self.ncc.update_map_favorite(ID, data)) 81 | mock.assert_called_with( 82 | method='PUT', 83 | auth=(USER, PASSWORD), 84 | url=f'{ENDPOINT}/index.php/apps/maps/api/1.0/favorites/{ID}', 85 | data=data, 86 | headers={}) 87 | 88 | def test_remove_favorite(self): # noqa: D102 89 | json_response = bytes('"DELETED"', 'utf-8') 90 | 91 | with patch( 92 | 'httpx.AsyncClient.request', 93 | new_callable=AsyncMock, 94 | return_value=httpx.Response( 95 | status_code=200, 96 | content=json_response)) as mock: 97 | asyncio.run(self.ncc.remove_map_favorite(ID)) 98 | mock.assert_called_with( 99 | method='DELETE', 100 | auth=(USER, PASSWORD), 101 | url=f'{ENDPOINT}/index.php/apps/maps/api/1.0/favorites/{ID}', 102 | data={}, 103 | headers={}) 104 | -------------------------------------------------------------------------------- /tests/test_notifications.py: -------------------------------------------------------------------------------- 1 | """Test Nextcloud Notifications API. 2 | 3 | https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md 4 | """ 5 | 6 | from .base import BaseTestCase 7 | from .helpers import AsyncMock 8 | from .constants import ( 9 | USER, ENDPOINT, PASSWORD, EMPTY_200) 10 | 11 | import asyncio 12 | import httpx 13 | 14 | from unittest.mock import patch 15 | 16 | 17 | class OCSNotificationAPI(BaseTestCase): 18 | """Interact with Nextcloud Notifications API. 19 | 20 | Get user notifications, mark them read. What more could you ever need? 21 | """ 22 | 23 | def test_get_notifications(self): # noqa: D102 24 | with patch( 25 | 'httpx.AsyncClient.request', 26 | new_callable=AsyncMock, 27 | return_value=httpx.Response( 28 | status_code=200, 29 | content=EMPTY_200)) as mock: 30 | asyncio.run(self.ncc.get_notifications()) 31 | mock.assert_called_with( 32 | method='GET', 33 | auth=(USER, PASSWORD), 34 | url=f'{ENDPOINT}/ocs/v2.php/apps/notifications/api/v2/notifications?format=json', 35 | data=None, 36 | headers={'OCS-APIRequest': 'true'}) 37 | 38 | def test_get_notification(self): # noqa: D102 39 | NOT_ID = 7 40 | json_response = bytes( 41 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},' 42 | f'"data":{{"notification_id":{NOT_ID},"app":"updatenotifi' 43 | 'cation","user":"admin","datetime":"2022-07-04T14:10:22+00:00","o' 44 | 'bject_type":"core","object_id":"24.0.2.1","subject":"Update to N' 45 | 'extcloud 24.0.2 is available.","message":"","link":"http:\\/\\/l' 46 | 'ocalhost:8181\\/settings\\/admin\\/overview#version","subjectRic' 47 | 'h":"","subjectRichParameters":[],"messageRich":"","messageRichPa' 48 | 'rameters":[],"icon":"http:\\/\\/localhost:8181\\/apps\\/updateno' 49 | 'tification\\/img\\/notification.svg","actions":[]}}}', 'utf-8') 50 | 51 | with patch( 52 | 'httpx.AsyncClient.request', 53 | new_callable=AsyncMock, 54 | return_value=httpx.Response( 55 | status_code=200, 56 | content=json_response)) as mock: 57 | response = asyncio.run(self.ncc.get_notification(NOT_ID)) 58 | mock.assert_called_with( 59 | method='GET', 60 | auth=(USER, PASSWORD), 61 | url=f'{ENDPOINT}/ocs/v2.php/apps/notifications/api/v2/notifications/{NOT_ID}?format=json', 62 | data=None, 63 | headers={'OCS-APIRequest': 'true'}) 64 | assert response['notification_id'] == NOT_ID 65 | 66 | def test_remove_notifications(self): # noqa: D102 67 | with patch( 68 | 'httpx.AsyncClient.request', 69 | new_callable=AsyncMock, 70 | return_value=httpx.Response( 71 | status_code=200, 72 | content=EMPTY_200)) as mock: 73 | asyncio.run(self.ncc.remove_notifications()) 74 | mock.assert_called_with( 75 | method='DELETE', 76 | auth=(USER, PASSWORD), 77 | url=f'{ENDPOINT}/ocs/v2.php/apps/notifications/api/v2/notifications', 78 | data={'format': 'json'}, 79 | headers={'OCS-APIRequest': 'true'}) 80 | 81 | def test_remove_notification(self): # noqa: D102 82 | NOTIFICATION = 'xxxx' 83 | with patch( 84 | 'httpx.AsyncClient.request', 85 | new_callable=AsyncMock, 86 | return_value=httpx.Response( 87 | status_code=200, 88 | content=EMPTY_200)) as mock: 89 | asyncio.run(self.ncc.remove_notification(NOTIFICATION)) 90 | mock.assert_called_with( 91 | method='DELETE', 92 | auth=(USER, PASSWORD), 93 | url=f'{ENDPOINT}/ocs/v2.php/apps/notifications/api/' 94 | f'v2/notifications/{NOTIFICATION}', 95 | data={'format': 'json'}, 96 | headers={'OCS-APIRequest': 'true'}) 97 | -------------------------------------------------------------------------------- /nextcloud_async/api/ocs/talk/rich_objects.py: -------------------------------------------------------------------------------- 1 | """Classes representing Rich Objects for sharing. 2 | 3 | https://github.com/nextcloud/server/blob/master/lib/public/RichObjectStrings/Definitions.php 4 | 5 | I don't really understand why most of these exist when they don't 6 | seem to enforce any kind of argument checking. You can put in 7 | whatever you want for 'id' in most of them, and it'll be accepted 8 | and entered into the chat. 9 | """ 10 | 11 | 12 | class NextCloudTalkRichObject(object): 13 | """Base Class for Rich Objects.""" 14 | 15 | object_type = None 16 | 17 | def __init__(self, id: str, name: str, **kwargs): 18 | """Set object metadata.""" 19 | self.__dict__.update(kwargs) 20 | self.id = id 21 | self.name = name 22 | 23 | @property 24 | def metadata(self): 25 | """Return metadata array.""" 26 | return {'id': self.id, 'name': self.name} 27 | 28 | 29 | class AddressBook(NextCloudTalkRichObject): 30 | """Address book.""" 31 | 32 | object_type = 'addressbook' 33 | 34 | 35 | class AddressBookContact(NextCloudTalkRichObject): 36 | """Addressbook contact.""" 37 | 38 | object_type = 'addressbook-contact' 39 | 40 | 41 | class Announcement(NextCloudTalkRichObject): 42 | """Announcement.""" 43 | 44 | object_type = 'announcement' 45 | 46 | 47 | class Calendar(NextCloudTalkRichObject): 48 | """Calendar.""" 49 | 50 | object_type = 'calendar' 51 | 52 | 53 | class CalendarEvent(NextCloudTalkRichObject): 54 | """Calendar Event.""" 55 | 56 | object_type = 'calendar-event' 57 | 58 | 59 | class Call(NextCloudTalkRichObject): 60 | """Nextcloud Talk Call.""" 61 | 62 | object_type = 'call' 63 | call_type = '' 64 | 65 | @property 66 | def metadata(self): 67 | """Return object metadata.""" 68 | return { 69 | 'id': self.id, 70 | 'name': self.name, 71 | 'call-type': self.call_type 72 | } 73 | 74 | 75 | class Circle(NextCloudTalkRichObject): 76 | """Cirle.""" 77 | 78 | object_type = 'circle' 79 | 80 | 81 | class DeckBoard(NextCloudTalkRichObject): 82 | """Deck board.""" 83 | 84 | object_type = 'deck-board' 85 | 86 | 87 | class DeckCard(NextCloudTalkRichObject): 88 | """Deck card.""" 89 | 90 | object_type = 'deck-card' 91 | 92 | 93 | class Email(NextCloudTalkRichObject): 94 | """E-mail.""" 95 | 96 | object_type = 'email' 97 | 98 | 99 | class File(NextCloudTalkRichObject): 100 | """File.""" 101 | 102 | object_type = 'file' 103 | path = '' 104 | 105 | allowed_props = ['size', 'link', 'mimetype', 'preview-available', 'mtime'] 106 | 107 | def __init__(self, name: str, path: str, **kwargs): 108 | """Set file object metadata.""" 109 | 110 | if not all(key in self.allowed_props for key in kwargs): 111 | raise ValueError(f'Supported properties {self.allowed_props}') 112 | 113 | init = { 114 | 'id': name, 115 | 'name': name, 116 | 'path': path, 117 | } 118 | data = { **init, **kwargs } 119 | super().__init__(**data) 120 | 121 | @property 122 | def metadata(self): 123 | """Return object metadata.""" 124 | return self.__dict__ 125 | 126 | class Form(NextCloudTalkRichObject): 127 | """Form.""" 128 | 129 | object_type = 'forms-form' 130 | 131 | 132 | class GeoLocation(NextCloudTalkRichObject): 133 | """Geo-location.""" 134 | 135 | object_type = 'geo-location' 136 | latitude = None 137 | longitude = None 138 | 139 | def __init__(self, name: str, latitude: str, longitude: str): 140 | """Set Geolocation metadata.""" 141 | data = { 142 | 'id': f'geo:{latitude},{longitude}', 143 | 'name': name, 144 | 'longitude': longitude, 145 | 'latitude': latitude} 146 | super().__init__(**data) 147 | 148 | def __str__(self): 149 | return f'{__class__.__name__}'\ 150 | f'(latitude={self.latitude}, longitude={self.longitude}, name={self.name})' 151 | 152 | @property 153 | def metadata(self): 154 | """Return geolocation metadata.""" 155 | return { 156 | 'id': f'geo:{self.latitude},{self.longitude}', 157 | 'name': self.name, 158 | 'latitude': self.latitude, 159 | 'longitude': self.longitude, 160 | } 161 | 162 | 163 | class TalkAttachment(NextCloudTalkRichObject): 164 | """Talk Attachment.""" 165 | 166 | object_type = 'talk-attachment' 167 | 168 | 169 | class User(NextCloudTalkRichObject): 170 | """User.""" 171 | 172 | object_type = 'user' 173 | 174 | 175 | class UserGroup(NextCloudTalkRichObject): 176 | """User group.""" 177 | 178 | object_type = 'user-group' 179 | -------------------------------------------------------------------------------- /nextcloud_async/api/loginflow.py: -------------------------------------------------------------------------------- 1 | """Implement the Nextcloud Login Flow v2. 2 | 3 | This will allow your application to: 4 | 5 | * Use an app token to log in as a user 6 | * Check for Remote Wipe status (see api.wipe) 7 | 8 | Using an app token for authorization will allow the user to: 9 | 10 | * Have the ability to disable access for your application 11 | * Signal your application to wipe all of its data (see api.wipe) 12 | 13 | Reference: 14 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html 15 | """ 16 | 17 | import asyncio 18 | 19 | from importlib.metadata import version 20 | 21 | import datetime as dt 22 | 23 | from typing import Dict, Optional 24 | 25 | from nextcloud_async.exceptions import NextCloudLoginFlowTimeout 26 | from nextcloud_async.api import NextCloudBaseAPI 27 | 28 | __VERSION__ = version('nextcloud_async') 29 | 30 | 31 | class LoginFlowV2(NextCloudBaseAPI): 32 | """Obtain an app password after user web authorization. 33 | 34 | Simply: 35 | > login_flow = await ncc.login_flow_initiate() 36 | > print(login_flow('login')) # Direct user to open the provided URL 37 | > token = login_flow['poll']['token'] 38 | > results = await ncc.login_flow_wait_confirm(token, timeout=60)) 39 | > print(results['appPassword']) 40 | 41 | You may then use `appPassword` to log in as the user with your application. 42 | """ 43 | 44 | async def login_flow_initiate( 45 | self, 46 | user_agent: Optional[str] = f'nextcloud_async/{__VERSION__}') -> Dict: 47 | r"""Initiate login flow v2. 48 | 49 | Args 50 | ---- 51 | user_agent (str, optional): The name of your application. Defaults to 52 | 'nextcloud_async/{__VERSION__}'. 53 | 54 | Returns 55 | ------- 56 | Dict: Containing the user login URL and your temporary token for polling results. 57 | 58 | Example: 59 | 60 | { 61 | 62 | "poll":{ 63 | 64 | "token":"mQU...c6k8Wrs1", 65 | 66 | "endpoint":"https:\/\/cloud.example.com\/login\/v2\/poll" 67 | }, 68 | 69 | "login":"https:\/\/cloud.example.com\/login\/v2\/flow\/guyjG...YFg" 70 | 71 | } 72 | 73 | """ 74 | response = await self.request( 75 | method='POST', 76 | url=f'{self.endpoint}/index.php/login/v2', 77 | headers={'user-agent': user_agent}) 78 | return response.json() 79 | 80 | async def login_flow_wait_confirm(self, token, timeout: int = 60) -> Dict: 81 | r"""Wait for user to confirm application authorization. 82 | 83 | Args 84 | ---- 85 | token (str): The temporary token provided by login_flow_initiate() 86 | 87 | timeout (int, optional): How long to wait for user authorization. Defaults to 88 | 60 seconds. 89 | 90 | Raises 91 | ------ 92 | NextCloudLoginFlowTimeout: When the user hasn't logged in by the given timeout. 93 | This function may be called repeatedly until the user accepts. 94 | 95 | Returns 96 | ------- 97 | Dict: Your new credentials. 98 | 99 | Example: 100 | 101 | { 102 | 103 | "server":"https:\/\/cloud.example.com", 104 | 105 | "loginName":"username", 106 | 107 | "appPassword":"yKTVA4zgx...olYSuJ6sCN" 108 | } 109 | 110 | """ 111 | start_dt = dt.datetime.now() 112 | running_time = 0 113 | 114 | response = await self.request( 115 | method='POST', 116 | url=f'{self.endpoint}/index.php/login/v2/poll', 117 | data={'token': token}) 118 | 119 | while response.status_code == 404 and running_time < timeout: 120 | response = await self.request( 121 | method='POST', 122 | url=f'{self.endpoint}/index.php/login/v2/poll', 123 | data={'token': token}) 124 | running_time = (dt.datetime.now() - start_dt).seconds 125 | await asyncio.sleep(1) 126 | 127 | if response.status_code == 404: 128 | raise NextCloudLoginFlowTimeout( 129 | 'Login flow timed out. You can try again.') 130 | 131 | return response.json() 132 | 133 | async def destroy_login_token(self): 134 | """Delete an app password generated by Login Flow v2. 135 | 136 | You must currently be logged in using the app password. 137 | 138 | Returns 139 | ------- 140 | Empty 200 Response 141 | 142 | """ 143 | return await self.ocs_query( 144 | method='DELETE', 145 | sub='/ocs/v2.php/core/apppassword') 146 | -------------------------------------------------------------------------------- /tests/test_groups.py: -------------------------------------------------------------------------------- 1 | # noqa: D100 2 | 3 | from urllib.parse import urlencode 4 | from .base import BaseTestCase 5 | from .helpers import AsyncMock 6 | from .constants import ( 7 | USER, ENDPOINT, PASSWORD, EMPTY_100, SIMPLE_100) 8 | 9 | import asyncio 10 | import httpx 11 | 12 | from unittest.mock import patch 13 | 14 | 15 | class OCSGroupsAPI(BaseTestCase): # noqa: D101 16 | 17 | def test_search_groups(self): # noqa: D102 18 | SEARCH = 'OK Go' 19 | RESPONSE = 'OK_GROUP' 20 | json_response = bytes( 21 | '{"ocs":{"meta":{"status":"ok","statuscode":100,"message":"OK","t' 22 | 'otalitems":"","itemsperpage":""},"data":{"groups":[' 23 | f'"{RESPONSE}"' 24 | ']}}}', 'utf-8') 25 | search_encoded = urlencode({'search': SEARCH}) 26 | with patch( 27 | 'httpx.AsyncClient.request', 28 | new_callable=AsyncMock, 29 | return_value=httpx.Response( 30 | status_code=100, 31 | content=json_response)) as mock: 32 | response = asyncio.run(self.ncc.search_groups(SEARCH)) 33 | mock.assert_called_with( 34 | method='GET', 35 | auth=(USER, PASSWORD), 36 | url=f'{ENDPOINT}/ocs/v1.php/cloud/groups?' 37 | f'limit=100&offset=0&{search_encoded}&format=json', 38 | data=None, 39 | headers={'OCS-APIRequest': 'true'}) 40 | assert RESPONSE in response 41 | 42 | def test_create_group(self): # noqa: D102 43 | GROUP = 'BobMouldFanClub' 44 | with patch( 45 | 'httpx.AsyncClient.request', 46 | new_callable=AsyncMock, 47 | return_value=httpx.Response( 48 | status_code=100, 49 | content=EMPTY_100)) as mock: 50 | response = asyncio.run(self.ncc.create_group(GROUP)) 51 | mock.assert_called_with( 52 | method='POST', 53 | auth=(USER, PASSWORD), 54 | url=f'{ENDPOINT}/ocs/v1.php/cloud/groups', 55 | data={'groupid': GROUP, 'format': 'json'}, 56 | headers={'OCS-APIRequest': 'true'}) 57 | assert response == [] 58 | 59 | def test_get_group_members(self): # noqa: D102 60 | GROUP = 'FeelinAlright' 61 | GROUPUSER = 'JoeCocker' 62 | json_response = bytes(SIMPLE_100.format( 63 | r'{"users":["' 64 | f'{GROUPUSER}' 65 | r'"]}'), 'utf-8') 66 | with patch( 67 | 'httpx.AsyncClient.request', 68 | new_callable=AsyncMock, 69 | return_value=httpx.Response( 70 | status_code=100, 71 | content=json_response)) as mock: 72 | response = asyncio.run(self.ncc.get_group_members(GROUP)) 73 | mock.assert_called_with( 74 | method='GET', 75 | auth=(USER, PASSWORD), 76 | url=f'{ENDPOINT}/ocs/v1.php/cloud/groups/{GROUP}?format=json', 77 | data=None, 78 | headers={'OCS-APIRequest': 'true'}) 79 | assert GROUPUSER in response 80 | 81 | def test_get_group_subadmins(self): # noqa: D102 82 | GROUP = 'UMO' 83 | GROUPUSER = 'Ruban Nielson' 84 | json_response = bytes(SIMPLE_100.format( 85 | r'{"users":["' 86 | f'{GROUPUSER}' 87 | r'"]}'), 'utf-8') 88 | with patch( 89 | 'httpx.AsyncClient.request', 90 | new_callable=AsyncMock, 91 | return_value=httpx.Response( 92 | status_code=100, 93 | content=json_response)) as mock: 94 | response = asyncio.run(self.ncc.get_group_subadmins(GROUP)) 95 | mock.assert_called_with( 96 | method='GET', 97 | auth=(USER, PASSWORD), 98 | url=f'{ENDPOINT}/ocs/v1.php/cloud/groups/{GROUP}/subadmins?format=json', 99 | data=None, 100 | headers={'OCS-APIRequest': 'true'}) 101 | assert GROUPUSER in response['users'] 102 | 103 | def test_remove_group(self): # noqa: D102 104 | GROUP = 'BobMouldFanClub' 105 | with patch( 106 | 'httpx.AsyncClient.request', 107 | new_callable=AsyncMock, 108 | return_value=httpx.Response( 109 | status_code=100, 110 | content=EMPTY_100)) as mock: 111 | response = asyncio.run(self.ncc.remove_group(GROUP)) 112 | mock.assert_called_with( 113 | method='DELETE', 114 | auth=(USER, PASSWORD), 115 | url=f'{ENDPOINT}/ocs/v1.php/cloud/groups/{GROUP}', 116 | data={'format': 'json'}, 117 | headers={'OCS-APIRequest': 'true'}) 118 | assert response == [] 119 | -------------------------------------------------------------------------------- /nextcloud_async/__init__.py: -------------------------------------------------------------------------------- 1 | """Asynchronous client for Nextcloud. 2 | 3 | Reference: 4 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/index.html 5 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html 6 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-share-api.html 7 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-status-api.html 8 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html 9 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/RemoteWipe/index.html 10 | https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_auth_ldap_api.html 11 | https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/instruction_set_for_apps.html 12 | https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/instruction_set_for_users.html 13 | https://github.com/nextcloud/groupfolders#api 14 | https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md 15 | https://github.com/nextcloud/activity/blob/master/docs/endpoint-v2.md 16 | https://nextcloud-talk.readthedocs.io/en/latest/ 17 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/RemoteWipe/index.html 18 | https://github.com/nextcloud/maps/blob/master/openapi.yml 19 | 20 | # TODO: 21 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-share-api.html#federated-cloud-shares 22 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-sharee-api.html 23 | https://nextcloud-talk.readthedocs.io/en/latest/reaction/ 24 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-user-preferences-api.html 25 | https://github.com/nextcloud/cookbook/blob/0360f7184b0dee58a6dc1ec6068d40685756d1e0/docs/dev/api/0.0.4/openapi-cookbook.yaml 26 | https://git.mdns.eu/nextcloud/passwords/-/wikis/Developers/Index 27 | https://github.com/nextcloud/notes/tree/master/docs/api 28 | https://deck.readthedocs.io/en/latest/API/ 29 | https://sabre.io/dav/building-a-caldav-client/ 30 | https://sabre.io/dav/building-a-carddav-client/ 31 | https://nextcloud-talk.readthedocs.io/en/latest/poll/ 32 | """ 33 | 34 | from nextcloud_async.api.ocs import NextCloudOCSAPI 35 | from nextcloud_async.api.ocs.talk import NextCloudTalkAPI 36 | from nextcloud_async.api.ocs.users import UserManager 37 | from nextcloud_async.api.ocs.status import OCSStatusAPI 38 | from nextcloud_async.api.ocs.shares import OCSShareAPI 39 | from nextcloud_async.api.ocs.ldap import OCSLdapAPI 40 | from nextcloud_async.api.ocs.apps import AppManager 41 | from nextcloud_async.api.ocs.groups import GroupManager 42 | from nextcloud_async.api.ocs.groupfolders import GroupFolderManager 43 | from nextcloud_async.api.ocs.notifications import NotificationManager 44 | 45 | from nextcloud_async.api.dav import NextCloudDAVAPI 46 | from nextcloud_async.api.dav.files import FileManager 47 | 48 | from nextcloud_async.api.loginflow import LoginFlowV2 49 | from nextcloud_async.api.wipe import Wipe 50 | from nextcloud_async.api.maps import Maps 51 | 52 | 53 | class NextCloudAsync( 54 | NextCloudDAVAPI, 55 | NextCloudOCSAPI, 56 | OCSStatusAPI, 57 | OCSShareAPI, 58 | OCSLdapAPI, 59 | NextCloudTalkAPI, 60 | UserManager, 61 | GroupManager, 62 | AppManager, 63 | FileManager, 64 | GroupFolderManager, 65 | NotificationManager, 66 | LoginFlowV2, 67 | Wipe, 68 | Maps): 69 | """The Asynchronous Nextcloud Client. 70 | 71 | This project aims to provide an async-friendly python wrapper for all 72 | public APIs provided by the Nextcloud project. 73 | 74 | Currently covered: 75 | File Management API 76 | User Management API 77 | Group Management API 78 | App Management API 79 | LDAP Configuration API 80 | Status API 81 | Share API (except Federated shares) 82 | Talk/spreed API 83 | Notifications API 84 | Login Flow v2 API 85 | Remote Wipe API 86 | Maps API 87 | 88 | To do: 89 | Sharee API 90 | Reaction API 91 | User Preferences API 92 | Federated Shares API 93 | Cookbook API 94 | Passwords API 95 | Notes API 96 | Deck API 97 | Contacts API (CardDAV) 98 | Calendar API (CalDAV) 99 | Tasks API (CalDAV) 100 | Talk Polls 101 | 102 | Please open an issue if I am missing any APIs so they can be added: 103 | https://github.com/aaronsegura/nextcloud_async/issues 104 | 105 | ### Simple usage example 106 | 107 | > from nextcloud_async import NextCloudAsync 108 | > import httpx 109 | > import asyncio 110 | 111 | > u = 'user' 112 | > p = 'password' 113 | > e = 'https://cloud.example.com' 114 | > c = httpx.AsyncClient() 115 | > nca = NextCloudAsync(client=c, user=u, password=p, endpoint=e) 116 | > users = asyncio.run(nca.get_users()) 117 | > print(users) 118 | ['admin', 'slippinjimmy', 'chunks', 'flipper', 'squishface'] 119 | """ 120 | 121 | pass 122 | -------------------------------------------------------------------------------- /tests/test_ldap.py: -------------------------------------------------------------------------------- 1 | 2 | from nextcloud_async.helpers import recursive_urlencode 3 | from .base import BaseTestCase 4 | from .helpers import AsyncMock 5 | from .constants import ( 6 | USER, ENDPOINT, PASSWORD, SIMPLE_100, EMPTY_200) 7 | 8 | import asyncio 9 | import httpx 10 | 11 | from unittest.mock import patch 12 | 13 | 14 | class OCSLdapAPI(BaseTestCase): 15 | 16 | def test_create_ldap_config(self): 17 | NEW_CONFIG = 's01' 18 | xml_response = bytes(SIMPLE_100.format( 19 | r'{"configID": ' 20 | f'"{NEW_CONFIG}"' 21 | r'}'), 'utf-8') 22 | with patch( 23 | 'httpx.AsyncClient.request', 24 | new_callable=AsyncMock, 25 | return_value=httpx.Response( 26 | status_code=200, 27 | content=xml_response)) as mock: 28 | response = asyncio.run(self.ncc.create_ldap_config()) 29 | mock.assert_called_with( 30 | method='POST', 31 | auth=(USER, PASSWORD), 32 | url=f'{ENDPOINT}/ocs/v2.php/apps/user_ldap/api/v1/config', 33 | data={"format": "json"}, 34 | headers={'OCS-APIRequest': 'true'}) 35 | assert NEW_CONFIG in response['configID'] 36 | 37 | def test_remove_ldap_config(self): 38 | CONFIG = 's01' 39 | with patch( 40 | 'httpx.AsyncClient.request', 41 | new_callable=AsyncMock, 42 | return_value=httpx.Response( 43 | status_code=200, 44 | content=EMPTY_200)) as mock: 45 | response = asyncio.run(self.ncc.remove_ldap_config(CONFIG)) 46 | mock.assert_called_with( 47 | method='DELETE', 48 | auth=(USER, PASSWORD), 49 | url=f'{ENDPOINT}/ocs/v2.php/apps/user_ldap/api/v1/config/{CONFIG}', 50 | data={"format": "json"}, 51 | headers={'OCS-APIRequest': 'true'}) 52 | assert response == [] 53 | 54 | def test_get_ldap_config(self): 55 | CONFIG = 's01' 56 | xml_response = bytes( 57 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},' 58 | '"data":{"ldapHost":"","ldapPort":"","ldapBackupHost":"","ldapBa' 59 | 'ckupPort":"","ldapBase":"","ldapBaseUsers":"","ldapBaseGroups":' 60 | '"","ldapAgentName":"","ldapAgentPassword":"***","ldapTLS":"0","' 61 | 'turnOffCertCheck":"0","ldapIgnoreNamingRules":"","ldapUserDispl' 62 | 'ayName":"displayName","ldapUserDisplayName2":"","ldapUserAvatar' 63 | 'Rule":"default","ldapGidNumber":"gidNumber","ldapUserFilterObje' 64 | 'ctclass":"","ldapUserFilterGroups":"","ldapUserFilter":"","ldap' 65 | 'UserFilterMode":"0","ldapGroupFilter":"","ldapGroupFilterMode":' 66 | '"0","ldapGroupFilterObjectclass":"","ldapGroupFilterGroups":"",' 67 | '"ldapGroupDisplayName":"cn","ldapGroupMemberAssocAttr":"","ldap' 68 | 'LoginFilter":"","ldapLoginFilterMode":"0","ldapLoginFilterEmail' 69 | '":"0","ldapLoginFilterUsername":"1","ldapLoginFilterAttributes"' 70 | ':"","ldapQuotaAttribute":"","ldapQuotaDefault":"","ldapEmailAtt' 71 | 'ribute":"","ldapCacheTTL":"600","ldapUuidUserAttribute":"auto",' 72 | '"ldapUuidGroupAttribute":"auto","ldapOverrideMainServer":"","ld' 73 | 'apConfigurationActive":"","ldapAttributesForUserSearch":"","lda' 74 | 'pAttributesForGroupSearch":"","ldapExperiencedAdmin":"0","homeF' 75 | 'olderNamingRule":"","hasMemberOfFilterSupport":"0","useMemberOf' 76 | 'ToDetectMembership":"1","ldapExpertUsernameAttr":"","ldapExpert' 77 | 'UUIDUserAttr":"","ldapExpertUUIDGroupAttr":"","lastJpegPhotoLoo' 78 | 'kup":"0","ldapNestedGroups":"0","ldapPagingSize":"500","turnOnP' 79 | 'asswordChange":"0","ldapDynamicGroupMemberURL":"","ldapDefaultP' 80 | 'PolicyDN":"","ldapExtStorageHomeAttribute":"","ldapMatchingRule' 81 | r'InChainState":"unknown"}}}', 'utf-8') 82 | with patch( 83 | 'httpx.AsyncClient.request', 84 | new_callable=AsyncMock, 85 | return_value=httpx.Response( 86 | status_code=200, 87 | content=xml_response)) as mock: 88 | asyncio.run(self.ncc.get_ldap_config(CONFIG)) 89 | mock.assert_called_with( 90 | method='GET', 91 | auth=(USER, PASSWORD), 92 | url=f'{ENDPOINT}/ocs/v2.php/apps/user_ldap/api/v1/config/{CONFIG}?format=json', 93 | data=None, 94 | headers={'OCS-APIRequest': 'true'}) 95 | 96 | def test_set_ldap_config(self): 97 | CONFIG = 's01' 98 | CONFIG_DATA = { 99 | 'configData': 100 | { 101 | 'ldapLoginFilter': 'Randall Carlson', 102 | 'ldapGroupFilterMode': 'Graham Hancock' 103 | } 104 | } 105 | URL_DATA = recursive_urlencode(CONFIG_DATA) 106 | with patch( 107 | 'httpx.AsyncClient.request', 108 | new_callable=AsyncMock, 109 | return_value=httpx.Response( 110 | status_code=200, 111 | content=EMPTY_200)) as mock: 112 | asyncio.run(self.ncc.set_ldap_config(CONFIG, CONFIG_DATA)) 113 | mock.assert_called_with( 114 | method='PUT', 115 | auth=(USER, PASSWORD), 116 | url=f'{ENDPOINT}/ocs/v2.php/apps/user_ldap/api/v1/config/{CONFIG}?{URL_DATA}', 117 | data={"format": "json"}, 118 | headers={'OCS-APIRequest': 'true'}) 119 | -------------------------------------------------------------------------------- /tests/test_shares.py: -------------------------------------------------------------------------------- 1 | # noqa: D100 2 | 3 | from nextcloud_async.helpers import recursive_urlencode 4 | from .base import BaseTestCase 5 | from .helpers import AsyncMock 6 | from .constants import USER, ENDPOINT, PASSWORD 7 | 8 | import asyncio 9 | import httpx 10 | 11 | from unittest.mock import patch 12 | 13 | 14 | class OCSShareAPI(BaseTestCase): # noqa: D101 15 | 16 | def test_get_all_shares(self): # noqa: D102 17 | json_response = bytes( 18 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"' 19 | 'data":[{"id":"1","share_type":0,"uid_owner":"admin","displayname' 20 | '_owner":"admin","permissions":19,"can_edit":true,"can_delete":tr' 21 | 'ue,"stime":1656094271,"parent":null,"expiration":null,"token":nu' 22 | 'll,"uid_file_owner":"admin","note":"","label":null,"displayname_' 23 | 'file_owner":"admin","path":"\\/Nextcloud Manual.pdf","item_type"' 24 | ':"file","mimetype":"application\\/pdf","has_preview":false,"stor' 25 | 'age_id":"home::admin","storage":1,"item_source":30,"file_source"' 26 | ':30,"file_parent":2,"file_target":"\\/Nextcloud Manual.pdf","sha' 27 | 're_with":"testuser","share_with_displayname":"Test User","share_' 28 | 'with_displayname_unique":"test@example.com","status":[],"mail_se' 29 | 'nd":0,"hide_download":0}]}}', 'utf-8') 30 | with patch( 31 | 'httpx.AsyncClient.request', 32 | new_callable=AsyncMock, 33 | return_value=httpx.Response( 34 | status_code=200, 35 | content=json_response)) as mock: 36 | asyncio.run(self.ncc.get_all_shares()) 37 | mock.assert_called_with( 38 | method='GET', 39 | auth=(USER, PASSWORD), 40 | url=f'{ENDPOINT}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json', 41 | data=None, 42 | headers={'OCS-APIRequest': 'true'}) 43 | 44 | def test_get_file_shares(self): # noqa: D102 45 | PATH = b'' 46 | RESHARES = 'True' 47 | SUBFILES = 'True' 48 | json_response = bytes( 49 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"' 50 | 'data":[{"id":"1","share_type":0,"uid_owner":"admin","displayname' 51 | '_owner":"admin","permissions":19,"can_edit":true,"can_delete":tr' 52 | 'ue,"stime":1656094271,"parent":null,"expiration":null,"token":nu' 53 | 'll,"uid_file_owner":"admin","note":"","label":null,"displayname_' 54 | 'file_owner":"admin","path":"\\/Nextcloud Manual.pdf","item_type"' 55 | ':"file","mimetype":"application\\/pdf","has_preview":false,"stor' 56 | 'age_id":"home::admin","storage":1,"item_source":30,"file_source"' 57 | ':30,"file_parent":2,"file_target":"\\/Nextcloud Manual.pdf","sha' 58 | 're_with":"testuser","share_with_displayname":"Test User","share_' 59 | 'with_displayname_unique":"test@example.com","status":[],"mail_se' 60 | 'nd":0,"hide_download":0}]}}', 'utf-8') 61 | with patch( 62 | 'httpx.AsyncClient.request', 63 | new_callable=AsyncMock, 64 | return_value=httpx.Response( 65 | status_code=200, 66 | content=json_response)) as mock: 67 | urldata = recursive_urlencode({ 68 | 'path': PATH, 69 | 'reshares': RESHARES, 70 | 'subfiles': SUBFILES}) 71 | asyncio.run(self.ncc.get_file_shares(PATH, RESHARES, SUBFILES)) 72 | mock.assert_called_with( 73 | method='GET', 74 | auth=(USER, PASSWORD), 75 | url=f'{ENDPOINT}/ocs/v2.php/apps/files_sharing/api/v1/shares?' 76 | f'{urldata}&format=json', 77 | data=None, 78 | headers={'OCS-APIRequest': 'true'}) 79 | 80 | def test_get_share(self): # noqa: D102 81 | SHARE_ID = 1 82 | json_response = bytes( 83 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"' 84 | f'data":[{{"id":"{SHARE_ID}","share_type":0,"uid_owner":"admin","displayname' 85 | '_owner":"admin","permissions":19,"can_edit":true,"can_delete":tr' 86 | 'ue,"stime":1656094271,"parent":null,"expiration":null,"token":nu' 87 | 'll,"uid_file_owner":"admin","note":"","label":null,"displayname_' 88 | 'file_owner":"admin","path":"\\/Nextcloud Manual.pdf","item_type"' 89 | ':"file","mimetype":"application\\/pdf","has_preview":false,"stor' 90 | 'age_id":"home::admin","storage":1,"item_source":30,"file_source"' 91 | ':30,"file_parent":2,"file_target":"\\/Nextcloud Manual.pdf","sha' 92 | 're_with":"testuser","share_with_displayname":"Test User","share_' 93 | 'with_displayname_unique":"test@example.com","status":[],"mail_se' 94 | 'nd":0,"hide_download":0}]}}', 'utf-8') 95 | with patch( 96 | 'httpx.AsyncClient.request', 97 | new_callable=AsyncMock, 98 | return_value=httpx.Response( 99 | status_code=200, 100 | content=json_response)) as mock: 101 | response = asyncio.run(self.ncc.get_share(SHARE_ID)) 102 | mock.assert_called_with( 103 | method='GET', 104 | auth=(USER, PASSWORD), 105 | url=f'{ENDPOINT}/ocs/v2.php/apps/files_sharing/api' 106 | f'/v1/shares/{SHARE_ID}?share_id={SHARE_ID}&format=json', 107 | data=None, 108 | headers={'OCS-APIRequest': 'true'}) 109 | assert isinstance(response, dict) 110 | 111 | # TODO: Finish shares api tests 112 | -------------------------------------------------------------------------------- /nextcloud_async/api/ocs/status.py: -------------------------------------------------------------------------------- 1 | """Interact with Nextcloud Status API. 2 | 3 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-status-api.html 4 | """ 5 | 6 | import datetime as dt 7 | 8 | from enum import Enum, auto 9 | from typing import Optional, Union 10 | 11 | from nextcloud_async.exceptions import NextCloudException 12 | 13 | 14 | class StatusType(Enum): 15 | """Status Types.""" 16 | 17 | online = auto() 18 | away = auto() 19 | dnd = auto() 20 | invisible = auto() 21 | offline = auto() 22 | 23 | 24 | class OCSStatusAPI(object): 25 | """Manage a user's status on Nextcloud instances.""" 26 | 27 | async def get_status(self): 28 | """Get current status. 29 | 30 | Returns 31 | ------- 32 | dict: Status description 33 | 34 | """ 35 | return await self.ocs_query( 36 | method='GET', 37 | sub=r'/ocs/v2.php/apps/user_status/api/v1/user_status') 38 | 39 | async def set_status(self, status_type: StatusType): 40 | """Set user status. 41 | 42 | Args 43 | ---- 44 | status_type (StatusType): See StatusType Enum 45 | 46 | Returns 47 | ------- 48 | dict: New status description. 49 | 50 | """ 51 | return await self.ocs_query( 52 | method='PUT', 53 | sub=r'/ocs/v2.php/apps/user_status/api/v1/user_status/status', 54 | data={'statusType': status_type.name}) 55 | 56 | def __validate_future_timestamp(self, ts: Union[float, int]) -> None: 57 | """Verify the given unix timestamp is valid and in the future. 58 | 59 | Args 60 | ---- 61 | ts (float or int): Timestamp 62 | 63 | Raises 64 | ------ 65 | NextCloudException: Invalid timestamp or timestamp in the past 66 | 67 | """ 68 | try: 69 | clear_dt = dt.datetime.fromtimestamp(ts) 70 | except (TypeError, ValueError): 71 | raise NextCloudException('Invalid `clear_at`. Should be unix timestamp.') 72 | 73 | now = dt.datetime.now() 74 | if clear_dt <= now: 75 | raise NextCloudException('Invalid `clear_at`. Should be in the future.') 76 | 77 | async def get_predefined_statuses(self): 78 | """Get list of predefined statuses. 79 | 80 | Returns 81 | ------- 82 | list: Predefined statuses 83 | 84 | """ 85 | return await self.ocs_query( 86 | method='GET', 87 | sub=r'/ocs/v2.php/apps/user_status/api/v1/predefined_statuses') 88 | 89 | async def choose_predefined_status( 90 | self, 91 | message_id: int, 92 | clear_at: Union[int, None] = None): 93 | """Choose from predefined status messages. 94 | 95 | Args 96 | ---- 97 | message_id (int): Message ID (see get_predefined_statuses()) 98 | 99 | clear_at (int, optional): Unix timestamp at which to clear this status. Defaults 100 | to None. 101 | 102 | Returns 103 | ------- 104 | dict: New status description 105 | 106 | """ 107 | data = {'messageId': message_id} 108 | if clear_at: 109 | self.__validate_future_timestamp(clear_at) 110 | data.update({'clearAt': clear_at}) 111 | return await self.ocs_query( 112 | method='PUT', 113 | sub=r'/ocs/v2.php/apps/user_status/api/v1/user_status/message/predefined', 114 | data=data) 115 | 116 | async def set_status_message( 117 | self, 118 | message: str, 119 | status_icon: Optional[str] = None, 120 | clear_at: Optional[int] = None): 121 | """Set a custom status message. 122 | 123 | Args 124 | ---- 125 | message (str): Your custom message 126 | 127 | status_icon (str, optional): Emoji icon. Defaults to None. 128 | 129 | clear_at (int, optional): Unix timestamp at which to clear this message. Defaults 130 | to None. 131 | 132 | Returns 133 | ------- 134 | dict: New status description 135 | 136 | """ 137 | data = {'message': message} 138 | if status_icon: 139 | data.update({'statusIcon': status_icon}) 140 | if clear_at: 141 | self.__validate_future_timestamp(clear_at) 142 | data.update({'clearAt': clear_at}) 143 | return await self.ocs_query( 144 | method='PUT', 145 | sub=r'/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom', 146 | data=data) 147 | 148 | async def clear_status_message(self): 149 | """Clear status message. 150 | 151 | Returns 152 | ------- 153 | Empty 200 Response 154 | 155 | """ 156 | return await self.ocs_query( 157 | method='DELETE', 158 | sub=r'/ocs/v2.php/apps/user_status/api/v1/user_status/message') 159 | 160 | async def get_all_user_statuses( 161 | self, 162 | limit: Optional[int] = 100, 163 | offset: Optional[int] = 0): 164 | """Get all user statuses. 165 | 166 | Args 167 | ---- 168 | limit (int, optional): Results per page. Defaults to 100. 169 | 170 | offset (int, optional): Paging offset. Defaults to 0. 171 | 172 | Returns 173 | ------- 174 | list: User statuses 175 | 176 | """ 177 | return await self.ocs_query( 178 | method='GET', 179 | sub=r'/ocs/v2.php/apps/user_status/api/v1/statuses', 180 | data={'limit': limit, 'offset': offset}) 181 | 182 | async def get_user_status(self, user: str): 183 | """Get the status for a specific user. 184 | 185 | Args 186 | ---- 187 | user (str): User ID 188 | 189 | Returns 190 | ------- 191 | dict: User status description 192 | 193 | """ 194 | return await self.ocs_query( 195 | method='GET', 196 | sub=f'/ocs/v2.php/apps/user_status/api/v1/statuses/{user}') 197 | -------------------------------------------------------------------------------- /tests/test_apps.py: -------------------------------------------------------------------------------- 1 | # noqa: D100 2 | from .base import BaseTestCase 3 | from .helpers import AsyncMock 4 | from .constants import ( 5 | USER, ENDPOINT, PASSWORD, EMPTY_100) 6 | 7 | import asyncio 8 | import httpx 9 | 10 | from unittest.mock import patch 11 | 12 | 13 | class OCSAppsAPI(BaseTestCase): # noqa: D101 14 | 15 | def test_get_app(self): # noqa: D102 16 | APP = 'files' 17 | json_response = bytes( 18 | '{"ocs":{"meta":{"status":"ok","statuscode":100,"message":"OK","t' 19 | f'otalitems":"","itemsperpage":""}},"data":{{"id":"{APP}","name":' 20 | f'"{APP}","summary":"File Management","description":"File Managem' 21 | 'ent","version":"1.19.0","licence":"agpl","author":["Robin Appelm' 22 | 'an","Vincent Petry"],"default_enable":"","types":["filesystem"],' 23 | '"documentation":{"user":"user-files"},"category":"files","bugs":' 24 | '"https:\\/\\/github.com\\/nextcloud\\/server\\/issues","dependen' 25 | 'cies":{"nextcloud":{"@attributes":{"min-version":"24","max-versi' 26 | r'on":"24"}}},"background-jobs":["OCA\\\\Files\\\\BackgroundJob' 27 | '\\\\ScanFiles","OCA\\\\Files\\\\BackgroundJob\\\\DeleteOrphanedI' 28 | 'tems","OCA\\\\Files\\\\BackgroundJob\\\\CleanupFileLocks","OCA' 29 | '\\\\Files\\\\BackgroundJob\\\\CleanupDirectEditingTokens"],"comm' 30 | 'ands":["OCA\\\\Files\\\\Command\\\\Scan","OCA\\\\Files\\\\Comman' 31 | 'd\\\\DeleteOrphanedFiles","OCA\\\\Files\\\\Command\\\\TransferOw' 32 | 'nership","OCA\\\\Files\\\\Command\\\\ScanAppData","OCA\\\\Files' 33 | '\\\\Command\\\\RepairTree"],"activity":{"settings":["OCA\\\\Fil' 34 | 'es\\\\Activity\\\\Settings\\\\FavoriteAction","OCA\\\\Files\\\\' 35 | 'Activity\\\\Settings\\\\FileChanged","OCA\\\\Files\\\\Activity' 36 | '\\\\Settings\\\\FileFavoriteChanged"],"filters":["OCA\\\\Files' 37 | '\\\\Activity\\\\Filter\\\\FileChanges","OCA\\\\Files\\\\Activity' 38 | '\\\\Filter\\\\Favorites"],"providers":["OCA\\\\Files\\\\Activity' 39 | '\\\\FavoriteProvider","OCA\\\\Files\\\\Activity\\\\Provider"]},"' 40 | 'navigations":{"navigation":[{"name":"Files","route":"files.view.' 41 | 'index","order":"0"}]},"settings":{"personal":["OCA\\\\Files\\\\S' 42 | 'ettings\\\\PersonalSettings"],"admin":[],"admin-section":[],"per' 43 | 'sonal-section":[]},"info":[],"remote":[],"public":[],"repair-ste' 44 | 'ps":{"install":[],"pre-migration":[],"post-migration":[],"live-m' 45 | 'igration":[],"uninstall":[]},"two-factor-providers":[]}}}', 'utf-8') 46 | with patch( 47 | 'httpx.AsyncClient.request', 48 | new_callable=AsyncMock, 49 | return_value=httpx.Response( 50 | status_code=100, 51 | content=json_response)) as mock: 52 | response = asyncio.run(self.ncc.get_app(APP)) 53 | mock.assert_called_with( 54 | method='GET', 55 | auth=(USER, PASSWORD), 56 | url=f'{ENDPOINT}/ocs/v1.php/cloud/apps/{APP}?format=json', 57 | data=None, 58 | headers={'OCS-APIRequest': 'true'}) 59 | 60 | match response: 61 | case {'id': APP}: 62 | pass 63 | case _: 64 | assert False 65 | 66 | def test_get_apps(self): # noqa: D102 67 | APPS = [ 68 | 'serverinfo', 69 | 'files_trashbin', 70 | 'weather_status', 71 | 'systemtags', 72 | 'files_external', 73 | 'encryption', 74 | 'spreed'] 75 | json_response = bytes( 76 | '{"ocs":{"meta":{"status":"ok","statuscode":100,"message":"OK",' 77 | '"totalitems":"","itemsperpage":""},"data":{"apps":["serverinfo' 78 | '","files_trashbin","weather_status","systemtags","files_extern' 79 | 'al","encryption","spreed"]}}}', 'utf-8') 80 | with patch( 81 | 'httpx.AsyncClient.request', 82 | new_callable=AsyncMock, 83 | return_value=httpx.Response( 84 | status_code=100, 85 | content=json_response)) as mock: 86 | response = asyncio.run(self.ncc.get_apps()) 87 | mock.assert_called_with( 88 | method='GET', 89 | auth=(USER, PASSWORD), 90 | url=f'{ENDPOINT}/ocs/v1.php/cloud/apps?format=json', 91 | data=None, 92 | headers={'OCS-APIRequest': 'true'}) 93 | for app in APPS: 94 | assert app in response 95 | 96 | def test_enable_app(self): # noqa: D102 97 | APP = 'FavoriteThing' 98 | with patch( 99 | 'httpx.AsyncClient.request', 100 | new_callable=AsyncMock, 101 | return_value=httpx.Response( 102 | status_code=100, 103 | content=EMPTY_100)) as mock: 104 | response = asyncio.run(self.ncc.enable_app(APP)) 105 | mock.assert_called_with( 106 | method='POST', 107 | auth=(USER, PASSWORD), 108 | url=f'{ENDPOINT}/ocs/v1.php/cloud/apps/{APP}', 109 | data={'format': 'json'}, 110 | headers={'OCS-APIRequest': 'true'}) 111 | assert response == [] 112 | 113 | def test_disable_app(self): # noqa: D102 114 | APP = 'FavoriteThing' 115 | with patch( 116 | 'httpx.AsyncClient.request', 117 | new_callable=AsyncMock, 118 | return_value=httpx.Response( 119 | status_code=100, 120 | content=EMPTY_100)) as mock: 121 | response = asyncio.run(self.ncc.disable_app(APP)) 122 | mock.assert_called_with( 123 | method='DELETE', 124 | auth=(USER, PASSWORD), 125 | url=f'{ENDPOINT}/ocs/v1.php/cloud/apps/{APP}', 126 | data={'format': 'json'}, 127 | headers={'OCS-APIRequest': 'true'}) 128 | assert response == [] 129 | -------------------------------------------------------------------------------- /nextcloud_async/api/ocs/__init__.py: -------------------------------------------------------------------------------- 1 | """Request Wrapper for Nextcloud OCS APIs. 2 | 3 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html 4 | """ 5 | 6 | import json 7 | 8 | from typing import Dict, Any, Optional, List 9 | 10 | from nextcloud_async.api import NextCloudBaseAPI 11 | from nextcloud_async.exceptions import NextCloudException 12 | 13 | 14 | class NextCloudOCSAPI(NextCloudBaseAPI): 15 | """Nextcloud OCS API. 16 | 17 | All OCS queries must have an {'OCS-APIRequest': 'true'} header. Additionally, we 18 | request all data to be returned to us in json format. 19 | """ 20 | 21 | __capabilities = None 22 | 23 | async def ocs_query( 24 | self, 25 | method: str = 'GET', 26 | url: str = None, 27 | sub: str = '', 28 | data: Dict[Any, Any] = {}, 29 | headers: Dict[Any, Any] = {}, 30 | include_headers: Optional[List] = []) -> Dict: 31 | """Submit OCS-type query to cloud endpoint. 32 | 33 | Args 34 | ---- 35 | method (str): HTTP Method (eg, `GET`, `POST`, etc...) 36 | 37 | url (str, optional): Use a URL outside of the given endpoint. Defaults to None. 38 | 39 | sub (str, optional): The portion of the URL after the host. Defaults to ''. 40 | 41 | data (Dict, optional): Data for submission. Data for GET requests is translated by 42 | urlencode and tacked on to the end of the URL as arguments. Defaults to {}. 43 | 44 | headers (Dict, optional): Headers for submission. Defaults to {}. 45 | 46 | include_headers (List, optional): Return these headers from response. 47 | Defaults to []. 48 | 49 | Raises 50 | ------ 51 | NextCloudException: Server API Errors 52 | 53 | Returns 54 | ------- 55 | Dict: Response Data 56 | 57 | The OCS Endpoint returns metadata about the response in addition to the data 58 | what was requested. The metadata is stripped after checking for request 59 | success, and only the data portion of the response is returned. 60 | 61 | >>> response = await self.ocs_query(sub='/ocs/v1.php/cloud/capabilities') 62 | 63 | Dict, Dict: Response Data and Included Headers 64 | 65 | If ocs_query() is called with an `include_headers` argument, both response data 66 | and the requested headers are returned. 67 | 68 | >>> response, headers = await self.ocs_query(..., include_headers=['Some-Header']) 69 | 70 | """ 71 | response_headers = {} 72 | headers.update({'OCS-APIRequest': 'true'}) 73 | data.update({"format": "json"}) 74 | 75 | response = await self.request( 76 | method, url=url, sub=sub, data=data, headers=headers) 77 | 78 | if response.content: 79 | response_content = json.loads(response.content.decode('utf-8')) 80 | ocs_meta = response_content['ocs']['meta'] 81 | if ocs_meta['status'] != 'ok': 82 | raise NextCloudException( 83 | status_code=ocs_meta['statuscode'], 84 | reason=ocs_meta['message']) 85 | else: 86 | response_data = response_content['ocs']['data'] 87 | if include_headers: 88 | for header in include_headers: 89 | response_headers.setdefault(header, response.headers.get(header, None)) 90 | return response_data, response_headers 91 | else: 92 | return response_data 93 | else: 94 | return None 95 | 96 | async def get_capabilities(self, capability: Optional[str] = None) -> Dict: 97 | """Return capabilities for this server. 98 | 99 | Args 100 | ---- 101 | slice (str optional): Only return specific portion of results. Defaults to ''. 102 | 103 | Raises 104 | ------ 105 | NextCloudException(404) on capability mising 106 | NextCloudException(400) invalid capability string 107 | 108 | Returns 109 | ------- 110 | Dict: Capabilities filtered by slice. 111 | 112 | """ 113 | if not self.__capabilities: 114 | self.__capabilities = await self.ocs_query( 115 | method='GET', 116 | sub=r'/ocs/v1.php/cloud/capabilities') 117 | ret = self.__capabilities 118 | 119 | if capability: 120 | if isinstance(capability, str): 121 | for item in capability.split('.'): 122 | if item in ret: 123 | try: 124 | ret = ret[item] 125 | except TypeError: 126 | raise NextCloudException(status_code=404, reason=f'Capability not found: {item}') 127 | else: 128 | raise NextCloudException(status_code=404, reason=f'Capability not found: {item}') 129 | else: 130 | raise NextCloudException(status_code=400, reason=f'`capability` must be a string.') 131 | 132 | return ret 133 | 134 | async def get_file_guest_link(self, file_id: int) -> str: 135 | """Generate a generic sharable link for a file. 136 | 137 | Link expires in 8 hours. 138 | 139 | Args 140 | ---- 141 | file_id (int): File ID to generate link for 142 | 143 | Returns 144 | ------- 145 | str: Link to file 146 | 147 | """ 148 | result = await self.ocs_query( 149 | method='POST', 150 | sub=r'/ocs/v2.php/apps/dav/api/v1/direct', 151 | data={'fileId': file_id}) 152 | return result['url'] 153 | 154 | async def get_activity( 155 | self, 156 | since: Optional[int] = 0, 157 | object_id: Optional[str] = None, 158 | object_type: Optional[str] = None, 159 | sort: Optional[str] = 'desc', 160 | limit: Optional[int] = 50): 161 | """Get Recent activity for the current user. 162 | 163 | Args 164 | ---- 165 | since (int optional): Only return ativity since activity with given ID. Defaults 166 | to 0. 167 | 168 | object_id (str optional): object_id filter. Defaults to None. 169 | 170 | object_type (str optional): object_type filter. Defaults to None. 171 | 172 | sort (str optional): Sort order; either `asc` or `desc`. Defaults to 'desc'. 173 | 174 | limit (int optional): How many results per request. Defaults to 50. 175 | 176 | Raises 177 | ------ 178 | NextCloudException: When given invalid argument combination 179 | 180 | Returns 181 | ------- 182 | dict: activity results 183 | 184 | """ 185 | data = {} 186 | filter = '' 187 | if object_id and object_type: 188 | filter = '/filter' 189 | data.update({ 190 | 'object_type': object_type, 191 | 'object_id': object_id}) 192 | elif object_id or object_type: 193 | raise NextCloudException( 194 | 'filter_object_type and filter_object are both required.') 195 | 196 | data.update({ 197 | 'limit': limit, 198 | 'sort': sort, 199 | 'since': since}) 200 | 201 | return await self.ocs_query( 202 | method='GET', 203 | sub=f'/ocs/v2.php/apps/activity/api/v2/activity{filter}', 204 | data=data, 205 | include_headers=['X-Activity-First-Known', 'X-Activity-Last-Given']) 206 | -------------------------------------------------------------------------------- /nextcloud_async/api/ocs/groupfolders.py: -------------------------------------------------------------------------------- 1 | """Implement Nextcloud Group Folders Interaction. 2 | 3 | https://github.com/nextcloud/groupfolders#api 4 | """ 5 | 6 | from enum import IntFlag 7 | 8 | 9 | class Permissions(IntFlag): 10 | """Groupfolders Permissions.""" 11 | 12 | read = 1 13 | update = 2 14 | create = 4 15 | delete = 8 16 | share = 16 17 | all = 31 18 | 19 | 20 | class GroupFolderManager(object): 21 | """Manage Group Folders. 22 | 23 | Must have Group Folders application enabled on server. 24 | """ 25 | 26 | async def get_all_group_folders(self): 27 | """Get list of all group folders. 28 | 29 | Returns 30 | ------- 31 | list: List of group folders. 32 | 33 | """ 34 | response = await self.ocs_query( 35 | method='GET', 36 | sub='/apps/groupfolders/folders') 37 | if isinstance(response, dict): 38 | return [response] 39 | return response 40 | 41 | async def create_group_folder(self, path: str): 42 | """Create new group folder. 43 | 44 | Args 45 | ---- 46 | path (str): Path of new group folder. 47 | 48 | Returns 49 | ------- 50 | dict: New groupfolder id 51 | 52 | Example: 53 | 54 | { 'id': 1 } 55 | 56 | """ 57 | return await self.ocs_query( 58 | method='POST', 59 | sub='/apps/groupfolders/folders', 60 | data={'mountpoint': path}) 61 | 62 | async def get_group_folder(self, folder_id: int): 63 | """Get group folder with id `folder_id`. 64 | 65 | Args 66 | ---- 67 | folder_id (int): Group folder ID 68 | 69 | Returns 70 | ------- 71 | dict: Group folder description. 72 | 73 | """ 74 | return await self.ocs_query( 75 | method='GET', 76 | sub=f'/apps/groupfolders/folders/{folder_id}') 77 | 78 | async def remove_group_folder(self, folder_id: int): 79 | """Delete group folder with id `folder_id`. 80 | 81 | Args 82 | ---- 83 | folder_id (int): Group folder ID 84 | 85 | Returns 86 | ------- 87 | dict: { 'success': True|False } 88 | 89 | """ 90 | return await self.ocs_query( 91 | method='DELETE', 92 | sub=f'/apps/groupfolders/folders/{folder_id}') 93 | 94 | async def add_group_to_group_folder(self, group_id: str, folder_id: int): 95 | """Give `group_id` access to `folder_id`. 96 | 97 | Args 98 | ---- 99 | group_id (str): Group ID 100 | 101 | folder_id (int): Folder ID 102 | 103 | Returns 104 | ------- 105 | dict: { 'success': True|False } 106 | 107 | """ 108 | return await self.ocs_query( 109 | method='POST', 110 | sub=f'/apps/groupfolders/folders/{folder_id}/groups', 111 | data={'group': group_id}) 112 | 113 | async def remove_group_from_group_folder(self, group_id: str, folder_id: int): 114 | """Remove `group_id` access from `folder_id`. 115 | 116 | Args 117 | ---- 118 | group_id (str): Group ID 119 | 120 | folder_id (int): Folder ID 121 | 122 | Returns 123 | ------- 124 | dict: { 'success': True|False } 125 | 126 | """ 127 | return await self.ocs_query( 128 | method='DELETE', 129 | sub=f'/apps/groupfolders/folders/{folder_id}/groups/{group_id}') 130 | 131 | async def enable_group_folder_advanced_permissions(self, folder_id: int): 132 | """Enable advanced permissions on `folder_id`. 133 | 134 | Args 135 | ---- 136 | folder_id (int): Folder ID 137 | 138 | Returns 139 | ------- 140 | dict: { 'success': True|False } 141 | 142 | """ 143 | return await self.__advanced_permissions(folder_id, True) 144 | 145 | async def disable_group_folder_advanced_permissions(self, folder_id: int): 146 | """Disable advanced permissions on `folder_id`. 147 | 148 | Args 149 | ---- 150 | folder_id (int): Folder ID 151 | 152 | Returns 153 | ------- 154 | dict: { 'success': True|False } 155 | 156 | """ 157 | return await self.__advanced_permissions(folder_id, False) 158 | 159 | async def __advanced_permissions(self, folder_id: int, enable: bool): 160 | return await self.ocs_query( 161 | method='POST', 162 | sub=f'/apps/groupfolders/folders/{folder_id}/acl', 163 | data={'acl': 1 if enable else 0}) 164 | 165 | async def add_group_folder_advanced_permissions( 166 | self, 167 | folder_id: int, 168 | object_id: str, 169 | object_type: str): 170 | """Enable `object_id` as manager of advanced permissions. 171 | 172 | Args 173 | ---- 174 | folder_id (int): Folder ID 175 | 176 | object_id (str): Object ID 177 | 178 | object_type (str): either `user` or `group` 179 | 180 | Returns 181 | ------- 182 | dict: { 'success': True|False } 183 | 184 | """ 185 | return await self.__advanced_permissions_admin( 186 | folder_id, 187 | object_id=object_id, 188 | object_type=object_type, 189 | manage_acl=True) 190 | 191 | async def remove_group_folder_advanced_permissions( 192 | self, 193 | folder_id: int, 194 | object_id: str, 195 | object_type: str): 196 | """Disable `object_id` as manager of advanced permissions. 197 | 198 | Args 199 | ---- 200 | folder_id (int): Folder ID 201 | 202 | object_id (str): Object ID 203 | 204 | object_type (str): either `user` or `group` 205 | 206 | Returns 207 | ------- 208 | dict: { 'success': True|False } 209 | 210 | """ 211 | return await self.__advanced_permissions_admin( 212 | folder_id, 213 | object_id=object_id, 214 | object_type=object_type, 215 | manage_acl=False) 216 | 217 | async def __advanced_permissions_admin( 218 | self, 219 | folder_id: int, 220 | object_id: str, 221 | object_type: str, 222 | manage_acl: bool): 223 | return await self.ocs_query( 224 | method='POST', 225 | sub=f'/apps/groupfolders/folders/{folder_id}/manageACL', 226 | data={ 227 | 'mappingId': object_id, 228 | 'mappingType': object_type, 229 | 'manageAcl': manage_acl}) 230 | 231 | async def set_group_folder_permissions( 232 | self, 233 | folder_id: int, 234 | group_id: str, 235 | permissions: Permissions): 236 | """Set permissions a group has in a folder. 237 | 238 | Args 239 | ---- 240 | folder_id (int): Folder ID 241 | 242 | group_id (str): Group ID 243 | 244 | permissions (Permissions): New permissions. 245 | 246 | Returns 247 | ------- 248 | dict: { 'success': True|False } 249 | 250 | """ 251 | return await self.ocs_query( 252 | method='POST', 253 | sub=f'/apps/groupfolders/folders/{folder_id}/groups/{group_id}', 254 | data={'permissions': permissions.value}) 255 | 256 | async def set_group_folder_quota(self, folder_id: int, quota: int): 257 | """Set quota for group folder. 258 | 259 | Args 260 | ---- 261 | folder_id (int): Folder ID 262 | 263 | quota (int): Quota in bytes. -3 for unlimited. 264 | 265 | Returns 266 | ------- 267 | dict: { 'success': True|False } 268 | 269 | """ 270 | return await self.ocs_query( 271 | method='POST', 272 | sub=f'/apps/groupfolders/folders/{folder_id}/quota', 273 | data={'quota': quota}) 274 | 275 | async def rename_group_folder(self, folder_id: int, mountpoint: str): 276 | """Rename a group folder. 277 | 278 | Args 279 | ---- 280 | folder_id (int): Folder ID 281 | 282 | mountpoint (str): New mount point. 283 | 284 | Returns 285 | ------- 286 | dict: { 'success': True|False } 287 | 288 | """ 289 | return await self.ocs_query( 290 | method='POST', 291 | sub=f'/apps/groupfolders/folders/{folder_id}/mountpoint', 292 | data={'mountpoint': mountpoint}) 293 | -------------------------------------------------------------------------------- /tests/test_status.py: -------------------------------------------------------------------------------- 1 | # noqa: D100 2 | 3 | from nextcloud_async.api.ocs.status import StatusType as ST 4 | 5 | from .base import BaseTestCase 6 | from .helpers import AsyncMock 7 | from .constants import USER, ENDPOINT, PASSWORD, EMPTY_200 8 | 9 | import asyncio 10 | import httpx 11 | import datetime as dt 12 | 13 | from unittest.mock import patch 14 | 15 | CLEAR_AT = (dt.datetime.now() + dt.timedelta(seconds=300)).timestamp() 16 | 17 | 18 | class OCSStatusAPI(BaseTestCase): # noqa: D101 19 | 20 | def test_get_status(self): # noqa: D102 21 | json_response = bytes( 22 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},' 23 | f'"data":{{"userId":"{USER}","message":null,"messageId":null,"mes' 24 | 'sageIsPredefined":false,"icon":null,"clearAt":null,"status":"awa' 25 | 'y","statusIsUserDefined":false}}}', 'utf-8') 26 | with patch( 27 | 'httpx.AsyncClient.request', 28 | new_callable=AsyncMock, 29 | return_value=httpx.Response( 30 | status_code=200, 31 | content=json_response)) as mock: 32 | asyncio.run(self.ncc.get_status()) 33 | mock.assert_called_with( 34 | method='GET', 35 | auth=(USER, PASSWORD), 36 | url=f'{ENDPOINT}/ocs/v2.php/apps/user_status/api/v1/user_status?format=json', 37 | data=None, 38 | headers={'OCS-APIRequest': 'true'}) 39 | 40 | def test_set_status(self): # noqa: D102 41 | STATUS = ST['away'] 42 | json_response = bytes( 43 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},' 44 | f'"data":{{"userId":"{USER}","message":null,"messageId":null,"mes' 45 | 'sageIsPredefined":false,"icon":null,"clearAt":null,"status":' 46 | f'"{STATUS.name}","statusIsUserDefined":true}}}}}}', 'utf-8') 47 | with patch( 48 | 'httpx.AsyncClient.request', 49 | new_callable=AsyncMock, 50 | return_value=httpx.Response( 51 | status_code=200, 52 | content=json_response)) as mock: 53 | asyncio.run(self.ncc.set_status(STATUS)) 54 | mock.assert_called_with( 55 | method='PUT', 56 | auth=(USER, PASSWORD), 57 | url=f'{ENDPOINT}/ocs/v2.php/apps/user_status/api/v1/user_status/status', 58 | data={'format': 'json', 'statusType': STATUS.name}, 59 | headers={'OCS-APIRequest': 'true'}) 60 | 61 | def test_get_predefined_statuses(self): # noqa: D102 62 | json_response = bytes( 63 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},' 64 | '"data":[{"id":"meeting","icon":"\\ud83d\\udcc5","message":"In a ' 65 | 'meeting","clearAt":{"type":"period","time":3600}},{"id":"commuti' 66 | 'ng","icon":"\\ud83d\\ude8c","message":"Commuting","clearAt":{"ty' 67 | 'pe":"period","time":1800}},{"id":"remote-work","icon":"\\ud83c\\' 68 | 'udfe1","message":"Working remotely","clearAt":{"type":"end-of","' 69 | 'time":"day"}},{"id":"sick-leave","icon":"\\ud83e\\udd12","messag' 70 | 'e":"Out sick","clearAt":{"type":"end-of","time":"day"}},{"id":"v' 71 | 'acationing","icon":"\\ud83c\\udf34","message":"Vacationing","cle' 72 | 'arAt":null}]}}', 'utf-8') 73 | with patch( 74 | 'httpx.AsyncClient.request', 75 | new_callable=AsyncMock, 76 | return_value=httpx.Response( 77 | status_code=200, 78 | content=json_response)) as mock: 79 | asyncio.run(self.ncc.get_predefined_statuses()) 80 | mock.assert_called_with( 81 | method='GET', 82 | auth=(USER, PASSWORD), 83 | url=f'{ENDPOINT}/ocs/v2.php/apps/user_status/api/v1/predefined_statuses?' 84 | 'format=json', 85 | data=None, 86 | headers={'OCS-APIRequest': 'true'}) 87 | 88 | def test_choose_predefined_status(self): # noqa: D102 89 | MESSAGEID = 'meeting' 90 | 91 | json_response = bytes( 92 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},' 93 | f'"data":{{"userId":"{USER}","message":null,"messageId":"{MESSAGEID}"' 94 | f',"messageIsPredefined":true,"icon":null,"clearAt":{CLEAR_AT},"statu' 95 | 's":"away","statusIsUserDefined":true}}}', 'utf-8') 96 | with patch( 97 | 'httpx.AsyncClient.request', 98 | new_callable=AsyncMock, 99 | return_value=httpx.Response( 100 | status_code=200, 101 | content=json_response)) as mock: 102 | asyncio.run(self.ncc.choose_predefined_status(MESSAGEID, CLEAR_AT)) 103 | mock.assert_called_with( 104 | method='PUT', 105 | auth=(USER, PASSWORD), 106 | url=f'{ENDPOINT}/ocs/v2.php/apps/user_status/api/v1' 107 | '/user_status/message/predefined', 108 | data={'format': 'json', 'messageId': MESSAGEID, 'clearAt': CLEAR_AT}, 109 | headers={'OCS-APIRequest': 'true'}) 110 | 111 | def test_set_status_message(self): # noqa: D102 112 | MESSAGE = 'Stinkfist' 113 | json_response = bytes( 114 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},' 115 | f'"data":{{"userId":"admin","message":"{MESSAGE}","messageId":nul' 116 | f'l,"messageIsPredefined":false,"icon":null,"clearAt":{CLEAR_AT},' 117 | '"status":"away","statusIsUserDefined":true}}}', 'utf-8') 118 | with patch( 119 | 'httpx.AsyncClient.request', 120 | new_callable=AsyncMock, 121 | return_value=httpx.Response( 122 | status_code=200, 123 | content=json_response)) as mock: 124 | asyncio.run(self.ncc.set_status_message(MESSAGE, clear_at=CLEAR_AT)) 125 | mock.assert_called_with( 126 | method='PUT', 127 | auth=(USER, PASSWORD), 128 | url=f'{ENDPOINT}/ocs/v2.php/apps/user_status/api/v1' 129 | '/user_status/message/custom', 130 | data={'format': 'json', 'message': MESSAGE, 'clearAt': CLEAR_AT}, 131 | headers={'OCS-APIRequest': 'true'}) 132 | 133 | def test_clear_status_message(self): # noqa: D102 134 | with patch( 135 | 'httpx.AsyncClient.request', 136 | new_callable=AsyncMock, 137 | return_value=httpx.Response( 138 | status_code=200, 139 | content=EMPTY_200)) as mock: 140 | asyncio.run(self.ncc.clear_status_message()) 141 | mock.assert_called_with( 142 | method='DELETE', 143 | auth=(USER, PASSWORD), 144 | url=f'{ENDPOINT}/ocs/v2.php/apps/user_status/api/v1/user_status/message', 145 | data={'format': 'json'}, 146 | headers={'OCS-APIRequest': 'true'}) 147 | 148 | def test_get_all_user_statuses(self): # noqa: D102 149 | json_response = bytes( 150 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},' 151 | f'"data":[{{"userId":"{USER}","message":null,"icon":null,"clearA' 152 | 't":null,"status":"away"}]}}', 'utf-8') 153 | with patch( 154 | 'httpx.AsyncClient.request', 155 | new_callable=AsyncMock, 156 | return_value=httpx.Response( 157 | status_code=200, 158 | content=json_response)) as mock: 159 | asyncio.run(self.ncc.get_all_user_statuses()) 160 | mock.assert_called_with( 161 | method='GET', 162 | auth=(USER, PASSWORD), 163 | url=f'{ENDPOINT}/ocs/v2.php/apps/user_status/api/v1/statuses?' 164 | 'limit=100&offset=0&format=json', 165 | data=None, 166 | headers={'OCS-APIRequest': 'true'}) 167 | 168 | def test_get_user_status(self): # noqa: D102 169 | json_response = bytes( 170 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},' 171 | f'"data":{{"userId":"{USER}","message":null,"icon":null,"clearAt"' 172 | ':null,"status":"away"}}}', 'utf-8') 173 | with patch( 174 | 'httpx.AsyncClient.request', 175 | new_callable=AsyncMock, 176 | return_value=httpx.Response( 177 | status_code=200, 178 | content=json_response)) as mock: 179 | response = asyncio.run(self.ncc.get_user_status(USER)) 180 | mock.assert_called_with( 181 | method='GET', 182 | auth=(USER, PASSWORD), 183 | url=f'{ENDPOINT}/ocs/v2.php/apps/user_status/api/v1/statuses/{USER}' 184 | '?format=json', 185 | data=None, 186 | headers={'OCS-APIRequest': 'true'}) 187 | assert response['userId'] == USER 188 | -------------------------------------------------------------------------------- /nextcloud_async/api/ocs/shares.py: -------------------------------------------------------------------------------- 1 | """Implement Nextcloud Shares/Sharee APIs. 2 | 3 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-share-api.html 4 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-sharee-api.html 5 | 6 | Not Implemented: 7 | Federated share management 8 | """ 9 | 10 | import asyncio 11 | 12 | import datetime as dt 13 | 14 | from enum import Enum, IntFlag 15 | from typing import Any, Optional, List 16 | 17 | from nextcloud_async.exceptions import NextCloudException 18 | 19 | 20 | class ShareType(Enum): 21 | """Share types. 22 | 23 | Reference: 24 | https://github.com/nextcloud/server/blob/master/lib/public/Share/IShare.php 25 | """ 26 | 27 | user = 0 28 | group = 1 29 | public = 3 30 | email = 4 31 | federated = 6 32 | circle = 7 33 | guest = 8 34 | remote_group = 9 35 | room = 10 36 | deck = 12 37 | deck_user = 13 38 | 39 | 40 | class SharePermission(IntFlag): 41 | """Share Permissions.""" 42 | 43 | read = 1 44 | update = 2 45 | create = 4 46 | delete = 8 47 | share = 16 48 | all = 31 49 | 50 | 51 | class OCSShareAPI(object): 52 | """Manage local shares on Nextcloud instances.""" 53 | 54 | async def get_all_shares( 55 | self, 56 | reshares: Optional[bool] = False, 57 | subfiles: Optional[bool] = False, 58 | shared_with_me: Optional[bool] = False, 59 | include_tags: Optional[bool] = False): 60 | """Return list of all shares. 61 | 62 | This is just a wrapper for self.get_file_shares() with path set to ''. 63 | 64 | Args 65 | ---- 66 | reshares (bool, optional): Also list reshares. Defaults to False. 67 | 68 | subfiles (bool, optional): List recursively if `path` is a folder. Defaults to 69 | False. 70 | 71 | shared_with_me (bool, optional): Only get shares with the current user 72 | 73 | include_tags (bool, optional): Include tags in the share 74 | 75 | Returns 76 | ------- 77 | list: Share descriptions 78 | 79 | """ 80 | return await self.get_file_shares( 81 | reshares=reshares, 82 | subfiles=subfiles, 83 | shared_with_me=shared_with_me, 84 | include_tags=include_tags) 85 | 86 | async def get_file_shares( 87 | self, 88 | path: Optional[str] = '', 89 | reshares: Optional[bool] = False, 90 | subfiles: Optional[bool] = False, 91 | shared_with_me: Optional[bool] = False, 92 | include_tags: Optional[bool] = False): 93 | """Return list of shares for given file/folder. 94 | 95 | Args 96 | ---- 97 | path (str): Path to file 98 | 99 | reshares (bool, optional): Also list reshares. Defaults to False. 100 | 101 | subfiles (bool, optional): List recursively if `path` is a folder. Defaults to 102 | False. 103 | 104 | shared_with_me (bool, optional): Only get shares with the current user 105 | 106 | include_tags (bool, optional): Include tags in the share 107 | 108 | Returns 109 | ------- 110 | list: File share descriptions 111 | 112 | """ 113 | return await self.ocs_query( 114 | method='GET', 115 | sub='/ocs/v2.php/apps/files_sharing/api/v1/shares', 116 | data={ 117 | 'path': path, 118 | 'reshares': str(reshares).lower(), 119 | 'subfiles': str(subfiles).lower(), 120 | 'shared_with_me': str(shared_with_me).lower(), 121 | 'include_tags': str(include_tags).lower()}) 122 | 123 | async def get_share(self, share_id: int): 124 | """Return information about a known share. 125 | 126 | Args 127 | ---- 128 | share_id (int): Share ID 129 | 130 | Returns 131 | ------- 132 | dict: Share description 133 | 134 | """ 135 | return (await self.ocs_query( 136 | method='GET', 137 | sub=f'/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}', 138 | data={'share_id': share_id}))[0] 139 | 140 | async def create_share( 141 | self, 142 | path: str, 143 | share_type: ShareType, 144 | permissions: SharePermission, 145 | share_with: Optional[str] = None, 146 | allow_public_upload: bool = False, 147 | password: Optional[str] = None, 148 | expire_date: Optional[str] = None, 149 | note: Optional[str] = None): 150 | """Create a new share. 151 | 152 | Args 153 | ---- 154 | path (str): File to share 155 | 156 | share_type (ShareType): See ShareType Enum 157 | 158 | permissions (SharePermission): See SharePermissions Enum 159 | 160 | share_with (str, optional): Target of your sharing. Defaults to None. 161 | 162 | allow_public_upload (bool, optional): Whether to allow public upload to shared 163 | folder. Defaults to False. 164 | 165 | password (str, optional): Set a password on this share. Defaults to None. 166 | 167 | expire_date (str, optional): Expiration date (YYYY-MM-DD) for this share. 168 | Defaults to None. 169 | 170 | note (str, optional): Optional note to sharees. Defaults to None. 171 | 172 | Raises 173 | ------ 174 | NextCloudException: Invalid expiration date or date in the past. 175 | 176 | Returns 177 | ------- 178 | # TODO : fill me in 179 | 180 | """ 181 | 182 | # Checks the expire_date argument exists before evaluation, otherwise continues. 183 | if expire_date: 184 | try: 185 | expire_dt = dt.datetime.strptime(expire_date, r'%Y-%m-%d') 186 | except ValueError: 187 | raise NextCloudException('Invalid date. Should be YYYY-MM-DD') 188 | else: 189 | now = dt.datetime.now() 190 | if expire_dt < now: 191 | raise NextCloudException('Invalid date. Should be in the future.') 192 | 193 | return await self.ocs_query( 194 | method='POST', 195 | sub='/ocs/v2.php/apps/files_sharing/api/v1/shares', 196 | data={ 197 | 'path': path, 198 | 'shareType': share_type.value, 199 | 'shareWith': share_with, 200 | 'permissions': permissions.value, 201 | 'publicUpload': str(allow_public_upload).lower(), 202 | 'password': password, 203 | 'expireDate': expire_date, 204 | 'note': note}) 205 | 206 | async def delete_share(self, share_id: int): 207 | """Delete an existing share. 208 | 209 | Args 210 | ---- 211 | share_id (int): The Share ID to delete 212 | 213 | Returns 214 | ------- 215 | Query results. 216 | 217 | """ 218 | return await self.ocs_query( 219 | method='DELETE', 220 | sub=f'/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}', 221 | data={'share_id': share_id} 222 | ) 223 | 224 | async def update_share( 225 | self, 226 | share_id: int, 227 | permissions: Optional[SharePermission] = None, 228 | password: Optional[str] = None, 229 | allow_public_upload: Optional[bool] = None, 230 | expire_date: Optional[str] = None, # YYYY-MM-DD 231 | note: Optional[str] = None) -> List: 232 | """Update properties of an existing share. 233 | 234 | This function makes asynchronous calls to the __update_share function 235 | since the underlying API can only accept one modification per query. 236 | We launch one asynchronous request per given parameter and return a 237 | list containing the results of all queries. 238 | 239 | Args 240 | ---- 241 | share_id (int): The share ID to update 242 | 243 | permissions (SharePermission, optional): New permissions. 244 | Defaults to None. 245 | 246 | password (str, optional): New password. Defaults to None. 247 | 248 | allow_public_upload bool, optional): Whether to allow 249 | public uploads to shared folder. Defaults to None. 250 | 251 | expire_date str, optional): Expiration date (YYYY-MM-DD). 252 | Defaults to None. 253 | 254 | note (str): Note for this share. Defaults to None. 255 | 256 | Returns 257 | ------- 258 | List: responses from update queries 259 | 260 | """ 261 | reqs = [] 262 | attrs = [ 263 | ('permissions', permissions), 264 | ('password', password), 265 | ('publicUpload', str(allow_public_upload).lower()), 266 | ('expireDate', expire_date), 267 | ('note', note)] 268 | 269 | for a in attrs: 270 | if a[1]: 271 | reqs.append(self.__update_share(share_id, *a)) 272 | 273 | return await asyncio.gather(*reqs) 274 | 275 | async def __update_share(self, share_id, key: str, value: Any): 276 | return await self.ocs_query( 277 | method='PUT', 278 | sub=f'/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}', 279 | data={key: value}) 280 | 281 | async def search_sharees( 282 | self, 283 | item_type: str, 284 | lookup: bool = False, 285 | limit: int = 200, 286 | page: int = 1, 287 | search: str = None): 288 | """Search for people or groups to share things with. 289 | 290 | Args 291 | ---- 292 | item_type (str): Item type (`file`, `folder`, `calendar`, etc...) 293 | 294 | lookup (bool, optional): Whether to use global Nextcloud lookup service. 295 | Defaults to False. 296 | 297 | limit (int, optional): How many results to return per request. Defaults to 200. 298 | 299 | page (int, optional): Return this page of results. Defaults to 1. 300 | 301 | search (str, optional): Search term. Defaults to None. 302 | 303 | Returns 304 | ------- 305 | Dictionary of exact and potential matches. 306 | 307 | """ 308 | -------------------------------------------------------------------------------- /tests/test_ocs_general.py: -------------------------------------------------------------------------------- 1 | """Test Nextcloud Activity API 2 | 3 | Reference: 4 | https://github.com/nextcloud/activity/blob/master/docs/endpoint-v2.md 5 | """ 6 | 7 | from .base import BaseTestCase 8 | from .helpers import AsyncMock 9 | from .constants import USER, ENDPOINT, PASSWORD 10 | 11 | from nextcloud_async.exceptions import NextCloudException 12 | 13 | import asyncio 14 | import httpx 15 | 16 | from urllib.parse import urlencode 17 | from unittest.mock import patch 18 | 19 | 20 | capabilities_response = bytes( 21 | '{"ocs":{"meta":{"status":"ok","statuscode":100,"message":"OK","t' 22 | 'otalitems":"","itemsperpage":""},"data":{"version":{"major":24,"' 23 | 'minor":0,"micro":1,"string":"24.0.1","edition":"","extendedSuppo' 24 | 'rt":false},"capabilities":{"core":{"pollinterval":60,"webdav-roo' 25 | 't":"remote.php\\/webdav"},"bruteforce":{"delay":0},"metadataAvai' 26 | 'lable":{"size":["\\/image\\\\\\/.*\\/"]},"files":{"bigfilechunki' 27 | 'ng":true,"blacklisted_files":[".htaccess"],"directEditing":{"url' 28 | '":"http:\\/\\/localhost:8181\\/ocs\\/v2.php\\/apps\\/files\\/api' 29 | '\\/v1\\/directEditing","etag":"c748e8fc588b54fc5af38c4481a19d20"' 30 | '},"comments":true,"undelete":true,"versioning":true},"activity":' 31 | '{"apiv2":["filters","filters-api","previews","rich-strings"]},"c' 32 | 'ircles":{"version":"24.0.0","status":{"globalScale":false},"sett' 33 | 'ings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUs' 34 | 'erTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"' 35 | '1":"Single","2":"Personal","4":"System","8":"Visible","16":"Open' 36 | '","32":"Invite","64":"Join Request","128":"Friends","256":"Passw' 37 | 'ord Protected","512":"No Owner","1024":"Hidden","2048":"Backend"' 38 | ',"4096":"Local","8192":"Root","16384":"Circle Invite","32768":"F' 39 | 'ederated","65536":"Mount point"},"source":{"core":{"1":"Nextclou' 40 | 'd User","2":"Nextcloud Group","4":"Email Address","8":"Contact",' 41 | '"16":"Circle","10000":"Nextcloud App"},"extra":{"10001":"Circles' 42 | ' App","10002":"Admin Command Line"}}},"config":{"coreFlags":[1,2' 43 | ',4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"leve' 44 | 'l":{"1":"Member","4":"Moderator","8":"Admin","9":"Owner"}},"type' 45 | '":{"0":"single","1":"user","2":"group","4":"mail","8":"contact",' 46 | '"16":"circle","10000":"app"}}},"ocm":{"enabled":true,"apiVersion' 47 | '":"1.0-proposal1","endPoint":"http:\\/\\/localhost:8181\\/ocm","' 48 | 'resourceTypes":[{"name":"file","shareTypes":["user","group"],"pr' 49 | 'otocols":{"webdav":"\\/public.php\\/webdav\\/"}}]},"dav":{"chunk' 50 | 'ing":"1.0"},"files_sharing":{"api_enabled":true,"public":{"enabl' 51 | 'ed":true,"password":{"enforced":false,"askForOptionalPassword":f' 52 | 'alse},"expire_date":{"enabled":false},"multiple_links":true,"exp' 53 | 'ire_date_internal":{"enabled":false},"expire_date_remote":{"enab' 54 | 'led":false},"send_mail":false,"upload":true,"upload_files_drop":' 55 | 'true},"resharing":true,"user":{"send_mail":false,"expire_date":{' 56 | '"enabled":true}},"group_sharing":true,"group":{"enabled":true,"e' 57 | 'xpire_date":{"enabled":true}},"default_permissions":31,"federati' 58 | 'on":{"outgoing":true,"incoming":true,"expire_date":{"enabled":tr' 59 | 'ue},"expire_date_supported":{"enabled":true}},"sharee":{"query_l' 60 | 'ookup_default":false,"always_show_unique":true},"sharebymail":{"' 61 | 'enabled":true,"send_password_by_mail":true,"upload_files_drop":{' 62 | '"enabled":true},"password":{"enabled":true,"enforced":false},"ex' 63 | 'pire_date":{"enabled":true,"enforced":false}}},"notifications":{' 64 | '"ocs-endpoints":["list","get","delete","delete-all","icons","ric' 65 | 'h-strings","action-web","user-status"],"push":["devices","object' 66 | '-data","delete"],"admin-notifications":["ocs","cli"]},"password_' 67 | 'policy":{"minLength":10,"enforceNonCommonPassword":true,"enforce' 68 | 'NumericCharacters":false,"enforceSpecialCharacters":false,"enfor' 69 | 'ceUpperLowerCase":false,"api":{"generate":"http:\\/\\/localhost:' 70 | '8181\\/ocs\\/v2.php\\/apps\\/password_policy\\/api\\/v1\\/genera' 71 | 'te","validate":"http:\\/\\/localhost:8181\\/ocs\\/v2.php\\/apps' 72 | '\\/password_policy\\/api\\/v1\\/validate"}},"provisioning_api":' 73 | '{"version":"1.14.0","AccountPropertyScopesVersion":2,"AccountPro' 74 | 'pertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishe' 75 | 'dEnabled":true},"spreed":{"features":["audio","video","chat-v2",' 76 | '"conversation-v4","guest-signaling","empty-group-room","guest-di' 77 | 'splay-names","multi-room-users","favorites","last-room-activity"' 78 | ',"no-ping","system-messages","delete-messages","mention-flag","i' 79 | 'n-call-flags","conversation-call-flags","notification-levels","i' 80 | 'nvite-groups-and-mails","locked-one-to-one-rooms","read-only-roo' 81 | 'ms","listable-rooms","chat-read-marker","chat-unread","webinary-' 82 | 'lobby","start-call-flag","chat-replies","circles-support","force' 83 | '-mute","sip-support","chat-read-status","phonebook-search","rais' 84 | 'e-hand","room-description","rich-object-sharing","temp-user-avat' 85 | 'ar-api","geo-location-sharing","voice-message-sharing","signalin' 86 | 'g-v3","publishing-permissions","clear-history","direct-mention-f' 87 | 'lag","notification-calls","conversation-permissions","rich-objec' 88 | 't-list-media","rich-object-delete","reactions"],"config":{"attac' 89 | 'hments":{"allowed":true,"folder":"\\/Talk"},"chat":{"max-length"' 90 | ':32000,"read-privacy":0},"conversations":{"can-create":true},"pr' 91 | 'eviews":{"max-gif-size":3145728}}},"theming":{"name":"Nextcloud"' 92 | ',"url":"https:\\/\\/nextcloud.com","slogan":"a safe home for all' 93 | ' your data","color":"#0082c9","color-text":"#ffffff","color-elem' 94 | 'ent":"#0082c9","color-element-bright":"#0082c9","color-element-d' 95 | 'ark":"#0082c9","logo":"http:\\/\\/localhost:8181\\/core\\/img\\/' 96 | 'logo\\/logo.svg?v=0","background":"http:\\/\\/localhost:8181\\/c' 97 | 'ore\\/img\\/background.png?v=0","background-plain":false,"backgr' 98 | 'ound-default":true,"logoheader":"http:\\/\\/localhost:8181\\/cor' 99 | 'e\\/img\\/logo\\/logo.svg?v=0","favicon":"http:\\/\\/localhost:8' 100 | '181\\/core\\/img\\/logo\\/logo.svg?v=0"},"user_status":{"enabled' 101 | '":true,"supports_emoji":true},"weather_status":{"enabled":true}}}}}', 'utf-8') 102 | 103 | class OCSGeneralAPI(BaseTestCase): # noqa: D101 104 | 105 | def test_get_activity(self): # noqa: D102 106 | OBJ_ID = 30 107 | OBJ_TYPE = 'files' 108 | SINCE = 0 109 | LIMIT = 1 110 | SORT = 'desc' 111 | json_response = bytes( 112 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},' 113 | '"data":[{"activity_id":43,"app":"files_sharing","type":"shared",' 114 | f'"user":"{USER}","subject":"Shared with Test User","subject_rich' 115 | f'":["Shared with {{user}}",{{"file":{{"type":"file","id":"{OBJ_ID}' 116 | '","name":"Nextcloud Manual.pdf","path":"Nextcloud Manual.pdf","l' 117 | 'ink":"http:\\/\\/localhost:8181\\/f\\/30"},"user":{"type":"user"' 118 | ',"id":"testuser","name":"Test User"}}],"message":"","message_ric' 119 | f'h":["",[]],"object_type":"files","object_id":{OBJ_ID},"object_n' 120 | f'ame":"\\/Nextcloud Manual.pdf","objects":{{"{OBJ_ID}":"\\/Nextc' 121 | 'loud Manual.pdf"},"link":"http:\\/\\/localhost:8181\\/apps\\/fil' 122 | 'es\\/?dir=\\/","icon":"http:\\/\\/localhost:8181\\/core\\/img\\/' 123 | 'actions\\/share.svg","datetime":"2022-06-24T18:11:11+00:00"}]}}', 124 | 'utf-8') 125 | with patch( 126 | 'httpx.AsyncClient.request', 127 | new_callable=AsyncMock, 128 | return_value=httpx.Response( 129 | status_code=200, 130 | content=json_response, 131 | headers={ 132 | 'X-Activity-Last-Given': '43' 133 | })) as mock: 134 | asyncio.run(self.ncc.get_activity( 135 | since=SINCE, limit=LIMIT, object_id=OBJ_ID, object_type=OBJ_TYPE)) 136 | url_data = urlencode({ 137 | 'object_type': OBJ_TYPE, 138 | 'object_id': OBJ_ID, 139 | 'limit': LIMIT, 140 | 'sort': SORT, 141 | 'since': SINCE, 142 | 'format': 'json' 143 | }) 144 | mock.assert_called_with( 145 | method='GET', 146 | auth=(USER, PASSWORD), 147 | url=f'{ENDPOINT}/ocs/v2.php/apps/activity/api/v2/activity/filter?{url_data}', 148 | data=None, 149 | headers={'OCS-APIRequest': 'true'}) 150 | 151 | def test_get_file_guest_link(self): 152 | FILE_ID = 30 153 | json_response = bytes( 154 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},' 155 | '"data":{"url":"http:\\/\\/localhost:8181\\/remote.php\\/direct' 156 | '\\/HAMSci2FPfdZEtetn7XwtgxVsd17GdRMbQWchMK93QRDaEFxP8bZW2EgSVtR"}}}', 'utf-8') 157 | with patch( 158 | 'httpx.AsyncClient.request', 159 | new_callable=AsyncMock, 160 | return_value=httpx.Response( 161 | status_code=200, 162 | content=json_response)) as mock: 163 | asyncio.run(self.ncc.get_file_guest_link(FILE_ID)) 164 | mock.assert_called_with( 165 | method='POST', 166 | auth=(USER, PASSWORD), 167 | url=f'{ENDPOINT}/ocs/v2.php/apps/dav/api/v1/direct', 168 | data={'fileId': FILE_ID, 'format': 'json'}, 169 | headers={'OCS-APIRequest': 'true'}) 170 | 171 | def test_get_capabilities_no_slice(self): 172 | with patch( 173 | 'httpx.AsyncClient.request', 174 | new_callable=AsyncMock, 175 | return_value=httpx.Response( 176 | status_code=200, 177 | content=capabilities_response)) as mock: 178 | asyncio.run(self.ncc.get_capabilities()) 179 | mock.assert_called_with( 180 | method='GET', 181 | auth=(USER, PASSWORD), 182 | url=f'{ENDPOINT}/ocs/v1.php/cloud/capabilities?format=json', 183 | data=None, 184 | headers={'OCS-APIRequest': 'true'}) 185 | 186 | def test_get_capability_valid_slice(self): 187 | with patch( 188 | 'httpx.AsyncClient.request', 189 | new_callable=AsyncMock, 190 | return_value=httpx.Response( 191 | status_code=200, 192 | content=capabilities_response)) as mock: 193 | response = asyncio.run(self.ncc.get_capabilities('capabilities.spreed.features')) 194 | mock.assert_called_with( 195 | method='GET', 196 | auth=(USER, PASSWORD), 197 | url=f'{ENDPOINT}/ocs/v1.php/cloud/capabilities?format=json', 198 | data=None, 199 | headers={'OCS-APIRequest': 'true'}) 200 | assert 'chat-v2' in response 201 | 202 | def test_get_capability_invalid_slice(self): 203 | with patch( 204 | 'httpx.AsyncClient.request', 205 | new_callable=AsyncMock, 206 | return_value=httpx.Response( 207 | status_code=200, 208 | content=capabilities_response)) as mock: 209 | try: 210 | response = asyncio.run(self.ncc.get_capabilities('capabilities.invalid')) 211 | except NextCloudException: 212 | pass 213 | finally: 214 | mock.assert_called_with( 215 | method='GET', 216 | auth=(USER, PASSWORD), 217 | url=f'{ENDPOINT}/ocs/v1.php/cloud/capabilities?format=json', 218 | data=None, 219 | headers={'OCS-APIRequest': 'true'}) 220 | self.assertRaises(NextCloudException) 221 | -------------------------------------------------------------------------------- /nextcloud_async/api/ocs/users.py: -------------------------------------------------------------------------------- 1 | # noqa: D400 D415 2 | """https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/\ 3 | instruction_set_for_users.html""" 4 | 5 | import asyncio 6 | 7 | from typing import Optional, List, Dict 8 | from nextcloud_async.api.ocs.shares import ShareType 9 | 10 | 11 | class UserManager(): 12 | """Manage users on a Nextcloud instance.""" 13 | 14 | async def create_user( 15 | self, 16 | user_id: str, 17 | display_name: str, 18 | email: str, 19 | quota: str, 20 | language: str, 21 | groups: List = [], 22 | subadmin: List = [], 23 | password: Optional[str] = None) -> Dict[str, str]: 24 | """Create a new Nextcloud user. 25 | 26 | Args 27 | ---- 28 | user_id (str): New user ID 29 | 30 | display_name (str): User display Name (eg. "Your Name") 31 | 32 | email (str): E-mail Address 33 | 34 | quota (str): User quota, in bytes. "none" for unlimited. 35 | 36 | language (str): User language 37 | 38 | groups (List, optional): Groups user should be aded to. Defaults to []. 39 | 40 | subadmin (List, optional): Groups user should be admin for. Defaults to []. 41 | 42 | password (Optional[str], optional): User password. Defaults to None. 43 | 44 | Returns 45 | ------- 46 | dict: New user ID 47 | 48 | Example: 49 | 50 | { 'id': 'YourNewUser' } 51 | 52 | """ 53 | return await self.ocs_query( 54 | method='POST', 55 | sub='/ocs/v1.php/cloud/users', 56 | data={ 57 | 'userid': user_id, 58 | 'displayName': display_name, 59 | 'email': email, 60 | 'groups': groups, 61 | 'subadmin': subadmin, 62 | 'language': language, 63 | 'quota': quota, 64 | 'password': password}) 65 | 66 | async def search_users( 67 | self, 68 | search: str, 69 | limit: int = 100, 70 | offset: int = 0) -> List[str]: 71 | """Search for users. 72 | 73 | Args 74 | ---- 75 | search (str): Search string 76 | 77 | limit (int, optional): Results per request. Defaults to 100. 78 | 79 | offset (int, optional): Paging offset. Defaults to 0. 80 | 81 | Returns 82 | ------- 83 | list: User ID matches 84 | 85 | """ 86 | response = await self.ocs_query( 87 | method='GET', 88 | sub='/ocs/v1.php/cloud/users', 89 | data={ 90 | 'search': search, 91 | 'limit': limit, 92 | 'offset': offset}) 93 | return response['users'] 94 | 95 | async def get_user(self, user_id: str = None) -> Dict[str, str]: 96 | """Get a valid user. 97 | 98 | Args 99 | ---- 100 | user_id (str, optional): User ID. Defaults to None (current user). 101 | 102 | Returns 103 | ------- 104 | dict: User description. 105 | 106 | """ 107 | if not user_id: 108 | user_id = self.user 109 | return await self.ocs_query(method='GET', sub=f'/ocs/v1.php/cloud/users/{user_id}') 110 | 111 | async def get_users(self) -> List[str]: 112 | """Return all user IDs. 113 | 114 | Admin required 115 | 116 | Returns 117 | ------- 118 | List: User IDs 119 | 120 | """ 121 | response = await self.ocs_query(method='GET', sub=r'/ocs/v1.php/cloud/users') 122 | return response['users'] 123 | 124 | async def user_autocomplete( 125 | self, 126 | search: str, 127 | item_type: Optional[str] = None, 128 | item_id: Optional[str] = None, 129 | sorter: Optional[str] = None, 130 | share_types: Optional[List[ShareType]] = [ShareType['user']], 131 | limit: int = 25) -> List[Dict[str, str]]: 132 | """Search for a user using incomplete information. 133 | 134 | Reference: 135 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/\ 136 | ocs-api-overview.html#auto-complete-and-user-search 137 | 138 | https://github.com/nextcloud/server/blob/master/core/Controller/\ 139 | AutoCompleteController.php#L62 140 | 141 | Args 142 | ---- 143 | search (str): Search string 144 | 145 | item_type (str, optional): Item type, `users` or `groups`. Used for sorting. 146 | Defaults to None. 147 | 148 | item_id (str, optional): Item id, used for sorting. Defaults to None. 149 | 150 | sorter (str, optional): Can be piped, top priority first, e.g.: 151 | "commenters|share-recipients" 152 | 153 | share_types (ShareType, optional): ShareType, defaults to ShareType['user'] 154 | 155 | limit (int, optional): Results per page. Defaults to 25. 156 | 157 | Returns 158 | ------- 159 | list: Potential matches 160 | 161 | """ 162 | share_types_values = [x.value for x in share_types] 163 | return await self.ocs_query( 164 | method='GET', 165 | sub='/ocs/v2.php/core/autocomplete/get', 166 | data={ 167 | 'search': search, 168 | 'itemType': item_type, 169 | 'itemId': item_id, 170 | 'sorter': sorter, 171 | 'shareTypes[]': share_types_values, 172 | 'limit': limit}) 173 | 174 | async def update_user( 175 | self, 176 | user_id: str, 177 | new_data: Dict) -> List[List[str]]: 178 | """Update a user's information. 179 | 180 | Use async/await to update everything at once. 181 | 182 | Args 183 | ---- 184 | user_id (str): User ID 185 | 186 | new_data (Dict): New key/value pairs 187 | 188 | Returns 189 | ------- 190 | list: Responses 191 | 192 | """ 193 | reqs = [] 194 | for k, v in new_data.items(): 195 | reqs.append(self.__update_user(user_id, k, v)) 196 | 197 | return await asyncio.gather(*reqs) 198 | 199 | async def __update_user(self, user_id, k, v) -> List[str]: 200 | return await self.ocs_query( 201 | method='PUT', 202 | sub=f'/ocs/v1.php/cloud/users/{user_id}', 203 | data={'key': k, 'value': v}) 204 | 205 | async def get_user_editable_fields(self): 206 | """Get user-editable fields. 207 | 208 | Returns 209 | ------- 210 | list: User-editable fields 211 | 212 | """ 213 | return await self.ocs_query( 214 | method='GET', 215 | sub=r'/ocs/v1.php/cloud/user/fields') 216 | 217 | async def disable_user(self, user_id: str) -> List[str]: 218 | """Disable `user_id`. 219 | 220 | Must be admin. 221 | 222 | Args 223 | ---- 224 | user_id (str): User ID 225 | 226 | Returns 227 | ------- 228 | Empty 100 Response 229 | 230 | """ 231 | return await self.ocs_query( 232 | method='PUT', 233 | sub=f'/ocs/v1.php/cloud/users/{user_id}/disable') 234 | 235 | async def enable_user(self, user_id: str) -> List[str]: 236 | """Enable `user_id`. Must be admin. 237 | 238 | Args 239 | ---- 240 | user_id (str): User ID 241 | 242 | Returns 243 | ------- 244 | Empty 100 Response 245 | 246 | """ 247 | return await self.ocs_query( 248 | method='PUT', 249 | sub=f'/ocs/v1.php/cloud/users/{user_id}/enable') 250 | 251 | async def remove_user(self, user_id: str) -> List[str]: 252 | """Remove existing `user_id`. 253 | 254 | Args 255 | ---- 256 | user_id (str): User ID 257 | 258 | Returns 259 | ------- 260 | Empty 100 Response 261 | 262 | """ 263 | return await self.ocs_query( 264 | method='DELETE', 265 | sub=f'/ocs/v1.php/cloud/users/{user_id}') 266 | 267 | async def get_user_groups(self, user_id: Optional[str] = None) -> List[str]: 268 | """Get list of groups `user_id` belongs to. 269 | 270 | Args 271 | ---- 272 | user_id (str, optional): User ID. Defaults to current user. 273 | 274 | Returns 275 | ------- 276 | list: group ids 277 | 278 | """ 279 | response = await self.ocs_query( 280 | method='GET', 281 | sub=f'/ocs/v1.php/cloud/users/{user_id or self.user}/groups') 282 | return response['groups'] 283 | 284 | async def add_user_to_group(self, user_id: str, group_id: str) -> List[str]: 285 | """Add `user_id` to `group_id`. 286 | 287 | Args 288 | ---- 289 | user_id (str): User ID 290 | 291 | group_id (str): Group ID 292 | 293 | Returns 294 | ------- 295 | Empty 100 Response 296 | 297 | """ 298 | return await self.ocs_query( 299 | method='POST', 300 | sub=f'/ocs/v1.php/cloud/users/{user_id}/groups', 301 | data={'groupid': group_id}) 302 | 303 | async def remove_user_from_group(self, user_id: str, group_id: str) -> List[str]: 304 | """Remove `user_id` from `group_id`. 305 | 306 | Args 307 | ---- 308 | user_id (str): User ID 309 | 310 | group_id (str): Group Id 311 | 312 | Returns 313 | ------- 314 | Empty 100 Response 315 | 316 | """ 317 | return await self.ocs_query( 318 | method='DELETE', 319 | sub=f'/ocs/v1.php/cloud/users/{user_id}/groups', 320 | data={'groupid': group_id}) 321 | 322 | async def promote_user_to_subadmin(self, user_id: str, group_id: str) -> List[str]: 323 | """Make `user_id` a subadmin of `group_id`. 324 | 325 | Args 326 | ---- 327 | user_id (str): User ID 328 | 329 | group_id (str): Group ID 330 | 331 | Returns 332 | ------- 333 | Empty 100 Response 334 | 335 | """ 336 | return await self.ocs_query( 337 | method='POST', 338 | sub=f'/ocs/v1.php/cloud/users/{user_id}/subadmins', 339 | data={'groupid': group_id}) 340 | 341 | async def demote_user_from_subadmin(self, user_id: str, group_id: str) -> List[str]: 342 | """Demote `user_id` from subadmin of `group_id`. 343 | 344 | Args 345 | ---- 346 | user_id (str): User ID 347 | 348 | group_id (str): Group ID 349 | 350 | Returns 351 | ------- 352 | Empty 100 Response 353 | 354 | """ 355 | return await self.ocs_query( 356 | method='DELETE', 357 | sub=f'/ocs/v1.php/cloud/users/{user_id}/subadmins', 358 | data={'groupid': group_id}) 359 | 360 | async def get_user_subadmin_groups(self, user_id: str) -> List[str]: 361 | """Return list of groups of which `user_id` is subadmin. 362 | 363 | Args 364 | ---- 365 | user_id (str): User ID 366 | 367 | Returns 368 | ------- 369 | list: group ids 370 | 371 | """ 372 | return await self.ocs_query( 373 | method='GET', 374 | sub=f'/ocs/v1.php/cloud/users/{user_id}/subadmins') 375 | 376 | async def resend_welcome_email(self, user_id: str) -> List[str]: 377 | """Re-send initial welcome e-mail to `user_id`. 378 | 379 | Args 380 | ---- 381 | user_id (str): User ID 382 | 383 | Returns 384 | ------- 385 | Empty 100 Response 386 | 387 | """ 388 | return await self.ocs_query( 389 | method='POST', 390 | sub=f'/ocs/v1.php/cloud/users/{user_id}/welcome') 391 | -------------------------------------------------------------------------------- /tests/test_groupfolders.py: -------------------------------------------------------------------------------- 1 | """Test GroupFoldersManager.""" 2 | 3 | from .base import BaseTestCase 4 | from .helpers import AsyncMock 5 | from .constants import USER, ENDPOINT, PASSWORD, SIMPLE_100 6 | 7 | from nextcloud_async.api.ocs.groupfolders import Permissions as GP 8 | 9 | import asyncio 10 | import httpx 11 | 12 | from unittest.mock import patch 13 | 14 | FOLDER = 'GROUPFOLDER' 15 | FOLDERID = 2 16 | GROUP = 'somegroup' 17 | TESTUSER = 'testuser' 18 | 19 | 20 | class OCSGroupFoldersAPI(BaseTestCase): # noqa: D101 21 | 22 | def test_get_all_group_folders(self): # noqa: D102 23 | json_response = bytes( 24 | '{"ocs":{"meta":{"status":"ok","statuscode":100,"message":"OK",' 25 | f'"totalitems":"","itemsperpage":""}},"data":{{"{FOLDERID}":{{"id"' 26 | f':{FOLDERID},"mount_point":"{FOLDER}","groups":[],"quota":-3,"si' 27 | 'ze":0,"acl":false,"manage":[]}}}}', 'utf-8') 28 | with patch( 29 | 'httpx.AsyncClient.request', 30 | new_callable=AsyncMock, 31 | return_value=httpx.Response( 32 | status_code=100, 33 | content=json_response)) as mock: 34 | response = asyncio.run(self.ncc.get_all_group_folders()) 35 | mock.assert_called_with( 36 | method='GET', 37 | auth=(USER, PASSWORD), 38 | url=f'{ENDPOINT}/apps/groupfolders/folders?format=json', 39 | data=None, 40 | headers={'OCS-APIRequest': 'true'}) 41 | assert response[0][str(FOLDERID)]['id'] == FOLDERID 42 | 43 | def test_create_group_folder(self): # noqa: D102 44 | json_response = bytes(SIMPLE_100.format(f'{{"id":{FOLDERID}}}'), 'utf-8') 45 | with patch( 46 | 'httpx.AsyncClient.request', 47 | new_callable=AsyncMock, 48 | return_value=httpx.Response( 49 | status_code=100, 50 | content=json_response)) as mock: 51 | response = asyncio.run(self.ncc.create_group_folder(FOLDER)) 52 | mock.assert_called_with( 53 | method='POST', 54 | auth=(USER, PASSWORD), 55 | url=f'{ENDPOINT}/apps/groupfolders/folders', 56 | data={'mountpoint': FOLDER, 'format': 'json'}, 57 | headers={'OCS-APIRequest': 'true'}) 58 | assert response['id'] == FOLDERID 59 | 60 | def test_get_group_folder(self): # noqa: D102 61 | json_response = bytes( 62 | '{"ocs":{"meta":{"status":"ok","statuscode":100,"message":"OK",' 63 | f'"totalitems":"","itemsperpage":""}},"data":{{"id":{FOLDERID},' 64 | f'"mount_point":"{FOLDER}","groups":[],"quota":-3,"size":0,"acl' 65 | '":false,"manage":[]}}}', 'utf-8') 66 | with patch( 67 | 'httpx.AsyncClient.request', 68 | new_callable=AsyncMock, 69 | return_value=httpx.Response( 70 | status_code=100, 71 | content=json_response)) as mock: 72 | response = asyncio.run(self.ncc.get_group_folder(FOLDERID)) 73 | mock.assert_called_with( 74 | method='GET', 75 | auth=(USER, PASSWORD), 76 | url=f'{ENDPOINT}/apps/groupfolders/folders/{FOLDERID}?format=json', 77 | data=None, 78 | headers={'OCS-APIRequest': 'true'}) 79 | assert response['id'] == FOLDERID 80 | 81 | def test_remove_group_folder(self): # noqa: D102 82 | json_response = bytes(SIMPLE_100.format('{"success":true}'), 'utf-8') 83 | with patch( 84 | 'httpx.AsyncClient.request', 85 | new_callable=AsyncMock, 86 | return_value=httpx.Response( 87 | status_code=100, 88 | content=json_response)) as mock: 89 | response = asyncio.run(self.ncc.remove_group_folder(FOLDERID)) 90 | mock.assert_called_with( 91 | method='DELETE', 92 | auth=(USER, PASSWORD), 93 | url=f'{ENDPOINT}/apps/groupfolders/folders/{FOLDERID}', 94 | data={'format': 'json'}, 95 | headers={'OCS-APIRequest': 'true'}) 96 | assert response['success'] is True 97 | 98 | def test_add_group_to_group_folder(self): # noqa: D102 99 | json_response = bytes(SIMPLE_100.format('{"success":true}'), 'utf-8') 100 | with patch( 101 | 'httpx.AsyncClient.request', 102 | new_callable=AsyncMock, 103 | return_value=httpx.Response( 104 | status_code=100, 105 | content=json_response)) as mock: 106 | response = asyncio.run(self.ncc.add_group_to_group_folder(GROUP, FOLDERID)) 107 | mock.assert_called_with( 108 | method='POST', 109 | auth=(USER, PASSWORD), 110 | url=f'{ENDPOINT}/apps/groupfolders/folders/{FOLDERID}/groups', 111 | data={'format': 'json', 'group': GROUP}, 112 | headers={'OCS-APIRequest': 'true'}) 113 | assert response['success'] is True 114 | 115 | def test_remove_group_from_group_folder(self): # noqa: D102 116 | json_response = bytes(SIMPLE_100.format('{"success":true}'), 'utf-8') 117 | with patch( 118 | 'httpx.AsyncClient.request', 119 | new_callable=AsyncMock, 120 | return_value=httpx.Response( 121 | status_code=100, 122 | content=json_response)) as mock: 123 | response = asyncio.run(self.ncc.remove_group_from_group_folder(GROUP, FOLDERID)) 124 | mock.assert_called_with( 125 | method='DELETE', 126 | auth=(USER, PASSWORD), 127 | url=f'{ENDPOINT}/apps/groupfolders/folders/{FOLDERID}/groups/{GROUP}', 128 | data={'format': 'json'}, 129 | headers={'OCS-APIRequest': 'true'}) 130 | assert response['success'] is True 131 | 132 | def test_enable_advanced_permissions(self): # noqa: D102 133 | json_response = bytes(SIMPLE_100.format('{"success":true}'), 'utf-8') 134 | with patch( 135 | 'httpx.AsyncClient.request', 136 | new_callable=AsyncMock, 137 | return_value=httpx.Response( 138 | status_code=100, 139 | content=json_response)) as mock: 140 | response = asyncio.run( 141 | self.ncc.enable_group_folder_advanced_permissions(FOLDERID)) 142 | mock.assert_called_with( 143 | method='POST', 144 | auth=(USER, PASSWORD), 145 | url=f'{ENDPOINT}/apps/groupfolders/folders/{FOLDERID}/acl', 146 | data={'format': 'json', 'acl': 1}, 147 | headers={'OCS-APIRequest': 'true'}) 148 | assert response['success'] is True 149 | 150 | def test_disable_advanced_permissions(self): # noqa: D102 151 | json_response = bytes(SIMPLE_100.format('{"success":true}'), 'utf-8') 152 | with patch( 153 | 'httpx.AsyncClient.request', 154 | new_callable=AsyncMock, 155 | return_value=httpx.Response( 156 | status_code=100, 157 | content=json_response)) as mock: 158 | response = asyncio.run( 159 | self.ncc.disable_group_folder_advanced_permissions(FOLDERID)) 160 | mock.assert_called_with( 161 | method='POST', 162 | auth=(USER, PASSWORD), 163 | url=f'{ENDPOINT}/apps/groupfolders/folders/{FOLDERID}/acl', 164 | data={'format': 'json', 'acl': 0}, 165 | headers={'OCS-APIRequest': 'true'}) 166 | assert response['success'] is True 167 | 168 | def test_add_group_folder_advanced_permissions(self): # noqa: D102 169 | TYPE = 'user' 170 | json_response = bytes(SIMPLE_100.format('{"success":true}'), 'utf-8') 171 | with patch( 172 | 'httpx.AsyncClient.request', 173 | new_callable=AsyncMock, 174 | return_value=httpx.Response( 175 | status_code=100, 176 | content=json_response)) as mock: 177 | response = asyncio.run( 178 | self.ncc.add_group_folder_advanced_permissions(FOLDERID, TESTUSER, TYPE)) 179 | mock.assert_called_with( 180 | method='POST', 181 | auth=(USER, PASSWORD), 182 | url=f'{ENDPOINT}/apps/groupfolders/folders/{FOLDERID}/manageACL', 183 | data={ 184 | 'format': 'json', 185 | 'mappingId': TESTUSER, 186 | 'mappingType': TYPE, 187 | 'manageAcl': True}, 188 | headers={'OCS-APIRequest': 'true'}) 189 | assert response['success'] is True 190 | 191 | def test_remove_group_folder_advanced_permissions(self): # noqa: D102 192 | TYPE = 'user' 193 | json_response = bytes(SIMPLE_100.format('{"success":true}'), 'utf-8') 194 | with patch( 195 | 'httpx.AsyncClient.request', 196 | new_callable=AsyncMock, 197 | return_value=httpx.Response( 198 | status_code=100, 199 | content=json_response)) as mock: 200 | response = asyncio.run( 201 | self.ncc.remove_group_folder_advanced_permissions(FOLDERID, TESTUSER, TYPE)) 202 | mock.assert_called_with( 203 | method='POST', 204 | auth=(USER, PASSWORD), 205 | url=f'{ENDPOINT}/apps/groupfolders/folders/{FOLDERID}/manageACL', 206 | data={ 207 | 'format': 'json', 208 | 'mappingId': TESTUSER, 209 | 'mappingType': TYPE, 210 | 'manageAcl': False}, 211 | headers={'OCS-APIRequest': 'true'}) 212 | assert response['success'] is True 213 | 214 | def test_set_group_folder_permissions(self): # noqa: D102 215 | PERM = GP['create']|GP['delete'] 216 | 217 | json_response = bytes(SIMPLE_100.format('{"success":true}'), 'utf-8') 218 | with patch( 219 | 'httpx.AsyncClient.request', 220 | new_callable=AsyncMock, 221 | return_value=httpx.Response( 222 | status_code=100, 223 | content=json_response)) as mock: 224 | response = asyncio.run( 225 | self.ncc.set_group_folder_permissions(FOLDERID, GROUP, PERM)) 226 | mock.assert_called_with( 227 | method='POST', 228 | auth=(USER, PASSWORD), 229 | url=f'{ENDPOINT}/apps/groupfolders/folders/{FOLDERID}/groups/{GROUP}', 230 | data={ 231 | 'format': 'json', 232 | 'permissions': PERM.value}, 233 | headers={'OCS-APIRequest': 'true'}) 234 | assert response['success'] is True 235 | 236 | def test_set_group_folder_quota(self): # noqa: D102 237 | QUOTA = -3 238 | 239 | json_response = bytes(SIMPLE_100.format('{"success":true}'), 'utf-8') 240 | with patch( 241 | 'httpx.AsyncClient.request', 242 | new_callable=AsyncMock, 243 | return_value=httpx.Response( 244 | status_code=100, 245 | content=json_response)) as mock: 246 | response = asyncio.run( 247 | self.ncc.set_group_folder_quota(FOLDERID, QUOTA)) 248 | mock.assert_called_with( 249 | method='POST', 250 | auth=(USER, PASSWORD), 251 | url=f'{ENDPOINT}/apps/groupfolders/folders/{FOLDERID}/quota', 252 | data={ 253 | 'format': 'json', 254 | 'quota': QUOTA}, 255 | headers={'OCS-APIRequest': 'true'}) 256 | assert response['success'] is True 257 | 258 | def test_rename_group_folder(self): # noqa: D102 259 | NEWNAME = 'TakeFive' 260 | 261 | json_response = bytes(SIMPLE_100.format('{"success":true}'), 'utf-8') 262 | with patch( 263 | 'httpx.AsyncClient.request', 264 | new_callable=AsyncMock, 265 | return_value=httpx.Response( 266 | status_code=100, 267 | content=json_response)) as mock: 268 | response = asyncio.run( 269 | self.ncc.rename_group_folder(FOLDERID, NEWNAME)) 270 | mock.assert_called_with( 271 | method='POST', 272 | auth=(USER, PASSWORD), 273 | url=f'{ENDPOINT}/apps/groupfolders/folders/{FOLDERID}/mountpoint', 274 | data={ 275 | 'format': 'json', 276 | 'mountpoint': NEWNAME}, 277 | headers={'OCS-APIRequest': 'true'}) 278 | assert response['success'] is True 279 | -------------------------------------------------------------------------------- /tests/test_users.py: -------------------------------------------------------------------------------- 1 | # noqa 2 | from nextcloud_async.api.ocs.shares import ShareType 3 | from .base import BaseTestCase 4 | from .helpers import AsyncMock 5 | from .constants import ( 6 | USER, NAME, ENDPOINT, PASSWORD, EMAIL, EMPTY_100, SIMPLE_100) 7 | 8 | from nextcloud_async.exceptions import NextCloudException 9 | 10 | import asyncio 11 | import httpx 12 | 13 | from unittest.mock import patch 14 | 15 | 16 | class OCSUserAPI(BaseTestCase): # noqa: D101 17 | 18 | def test_create_user(self): # noqa: D102 19 | json_response = bytes(SIMPLE_100.format(f'{{"id": "{USER}"}}\n'), 'utf-8') 20 | QUOTA = '1G' 21 | LANG = 'en' 22 | 23 | with patch( 24 | 'httpx.AsyncClient.request', 25 | new_callable=AsyncMock, 26 | return_value=httpx.Response( 27 | status_code=100, 28 | content=json_response)) as mock: 29 | response = asyncio.run(self.ncc.create_user( 30 | user_id=USER, 31 | email=EMAIL, 32 | display_name=NAME, 33 | password=PASSWORD, 34 | language=LANG, 35 | quota=QUOTA)) 36 | mock.assert_called_with( 37 | method='POST', 38 | auth=(USER, PASSWORD), 39 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users', 40 | data={ 41 | 'userid': USER, 42 | 'displayName': NAME, 43 | 'email': EMAIL, 44 | 'groups': [], 'subadmin': [], 45 | 'language': LANG, 46 | 'quota': QUOTA, 47 | 'password': PASSWORD, 48 | 'format': 'json'}, 49 | headers={'OCS-APIRequest': 'true'}) 50 | assert response['id'] == USER 51 | 52 | def test_search_users(self): # noqa: D102 53 | json_response = bytes(SIMPLE_100.format( 54 | f'{{"users": ["{USER}"]}}\n'), 'utf-8') 55 | SEARCH = 'MUTEMATH' 56 | with patch( 57 | 'httpx.AsyncClient.request', 58 | new_callable=AsyncMock, 59 | return_value=httpx.Response( 60 | status_code=100, 61 | content=json_response)) as mock: 62 | response = asyncio.run(self.ncc.search_users(SEARCH)) 63 | mock.assert_called_with( 64 | method='GET', 65 | auth=(USER, PASSWORD), 66 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users?search={SEARCH}' 67 | '&limit=100&offset=0&format=json', 68 | data=None, 69 | headers={'OCS-APIRequest': 'true'}) 70 | assert USER in response 71 | 72 | def test_get_user(self): # noqa: D102 73 | json_response = bytes( 74 | '{"ocs":{"meta":{"status":"ok","statuscode":100,"message":"OK","t' 75 | 'otalitems":"","itemsperpage":""},"data":{"enabled":true,"storage' 76 | f'Location":"\\/opt\\/nextcloud\\/\\/{USER}","id":"{USER}","lastLo' 77 | 'gin":1656858534000,"backend":"Database","subadmin":[],"quota":{"' 78 | 'free":53003714560,"used":106514002334,"total":159517716894,"rela' 79 | 'tive":66.77,"quota":-3},"avatarScope":"v2-federated","email":"' 80 | f'{EMAIL}","emailScope":"v2-federated","additional_mail":[],"addi' 81 | f'tional_mailScope":[],"displayname":"{NAME}","displaynameScope":' 82 | '"v2-federated","phone":"","phoneScope":"v2-local","address":"","' 83 | 'addressScope":"v2-local","website":"","websiteScope":"v2-local",' 84 | '"twitter":"","twitterScope":"v2-local","organisation":"","organi' 85 | 'sationScope":"v2-local","role":"","roleScope":"v2-local","headli' 86 | 'ne":"","headlineScope":"v2-local","biography":"","biographyScope' 87 | '":"v2-local","profile_enabled":"1","profile_enabledScope":"v2-lo' 88 | 'cal","groups":["admin"],"language":"en","locale":"en","notify_em' 89 | 'ail":null,"backendCapabilities":{"setDisplayName":true,"setPassw' 90 | 'ord":true}}}}', 'utf-8') 91 | with patch( 92 | 'httpx.AsyncClient.request', 93 | new_callable=AsyncMock, 94 | return_value=httpx.Response( 95 | status_code=100, 96 | content=json_response)) as mock: 97 | response = asyncio.run(self.ncc.get_user(USER)) 98 | mock.assert_called_with( 99 | method='GET', 100 | auth=(USER, PASSWORD), 101 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{USER}?format=json', 102 | data=None, 103 | headers={'OCS-APIRequest': 'true'}) 104 | assert response['displayname'] == NAME \ 105 | and response['id'] == USER \ 106 | and response['email'] == EMAIL 107 | 108 | def test_get_users(self): # noqa: D102 109 | TESTUSER = 'testuser' 110 | json_response = bytes(SIMPLE_100.format( 111 | f'{{"users":["{USER}","{TESTUSER}"]}}'), 'utf-8') 112 | with patch( 113 | 'httpx.AsyncClient.request', 114 | new_callable=AsyncMock, 115 | return_value=httpx.Response( 116 | status_code=100, 117 | content=json_response)) as mock: 118 | response = asyncio.run(self.ncc.get_users()) 119 | mock.assert_called_with( 120 | method='GET', 121 | auth=(USER, PASSWORD), 122 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users?format=json', 123 | data=None, 124 | headers={'OCS-APIRequest': 'true'}) 125 | assert response == [USER, TESTUSER] 126 | 127 | def test_user_autocomplete(self): # noqa: D102 128 | json_response = bytes( 129 | '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"' 130 | f'data":[{{"id":"{USER}","label":"{NAME}","icon":"icon-user","sou' 131 | 'rce":"users","status":[],"subline":"","shareWithDisplayNameUniqu' 132 | f'e":"{USER}"}}]}}}}', 'utf-8') 133 | SEARCH = 'dk' 134 | with patch( 135 | 'httpx.AsyncClient.request', 136 | new_callable=AsyncMock, 137 | return_value=httpx.Response( 138 | status_code=200, 139 | content=json_response)) as mock: 140 | asyncio.run(self.ncc.user_autocomplete( 141 | SEARCH, 142 | share_types=[ShareType['user'], ShareType['group']])) 143 | mock.assert_called_with( 144 | method='GET', 145 | auth=(USER, PASSWORD), 146 | url='https://cloud.example.com/ocs/v2.php/core/autocomplete/get' 147 | '?search=dk&itemType=None&itemId=None&sorter=None&shareType' 148 | 's%5B%5D=0&shareTypes%5B%5D=1&limit=25&format=json', 149 | data=None, 150 | headers={'OCS-APIRequest': 'true'}) 151 | 152 | def test_update_user(self): # noqa: D102 153 | WEBSITE = 'website' 154 | DISPLAYNAME = 'displayname' 155 | with patch( 156 | 'httpx.AsyncClient.request', 157 | new_callable=AsyncMock, 158 | return_value=httpx.Response( 159 | status_code=100, 160 | content=EMPTY_100)) as mock: 161 | asyncio.run(self.ncc.update_user( 162 | USER, {DISPLAYNAME: NAME, WEBSITE: ENDPOINT})) 163 | mock.assert_any_call( 164 | method='PUT', 165 | auth=(USER, PASSWORD), 166 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{USER}', 167 | data={'key': DISPLAYNAME, 'value': NAME, 'format': 'json'}, 168 | headers={'OCS-APIRequest': 'true'}) 169 | mock.assert_any_call( 170 | method='PUT', 171 | auth=(USER, PASSWORD), 172 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{USER}', 173 | data={'key': WEBSITE, 'value': ENDPOINT, 'format': 'json'}, 174 | headers={'OCS-APIRequest': 'true'}) 175 | assert mock.call_count == 2 176 | 177 | def test_get_user_editable_fields(self): # noqa: D102 178 | FIELDS = [ 179 | 'displayname', 'email', 'additional_mail', 'phone', 'address', 180 | 'website', 'twitter', 'organisation', 'role', 'headline', 'biography', 181 | 'profile_enabled' 182 | ] 183 | json_response = bytes( 184 | SIMPLE_100 185 | .format( 186 | '["displayname","' 187 | 'email","additional_mail","phone","address","website","twitter","' 188 | 'organisation","role","headline","biography","profile_enabled"]'), 'utf-8') 189 | with patch( 190 | 'httpx.AsyncClient.request', 191 | new_callable=AsyncMock, 192 | return_value=httpx.Response( 193 | status_code=100, 194 | content=json_response)) as mock: 195 | response = asyncio.run(self.ncc.get_user_editable_fields()) 196 | mock.assert_called_with( 197 | method='GET', 198 | auth=(USER, PASSWORD), 199 | url=f'{ENDPOINT}/ocs/v1.php/cloud/user/fields?format=json', 200 | data=None, 201 | headers={'OCS-APIRequest': 'true'}) 202 | for field in FIELDS: 203 | assert field in response 204 | 205 | def test_disable_user(self): # noqa: D102 206 | with patch( 207 | 'httpx.AsyncClient.request', 208 | new_callable=AsyncMock, 209 | return_value=httpx.Response( 210 | status_code=100, 211 | content=EMPTY_100)) as mock: 212 | asyncio.run(self.ncc.disable_user(USER)) 213 | mock.assert_called_with( 214 | method='PUT', 215 | auth=(USER, PASSWORD), 216 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{USER}/disable', 217 | data={'format': 'json'}, 218 | headers={'OCS-APIRequest': 'true'}) 219 | 220 | def test_enable_user(self): # noqa: D102 221 | with patch( 222 | 'httpx.AsyncClient.request', 223 | new_callable=AsyncMock, 224 | return_value=httpx.Response( 225 | status_code=100, 226 | content=EMPTY_100)) as mock: 227 | asyncio.run(self.ncc.enable_user(USER)) 228 | mock.assert_called_with( 229 | method='PUT', 230 | auth=(USER, PASSWORD), 231 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{USER}/enable', 232 | data={'format': 'json'}, 233 | headers={'OCS-APIRequest': 'true'}) 234 | 235 | def test_remove_user(self): # noqa: D102 236 | with patch( 237 | 'httpx.AsyncClient.request', 238 | new_callable=AsyncMock, 239 | return_value=httpx.Response( 240 | status_code=100, 241 | content=EMPTY_100)) as mock: 242 | asyncio.run(self.ncc.remove_user(user_id=USER)) 243 | mock.assert_called_with( 244 | method='DELETE', 245 | auth=(USER, PASSWORD), 246 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{USER}', 247 | data={'format': 'json'}, 248 | headers={'OCS-APIRequest': 'true'}) 249 | 250 | def test_get_self_groups(self): # noqa: D102 251 | json_response = bytes(SIMPLE_100.format('{"groups": []}'), 'utf-8') 252 | with patch( 253 | 'httpx.AsyncClient.request', 254 | new_callable=AsyncMock, 255 | return_value=httpx.Response( 256 | status_code=100, 257 | content=json_response)) as mock: 258 | response = asyncio.run(self.ncc.get_user_groups()) 259 | mock.assert_called_with( 260 | method='GET', 261 | auth=(USER, PASSWORD), 262 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{USER}/groups?format=json', 263 | data=None, 264 | headers={'OCS-APIRequest': 'true'}) 265 | assert response == [] 266 | 267 | def test_get_user_groups(self): # noqa: D102 268 | TESTUSER = 'testuser' 269 | json_response = bytes(SIMPLE_100.format('{"groups": []}'), 'utf-8') 270 | with patch( 271 | 'httpx.AsyncClient.request', 272 | new_callable=AsyncMock, 273 | return_value=httpx.Response( 274 | status_code=100, 275 | content=json_response)) as mock: 276 | response = asyncio.run(self.ncc.get_user_groups(TESTUSER)) 277 | mock.assert_called_with( 278 | method='GET', 279 | auth=(USER, PASSWORD), 280 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{TESTUSER}/groups?format=json', 281 | data=None, 282 | headers={'OCS-APIRequest': 'true'}) 283 | assert response == [] 284 | 285 | def test_add_user_to_group(self): # noqa: D102 286 | GROUP = 'group' 287 | with patch( 288 | 'httpx.AsyncClient.request', 289 | new_callable=AsyncMock, 290 | return_value=httpx.Response( 291 | status_code=100, 292 | content=EMPTY_100)) as mock: 293 | asyncio.run(self.ncc.add_user_to_group(USER, GROUP)) 294 | mock.assert_called_with( 295 | method='POST', 296 | auth=(USER, PASSWORD), 297 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{USER}/groups', 298 | data={'groupid': GROUP, 'format': 'json'}, 299 | headers={'OCS-APIRequest': 'true'}) 300 | 301 | def test_add_user_to_nonexistent_group(self): # noqa: D102 302 | GROUP = 'noexist_group' 303 | with patch( 304 | 'httpx.AsyncClient.request', 305 | new_callable=AsyncMock) as mock: 306 | mock.side_effect = NextCloudException(status_code=102, reason='None') 307 | try: 308 | asyncio.run(self.ncc.add_user_to_group(USER, GROUP)) 309 | except NextCloudException: 310 | pass 311 | finally: 312 | mock.assert_called_with( 313 | method='POST', 314 | auth=(USER, PASSWORD), 315 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{USER}/groups', 316 | data={'groupid': GROUP, 'format': 'json'}, 317 | headers={'OCS-APIRequest': 'true'}) 318 | self.assertRaises(NextCloudException) 319 | 320 | def test_remove_user_from_group(self): # noqa: D102 321 | GROUP = 'group' 322 | with patch( 323 | 'httpx.AsyncClient.request', 324 | new_callable=AsyncMock, 325 | return_value=httpx.Response( 326 | status_code=100, 327 | content=EMPTY_100)) as mock: 328 | asyncio.run(self.ncc.remove_user_from_group(USER, GROUP)) 329 | mock.assert_called_with( 330 | method='DELETE', 331 | auth=(USER, PASSWORD), 332 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{USER}/groups', 333 | data={'groupid': GROUP, 'format': 'json'}, 334 | headers={'OCS-APIRequest': 'true'}) 335 | 336 | def test_promote_user_to_subadmin(self): # noqa: D102 337 | GROUP = 'group' 338 | with patch( 339 | 'httpx.AsyncClient.request', 340 | new_callable=AsyncMock, 341 | return_value=httpx.Response( 342 | status_code=100, 343 | content=EMPTY_100)) as mock: 344 | asyncio.run(self.ncc.promote_user_to_subadmin(USER, GROUP)) 345 | mock.assert_called_with( 346 | method='POST', 347 | auth=(USER, PASSWORD), 348 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{USER}/subadmins', 349 | data={'groupid': GROUP, 'format': 'json'}, 350 | headers={'OCS-APIRequest': 'true'}) 351 | 352 | def test_demote_user_from_subadmin(self): # noqa: D102 353 | GROUP = 'group' 354 | with patch( 355 | 'httpx.AsyncClient.request', 356 | new_callable=AsyncMock, 357 | return_value=httpx.Response( 358 | status_code=100, 359 | content=EMPTY_100)) as mock: 360 | asyncio.run(self.ncc.demote_user_from_subadmin(USER, GROUP)) 361 | mock.assert_called_with( 362 | method='DELETE', 363 | auth=(USER, PASSWORD), 364 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{USER}/subadmins', 365 | data={'groupid': GROUP, 'format': 'json'}, 366 | headers={'OCS-APIRequest': 'true'}) 367 | 368 | def test_get_user_subadmin_groups(self): # noqa: D102 369 | TESTUSER = 'testuser' 370 | json_response = EMPTY_100 371 | with patch( 372 | 'httpx.AsyncClient.request', 373 | new_callable=AsyncMock, 374 | return_value=httpx.Response( 375 | status_code=100, 376 | content=json_response)) as mock: 377 | response = asyncio.run(self.ncc.get_user_subadmin_groups(TESTUSER)) 378 | mock.assert_called_with( 379 | method='GET', 380 | auth=(USER, PASSWORD), 381 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{TESTUSER}/subadmins?format=json', 382 | data=None, 383 | headers={'OCS-APIRequest': 'true'}) 384 | assert response == [] 385 | 386 | def test_resend_welcome_email(self): # noqa: D102 387 | TESTUSER = 'testuser' 388 | json_response = EMPTY_100 389 | with patch( 390 | 'httpx.AsyncClient.request', 391 | new_callable=AsyncMock, 392 | return_value=httpx.Response( 393 | status_code=100, 394 | content=json_response)) as mock: 395 | response = asyncio.run(self.ncc.resend_welcome_email(TESTUSER)) 396 | mock.assert_called_with( 397 | method='POST', 398 | auth=(USER, PASSWORD), 399 | url=f'{ENDPOINT}/ocs/v1.php/cloud/users/{TESTUSER}/welcome', 400 | data={'format': 'json'}, 401 | headers={'OCS-APIRequest': 'true'}) 402 | assert response == [] 403 | -------------------------------------------------------------------------------- /nextcloud_async/api/dav/files.py: -------------------------------------------------------------------------------- 1 | """Implement Nextcloud DAV API for File Management.""" 2 | import io 3 | import os 4 | import uuid 5 | import json 6 | import re 7 | from urllib.parse import quote 8 | 9 | import platformdirs as pdir 10 | import xml.etree.ElementTree as etree 11 | 12 | from typing import List, Optional, Any, Dict, ByteString 13 | 14 | from nextcloud_async.exceptions import ( 15 | NextCloudChunkedUploadException, 16 | NextCloudException) 17 | 18 | 19 | class FileManager(object): 20 | """Interact with Nextcloud DAV Files Endpoint.""" 21 | 22 | async def list_files(self, path: str, properties: List[str] = []) -> Dict[str, Any]: 23 | """Return a list of files at `path`. 24 | 25 | If `properties` is passed, only those properties requested are 26 | returned. 27 | 28 | Args 29 | ---- 30 | path (str): Filesystem path 31 | 32 | properties (list, optional): List of properties to return. Defaults to []. 33 | 34 | Returns 35 | ------- 36 | list: File descriptions 37 | 38 | """ 39 | data = None 40 | 41 | # if user passes in parameters, they must be built into an Element 42 | # tree so they can be dumped to an XML document and then sent 43 | # as the query body 44 | root = etree.Element( 45 | "d:propfind", 46 | attrib={ 47 | 'xmlns:d': 'DAV:', 48 | 'xmlns:oc': 'http://owncloud.org/ns', 49 | 'xmlns:nc': 'http://nextcloud.org/ns'}) 50 | prop = etree.SubElement(root, 'd:prop') 51 | for t in properties: 52 | etree.SubElement(prop, t) 53 | 54 | tree = etree.ElementTree(root) 55 | 56 | # Write XML file to memory, then read it into `data` 57 | with io.BytesIO() as _mem: 58 | tree.write(_mem, xml_declaration=True) 59 | _mem.seek(0) 60 | data = _mem.read().decode('utf-8') 61 | 62 | return await self.dav_query( 63 | method='PROPFIND', 64 | sub=f'/remote.php/dav/files/{self.user}/{path}', 65 | data=data) 66 | 67 | async def download_file(self, path: str) -> ByteString: 68 | """Download the file at `path`. 69 | 70 | Args 71 | ---- 72 | path (str): File path 73 | 74 | Returns 75 | ------- 76 | str: File content 77 | 78 | """ 79 | return (await self.request( 80 | method='GET', 81 | sub=f'/remote.php/dav/files/{self.user}/{path}', 82 | data={})).content 83 | 84 | async def upload_file(self, local_path: str, remote_path: str): 85 | """Upload a file. 86 | 87 | Args 88 | ---- 89 | local_path (str): Local path 90 | 91 | remote_path (str): Desination path 92 | 93 | Returns 94 | ------- 95 | Empty 200 Response 96 | 97 | """ 98 | with open(local_path, 'rb') as fp: 99 | return await self.dav_query( 100 | method='PUT', 101 | sub=f'/remote.php/dav/files/{self.user}/{remote_path}', 102 | data=fp.read()) 103 | 104 | async def create_folder(self, path: str, create_parents: bool = False): 105 | """Create a new folder/directory. 106 | 107 | Args 108 | ---- 109 | path (str): Filesystem path 110 | 111 | create_parents (bool): Create directory parents (mkdir -p) 112 | 113 | Returns 114 | ------- 115 | Empty 200 Response 116 | 117 | """ 118 | if create_parents: 119 | return await self.create_folder_with_parents(path) 120 | 121 | return await self.dav_query( 122 | method='MKCOL', 123 | sub=f'/remote.php/dav/files/{self.user}/{path}') 124 | 125 | async def delete(self, path: str): 126 | """Delete file or folder. 127 | 128 | Args 129 | ---- 130 | path (str): Filesystem path 131 | 132 | Returns 133 | ------- 134 | Empty 200 Response 135 | 136 | """ 137 | return await self.dav_query( 138 | method='DELETE', 139 | sub=f'/remote.php/dav/files/{self.user}/{path}') 140 | 141 | async def move(self, source: str, dest: str, overwrite: bool = False): 142 | """Move a file or folder. 143 | 144 | Args 145 | ---- 146 | source (str): Source path 147 | 148 | dest (str): Destination path 149 | 150 | overwrite (bool, optional): Overwrite destination if exists. Defaults to False. 151 | 152 | Returns 153 | ------- 154 | Empty 200 Response 155 | 156 | """ 157 | return await self.dav_query( 158 | method='MOVE', 159 | sub=f'/remote.php/dav/files/{self.user}/{source}', 160 | headers={ 161 | 'Destination': 162 | f'{self.endpoint}/remote.php/dav/files/{self.user}/{quote(dest)}', 163 | 'Overwrite': 'T' if overwrite else 'F'}) 164 | 165 | async def copy(self, source: str, dest: str, overwrite: bool = False): 166 | """Copy a file or folder. 167 | 168 | Args 169 | ---- 170 | source (str): Source path 171 | 172 | dest (str): Destination path 173 | 174 | overwrite (bool, optional): Overwrite destination if exists. Defaults to False. 175 | 176 | Returns 177 | ------- 178 | Empty 200 Response 179 | 180 | """ 181 | return await self.dav_query( 182 | method='COPY', 183 | sub=f'/remote.php/dav/files/{self.user}/{source}', 184 | headers={ 185 | 'Destination': 186 | f'{self.endpoint}/remote.php/dav/files/{self.user}/{quote(dest)}', 187 | 'Overwrite': 'T' if overwrite else 'F'}) 188 | 189 | async def __favorite(self, path: str, set: bool) -> Dict[str, Any]: 190 | """Set file/folder as a favorite. 191 | 192 | Args 193 | ---- 194 | path (str): Filesystem path 195 | 196 | set (bool): Make favorite 197 | 198 | Returns 199 | ------- 200 | dict: file info 201 | 202 | """ 203 | data = f''' 204 | 207 | 208 | {1 if set else 0} 209 | 210 | ''' 211 | 212 | return await self.dav_query( 213 | method='PROPPATCH', 214 | sub=f'/remote.php/dav/files/{self.user}/{path}', 215 | data=data) 216 | 217 | async def set_favorite(self, path: str): 218 | """Set file/folder as a favorite. 219 | 220 | Args 221 | ---- 222 | path (str): Filesystem path 223 | 224 | Returns 225 | ------- 226 | dict: File info 227 | 228 | """ 229 | return await self.__favorite(path, True) 230 | 231 | async def remove_favorite(self, path: str): 232 | """Remove file/folder as a favorite. 233 | 234 | Args 235 | ---- 236 | path (str): Filesystem path 237 | 238 | Returns 239 | ------- 240 | dict: File info 241 | 242 | """ 243 | return await self.__favorite(path, False) 244 | 245 | async def get_favorites(self, path: Optional[str] = ''): 246 | """List favorites below given Path. 247 | 248 | Args 249 | ---- 250 | path (str, optional): Filesystem path. Defaults to ''. 251 | 252 | Returns 253 | ------- 254 | list: list of favorites 255 | 256 | """ 257 | data = ''' 259 | 1 260 | ''' 261 | return await self.dav_query( 262 | method='REPORT', 263 | sub=f'/remote.php/dav/files/{self.user}/{path}', 264 | data=data) 265 | 266 | async def get_trashbin(self): 267 | """Get items in the trash. 268 | 269 | Returns 270 | ------- 271 | list: Trashed items 272 | 273 | """ 274 | return await self.dav_query( 275 | method='PROPFIND', 276 | sub=f'/remote.php/dav/trashbin/{self.user}/trash') 277 | 278 | async def restore_from_trashbin(self, path: str): 279 | """Restore a file from the trash. 280 | 281 | Args 282 | ---- 283 | path (str): Trash path 284 | 285 | Returns 286 | ------- 287 | Empty 200 Response 288 | 289 | """ 290 | return await self.dav_query( 291 | method='MOVE', 292 | sub=path, 293 | headers={ 294 | 'Destination': 295 | f'{self.endpoint}/remote.php/dav/trashbin/{self.user}/restore/file'}) 296 | 297 | async def empty_trashbin(self): 298 | """Empty the trash. 299 | 300 | Returns 301 | ------- 302 | Empty 200 Response 303 | 304 | """ 305 | return await self.dav_query( 306 | method='DELETE', 307 | sub=f'/remote.php/dav/trashbin/{self.user}/trash') 308 | 309 | async def get_file_versions(self, file: str): 310 | """List of file versions. 311 | 312 | Args 313 | ---- 314 | file (str): File path 315 | 316 | Returns 317 | ------- 318 | list: File versions 319 | 320 | """ 321 | file_id = file 322 | if isinstance(file, str): 323 | f = await self.list_files(file, properties=['oc:fileid']) 324 | file_id = f["d:propstat"]["d:prop"]["oc:fileid"] 325 | 326 | return await self.dav_query( 327 | method='PROPFIND', 328 | sub=f'/remote.php/dav/versions/{self.user}/versions/{file_id}') 329 | 330 | async def restore_file_version(self, path: str): 331 | """Restore an old file version. 332 | 333 | Args 334 | ---- 335 | path (str): File version path 336 | 337 | Returns 338 | ------- 339 | Empty 200 Response 340 | 341 | """ 342 | return await self.dav_query( 343 | method='MOVE', 344 | sub=path, 345 | headers={ 346 | 'Destination': 347 | f'{self.endpoint}/remote.php/dav/versions/{self.user}/restore/file'}) 348 | 349 | def __replace_slashes(self, string: str): 350 | """Replace path slashes with underscores.""" 351 | return string.replace('/', '_').replace('\\', '_') 352 | 353 | async def create_folder_with_parents(self, path: str): 354 | """Create folder with parents (mkdir -p). 355 | 356 | Args 357 | ---- 358 | path (str): Path to folder 359 | 360 | Returns 361 | ------- 362 | Empty 200 response on success 363 | 364 | Raises 365 | ------ 366 | NextCloudException: Errors from self.create_folder() 367 | 368 | """ 369 | # TODO: Write test 370 | path_chunks = path.strip('/').split('/') 371 | result = None 372 | for count in range(1, len(path_chunks) + 1): 373 | try: 374 | result = await self.create_folder("/".join(path_chunks[0:count])) 375 | except NextCloudException as e: 376 | if 'already exists' not in str(e): 377 | raise 378 | return result 379 | 380 | async def __upload_file_chunk(self, local_path: str, uuid_dir: str): 381 | with open(local_path, 'rb') as fp: 382 | return await self.dav_query( 383 | method='PUT', 384 | sub=f'/remote.php/dav/uploads/{self.user}/' 385 | f'{uuid_dir}/{os.path.basename(local_path)}', 386 | data=fp.read()) 387 | 388 | async def __assemble_chunks( 389 | self, 390 | uuid_dir: str, 391 | remote_path: str): 392 | return await self.dav_query( 393 | method='MOVE', 394 | sub=f'/remote.php/dav/uploads/{self.user}/{uuid_dir}/.file', 395 | headers={ 396 | 'Destination': 397 | f'{self.endpoint}/remote.php/dav/files/' 398 | f'{self.user}/{quote(remote_path.strip("/"))}', 399 | 'Overwrite': 'T'}) 400 | 401 | async def upload_file_chunked( 402 | self, 403 | local_path: str, 404 | remote_path: str, 405 | chunk_size: int): 406 | """Upload a large file in chunks. 407 | 408 | https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/chunking.html 409 | 410 | Args 411 | ---- 412 | local_path (str): Local file to upload 413 | 414 | remote_path (str): Remote path for finished file 415 | 416 | chunk_size (int): Upload file this many bytes per chunk 417 | 418 | Raises 419 | ------ 420 | NextCloudChunkedCacheExists: When previous failed attempt is detected. 421 | 422 | """ 423 | # TODO: Write test 424 | file_position = 0 425 | padding = len(str(os.stat(local_path).st_size)) 426 | 427 | local_path_escaped = self.__replace_slashes(local_path) 428 | remote_path_escaped = self.__replace_slashes(remote_path) 429 | uuid_dir = str(uuid.uuid4()) 430 | 431 | local_cache_dir = \ 432 | f'{pdir.user_cache_dir("nextcloud-async")}' \ 433 | f'/chunked_uploads/{local_path_escaped}-{remote_path_escaped}' 434 | 435 | try: 436 | os.makedirs(local_cache_dir) 437 | except OSError: 438 | # Cache maybe exists, read metadata and attempt to resume upload 439 | with open(f'{local_cache_dir}/metadata.json', 'r') as metadata_fp: 440 | metadata = json.loads(metadata_fp.read()) 441 | uuid_dir = metadata['uuid'] 442 | else: 443 | # Write metadata to file in case of error uploading 444 | with open(f'{local_cache_dir}/metadata.json', 'w') as metadata_fp: 445 | metadata = {'uuid': uuid_dir} 446 | metadata_fp.write(json.dumps(metadata)) 447 | 448 | resume_chunk = None 449 | for file in os.listdir(local_cache_dir): 450 | # Check for existing chunk, resume there and proceed with 451 | # rest of file. 452 | if (span := re.match(r'[0-9]+-([0-9]+)$', file)): 453 | if resume_chunk: 454 | raise NextCloudChunkedUploadException() 455 | resume_chunk = file 456 | file_position = int(span[1]) 457 | 458 | if resume_chunk: 459 | await self.__upload_file_chunk(f'{local_cache_dir}/{resume_chunk}', uuid_dir) 460 | os.remove(f'{local_cache_dir}/{resume_chunk}') 461 | else: 462 | # Make remote upload directory 463 | await self.dav_query( 464 | method='MKCOL', 465 | sub=f'/remote.php/dav/uploads/{self.user}/{uuid_dir}') 466 | 467 | with open(local_path, 'rb') as source_fp: 468 | source_fp.seek(file_position) 469 | while (data := source_fp.read(chunk_size)): 470 | chunk_name = \ 471 | f'{file_position:0{padding}}-{(file_position + len(data)):0{padding}}' 472 | with open(f'{local_cache_dir}/{chunk_name}', 'wb') as chunk_fp: 473 | chunk_fp.write(data) 474 | file_position += len(data) 475 | await self.__upload_file_chunk(f'{local_cache_dir}/{chunk_name}', uuid_dir) 476 | os.remove(f'{local_cache_dir}/{chunk_name}') 477 | 478 | # Assemble chunks. Server takes care of directory removal. 479 | await self.__assemble_chunks(uuid_dir, remote_path.strip('/')) 480 | 481 | # Remove local cache directory 482 | for file in os.listdir(local_cache_dir): 483 | os.remove(f'{local_cache_dir}/{file}') 484 | os.rmdir(local_cache_dir) 485 | 486 | async def get_groupfolder_acl(self, path: str, inherited: bool=False) -> Dict[str, Any]: 487 | """Return a list of groupfolder ACL rules set for `path`. 488 | 489 | Args 490 | ---- 491 | path (str): Filesystem path 492 | inherited (bool): Return inherited rules instead of normal rules 493 | 494 | Returns 495 | ------- 496 | list: ACL rules 497 | 498 | """ 499 | data = None 500 | ruleprop = 'nc:acl-list' 501 | if inherited: 502 | ruleprop = 'nc:inherited-acl-list' 503 | 504 | root = etree.Element( 505 | "d:propfind", 506 | attrib={ 507 | 'xmlns:d': 'DAV:', 508 | 'xmlns:oc': 'http://owncloud.org/ns', 509 | 'xmlns:nc': 'http://nextcloud.org/ns'}) 510 | prop = etree.SubElement(root, 'd:prop') 511 | etree.SubElement(prop, ruleprop) 512 | 513 | tree = etree.ElementTree(root) 514 | 515 | # Write XML file to memory, then read it into `data` 516 | with io.BytesIO() as _mem: 517 | tree.write(_mem, xml_declaration=True) 518 | _mem.seek(0) 519 | data = _mem.read().decode('utf-8') 520 | 521 | result = (await self.dav_query( 522 | method='PROPFIND', 523 | sub=f'/remote.php/dav/files/{self.user}/{path}', 524 | data=data)) 525 | 526 | if result['d:propstat']['d:prop'][ruleprop]: 527 | result = result['d:propstat']['d:prop'][ruleprop]['nc:acl'] 528 | else: 529 | result = [] 530 | 531 | if type(result) is not list: 532 | return [result] 533 | 534 | return result 535 | 536 | async def set_groupfolder_acl(self, path: str, acls: List[dict]): 537 | """Apply a list of groupfolder ACL rules to `path`. 538 | 539 | Args 540 | ---- 541 | path (str): Filesystem path 542 | acls (List[int]): List of ACL rule dicts 543 | 544 | Returns 545 | ------- 546 | Empty 200 Response 547 | 548 | """ 549 | data = None 550 | 551 | root = etree.Element( 552 | "d:propertyupdate", 553 | attrib={ 554 | 'xmlns:d': 'DAV:', 555 | 'xmlns:oc': 'http://owncloud.org/ns', 556 | 'xmlns:nc': 'http://nextcloud.org/ns'}) 557 | prop = etree.SubElement(root, 'd:set') 558 | prop = etree.SubElement(prop, 'd:prop') 559 | prop = etree.SubElement(prop, 'nc:acl-list') 560 | for acl in acls: 561 | aclprop = etree.SubElement(prop, 'nc:acl') 562 | for key, val in acl.items(): 563 | child = etree.Element(key) 564 | child.text = str(val) 565 | aclprop.append(child) 566 | 567 | tree = etree.ElementTree(root) 568 | 569 | # Write XML file to memory, then read it into `data` 570 | with io.BytesIO() as _mem: 571 | tree.write(_mem, xml_declaration=True) 572 | _mem.seek(0) 573 | data = _mem.read().decode('utf-8') 574 | 575 | return (await self.dav_query( 576 | method='PROPPATCH', 577 | sub=f'/remote.php/dav/files/{self.user}/{path}', 578 | data=data)) 579 | --------------------------------------------------------------------------------