Skip to content

added Files Locking support #227

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/analysis-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,14 @@ jobs:
ref: "main"
path: apps/notes

- name: Checkout Files Locking
uses: actions/checkout@v4
if: ${{ !startsWith(matrix.nextcloud, 'master') }}
with:
repository: nextcloud/files_lock
ref: ${{ matrix.nextcloud }}
path: apps/files_lock

- name: Set up & run Nextcloud
env:
DB_PORT: 4444
Expand All @@ -518,6 +526,10 @@ jobs:
./occ app:enable notifications
PHP_CLI_SERVER_WORKERS=2 php -S localhost:8080 &

- name: Enable Files Locking
if: ${{ !startsWith(matrix.nextcloud, 'master') }}
run: ./occ app:enable files_lock

- name: Enable Notes
if: ${{ !startsWith(matrix.nextcloud, 'master') }}
run: ./occ app:enable notes
Expand Down Expand Up @@ -831,6 +843,14 @@ jobs:
ref: "main"
path: apps/notes

- name: Checkout Files Locking
uses: actions/checkout@v4
if: ${{ !startsWith(matrix.nextcloud, 'stable26') && !startsWith(matrix.nextcloud, 'master') }}
with:
repository: nextcloud/files_lock
ref: ${{ matrix.nextcloud }}
path: apps/files_lock

- name: Set up & run Nextcloud
env:
DB_PORT: 4444
Expand All @@ -848,6 +868,10 @@ jobs:
if: ${{ !startsWith(matrix.nextcloud, 'master') }}
run: ./occ app:enable notes

- name: Enable Files Locking
if: ${{ !startsWith(matrix.nextcloud, 'stable26') && !startsWith(matrix.nextcloud, 'master') }}
run: ./occ app:enable files_lock

- name: Checkout NcPyApi
uses: actions/checkout@v4
with:
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

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

## [0.11.0 - 2024-0x-xx]

### Added

- Files: `lock` and `unlock` methods, lock file information to `FsNode`. #227

## [0.10.0 - 2024-02-14]

### Added
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/Files/Files.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ All File APIs are designed to work relative to the current user.

.. autoclass:: nc_py_api.files.SystemTag
:members:

.. autoclass:: nc_py_api.files.LockType
:members:

