nc-py-api 0.9.0__py3-none-any.whl → 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nc_py_api/__init__.py CHANGED
@@ -7,6 +7,6 @@ from ._exceptions import (
7
7
  NextcloudMissingCapabilities,
8
8
  )
9
9
  from ._version import __version__
10
- from .files import FilePermissions, FsNode
10
+ from .files import FilePermissions, FsNode, LockType
11
11
  from .files.sharing import ShareType
12
12
  from .nextcloud import AsyncNextcloud, AsyncNextcloudApp, Nextcloud, NextcloudApp
@@ -106,7 +106,7 @@ class _AsyncBasicAppCfgPref:
106
106
 
107
107
 
108
108
  class PreferencesExAPI(_BasicAppCfgPref):
109
- """User specific preferences API."""
109
+ """User specific preferences API, avalaible as **nc.preferences_ex.<method>**."""
110
110
 
111
111
  _url_suffix = "ex-app/preference"
112
112
 
@@ -134,7 +134,7 @@ class AsyncPreferencesExAPI(_AsyncBasicAppCfgPref):
134
134
 
135
135
 
136
136
  class AppConfigExAPI(_BasicAppCfgPref):
137
- """Non-user(App) specific preferences API."""
137
+ """Non-user(App) specific preferences API, avalaible as **nc.appconfig_ex.<method>**."""
138
138
 
139
139
  _url_suffix = "ex-app/config"
140
140
 
