Skip to content

Commit e927247

Browse files
authored
FilesAPI: SystemTags implementation (#115)
Note: New `list_by_criteria` should be much faster then `listfav` --------- Signed-off-by: Alexander Piskun <[email protected]>
1 parent 0f85ea5 commit e927247

File tree

8 files changed

+272
-46
lines changed

8 files changed

+272
-46
lines changed

CHANGELOG.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22

33
All notable changes to this project will be documented in this file.
44

5-
## [0.0.44 - 2023-09-0x]
5+
## [0.1.0 - 2023-09-06]
66

77
### Added
88

9-
- Activity API: `get_filters` and `get_activities`.
9+
- Activity API: `get_filters` and `get_activities`. #112
10+
- FilesAPI: added `tags` support. #115
11+
12+
### Changed
13+
14+
- FilesAPI: removed `listfav` method, use new more powerful `list_by_criteria` method. #115
1015

1116
### Fixed
1217

README.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<img src="https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/docs/resources/nc_py_api_logo.png" width="250" alt="NcPyApi logo">
33
</p>
44

5-
# Official Nextcloud Python Framework
5+
# Nextcloud Python Framework
66

77
[![Analysis & Coverage](https://github.com/cloud-py-api/nc_py_api/actions/workflows/analysis-coverage.yml/badge.svg)](https://github.com/cloud-py-api/nc_py_api/actions/workflows/analysis-coverage.yml)
88
[![Docs](https://github.com/cloud-py-api/nc_py_api/actions/workflows/docs.yml/badge.svg)](https://cloud-py-api.github.io/nc_py_api/)
@@ -23,18 +23,18 @@ Python library that provides a robust and well-documented API that allows develo
2323
* **Easy**: Designed to be easy to use with excellent documentation.
2424

2525
### Capabilities
26-
| **_Capability_** | Nextcloud 26 | Nextcloud 27 | Nextcloud 28 |
27-
|------------------|:------------:|:------------:|:------------:|
28-
| File System ||||
29-
| Shares ||||
30-
| Users & Groups ||||
31-
| User status ||||
32-
| Weather status ||||
33-
| Notifications ||||
34-
| Nextcloud Talk** ||||
35-
| Talk Bot API* | N/A |||
36-
| Text Processing* | N/A |||
37-
| SpeechToText* | N/A |||
26+
| **_Capability_** | Nextcloud 26 | Nextcloud 27 | Nextcloud 28 |
27+
|-----------------------|:------------:|:------------:|:------------:|
28+
| File System & Tags ||||
29+
| Nextcloud Talk** ||||
30+
| Notifications ||||
31+
| Shares ||||
32+
| Users & Groups ||||
33+
| User & Weather status ||||
34+
| Weather status ||||
35+
| Talk Bot API* | N/A |||
36+
| Text Processing* | N/A |||
37+
| SpeechToText* | N/A |||
3838

3939
&ast;_available only for NextcloudApp_<br>
4040
&ast;&ast; _work is in progress, not all API's is described, yet._

docs/reference/Files/Files.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ All File APIs are designed to work relative to the current user.
1717

1818
.. autoclass:: nc_py_api.files.FilePermissions
1919
:members:
20+
21+
.. autoclass:: nc_py_api.files.SystemTag
22+
:members:

nc_py_api/_session.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,21 @@ def _ocs(self, method: str, path_params: str, headers: dict, data: Optional[byte
229229
raise NextcloudException(status_code=ocs_meta["statuscode"], reason=ocs_meta["message"], info=info)
230230
return response_data["ocs"]["data"]
231231

232-
def dav(self, method: str, path: str, data: Optional[Union[str, bytes]] = None, **kwargs) -> Response:
232+
def dav(
233+
self,
234+
method: str,
235+
path: str,
236+
data: Optional[Union[str, bytes]] = None,
237+
json: Optional[Union[dict, list]] = None,
238+
**kwargs,
239+
) -> Response:
233240
headers = kwargs.pop("headers", {})
234241
data_bytes = None
235242
if data is not None:
236243
data_bytes = data.encode("UTF-8") if isinstance(data, str) else data
244+
elif json is not None:
245+
headers.update({"Content-Type": "application/json"})
246+
data_bytes = dumps(json).encode("utf-8")
237247
return self._dav(method, quote(self.cfg.dav_url_suffix + path), headers, data_bytes, **kwargs)
238248

239249
def dav_stream(

nc_py_api/files/__init__.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,31 @@ class FilePermissions(enum.IntFlag):
208208
"""Access to remove object(s)"""
209209
PERMISSION_SHARE = 16
210210
"""Access to re-share object(s)"""
211+
212+
213+
@dataclasses.dataclass
214+
class SystemTag:
215+
"""Nextcloud System Tag."""
216+
217+
def __init__(self, raw_data: dict):
218+
self._raw_data = raw_data
219+
220+
@property
221+
def tag_id(self) -> int:
222+
"""Unique numeric identifier of the Tag."""
223+
return int(self._raw_data["oc:id"])
224+
225+
@property
226+
def display_name(self) -> str:
227+
"""The visible Tag name."""
228+
return self._raw_data.get("oc:display-name", str(self.tag_id))
229+
230+
@property
231+
def user_visible(self) -> bool:
232+
"""Flag indicating if the Tag is visible in the UI."""
233+
return bool(self._raw_data.get("oc:user-visible", "false").lower() == "true")
234+
235+
@property
236+
def user_assignable(self) -> bool:
237+
"""Flag indicating if User can assign this Tag."""
238+
return bool(self._raw_data.get("oc:user-assignable", "false").lower() == "true")

nc_py_api/files/files.py

Lines changed: 138 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@
1515
import xmltodict
1616
from httpx import Response
1717

18-
from .._exceptions import NextcloudException, check_error
19-
from .._misc import require_capabilities
18+
from .._exceptions import NextcloudException, NextcloudExceptionNotFound, check_error
19+
from .._misc import clear_from_params_empty, require_capabilities
2020
from .._session import NcSessionBasic
21-
from . import FsNode
21+
from . import FsNode, SystemTag
2222
from .sharing import _FilesSharingAPI
2323

2424
PROPFIND_PROPERTIES = [
@@ -60,9 +60,8 @@ class PropFindType(enum.IntEnum):
6060

6161
DEFAULT = 0
6262
TRASHBIN = 1
63-
FAVORITE = 2
64-
VERSIONS_FILEID = 3
65-
VERSIONS_FILE_ID = 4
63+
VERSIONS_FILEID = 2
64+
VERSIONS_FILE_ID = 3
6665

6766

6867
class FilesAPI:
@@ -130,7 +129,7 @@ def find(self, req: list, path: Union[str, FsNode] = "") -> list[FsNode]:
130129
headers = {"Content-Type": "text/xml"}
131130
webdav_response = self._session.dav("SEARCH", "", data=self._element_tree_as_str(root), headers=headers)
132131
request_info = f"find: {self._session.user}, {req}, {path}"
133-
return self._lf_parse_webdav_records(webdav_response, request_info)
132+
return self._lf_parse_webdav_response(webdav_response, request_info)
134133

135134
def download(self, path: Union[str, FsNode]) -> bytes:
136135
"""Downloads and returns the content of a file.
@@ -305,20 +304,37 @@ def copy(self, path_src: Union[str, FsNode], path_dest: Union[str, FsNode], over
305304
check_error(response.status_code, f"copy: user={self._session.user}, src={path_src}, dest={dest}, {overwrite}")
306305
return self.find(req=["eq", "fileid", response.headers["OC-FileId"]])[0]
307306

308-
def listfav(self) -> list[FsNode]:
309-
"""Returns a list of the current user's favorite files."""
307+
def list_by_criteria(
308+
self, properties: Optional[list[str]] = None, tags: Optional[list[Union[int, SystemTag]]] = None
309+
) -> list[FsNode]:
310+
"""Returns a list of all files/directories for the current user filtered by the specified values.
311+
312+
:param properties: List of ``properties`` that should have been set for the file.
313+
Supported values: **favorite**
314+
:param tags: List of ``tags ids`` or ``SystemTag`` that should have been set for the file.
315+
"""
316+
if not properties and not tags:
317+
raise ValueError("Either specify 'properties' or 'tags' to filter results.")
310318
root = ElementTree.Element(
311319
"oc:filter-files",
312320
attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"},
313321
)
322+
prop = ElementTree.SubElement(root, "d:prop")
323+
for i in PROPFIND_PROPERTIES:
324+
ElementTree.SubElement(prop, i)
314325
xml_filter_rules = ElementTree.SubElement(root, "oc:filter-rules")
315-
ElementTree.SubElement(xml_filter_rules, "oc:favorite").text = "1"
326+
if properties and "favorite" in properties:
327+
ElementTree.SubElement(xml_filter_rules, "oc:favorite").text = "1"
328+
if tags:
329+
for v in tags:
330+
tag_id = v.tag_id if isinstance(v, SystemTag) else v
331+
ElementTree.SubElement(xml_filter_rules, "oc:systemtag").text = str(tag_id)
316332
webdav_response = self._session.dav(
317333
"REPORT", self._dav_get_obj_path(self._session.user), data=self._element_tree_as_str(root)
318334
)
319-
request_info = f"listfav: {self._session.user}"
335+
request_info = f"list_files_by_criteria: {self._session.user}"
320336
check_error(webdav_response.status_code, request_info)
321-
return self._lf_parse_webdav_records(webdav_response, request_info, PropFindType.FAVORITE)
337+
return self._lf_parse_webdav_response(webdav_response, request_info)
322338

323339
def setfav(self, path: Union[str, FsNode], value: Union[int, bool]) -> None:
324340
"""Sets or unsets favourite flag for specific file.
@@ -408,6 +424,108 @@ def restore_version(self, file_object: FsNode) -> None:
408424
)
409425
check_error(response.status_code, f"restore_version: user={self._session.user}, src={file_object.user_path}")
410426

427+
def list_tags(self) -> list[SystemTag]:
428+
"""Returns list of the avalaible Tags."""
429+
root = ElementTree.Element(
430+
"d:propfind",
431+
attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns"},
432+
)
433+
properties = ["oc:id", "oc:display-name", "oc:user-visible", "oc:user-assignable"]
434+
prop_element = ElementTree.SubElement(root, "d:prop")
435+
for i in properties:
436+
ElementTree.SubElement(prop_element, i)
437+
response = self._session.dav("PROPFIND", "/systemtags", self._element_tree_as_str(root))
438+
result = []
439+
records = self._webdav_response_to_records(response, "list_tags")
440+
for record in records:
441+
prop_stat = record["d:propstat"]
442+
if str(prop_stat.get("d:status", "")).find("200 OK") == -1:
443+
continue
444+
result.append(SystemTag(prop_stat["d:prop"]))
445+
return result
446+
447+
def create_tag(self, name: str, user_visible: bool = True, user_assignable: bool = True) -> None:
448+
"""Creates a new Tag.
449+
450+
:param name: Name of the tag.
451+
:param user_visible: Should be Tag visible in the UI.
452+
:param user_assignable: Can Tag be assigned from the UI.
453+
"""
454+
response = self._session.dav(
455+
"POST",
456+
path="/systemtags",
457+
json={
458+
"name": name,
459+
"userVisible": user_visible,
460+
"userAssignable": user_assignable,
461+
},
462+
)
463+
check_error(response.status_code, info=f"create_tag({name})")
464+
465+
def update_tag(
466+
self,
467+
tag_id: Union[int, SystemTag],
468+
name: Optional[str] = None,
469+
user_visible: Optional[bool] = None,
470+
user_assignable: Optional[bool] = None,
471+
) -> None:
472+
"""Updates the Tag information."""
473+
tag_id = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id
474+
root = ElementTree.Element(
475+
"d:propertyupdate",
476+
attrib={
477+
"xmlns:d": "DAV:",
478+
"xmlns:oc": "http://owncloud.org/ns",
479+
},
480+
)
481+
properties = {
482+
"oc:display-name": name,
483+
"oc:user-visible": "true" if user_visible is True else "false" if user_visible is False else None,
484+
"oc:user-assignable": "true" if user_assignable is True else "false" if user_assignable is False else None,
485+
}
486+
clear_from_params_empty(list(properties.keys()), properties)
487+
if not properties:
488+
raise ValueError("No property specified to change.")
489+
xml_set = ElementTree.SubElement(root, "d:set")
490+
prop_element = ElementTree.SubElement(xml_set, "d:prop")
491+
for k, v in properties.items():
492+
ElementTree.SubElement(prop_element, k).text = v
493+
response = self._session.dav("PROPPATCH", f"/systemtags/{tag_id}", self._element_tree_as_str(root))
494+
check_error(response.status_code, info=f"update_tag({tag_id})")
495+
496+
def delete_tag(self, tag_id: Union[int, SystemTag]) -> None:
497+
"""Deletes the tag."""
498+
tag_id = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id
499+
response = self._session.dav("DELETE", f"/systemtags/{tag_id}")
500+
check_error(response.status_code, info=f"delete_tag({tag_id})")
501+
502+
def tag_by_name(self, tag_name: str) -> SystemTag:
503+
"""Returns Tag info by its name if found or ``None`` otherwise."""
504+
r = [i for i in self.list_tags() if i.display_name == tag_name]
505+
if not r:
506+
raise NextcloudExceptionNotFound(f"Tag with name='{tag_name}' not found.")
507+
return r[0]
508+
509+
def assign_tag(self, file_id: Union[FsNode, int], tag_id: Union[SystemTag, int]) -> None:
510+
"""Assigns Tag to a file/directory."""
511+
self._file_change_tag_state(file_id, tag_id, True)
512+
513+
def unassign_tag(self, file_id: Union[FsNode, int], tag_id: Union[SystemTag, int]) -> None:
514+
"""Removes Tag from a file/directory."""
515+
self._file_change_tag_state(file_id, tag_id, False)
516+
517+
def _file_change_tag_state(
518+
self, file_id: Union[FsNode, int], tag_id: Union[SystemTag, int], tag_state: bool
519+
) -> None:
520+
request = "PUT" if tag_state else "DELETE"
521+
fs_object = file_id.info.fileid if isinstance(file_id, FsNode) else file_id
522+
tag = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id
523+
response = self._session.dav(request, f"/systemtags-relations/files/{fs_object}/{tag}")
524+
check_error(
525+
response.status_code,
526+
info=f"({'Adding' if tag_state else 'Removing'} `{tag}` {'to' if tag_state else 'from'} {fs_object})",
527+
)
528+
411529
def _listdir(
412530
self,
413531
user: str,
@@ -437,7 +555,7 @@ def _listdir(
437555
headers={"Depth": "infinity" if depth == -1 else str(depth)},
438556
)
439557

440-
result = self._lf_parse_webdav_records(
558+
result = self._lf_parse_webdav_response(
441559
webdav_response,
442560
f"list: {user}, {path}, {properties}",
443561
prop_type,
@@ -467,12 +585,7 @@ def _parse_records(self, fs_records: list[dict], response_type: PropFindType) ->
467585
fs_node.file_id = str(fs_node.info.fileid)
468586
else:
469587
fs_node.file_id = fs_node.full_path.rsplit("/", 2)[-2]
470-
if response_type == PropFindType.FAVORITE and not fs_node.file_id:
471-
_fs_node = self.by_path(fs_node.user_path)
472-
if _fs_node:
473-
_fs_node.info.favorite = True
474-
result.append(_fs_node)
475-
elif fs_node.file_id:
588+
if fs_node.file_id:
476589
result.append(fs_node)
477590
return result
478591

@@ -509,9 +622,13 @@ def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode:
509622
# xz = prop.get("oc:dDC", "")
510623
return FsNode(full_path, **fs_node_args)
511624

512-
def _lf_parse_webdav_records(
625+
def _lf_parse_webdav_response(
513626
self, webdav_res: Response, info: str, response_type: PropFindType = PropFindType.DEFAULT
514627
) -> list[FsNode]:
628+
return self._parse_records(self._webdav_response_to_records(webdav_res, info), response_type)
629+
630+
@staticmethod
631+
def _webdav_response_to_records(webdav_res: Response, info: str) -> list[dict]:
515632
check_error(webdav_res.status_code, info=info)
516633
if webdav_res.status_code != 207: # multistatus
517634
raise NextcloudException(webdav_res.status_code, "Response is not a multistatus.", info=info)
@@ -520,7 +637,7 @@ def _lf_parse_webdav_records(
520637
err = response_data["d:error"]
521638
raise NextcloudException(reason=f'{err["s:exception"]}: {err["s:message"]}'.replace("\n", ""), info=info)
522639
response = response_data["d:multistatus"].get("d:response", [])
523-
return self._parse_records([response] if isinstance(response, dict) else response, response_type)
640+
return [response] if isinstance(response, dict) else response
524641

525642
@staticmethod
526643
def _dav_get_obj_path(user: str, path: str = "", root_path="/files") -> str:

tests/actual_tests/conftest.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,16 @@ def init_filesystem_for_user(nc_any, rand_bytes):
2828
/test_dir/subdir/test_empty_text.txt
2929
/test_dir/subdir/test_64_bytes.bin
3030
/test_dir/subdir/test_12345_text.txt
31-
/test_dir/subdir/test_generated_image.png
31+
/test_dir/subdir/test_generated_image.png **Favorite**
3232
/test_dir/test_empty_child_dir/
3333
/test_dir/test_empty_text.txt
3434
/test_dir/test_64_bytes.bin
3535
/test_dir/test_12345_text.txt
36-
/test_dir/test_generated_image.png
36+
/test_dir/test_generated_image.png **Favorite**
3737
/test_empty_text.txt
3838
/test_64_bytes.bin
3939
/test_12345_text.txt
40-
/test_generated_image.png
40+
/test_generated_image.png **Favorite**
4141
/test_dir_tmp
4242
"""
4343
clean_filesystem_for_user(nc_any)
@@ -55,6 +55,7 @@ def init_folder(folder: str = ""):
5555
nc_any.files.upload(path.join(folder, "test_12345_text.txt"), content="12345")
5656
im.seek(0)
5757
nc_any.files.upload(path.join(folder, "test_generated_image.png"), content=im.read())
58+
nc_any.files.setfav(path.join(folder, "test_generated_image.png"), True)
5859

5960
init_folder()
6061
init_folder("test_dir")

0 commit comments

Comments
 (0)