.. autoclass:: nc_py_api.files.FsNodeLockInfo
:members:
2 changes: 1 addition & 1 deletion nc_py_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
NextcloudMissingCapabilities,
)
from ._version import __version__
from .files import FilePermissions, FsNode
from .files import FilePermissions, FsNode, LockType
from .files.sharing import ShareType
from .nextcloud import AsyncNextcloud, AsyncNextcloudApp, Nextcloud, NextcloudApp
2 changes: 1 addition & 1 deletion nc_py_api/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Version of nc_py_api."""

__version__ = "0.10.0"
__version__ = "0.11.0.dev0"
61 changes: 61 additions & 0 deletions nc_py_api/files/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,63 @@
from .. import _misc


class LockType(enum.IntEnum):
"""Nextcloud File Locks types."""

MANUAL_LOCK = 0
COLLABORATIVE_LOCK = 1
WEBDAV_TOKEN = 2


@dataclasses.dataclass
class FsNodeLockInfo:
"""File Lock information if Nextcloud `files_lock` is enabled."""

def __init__(self, **kwargs):
self._is_locked = bool(int(kwargs.get("is_locked", False)))
self._lock_owner_type = LockType(int(kwargs.get("lock_owner_type", 0)))
self._lock_owner = kwargs.get("lock_owner", "")
self._owner_display_name = kwargs.get("owner_display_name", "")
self._owner_editor = kwargs.get("lock_owner_editor", "")
self._lock_time = int(kwargs.get("lock_time", 0))
self._lock_ttl = int(kwargs.get("_lock_ttl", 0))

@property
def is_locked(self) -> bool:
"""Returns ``True`` if the file is locked, ``False`` otherwise."""
return self._is_locked

@property
def type(self) -> LockType:
"""Type of the lock."""
return LockType(self._lock_owner_type)

@property
def owner(self) -> str:
"""User id of the lock owner."""
return self._lock_owner

@property
def owner_display_name(self) -> str:
"""Display name of the lock owner."""
return self._owner_display_name

@property
def owner_editor(self) -> str:
"""App id of an app owned lock to allow clients to suggest joining the collaborative editing session."""
return self._owner_editor

@property
def lock_creation_time(self) -> datetime.datetime:
"""Lock creation time."""
return datetime.datetime.utcfromtimestamp(self._lock_time).replace(tzinfo=datetime.timezone.utc)

@property
def lock_ttl(self) -> int:
"""TTL of the lock in seconds staring from the creation time. A value of 0 means the timeout is infinite."""
return self._lock_ttl


@dataclasses.dataclass
class FsNodeInfo:
"""Extra FS object attributes from Nextcloud."""
Expand Down Expand Up @@ -116,11 +173,15 @@ class FsNode:
info: FsNodeInfo
"""Additional extra information for the object"""

lock_info: FsNodeLockInfo
"""Class describing `lock` information if any."""

def __init__(self, full_path: str, **kwargs):
self.full_path = full_path
self.file_id = kwargs.get("file_id", "")
self.etag = kwargs.get("etag", "")
self.info = FsNodeInfo(**kwargs)
self.lock_info = FsNodeLockInfo(**kwargs)

@property
def is_dir(self) -> bool:
Expand Down
42 changes: 32 additions & 10 deletions nc_py_api/files/_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from httpx import Response

from .._exceptions import NextcloudException, check_error
from .._misc import clear_from_params_empty
from .._misc import check_capabilities, clear_from_params_empty
from . import FsNode, SystemTag

PROPFIND_PROPERTIES = [
Expand All @@ -29,13 +29,16 @@
"oc:share-types",
"oc:favorite",
"nc:is-encrypted",
]

PROPFIND_LOCKING_PROPERTIES = [
"nc:lock",
"nc:lock-owner-displayname",
"nc:lock-owner",
"nc:lock-owner-type",
"nc:lock-owner-editor",
"nc:lock-time",
"nc:lock-timeout",
"nc:lock-owner-editor", # App id of an app owned lock
"nc:lock-time", # Timestamp of the log creation time
"nc:lock-timeout", # TTL of the lock in seconds staring from the creation time
]

SEARCH_PROPERTIES_MAP = {
Expand All @@ -57,15 +60,22 @@ class PropFindType(enum.IntEnum):
VERSIONS_FILE_ID = 3


def build_find_request(req: list, path: str | FsNode, user: str) -> ElementTree.Element:
def get_propfind_properties(capabilities: dict) -> list:
r = PROPFIND_PROPERTIES
if not check_capabilities("files.locking", capabilities):
r += PROPFIND_LOCKING_PROPERTIES
return r


def build_find_request(req: list, path: str | FsNode, user: str, capabilities: dict) -> ElementTree.Element:
path = path.user_path if isinstance(path, FsNode) else path
root = ElementTree.Element(
"d:searchrequest",
attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"},
)
xml_search = ElementTree.SubElement(root, "d:basicsearch")
xml_select_prop = ElementTree.SubElement(ElementTree.SubElement(xml_search, "d:select"), "d:prop")
for i in PROPFIND_PROPERTIES:
for i in get_propfind_properties(capabilities):
ElementTree.SubElement(xml_select_prop, i)
xml_from_scope = ElementTree.SubElement(ElementTree.SubElement(xml_search, "d:from"), "d:scope")
href = f"/files/{user}/{path.removeprefix('/')}"
Expand All @@ -76,15 +86,17 @@ def build_find_request(req: list, path: str | FsNode, user: str) -> ElementTree.
return root


def build_list_by_criteria_req(properties: list[str] | None, tags: list[int | SystemTag] | None) -> ElementTree.Element:
def build_list_by_criteria_req(
properties: list[str] | None, tags: list[int | SystemTag] | None, capabilities: dict
) -> ElementTree.Element:
if not properties and not tags:
raise ValueError("Either specify 'properties' or 'tags' to filter results.")
root = ElementTree.Element(
"oc:filter-files",
attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"},
)
prop = ElementTree.SubElement(root, "d:prop")
for i in PROPFIND_PROPERTIES:
for i in get_propfind_properties(capabilities):
ElementTree.SubElement(prop, i)
xml_filter_rules = ElementTree.SubElement(root, "oc:filter-rules")
if properties and "favorite" in properties:
Expand Down Expand Up @@ -243,7 +255,7 @@ def etag_fileid_from_response(response: Response) -> dict:
return {"etag": response.headers.get("OC-Etag", ""), "file_id": response.headers["OC-FileId"]}


def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode:
def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode: # noqa pylint: disable = too-many-branches
fs_node_args = {}
for prop_stat in prop_stats:
if str(prop_stat.get("d:status", "")).find("200 OK") == -1:
Expand Down Expand Up @@ -274,7 +286,17 @@ def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode:
fs_node_args["trashbin_original_location"] = prop["nc:trashbin-original-location"]
if "nc:trashbin-deletion-time" in prop_keys:
fs_node_args["trashbin_deletion_time"] = prop["nc:trashbin-deletion-time"]
# xz = prop.get("oc:dDC", "")
for k, v in {
"nc:lock": "is_locked",
"nc:lock-owner-type": "lock_owner_type",
"nc:lock-owner": "lock_owner",
"nc:lock-owner-displayname": "lock_owner_displayname",
"nc:lock-owner-editor": "lock_owner_editor",
"nc:lock-time": "lock_time",
"nc:lock-timeout": "lock_ttl",
}.items():
if k in prop_keys and prop[k] is not None:
fs_node_args[v] = prop[k]
return FsNode(full_path, **fs_node_args)


Expand Down
Loading