nc_py_api/_talk_api.py CHANGED
@@ -26,7 +26,7 @@ from .talk import (
26
26
 
27
27
 
28
28
  class _TalkAPI:
29
- """Class provides API to work with Nextcloud Talk."""
29
+ """Class provides API to work with Nextcloud Talk, avalaible as **nc.talk.<method>**."""
30
30
 
31
31
  _ep_base: str = "/ocs/v2.php/apps/spreed"
32
32
  config_sha: str
nc_py_api/_version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Version of nc_py_api."""
2
2
 
3
- __version__ = "0.9.0"
3
+ __version__ = "0.11.0"
@@ -11,4 +11,5 @@ from .integration_fastapi import (
11
11
  )
12
12
  from .misc import get_model_path, persistent_storage, verify_version
13
13
  from .ui.files_actions import UiActionFileInfo
14
+ from .ui.settings import SettingsField, SettingsFieldType, SettingsForm
14
15
  from .uvicorn_fastapi import run_app
nc_py_api/ex_app/defs.py CHANGED
@@ -19,7 +19,7 @@ class LogLvl(enum.IntEnum):
19
19
 
20
20
 
21
21
  class ApiScope(enum.IntEnum):
22
- """Default API scopes. Should be used as a parameter to the :py:meth:`~.NextcloudApp.scope_allowed` method."""
22
+ """Defined API scopes."""
23
23
 
24
24
  SYSTEM = 2
25
25
  """Allows access to the System APIs."""
@@ -1,49 +1,45 @@
1
1
  """FastAPI directly related stuff."""
2
2
 
3
3
  import asyncio
4
+ import builtins
5
+ import fnmatch
6
+ import hashlib
4
7
  import json
5
8
  import os
6
9
  import typing
10
+ from urllib.parse import urlparse
7
11
 
12
+ import httpx
8
13
  from fastapi import (
9
14
  BackgroundTasks,
10
15
  Depends,
11
16
  FastAPI,
12
17
  HTTPException,
13
- responses,
14
18
  staticfiles,
15
19
  status,
16
20
  )
21
+ from fastapi.responses import JSONResponse, PlainTextResponse
17
22
  from starlette.requests import HTTPConnection, Request
18
23
  from starlette.types import ASGIApp, Receive, Scope, Send
19
24
 
20
25
  from .._misc import get_username_secret_from_headers
21
26
  from ..nextcloud import AsyncNextcloudApp, NextcloudApp
22
27
  from ..talk_bot import TalkBotMessage
28
+ from .defs import LogLvl
23
29
  from .misc import persistent_storage
24
30
 
25
31
 
26
32
  def nc_app(request: HTTPConnection) -> NextcloudApp:
27
33
  """Authentication handler for requests from Nextcloud to the application."""
28
- user = get_username_secret_from_headers(
29
- {"AUTHORIZATION-APP-API": request.headers.get("AUTHORIZATION-APP-API", "")}
30
- )[0]
31
- request_id = request.headers.get("AA-REQUEST-ID", None)
32
- nextcloud_app = NextcloudApp(user=user, headers={"AA-REQUEST-ID": request_id} if request_id else {})
33
- if not nextcloud_app.request_sign_check(request):
34
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
34
+ nextcloud_app = NextcloudApp(**__nc_app(request))
35
+ __request_sign_check_if_needed(request, nextcloud_app)
35
36
  return nextcloud_app
36
37
 
37
38
 
38
39
  def anc_app(request: HTTPConnection) -> AsyncNextcloudApp:
39
40
  """Async Authentication handler for requests from Nextcloud to the application."""
40
- user = get_username_secret_from_headers(
41
- {"AUTHORIZATION-APP-API": request.headers.get("AUTHORIZATION-APP-API", "")}
42
- )[0]
43
- request_id = request.headers.get("AA-REQUEST-ID", None)
44
- nextcloud_app = AsyncNextcloudApp(user=user, headers={"AA-REQUEST-ID": request_id} if request_id else {})
45
- if not nextcloud_app.request_sign_check(request):
46
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
41
+ nextcloud_app = AsyncNextcloudApp(**__nc_app(request))
42
+ __request_sign_check_if_needed(request, nextcloud_app)
47
43
  return nextcloud_app
48
44
 
49
45
 
@@ -60,8 +56,8 @@ async def atalk_bot_msg(request: Request) -> TalkBotMessage:
60
56
  def set_handlers(
61
57
  fast_api_app: FastAPI,
62
58
  enabled_handler: typing.Callable[[bool, AsyncNextcloudApp | NextcloudApp], typing.Awaitable[str] | str],
63
- heartbeat_handler: typing.Callable[[], typing.Awaitable[str] | str] | None = None,
64
- init_handler: typing.Callable[[AsyncNextcloudApp | NextcloudApp], typing.Awaitable[None] | None] | None = None,
59
+ default_heartbeat: bool = True,
60
+ default_init: bool = True,
65
61
  models_to_fetch: dict[str, dict] | None = None,
66
62
  map_app_static: bool = True,
67
63
  ):
@@ -69,85 +65,46 @@ def set_handlers(
69
65
 
70
66
  :param fast_api_app: FastAPI() call return value.
71
67
  :param enabled_handler: ``Required``, callback which will be called for `enabling`/`disabling` app event.
72
- :param heartbeat_handler: Optional, callback that will be called for the `heartbeat` deploy event.
73
- :param init_handler: Optional, callback that will be called for the `init` event.
68
+ :param default_heartbeat: Set to ``False`` to disable the default `heartbeat` route handler.
69
+ :param default_init: Set to ``False`` to disable the default `init` route handler.
74
70
 
75
- .. note:: This parameter is **mutually exclusive** with ``models_to_fetch``.
71
+ .. note:: When this parameter is ``False``, the provision of ``models_to_fetch`` is not allowed.
76
72
 
77
73
  :param models_to_fetch: Dictionary describing which models should be downloaded during `init`.
78
74
 
79
- .. note:: ```huggingface_hub`` package should be present for automatic models fetching.
75
+ .. note:: ``huggingface_hub`` package should be present for automatic models fetching.
80
76
 
81
77
  :param map_app_static: Should be folders ``js``, ``css``, ``l10n``, ``img`` automatically mounted in FastAPI or not.
82
78
 
83
79
  .. note:: First, presence of these directories in the current working dir is checked, then one directory higher.
84
80
  """
85
- if models_to_fetch is not None and init_handler is not None:
86
- raise ValueError("Only `init_handler` OR `models_to_fetch` can be defined.")
81
+ if models_to_fetch is not None and default_init is False:
82
+ raise ValueError("`models_to_fetch` can be defined only with `default_init`=True.")
87
83
 
88
84
  if asyncio.iscoroutinefunction(enabled_handler):
89
85
 
90
86
  @fast_api_app.put("/enabled")
91
87
  async def enabled_callback(enabled: bool, nc: typing.Annotated[AsyncNextcloudApp, Depends(anc_app)]):
92
- return responses.JSONResponse(content={"error": await enabled_handler(enabled, nc)}, status_code=200)
88
+ return JSONResponse(content={"error": await enabled_handler(enabled, nc)})
93
89
 
94
90
  else:
95
91
 
96
92
  @fast_api_app.put("/enabled")
97
93
  def enabled_callback(enabled: bool, nc: typing.Annotated[NextcloudApp, Depends(nc_app)]):
98
- return responses.JSONResponse(content={"error": enabled_handler(enabled, nc)}, status_code=200)
99
-
100
- if heartbeat_handler is None:
101
-
102
- @fast_api_app.get("/heartbeat")
103
- async def heartbeat_callback():
104
- return responses.JSONResponse(content={"status": "ok"}, status_code=200)
94
+ return JSONResponse(content={"error": enabled_handler(enabled, nc)})
105
95
 
106
- elif asyncio.iscoroutinefunction(heartbeat_handler):
96
+ if default_heartbeat:
107
97
 
108
98
  @fast_api_app.get("/heartbeat")
109
99
  async def heartbeat_callback():
110
- return responses.JSONResponse(content={"status": await heartbeat_handler()}, status_code=200)
111
-
112
- else:
113
-
114
- @fast_api_app.get("/heartbeat")
115
- def heartbeat_callback():
116
- return responses.JSONResponse(content={"status": heartbeat_handler()}, status_code=200)
117
-
118
- if init_handler is None:
100
+ return JSONResponse(content={"status": "ok"})
119
101
 
120
- @fast_api_app.post("/init")
121
- async def init_callback(
122
- background_tasks: BackgroundTasks,
123
- nc: typing.Annotated[NextcloudApp, Depends(nc_app)],
124
- ):
125
- background_tasks.add_task(
126
- __fetch_models_task,
127
- nc,
128
- models_to_fetch if models_to_fetch else {},
129
- )
130
- return responses.JSONResponse(content={}, status_code=200)
131
-
132
- elif asyncio.iscoroutinefunction(init_handler):
133
-
134
- @fast_api_app.post("/init")
135
- async def init_callback(
136
- background_tasks: BackgroundTasks,
137
- nc: typing.Annotated[AsyncNextcloudApp, Depends(anc_app)],
138
- ):
139
- background_tasks.add_task(init_handler, nc)
140
- return responses.JSONResponse(content={}, status_code=200)
141
-
142
- else:
102
+ if default_init:
143
103
 
144
104
  @fast_api_app.post("/init")
145
- def init_callback(
146
- background_tasks: BackgroundTasks,
147
- nc: typing.Annotated[NextcloudApp, Depends(nc_app)],
148
- ):
149
- background_tasks.add_task(init_handler, nc)
150
- return responses.JSONResponse(content={}, status_code=200)
105
+ async def init_callback(b_tasks: BackgroundTasks, nc: typing.Annotated[NextcloudApp, Depends(nc_app)]):
106
+ b_tasks.add_task(__fetch_models_task, nc, models_to_fetch if models_to_fetch else {})
107
+ return JSONResponse(content={})
151
108
 
152
109
  if map_app_static:
153
110
  __map_app_static_folders(fast_api_app)
@@ -163,26 +120,100 @@ def __map_app_static_folders(fast_api_app: FastAPI):
163
120
  fast_api_app.mount(f"/{mnt_dir}", staticfiles.StaticFiles(directory=mnt_dir_path), name=mnt_dir)
164
121
 
165
122
 
166
- def __fetch_models_task(
167
- nc: NextcloudApp,
168
- models: dict[str, dict],
169
- ) -> None:
123
+ def __fetch_models_task(nc: NextcloudApp, models: dict[str, dict]) -> None:
170
124
  if models:
171
- from huggingface_hub import snapshot_download # noqa isort:skip pylint: disable=C0415 disable=E0401
172
- from tqdm import tqdm # noqa isort:skip pylint: disable=C0415 disable=E0401
173
-
174
- class TqdmProgress(tqdm):
175
- def display(self, msg=None, pos=None):
176
- nc.set_init_status(min(int((self.n * 100 / self.total) / len(models)), 100))
177
- return super().display(msg, pos)
178
-
125
+ current_progress = 0
126
+ percent_for_each = min(int(100 / len(models)), 99)
179
127
  for model in models:
180
- workers = models[model].pop("max_workers", 2)
181
- cache = models[model].pop("cache_dir", persistent_storage())
182
- snapshot_download(model, tqdm_class=TqdmProgress, **models[model], max_workers=workers, cache_dir=cache)
128
+ if model.startswith(("http://", "https://")):
129
+ __fetch_model_as_file(current_progress, percent_for_each, nc, model, models[model])
130
+ else:
131
+ __fetch_model_as_snapshot(current_progress, percent_for_each, nc, model, models[model])
132
+ current_progress += percent_for_each
183
133
  nc.set_init_status(100)
184
134
 
185
135
 
136
+ def __fetch_model_as_file(
137
+ current_progress: int, progress_for_task: int, nc: NextcloudApp, model_path: str, download_options: dict
138
+ ) -> None:
139
+ result_path = download_options.pop("save_path", urlparse(model_path).path.split("/")[-1])
140
+ try:
141
+ with httpx.stream("GET", model_path, follow_redirects=True) as response:
142
+ if not response.is_success:
143
+ nc.log(LogLvl.ERROR, f"Downloading of '{model_path}' returned {response.status_code} status.")
144
+ return
145
+ downloaded_size = 0
146
+ linked_etag = ""
147
+ for each_history in response.history:
148
+ linked_etag = each_history.headers.get("X-Linked-ETag", "")
149
+ if linked_etag:
150
+ break
151
+ if not linked_etag:
152
+ linked_etag = response.headers.get("X-Linked-ETag", response.headers.get("ETag", ""))
153
+ total_size = int(response.headers.get("Content-Length"))
154
+ try:
155
+ existing_size = os.path.getsize(result_path)
156
+ except OSError:
157
+ existing_size = 0
158
+ if linked_etag and total_size == existing_size:
159
+ with builtins.open(result_path, "rb") as file:
160
+ sha256_hash = hashlib.sha256()
161
+ for byte_block in iter(lambda: file.read(4096), b""):
162
+ sha256_hash.update(byte_block)
163
+ if f'"{sha256_hash.hexdigest()}"' == linked_etag:
164
+ nc.set_init_status(min(current_progress + progress_for_task, 99))
165
+ return
166
+
167
+ with builtins.open(result_path, "wb") as file:
168
+ last_progress = current_progress
169
+ for chunk in response.iter_bytes(5 * 1024 * 1024):
170
+ downloaded_size += file.write(chunk)
171
+ if total_size:
172
+ new_progress = min(current_progress + int(progress_for_task * downloaded_size / total_size), 99)
173
+ if new_progress != last_progress:
174
+ nc.set_init_status(new_progress)
175
+ last_progress = new_progress
176
+ except Exception as e: # noqa pylint: disable=broad-exception-caught
177
+ nc.log(LogLvl.ERROR, f"Downloading of '{model_path}' raised an exception: {e}")
178
+
179
+
180
+ def __fetch_model_as_snapshot(
181
+ current_progress: int, progress_for_task, nc: NextcloudApp, mode_name: str, download_options: dict
182
+ ) -> None:
183
+ from huggingface_hub import snapshot_download # noqa isort:skip pylint: disable=C0415 disable=E0401
184
+ from tqdm import tqdm # noqa isort:skip pylint: disable=C0415 disable=E0401
185
+
186
+ class TqdmProgress(tqdm):
187
+ def display(self, msg=None, pos=None):
188
+ nc.set_init_status(min(current_progress + int(progress_for_task * self.n / self.total), 99))
189
+ return super().display(msg, pos)
190
+
191
+ workers = download_options.pop("max_workers", 2)
192
+ cache = download_options.pop("cache_dir", persistent_storage())
193
+ snapshot_download(mode_name, tqdm_class=TqdmProgress, **download_options, max_workers=workers, cache_dir=cache)
194
+
195
+
196
+ def __nc_app(request: HTTPConnection) -> dict:
197
+ user = get_username_secret_from_headers(
198
+ {"AUTHORIZATION-APP-API": request.headers.get("AUTHORIZATION-APP-API", "")}
199
+ )[0]
200
+ request_id = request.headers.get("AA-REQUEST-ID", None)
201
+ return {"user": user, "headers": {"AA-REQUEST-ID": request_id} if request_id else {}}
202
+
203
+
204
+ def __request_sign_check_if_needed(request: HTTPConnection, nextcloud_app: NextcloudApp | AsyncNextcloudApp) -> None:
205
+ if not [i for i in getattr(request.app, "user_middleware", []) if i.cls == AppAPIAuthMiddleware]:
206
+ _request_sign_check(request, nextcloud_app)
207
+
208
+
209
+ def _request_sign_check(request: HTTPConnection, nextcloud_app: NextcloudApp | AsyncNextcloudApp) -> None:
210
+ try:
211
+ nextcloud_app._session.sign_check(request) # noqa pylint: disable=protected-access
212
+ except ValueError as e:
213
+ print(e)
214
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from None
215
+
216
+
186
217
  class AppAPIAuthMiddleware:
187
218
  """Pure ASGI AppAPIAuth Middleware."""
188
219
 
@@ -205,9 +236,9 @@ class AppAPIAuthMiddleware:
205
236
 
206
237
  conn = HTTPConnection(scope)
207
238
  url_path = conn.url.path.lstrip("/")
208
- if url_path not in self._disable_for:
239
+ if not fnmatch.filter(self._disable_for, url_path):
209
240
  try:
210
- anc_app(conn)
241
+ _request_sign_check(conn, AsyncNextcloudApp())
211
242
  except HTTPException as exc:
212
243
  response = self._on_error(exc.status_code, exc.detail)
213
244
  await response(scope, receive, send)
@@ -216,5 +247,5 @@ class AppAPIAuthMiddleware:
216
247
  await self.app(scope, receive, send)
217
248
 
218
249
  @staticmethod
219
- def _on_error(status_code: int = 400, content: str = "") -> responses.PlainTextResponse:
220
- return responses.PlainTextResponse(content, status_code=status_code)
250
+ def _on_error(status_code: int = 400, content: str = "") -> PlainTextResponse:
251
+ return PlainTextResponse(content, status_code=status_code)
@@ -37,7 +37,7 @@ class SpeechToTextProvider:
37
37
 
38
38
 
39
39
  class _SpeechToTextProviderAPI:
40
- """API for Speech2Text providers."""
40
+ """API for Speech2Text providers, avalaible as **nc.providers.text_processing.<method>**."""
41
41
 
42
42
  def __init__(self, session: NcSessionApp):
43
43
  self._session = session
@@ -83,7 +83,7 @@ class _SpeechToTextProviderAPI:
83
83
 
84
84
 
85
85
  class _AsyncSpeechToTextProviderAPI:
86
- """API for Speech2Text providers."""
86
+ """Async API for Speech2Text providers."""
87
87
 
88
88
  def __init__(self, session: AsyncNcSessionApp):
89
89
  self._session = session
@@ -42,7 +42,7 @@ class TextProcessingProvider:
42
42
 
43
43
 
44
44
  class _TextProcessingProviderAPI:
45
- """API for TextProcessing providers."""
45
+ """API for TextProcessing providers, avalaible as **nc.providers.speech_to_text.<method>**."""
46
46
 
47
47
  def __init__(self, session: NcSessionApp):
48
48
  self._session = session
@@ -89,7 +89,7 @@ class _TextProcessingProviderAPI:
89
89
 
90
90
 
91
91
  class _AsyncTextProcessingProviderAPI:
92
- """API for TextProcessing providers."""
92
+ """Async API for TextProcessing providers."""
93
93
 
94
94
  def __init__(self, session: AsyncNcSessionApp):
95
95
  self._session = session
@@ -42,12 +42,17 @@ class TranslationsProvider:
42
42
  """Relative ExApp url which will be called by Nextcloud."""
43
43
  return self._raw_data["action_handler"]
44
44
 
45
+ @property
46
+ def action_handler_detect_lang(self) -> str:
47
+ """Relative ExApp url which will be called by Nextcloud to detect language."""
48
+ return self._raw_data.get("action_detect_lang", "")
49
+
45
50
  def __repr__(self):
46
51
  return f"<{self.__class__.__name__} name={self.name}, handler={self.action_handler}>"
47
52
 
48
53
 
49
54
  class _TranslationsProviderAPI:
50
- """API for Translations providers."""
55
+ """API for Translations providers, avalaible as **nc.providers.translations.<method>**."""
51
56
 
52
57
  def __init__(self, session: NcSessionApp):
53
58
  self._session = session
@@ -59,6 +64,7 @@ class _TranslationsProviderAPI:
59
64
  callback_url: str,
60
65
  from_languages: dict[str, str],
61
66
  to_languages: dict[str, str],
67
+ detect_lang_callback_url: str = "",
62
68
  ) -> None:
63
69
  """Registers or edit the Translations provider."""
64
70
  require_capabilities("app_api", self._session.capabilities)
@@ -68,6 +74,7 @@ class _TranslationsProviderAPI:
68
74
  "fromLanguages": from_languages,
69
75
  "toLanguages": to_languages,
70
76
  "actionHandler": callback_url,
77
+ "actionDetectLang": detect_lang_callback_url,
71
78
  }
72
79
  self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=params)
73
80
 
@@ -102,7 +109,7 @@ class _TranslationsProviderAPI:
102
109
 
103
110
 
104
111
  class _AsyncTranslationsProviderAPI:
105
- """API for Translations providers."""
112
+ """Async API for Translations providers."""
106
113
 
107
114
  def __init__(self, session: AsyncNcSessionApp):
108
115
  self._session = session
@@ -114,6 +121,7 @@ class _AsyncTranslationsProviderAPI:
114
121
  callback_url: str,
115
122
  from_languages: dict[str, str],
116
123
  to_languages: dict[str, str],
124
+ detect_lang_callback_url: str = "",
117
125
  ) -> None:
118
126
  """Registers or edit the Translations provider."""
119
127
  require_capabilities("app_api", await self._session.capabilities)
@@ -123,6 +131,7 @@ class _AsyncTranslationsProviderAPI:
123
131
  "fromLanguages": from_languages,
