├── 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 |
--------------------------------------------------------------------------------