124
132
  "toLanguages": to_languages,
125
133
  "actionHandler": callback_url,
134
+ "actionDetectLang": detect_lang_callback_url,
126
135
  }
127
136
  await self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=params)
128
137
 
@@ -119,7 +119,7 @@ class UiActionFileInfo(BaseModel):
119
119
 
120
120
 
121
121
  class _UiFilesActionsAPI:
122
- """API for the drop-down menu in Nextcloud **Files app**."""
122
+ """API for the drop-down menu in Nextcloud **Files app**, avalaible as **nc.ui.files_dropdown_menu.<method>**."""
123
123
 
124
124
  _ep_suffix: str = "ui/files-actions-menu"
125
125
 
@@ -77,7 +77,7 @@ class UiStyle(UiBase):
77
77
 
78
78
 
79
79
  class _UiResources:
80
- """API for adding scripts, styles, initial-states to the TopMenu pages."""
80
+ """API for adding scripts, styles, initial-states to the pages, avalaible as **nc.ui.resources.<method>**."""
81
81
 
82
82
  _ep_suffix_init_state: str = "ui/initial-state"
83
83
  _ep_suffix_js: str = "ui/script"
@@ -0,0 +1,178 @@
1
+ """Nextcloud API for declaring UI for settings."""
2
+
3
+ import dataclasses
4
+ import enum
5
+ import typing
6
+
7
+ from ..._exceptions import NextcloudExceptionNotFound
8
+ from ..._misc import require_capabilities
9
+ from ..._session import AsyncNcSessionApp, NcSessionApp
10
+
11
+
12
+ class SettingsFieldType(enum.Enum): # StrEnum
13
+ """Declarative Settings Field Type."""
14
+
15
+ TEXT = "text"
16
+ """NcInputField type text"""
17
+ PASSWORD = "password" # noqa
18
+ """NcInputField type password"""
19
+ EMAIL = "email"
20
+ """NcInputField type email"""
21
+ TEL = "tel"
22
+ """NcInputField type tel"""
23
+ URL = "url"
24
+ """NcInputField type url"""
25
+ NUMBER = "number"
26
+ """NcInputField type number"""
27
+ CHECKBOX = "checkbox"
28
+ """NcCheckboxRadioSwitch type checkbox"""
29
+ MULTI_CHECKBOX = "multi-checkbox"
30
+ """Multiple NcCheckboxRadioSwitch type checkbox representing a one config value (saved as JSON object)"""
31
+ RADIO = "radio"
32
+ """NcCheckboxRadioSwitch type radio"""
33
+ SELECT = "select"
34
+ """NcSelect"""
35
+ MULTI_SELECT = "multi-select"
36
+ """Multiple NcSelect representing a one config value (saved as JSON array)"""
37
+
38
+
39
+ @dataclasses.dataclass
40
+ class SettingsField:
41
+ """Section field."""
42
+
43
+ id: str
44
+ title: str
45
+ type: SettingsFieldType
46
+ default: bool | int | float | str | list[bool | int | float | str] | dict[str, typing.Any]
47
+ options: dict | list = dataclasses.field(default_factory=dict)
48
+ description: str = ""
49
+ placeholder: str = ""
50
+ label: str = ""
51
+ notify = False # to be supported in future
52
+
53
+ @classmethod
54
+ def from_dict(cls, data: dict) -> "SettingsField":
55
+ """Creates instance of class from dict, ignoring unknown keys."""
56
+ filtered_data = {
57
+ k: SettingsFieldType(v) if k == "type" else v for k, v in data.items() if k in cls.__annotations__
58
+ }
59
+ return cls(**filtered_data)
60
+
61
+ def to_dict(self) -> dict:
62
+ """Returns data in format that is accepted by AppAPI."""
63
+ return {
64
+ "id": self.id,
65
+ "title": self.title,
66
+ "type": self.type.value,
67
+ "default": self.default,
68
+ "description": self.description,
69
+ "options": (
70
+ [{"name": key, "value": value} for key, value in self.options.items()]
71
+ if isinstance(self.options, dict)
72
+ else self.options
73
+ ),
74
+ "placeholder": self.placeholder,
75
+ "label": self.label,
76
+ "notify": self.notify,
77
+ }
78
+
79
+
80
+ @dataclasses.dataclass
81
+ class SettingsForm:
82
+ """Settings Form and Section."""
83
+
84
+ id: str
85
+ section_id: str
86
+ title: str
87
+ fields: list[SettingsField] = dataclasses.field(default_factory=list)
88
+ description: str = ""
89
+ priority: int = 50
90
+ doc_url: str = ""
91
+ section_type: str = "personal"
92
+
93
+ @classmethod
94
+ def from_dict(cls, data: dict) -> "SettingsForm":
95
+ """Creates instance of class from dict, ignoring unknown keys."""
96
+ filtered_data = {k: v for k, v in data.items() if k in cls.__annotations__}
97
+ filtered_data["fields"] = [SettingsField.from_dict(i) for i in filtered_data.get("fields", [])]
98
+ return cls(**filtered_data)
99
+
100
+ def to_dict(self) -> dict:
101
+ """Returns data in format that is accepted by AppAPI."""
102
+ return {
103
+ "id": self.id,
104
+ "priority": self.priority,
105
+ "section_type": self.section_type,
106
+ "section_id": self.section_id,
107
+ "title": self.title,
108
+ "description": self.description,
109
+ "doc_url": self.doc_url,
110
+ "fields": [i.to_dict() for i in self.fields],
111
+ }
112
+
113
+
114
+ _EP_SUFFIX: str = "ui/settings"
115
+
116
+
117
+ class _DeclarativeSettingsAPI:
118
+ """Class providing API for creating UI for the ExApp settings, avalaible as **nc.ui.settings.<method>**."""
119
+
120
+ def __init__(self, session: NcSessionApp):
121
+ self._session = session
122
+
123
+ def register_form(self, form_schema: SettingsForm | dict[str, typing.Any]) -> None:
124
+ """Registers or edit the Settings UI Form."""
125
+ require_capabilities("app_api", self._session.capabilities)
126
+ param = {"formScheme": form_schema.to_dict() if isinstance(form_schema, SettingsForm) else form_schema}
127
+ self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=param)
128
+
129
+ def unregister_form(self, form_id: str, not_fail=True) -> None:
130
+ """Removes Settings UI Form."""
131
+ require_capabilities("app_api", self._session.capabilities)
132
+ try:
133
+ self._session.ocs("DELETE", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"formId": form_id})
134
+ except NextcloudExceptionNotFound as e:
135
+ if not not_fail:
136
+ raise e from None
137
+
138
+ def get_entry(self, form_id: str) -> SettingsForm | None:
139
+ """Get information of the Settings UI Form."""
140
+ require_capabilities("app_api", self._session.capabilities)
141
+ try:
142
+ return SettingsForm.from_dict(
143
+ self._session.ocs("GET", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"formId": form_id})
144
+ )
145
+ except NextcloudExceptionNotFound:
146
+ return None
147
+
148
+
149
+ class _AsyncDeclarativeSettingsAPI:
150
+ """Class providing async API for creating UI for the ExApp settings."""
151
+
152
+ def __init__(self, session: AsyncNcSessionApp):
153
+ self._session = session
154
+
155
+ async def register_form(self, form_schema: SettingsForm | dict[str, typing.Any]) -> None:
156
+ """Registers or edit the Settings UI Form."""
157
+ require_capabilities("app_api", await self._session.capabilities)
158
+ param = {"formScheme": form_schema.to_dict() if isinstance(form_schema, SettingsForm) else form_schema}
159
+ await self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=param)
160
+
161
+ async def unregister_form(self, form_id: str, not_fail=True) -> None:
162
+ """Removes Settings UI Form."""
163
+ require_capabilities("app_api", await self._session.capabilities)
164
+ try:
165
+ await self._session.ocs("DELETE", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"formId": form_id})
166
+ except NextcloudExceptionNotFound as e:
167
+ if not not_fail:
168
+ raise e from None
169
+
170
+ async def get_entry(self, form_id: str) -> SettingsForm | None:
171
+ """Get information of the Settings UI Form."""
172
+ require_capabilities("app_api", await self._session.capabilities)
173
+ try:
174
+ return SettingsForm.from_dict(
175
+ await self._session.ocs("GET", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"formId": form_id})
176
+ )
177
+ except NextcloudExceptionNotFound:
178
+ return None