akt-cli 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {akt_cli-0.1.0 → akt_cli-0.2.0}/PKG-INFO +48 -11
- {akt_cli-0.1.0 → akt_cli-0.2.0}/README.md +44 -10
- {akt_cli-0.1.0 → akt_cli-0.2.0}/pyproject.toml +4 -1
- {akt_cli-0.1.0 → akt_cli-0.2.0}/src/akt/__init__.py +1 -1
- {akt_cli-0.1.0 → akt_cli-0.2.0}/src/akt/cli.py +24 -0
- {akt_cli-0.1.0 → akt_cli-0.2.0}/src/akt/client.py +121 -2
- akt_cli-0.2.0/src/akt/commands.py +189 -0
- {akt_cli-0.1.0 → akt_cli-0.2.0}/src/akt/config.py +10 -0
- {akt_cli-0.1.0 → akt_cli-0.2.0}/src/akt/registry.py +5 -0
- {akt_cli-0.1.0 → akt_cli-0.2.0}/src/akt/resources.py +86 -0
- akt_cli-0.1.0/src/akt/commands.py +0 -88
- {akt_cli-0.1.0 → akt_cli-0.2.0}/LICENSE +0 -0
- {akt_cli-0.1.0 → akt_cli-0.2.0}/src/akt/output.py +0 -0
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: akt-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: akt — a CLI toolbox to fully drive an Akaunting accounting instance
|
|
5
5
|
Keywords: akaunting,accounting,cli,invoices,bills,api
|
|
6
6
|
Author: AsyncAlchemist
|
|
7
7
|
License-Expression: MIT
|
|
8
8
|
License-File: LICENSE
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
10
13
|
Classifier: Environment :: Console
|
|
11
14
|
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
12
15
|
Requires-Dist: requests>=2.31
|
|
@@ -16,20 +19,25 @@ Project-URL: Repository, https://github.com/AsyncAlchemist/akt-cli
|
|
|
16
19
|
Project-URL: Issues, https://github.com/AsyncAlchemist/akt-cli/issues
|
|
17
20
|
Description-Content-Type: text/markdown
|
|
18
21
|
|
|
22
|
+
<div align="center">
|
|
23
|
+
|
|
19
24
|
# akt — Akaunting CLI toolbox
|
|
25
|
+
### Drive your Akaunting accounting instance entirely from the command line
|
|
26
|
+
|
|
27
|
+
>`akt` gives you full create / read / update / delete for customers, vendors, items, invoices, bills, payments, accounts, categories, taxes, currencies and transfers — plus a `raw` escape hatch for any other endpoint. Built and tested against [Akaunting](https://akaunting.com) **3.1.x**; works with any 3.x deployment that exposes the REST API.
|
|
20
28
|
|
|
21
|
-
[](https://pypi.org/project/akt-cli/)
|
|
22
|
-
[](https://pypi.org/project/akt-cli/)
|
|
30
|
+
[](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml)
|
|
31
|
+
[](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/release.yml)
|
|
32
|
+
[](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/publish.yml)
|
|
33
|
+
[](https://codecov.io/github/AsyncAlchemist/akt-cli)
|
|
25
34
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
[](https://github.com/AsyncAlchemist/akt-cli/releases)
|
|
36
|
+
[](https://pypi.org/project/akt-cli/)
|
|
37
|
+
[](https://pypi.org/project/akt-cli/)
|
|
38
|
+
[](LICENSE)
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
deployment that exposes the REST API.
|
|
40
|
+
</div>
|
|
33
41
|
|
|
34
42
|
## Install
|
|
35
43
|
|
|
@@ -113,6 +121,17 @@ akt <noun> enable <id> # where applicable
|
|
|
113
121
|
akt <noun> disable <id>
|
|
114
122
|
```
|
|
115
123
|
|
|
124
|
+
Bills, invoices and payments additionally support **attachments** (scanned bills,
|
|
125
|
+
receipts, PDFs):
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
akt <noun> create ... --attachment ./file.pdf # repeatable; upload on create
|
|
129
|
+
akt <noun> update <id> --attachment ./file.pdf # attach to an existing record
|
|
130
|
+
akt <noun> update <id> --remove-attachment # clear existing attachment(s)
|
|
131
|
+
akt <noun> attachments <id> # list attached files (id, name, size)
|
|
132
|
+
akt <noun> download-attachment <id> [--out DIR] [--media-id ID]
|
|
133
|
+
```
|
|
134
|
+
|
|
116
135
|
Output is a table by default; add `--json` (works before or after the verb) for
|
|
117
136
|
raw JSON suitable for piping into `jq`.
|
|
118
137
|
|
|
@@ -154,6 +173,14 @@ akt payment create --invoice 34 --amount 750 \
|
|
|
154
173
|
akt bill create --contact 13 --item 'name=Paper,price=40,quantity=5'
|
|
155
174
|
akt payment create --bill 41
|
|
156
175
|
|
|
176
|
+
# Attachments: upload the source PDF/scan and fetch it back later
|
|
177
|
+
akt bill create --contact 13 --item 'name=Paper,price=40,quantity=5' \
|
|
178
|
+
--attachment ./supplier-bill.pdf
|
|
179
|
+
akt payment update 57 --attachment ./receipt.pdf # attach to an existing payment
|
|
180
|
+
akt bill attachments 41 # list attached files
|
|
181
|
+
akt bill download-attachment 41 --out ./downloads # save to disk
|
|
182
|
+
akt payment update 57 --remove-attachment # clear attachments
|
|
183
|
+
|
|
157
184
|
# Anything else: raw API access
|
|
158
185
|
akt raw GET reports
|
|
159
186
|
akt raw POST items --data '{"name":"Ad-hoc","type":"service","sale_price":99}'
|
|
@@ -177,6 +204,16 @@ Driving Akaunting's API directly has sharp edges; `akt` papers over these:
|
|
|
177
204
|
so they aren't lost.
|
|
178
205
|
* **Nested payment route** — paying a document must POST to
|
|
179
206
|
`documents/{id}/transactions`; the flat `transactions` endpoint rejects it.
|
|
207
|
+
The same applies to *updating* a document-linked payment (e.g. attaching a
|
|
208
|
+
file to it) — `akt` picks the nested route automatically.
|
|
209
|
+
* **Multipart uploads** — attachments switch the request from JSON to
|
|
210
|
+
`multipart/form-data` with the `attachment[]` field; updates are sent as
|
|
211
|
+
`POST` + `_method=PATCH` because PHP won't populate `$_FILES` on a real `PUT`.
|
|
212
|
+
* **Attachment download isn't on `/api`** — Akaunting only serves attachment
|
|
213
|
+
bytes from the session-authenticated web route `/{company}/uploads/{id}/download`.
|
|
214
|
+
`akt download-attachment` transparently logs in a web session with your admin
|
|
215
|
+
credentials (reused for the process) to fetch the file; metadata (id, name,
|
|
216
|
+
size) comes from the `/api` record itself.
|
|
180
217
|
* **Full-replace updates** — Akaunting PUT re-validates required fields, so
|
|
181
218
|
`akt` merges your changes onto the current record.
|
|
182
219
|
|
|
@@ -1,17 +1,22 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# akt — Akaunting CLI toolbox
|
|
4
|
+
### Drive your Akaunting accounting instance entirely from the command line
|
|
5
|
+
|
|
6
|
+
>`akt` gives you full create / read / update / delete for customers, vendors, items, invoices, bills, payments, accounts, categories, taxes, currencies and transfers — plus a `raw` escape hatch for any other endpoint. Built and tested against [Akaunting](https://akaunting.com) **3.1.x**; works with any 3.x deployment that exposes the REST API.
|
|
2
7
|
|
|
3
|
-
[](https://pypi.org/project/akt-cli/)
|
|
4
|
-
[](https://pypi.org/project/akt-cli/)
|
|
9
|
+
[](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml)
|
|
10
|
+
[](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/release.yml)
|
|
11
|
+
[](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/publish.yml)
|
|
12
|
+
[](https://codecov.io/github/AsyncAlchemist/akt-cli)
|
|
7
13
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
[](https://github.com/AsyncAlchemist/akt-cli/releases)
|
|
15
|
+
[](https://pypi.org/project/akt-cli/)
|
|
16
|
+
[](https://pypi.org/project/akt-cli/)
|
|
17
|
+
[](LICENSE)
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
deployment that exposes the REST API.
|
|
19
|
+
</div>
|
|
15
20
|
|
|
16
21
|
## Install
|
|
17
22
|
|
|
@@ -95,6 +100,17 @@ akt <noun> enable <id> # where applicable
|
|
|
95
100
|
akt <noun> disable <id>
|
|
96
101
|
```
|
|
97
102
|
|
|
103
|
+
Bills, invoices and payments additionally support **attachments** (scanned bills,
|
|
104
|
+
receipts, PDFs):
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
akt <noun> create ... --attachment ./file.pdf # repeatable; upload on create
|
|
108
|
+
akt <noun> update <id> --attachment ./file.pdf # attach to an existing record
|
|
109
|
+
akt <noun> update <id> --remove-attachment # clear existing attachment(s)
|
|
110
|
+
akt <noun> attachments <id> # list attached files (id, name, size)
|
|
111
|
+
akt <noun> download-attachment <id> [--out DIR] [--media-id ID]
|
|
112
|
+
```
|
|
113
|
+
|
|
98
114
|
Output is a table by default; add `--json` (works before or after the verb) for
|
|
99
115
|
raw JSON suitable for piping into `jq`.
|
|
100
116
|
|
|
@@ -136,6 +152,14 @@ akt payment create --invoice 34 --amount 750 \
|
|
|
136
152
|
akt bill create --contact 13 --item 'name=Paper,price=40,quantity=5'
|
|
137
153
|
akt payment create --bill 41
|
|
138
154
|
|
|
155
|
+
# Attachments: upload the source PDF/scan and fetch it back later
|
|
156
|
+
akt bill create --contact 13 --item 'name=Paper,price=40,quantity=5' \
|
|
157
|
+
--attachment ./supplier-bill.pdf
|
|
158
|
+
akt payment update 57 --attachment ./receipt.pdf # attach to an existing payment
|
|
159
|
+
akt bill attachments 41 # list attached files
|
|
160
|
+
akt bill download-attachment 41 --out ./downloads # save to disk
|
|
161
|
+
akt payment update 57 --remove-attachment # clear attachments
|
|
162
|
+
|
|
139
163
|
# Anything else: raw API access
|
|
140
164
|
akt raw GET reports
|
|
141
165
|
akt raw POST items --data '{"name":"Ad-hoc","type":"service","sale_price":99}'
|
|
@@ -159,6 +183,16 @@ Driving Akaunting's API directly has sharp edges; `akt` papers over these:
|
|
|
159
183
|
so they aren't lost.
|
|
160
184
|
* **Nested payment route** — paying a document must POST to
|
|
161
185
|
`documents/{id}/transactions`; the flat `transactions` endpoint rejects it.
|
|
186
|
+
The same applies to *updating* a document-linked payment (e.g. attaching a
|
|
187
|
+
file to it) — `akt` picks the nested route automatically.
|
|
188
|
+
* **Multipart uploads** — attachments switch the request from JSON to
|
|
189
|
+
`multipart/form-data` with the `attachment[]` field; updates are sent as
|
|
190
|
+
`POST` + `_method=PATCH` because PHP won't populate `$_FILES` on a real `PUT`.
|
|
191
|
+
* **Attachment download isn't on `/api`** — Akaunting only serves attachment
|
|
192
|
+
bytes from the session-authenticated web route `/{company}/uploads/{id}/download`.
|
|
193
|
+
`akt download-attachment` transparently logs in a web session with your admin
|
|
194
|
+
credentials (reused for the process) to fetch the file; metadata (id, name,
|
|
195
|
+
size) comes from the `/api` record itself.
|
|
162
196
|
* **Full-replace updates** — Akaunting PUT re-validates required fields, so
|
|
163
197
|
`akt` merges your changes onto the current record.
|
|
164
198
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "akt-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "akt — a CLI toolbox to fully drive an Akaunting accounting instance"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -12,6 +12,9 @@ requires-python = ">=3.12"
|
|
|
12
12
|
keywords = ["akaunting", "accounting", "cli", "invoices", "bills", "api"]
|
|
13
13
|
classifiers = [
|
|
14
14
|
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
15
18
|
"Environment :: Console",
|
|
16
19
|
"Topic :: Office/Business :: Financial :: Accounting",
|
|
17
20
|
]
|
|
@@ -9,8 +9,10 @@ from typing import Any
|
|
|
9
9
|
from .client import ApiError, Client
|
|
10
10
|
from .config import ConfigError, load_config
|
|
11
11
|
from .commands import (
|
|
12
|
+
cmd_attachments,
|
|
12
13
|
cmd_create,
|
|
13
14
|
cmd_delete,
|
|
15
|
+
cmd_download_attachment,
|
|
14
16
|
cmd_get,
|
|
15
17
|
cmd_list,
|
|
16
18
|
cmd_toggle,
|
|
@@ -38,6 +40,14 @@ def _add_field_args(p: argparse.ArgumentParser, res: Resource, *, for_update: bo
|
|
|
38
40
|
if res.endpoint == "documents":
|
|
39
41
|
p.add_argument("--item", action="append", metavar="K=V,...",
|
|
40
42
|
help="line item, e.g. 'name=Widget,price=10,quantity=2,tax_id=1' (repeatable)")
|
|
43
|
+
if res.supports_attachments:
|
|
44
|
+
p.add_argument("--attachment", action="append", metavar="PATH",
|
|
45
|
+
help="attach a file (pdf/jpg/png, repeatable); switches the "
|
|
46
|
+
"request to multipart upload")
|
|
47
|
+
if for_update:
|
|
48
|
+
p.add_argument("--remove-attachment", dest="remove_attachment",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="clear existing attachment(s) on this record")
|
|
41
51
|
p.add_argument("--set", dest="set_", action="append", metavar="KEY=VALUE",
|
|
42
52
|
help="set an arbitrary body field (repeatable; value JSON-coerced)")
|
|
43
53
|
p.add_argument("--data", metavar="JSON|@FILE",
|
|
@@ -100,6 +110,20 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
100
110
|
dp.add_argument("id")
|
|
101
111
|
dp.set_defaults(_handler=lambda res, c, ns: cmd_delete(res, c, ns))
|
|
102
112
|
|
|
113
|
+
if res.supports_attachments:
|
|
114
|
+
ap = verbs.add_parser("attachments", parents=[common],
|
|
115
|
+
help=f"List attachments on a {res.noun}")
|
|
116
|
+
ap.add_argument("id")
|
|
117
|
+
ap.set_defaults(_handler=lambda res, c, ns: cmd_attachments(res, c, ns))
|
|
118
|
+
|
|
119
|
+
dap = verbs.add_parser("download-attachment", parents=[common],
|
|
120
|
+
help=f"Download attachment(s) from a {res.noun}")
|
|
121
|
+
dap.add_argument("id")
|
|
122
|
+
dap.add_argument("--out", metavar="DIR", help="output directory (default .)")
|
|
123
|
+
dap.add_argument("--media-id", metavar="ID",
|
|
124
|
+
help="download only this media id (default: all)")
|
|
125
|
+
dap.set_defaults(_handler=lambda res, c, ns: cmd_download_attachment(res, c, ns))
|
|
126
|
+
|
|
103
127
|
if res.supports_toggle:
|
|
104
128
|
ep = verbs.add_parser("enable", parents=[common], help=f"Enable a {res.noun}")
|
|
105
129
|
ep.add_argument("id")
|
|
@@ -14,8 +14,11 @@ Akaunting specifics baked in here:
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
17
19
|
import time
|
|
18
20
|
from typing import Any, Iterator
|
|
21
|
+
from urllib.parse import unquote
|
|
19
22
|
|
|
20
23
|
import requests
|
|
21
24
|
|
|
@@ -66,13 +69,14 @@ class Client:
|
|
|
66
69
|
self._last_request = 0.0
|
|
67
70
|
self._settings_cache: dict[str, Any] = {}
|
|
68
71
|
self._settings_loaded = False
|
|
72
|
+
self._web_authed = False # whether a web (session-cookie) login has run
|
|
69
73
|
self._session = requests.Session()
|
|
70
74
|
self._session.auth = (config.email, config.password)
|
|
71
75
|
self._session.headers.update(
|
|
72
76
|
{
|
|
73
77
|
"Accept": "application/json",
|
|
74
78
|
"Content-Type": "application/json",
|
|
75
|
-
"User-Agent": "akt/0.
|
|
79
|
+
"User-Agent": "akt/0.2 (+akaunting-cli)",
|
|
76
80
|
}
|
|
77
81
|
)
|
|
78
82
|
|
|
@@ -85,8 +89,21 @@ class Client:
|
|
|
85
89
|
*,
|
|
86
90
|
params: dict | None = None,
|
|
87
91
|
json_body: Any = None,
|
|
92
|
+
form: Any = None,
|
|
93
|
+
files: Any = None,
|
|
88
94
|
type_scope: str | None = None,
|
|
89
95
|
) -> Any:
|
|
96
|
+
"""Perform an API request.
|
|
97
|
+
|
|
98
|
+
Bodies are mutually exclusive:
|
|
99
|
+
* ``json_body`` — serialized as JSON (the default surface).
|
|
100
|
+
* ``form`` + ``files`` — a multipart/form-data upload. ``form`` is an
|
|
101
|
+
iterable of ``(key, value)`` pairs (repeated keys allowed for
|
|
102
|
+
PHP-style ``attachment[]`` / ``items[0][name]`` encoding) and
|
|
103
|
+
``files`` an iterable of ``(field, (filename, bytes, mime))``. The
|
|
104
|
+
hardcoded ``Content-Type: application/json`` session header is
|
|
105
|
+
dropped for these so ``requests`` sets the multipart boundary.
|
|
106
|
+
"""
|
|
90
107
|
url = f"{self.config.api_root}/{path.lstrip('/')}"
|
|
91
108
|
query: dict[str, Any] = {"company_id": self.config.company_id}
|
|
92
109
|
if type_scope:
|
|
@@ -102,8 +119,15 @@ class Client:
|
|
|
102
119
|
continue # already merged
|
|
103
120
|
query[k] = v
|
|
104
121
|
|
|
122
|
+
multipart = files is not None or form is not None
|
|
105
123
|
data = None
|
|
106
|
-
|
|
124
|
+
headers = None
|
|
125
|
+
if multipart:
|
|
126
|
+
data = list(form or [])
|
|
127
|
+
# Drop the JSON content-type so requests builds the multipart body
|
|
128
|
+
# (with its boundary) itself.
|
|
129
|
+
headers = {"Content-Type": None}
|
|
130
|
+
elif json_body is not None:
|
|
107
131
|
data = json.dumps(json_body)
|
|
108
132
|
|
|
109
133
|
attempt = 0
|
|
@@ -118,6 +142,8 @@ class Client:
|
|
|
118
142
|
url,
|
|
119
143
|
params=query,
|
|
120
144
|
data=data,
|
|
145
|
+
files=files,
|
|
146
|
+
headers=headers,
|
|
121
147
|
timeout=self.timeout,
|
|
122
148
|
)
|
|
123
149
|
if attempt < self.max_retries and _is_transient(resp):
|
|
@@ -174,6 +200,99 @@ class Client:
|
|
|
174
200
|
def delete(self, path: str, **kw) -> Any:
|
|
175
201
|
return self.request("DELETE", path, **kw)
|
|
176
202
|
|
|
203
|
+
def post_multipart(self, path: str, form: Any, files: Any, **kw) -> Any:
|
|
204
|
+
"""Create a record with a multipart body (e.g. carrying attachments)."""
|
|
205
|
+
return self.request("POST", path, form=form, files=files, **kw)
|
|
206
|
+
|
|
207
|
+
def put_multipart(self, path: str, form: Any, files: Any, **kw) -> Any:
|
|
208
|
+
"""Update an existing record with a multipart body.
|
|
209
|
+
|
|
210
|
+
PHP does not populate ``$_FILES`` for a real PUT, so multipart updates
|
|
211
|
+
are sent as POST with a spoofed ``_method=PATCH`` field (the same trick
|
|
212
|
+
the Akaunting web UI uses)."""
|
|
213
|
+
form = [("_method", "PATCH"), *form]
|
|
214
|
+
return self.request("POST", path, form=form, files=files, **kw)
|
|
215
|
+
|
|
216
|
+
# ---- attachment download (web-session surface) --------------------
|
|
217
|
+
|
|
218
|
+
def _web_login(self) -> None:
|
|
219
|
+
"""Authenticate a browser-style session for the ``/uploads`` routes.
|
|
220
|
+
|
|
221
|
+
Attachment bytes are only served by ``GET /{company}/uploads/{id}/download``
|
|
222
|
+
behind the web ``auth`` guard — the ``/api`` Basic-auth surface exposes
|
|
223
|
+
attachment *metadata* but not the file. So we log in the same way the web
|
|
224
|
+
UI does (scrape the login form's CSRF ``_token``, POST credentials) and
|
|
225
|
+
reuse the resulting session cookie."""
|
|
226
|
+
if self._web_authed:
|
|
227
|
+
return
|
|
228
|
+
login_url = f"{self.config.web_root}/auth/login"
|
|
229
|
+
# No JSON content-type on these calls: the login form is url-encoded and
|
|
230
|
+
# the pages are HTML.
|
|
231
|
+
resp = self._session.get(login_url, headers={"Content-Type": None},
|
|
232
|
+
timeout=self.timeout)
|
|
233
|
+
if self._waf_blocked(resp):
|
|
234
|
+
raise ApiError(resp.status_code, "Blocked by bot-protection during web login.")
|
|
235
|
+
token = self._csrf_token(resp.text)
|
|
236
|
+
if not token:
|
|
237
|
+
raise ApiError(resp.status_code,
|
|
238
|
+
"Could not find a login CSRF token; web login failed.")
|
|
239
|
+
post = self._session.post(
|
|
240
|
+
login_url,
|
|
241
|
+
data={"_token": token, "email": self.config.email,
|
|
242
|
+
"password": self.config.password, "remember": "on"},
|
|
243
|
+
headers={"Content-Type": None},
|
|
244
|
+
allow_redirects=True,
|
|
245
|
+
timeout=self.timeout,
|
|
246
|
+
)
|
|
247
|
+
if self._waf_blocked(post):
|
|
248
|
+
raise ApiError(post.status_code, "Blocked by bot-protection during web login.")
|
|
249
|
+
self._web_authed = True
|
|
250
|
+
|
|
251
|
+
def _csrf_token(self, html: str) -> str | None:
|
|
252
|
+
m = re.search(r'name="_token"[^>]*value="([^"]+)"', html)
|
|
253
|
+
if m:
|
|
254
|
+
return m.group(1)
|
|
255
|
+
m = re.search(r'name="csrf-token"\s+content="([^"]+)"', html)
|
|
256
|
+
if m:
|
|
257
|
+
return m.group(1)
|
|
258
|
+
# Fall back to the XSRF-TOKEN cookie (Laravel accepts it as the token).
|
|
259
|
+
cookie = self._session.cookies.get("XSRF-TOKEN")
|
|
260
|
+
return unquote(cookie) if cookie else None
|
|
261
|
+
|
|
262
|
+
def download_media(self, media_id: int | str) -> "tuple[str, bytes]":
|
|
263
|
+
"""Return ``(filename, content)`` for an attachment media id.
|
|
264
|
+
|
|
265
|
+
Logs in a web session on first use (cached for the process)."""
|
|
266
|
+
self._web_login()
|
|
267
|
+
url = f"{self.config.web_root}/{self.config.company_id}/uploads/{media_id}/download"
|
|
268
|
+
resp = self._session.get(url, headers={"Content-Type": None},
|
|
269
|
+
allow_redirects=False, timeout=self.timeout)
|
|
270
|
+
if resp.status_code in (301, 302, 303, 307, 308):
|
|
271
|
+
raise ApiError(resp.status_code,
|
|
272
|
+
"Attachment download redirected (web session not "
|
|
273
|
+
"authenticated). Check the admin credentials.")
|
|
274
|
+
if not resp.ok or not resp.content:
|
|
275
|
+
raise ApiError(resp.status_code,
|
|
276
|
+
f"Attachment media {media_id} not found or empty.")
|
|
277
|
+
filename = self._disposition_filename(resp.headers.get("Content-Disposition", ""))
|
|
278
|
+
return filename or f"attachment-{media_id}", resp.content
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _disposition_filename(disposition: str) -> str | None:
|
|
282
|
+
name = None
|
|
283
|
+
m = re.search(r"filename\*=(?:UTF-8'')?([^;]+)", disposition, re.IGNORECASE)
|
|
284
|
+
if m:
|
|
285
|
+
name = unquote(m.group(1).strip().strip('"'))
|
|
286
|
+
else:
|
|
287
|
+
m = re.search(r'filename="?([^";]+)"?', disposition, re.IGNORECASE)
|
|
288
|
+
if m:
|
|
289
|
+
name = m.group(1).strip()
|
|
290
|
+
if not name:
|
|
291
|
+
return None
|
|
292
|
+
# Defense-in-depth: a server-supplied name must never carry a path.
|
|
293
|
+
name = os.path.basename(name.replace("\\", "/"))
|
|
294
|
+
return name or None
|
|
295
|
+
|
|
177
296
|
# ---- higher level helpers -----------------------------------------
|
|
178
297
|
|
|
179
298
|
def list(
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Generic command handlers shared by every resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .client import Client
|
|
9
|
+
from .output import emit
|
|
10
|
+
from .resources import Resource, body_from_fields, flatten_form, load_attachments
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cmd_list(res: Resource, client: Client, ns: Any) -> int:
|
|
14
|
+
search = ns.search
|
|
15
|
+
if res.search_default:
|
|
16
|
+
search = f"{res.search_default} {search}".strip() if search else res.search_default
|
|
17
|
+
rows = client.list(
|
|
18
|
+
res.endpoint,
|
|
19
|
+
type_scope=res.type_scope,
|
|
20
|
+
search=search or None,
|
|
21
|
+
all_pages=ns.all,
|
|
22
|
+
limit=ns.limit,
|
|
23
|
+
)
|
|
24
|
+
cols = [c[1] for c in res.columns]
|
|
25
|
+
heads = [c[0] for c in res.columns]
|
|
26
|
+
emit(rows, as_json=ns.json, columns=cols if not ns.json else None, headers=heads)
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cmd_get(res: Resource, client: Client, ns: Any) -> int:
|
|
31
|
+
row = client.show(res.endpoint, ns.id, type_scope=res.type_scope)
|
|
32
|
+
emit(row, as_json=True)
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_create(res: Resource, client: Client, ns: Any) -> int:
|
|
37
|
+
if res.build_create:
|
|
38
|
+
body = res.build_create(res, client, ns)
|
|
39
|
+
else:
|
|
40
|
+
body = body_from_fields(res, ns, for_update=False)
|
|
41
|
+
_require(res, body)
|
|
42
|
+
# A builder may override the target route via reserved keys (e.g. paying a
|
|
43
|
+
# document goes to documents/{id}/transactions).
|
|
44
|
+
endpoint = body.pop("__endpoint__", res.endpoint)
|
|
45
|
+
type_scope = body.pop("__type_scope__", res.type_scope)
|
|
46
|
+
files = load_attachments(getattr(ns, "attachment", None))
|
|
47
|
+
if files:
|
|
48
|
+
payload = client.post_multipart(endpoint, flatten_form(body), files,
|
|
49
|
+
type_scope=type_scope)
|
|
50
|
+
else:
|
|
51
|
+
payload = client.post(endpoint, body, type_scope=type_scope)
|
|
52
|
+
data = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
53
|
+
emit(data, as_json=True)
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_update(res: Resource, client: Client, ns: Any) -> int:
|
|
58
|
+
current = client.show(res.endpoint, ns.id, type_scope=res.type_scope)
|
|
59
|
+
if res.build_update:
|
|
60
|
+
body = res.build_update(res, client, ns, current)
|
|
61
|
+
else:
|
|
62
|
+
# PUT is a full replace in Akaunting, so merge changes onto the current
|
|
63
|
+
# record to satisfy required-field validation.
|
|
64
|
+
body = body_from_fields(res, ns, for_update=True, current=current)
|
|
65
|
+
|
|
66
|
+
# A resolver may redirect to a nested route (a document-linked payment must
|
|
67
|
+
# update via documents/{doc}/transactions/{id}; the flat route 400s on it).
|
|
68
|
+
if res.update_resolver:
|
|
69
|
+
path, type_scope = res.update_resolver(res, client, str(ns.id), current)
|
|
70
|
+
else:
|
|
71
|
+
path, type_scope = f"{res.endpoint}/{ns.id}", res.type_scope
|
|
72
|
+
|
|
73
|
+
# The nested transaction-update job (documents/{doc}/transactions/{id}) reads
|
|
74
|
+
# a SINGLE `attachment` file and 500s on the usual `attachment[]` array; every
|
|
75
|
+
# other route loops the array. Detect that route by its shape.
|
|
76
|
+
nested_txn = "/transactions/" in path
|
|
77
|
+
att_paths = getattr(ns, "attachment", None)
|
|
78
|
+
if nested_txn and att_paths and len(att_paths) > 1:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"a document-linked payment accepts only one attachment per update "
|
|
81
|
+
"(Akaunting's nested update route takes a single file)"
|
|
82
|
+
)
|
|
83
|
+
files = load_attachments(att_paths, field="attachment" if nested_txn else "attachment[]")
|
|
84
|
+
remove = getattr(ns, "remove_attachment", False)
|
|
85
|
+
if files:
|
|
86
|
+
form = flatten_form(body)
|
|
87
|
+
if remove:
|
|
88
|
+
form.append(("remove_attachment", "1"))
|
|
89
|
+
payload = client.put_multipart(path, form, files, type_scope=type_scope)
|
|
90
|
+
else:
|
|
91
|
+
if remove:
|
|
92
|
+
body["remove_attachment"] = 1
|
|
93
|
+
payload = client.put(path, body, type_scope=type_scope)
|
|
94
|
+
data = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
95
|
+
emit(data, as_json=True)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_delete(res: Resource, client: Client, ns: Any) -> int:
|
|
100
|
+
if res.delete_resolver:
|
|
101
|
+
path, type_scope = res.delete_resolver(res, client, str(ns.id))
|
|
102
|
+
else:
|
|
103
|
+
path, type_scope = f"{res.endpoint}/{ns.id}", res.type_scope
|
|
104
|
+
client.delete(path, type_scope=type_scope)
|
|
105
|
+
print(f"deleted {res.noun} {ns.id}")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmd_toggle(res: Resource, client: Client, ns: Any, enable: bool) -> int:
|
|
110
|
+
# Akaunting exposes GET enable/disable endpoints
|
|
111
|
+
action = "enable" if enable else "disable"
|
|
112
|
+
payload = client.get(f"{res.endpoint}/{ns.id}/{action}", type_scope=res.type_scope)
|
|
113
|
+
data = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
114
|
+
emit(data, as_json=True)
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _attachment_rows(record: dict) -> list[dict]:
|
|
119
|
+
"""Normalize a record's ``attachment`` field (an array of media, or ``False``
|
|
120
|
+
when empty) into display rows with a derived ``name`` (filename.extension)."""
|
|
121
|
+
raw = record.get("attachment")
|
|
122
|
+
if not raw or not isinstance(raw, list):
|
|
123
|
+
return []
|
|
124
|
+
rows: list[dict] = []
|
|
125
|
+
for m in raw:
|
|
126
|
+
name = m.get("basename") or m.get("filename", "")
|
|
127
|
+
ext = m.get("extension")
|
|
128
|
+
if ext and not str(name).endswith(f".{ext}"):
|
|
129
|
+
name = f"{name}.{ext}"
|
|
130
|
+
rows.append({
|
|
131
|
+
"id": m.get("id"),
|
|
132
|
+
"name": name,
|
|
133
|
+
"size": m.get("size"),
|
|
134
|
+
"mime_type": m.get("mime_type"),
|
|
135
|
+
})
|
|
136
|
+
return rows
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_attachments(res: Resource, client: Client, ns: Any) -> int:
|
|
140
|
+
record = client.show(res.endpoint, ns.id, type_scope=res.type_scope)
|
|
141
|
+
rows = _attachment_rows(record)
|
|
142
|
+
cols = ["id", "name", "size", "mime_type"]
|
|
143
|
+
emit(rows, as_json=ns.json, columns=None if ns.json else cols,
|
|
144
|
+
headers=["ID", "Name", "Size", "Type"])
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cmd_download_attachment(res: Resource, client: Client, ns: Any) -> int:
|
|
149
|
+
record = client.show(res.endpoint, ns.id, type_scope=res.type_scope)
|
|
150
|
+
rows = _attachment_rows(record)
|
|
151
|
+
if getattr(ns, "media_id", None):
|
|
152
|
+
rows = [r for r in rows if str(r["id"]) == str(ns.media_id)]
|
|
153
|
+
if not rows:
|
|
154
|
+
raise ValueError(f"no attachment with media id {ns.media_id} on {res.noun} {ns.id}")
|
|
155
|
+
if not rows:
|
|
156
|
+
print(f"{res.noun} {ns.id} has no attachments")
|
|
157
|
+
return 0
|
|
158
|
+
out_dir = getattr(ns, "out", None) or "."
|
|
159
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
160
|
+
real_out = os.path.realpath(out_dir)
|
|
161
|
+
saved: list[str] = []
|
|
162
|
+
for r in rows:
|
|
163
|
+
filename, content = client.download_media(r["id"])
|
|
164
|
+
# The filename comes from the server's Content-Disposition, so treat it
|
|
165
|
+
# as untrusted: strip any path components and reject traversal so a
|
|
166
|
+
# malicious/compromised response can't write outside --out.
|
|
167
|
+
safe = os.path.basename(filename)
|
|
168
|
+
if not safe or safe in (".", "..") or "/" in safe or "\\" in safe:
|
|
169
|
+
safe = f"attachment-{r['id']}"
|
|
170
|
+
dest = os.path.join(out_dir, safe)
|
|
171
|
+
if os.path.exists(dest): # avoid clobbering same-named attachments
|
|
172
|
+
dest = os.path.join(out_dir, f"{r['id']}-{safe}")
|
|
173
|
+
if os.path.commonpath([real_out, os.path.realpath(dest)]) != real_out:
|
|
174
|
+
raise ValueError(f"refusing to write attachment outside {out_dir}")
|
|
175
|
+
with open(dest, "wb") as fh:
|
|
176
|
+
fh.write(content)
|
|
177
|
+
saved.append(dest)
|
|
178
|
+
if ns.json:
|
|
179
|
+
emit([{"media_id": r["id"], "path": p} for r, p in zip(rows, saved)], as_json=True)
|
|
180
|
+
else:
|
|
181
|
+
for p in saved:
|
|
182
|
+
print(f"saved {p}")
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _require(res: Resource, body: dict) -> None:
|
|
187
|
+
missing = [fld.dest for fld in res.fields if fld.required and fld.dest not in body]
|
|
188
|
+
if missing:
|
|
189
|
+
raise ValueError(f"missing required field(s): {', '.join(missing)}")
|
|
@@ -60,6 +60,16 @@ class Config:
|
|
|
60
60
|
return base
|
|
61
61
|
return base + "/api"
|
|
62
62
|
|
|
63
|
+
@property
|
|
64
|
+
def web_root(self) -> str:
|
|
65
|
+
"""The web (non-API) origin, used for the session-authenticated upload
|
|
66
|
+
download route ``/{company}/uploads/{id}/download`` which lives outside
|
|
67
|
+
the ``/api`` surface."""
|
|
68
|
+
base = self.base_url.rstrip("/")
|
|
69
|
+
if base.endswith("/api"):
|
|
70
|
+
base = base[: -len("/api")]
|
|
71
|
+
return base.rstrip("/")
|
|
72
|
+
|
|
63
73
|
|
|
64
74
|
def load_config(
|
|
65
75
|
*,
|
|
@@ -10,6 +10,7 @@ from .resources import (
|
|
|
10
10
|
build_payment_create,
|
|
11
11
|
build_transfer_create,
|
|
12
12
|
resolve_payment_delete,
|
|
13
|
+
resolve_payment_update,
|
|
13
14
|
)
|
|
14
15
|
|
|
15
16
|
# Common column sets
|
|
@@ -189,6 +190,7 @@ INVOICE = Resource(
|
|
|
189
190
|
columns=_DOC_COLS,
|
|
190
191
|
build_create=build_document_create,
|
|
191
192
|
build_update=build_document_update,
|
|
193
|
+
supports_attachments=True,
|
|
192
194
|
help="Sales invoices",
|
|
193
195
|
)
|
|
194
196
|
|
|
@@ -201,6 +203,7 @@ BILL = Resource(
|
|
|
201
203
|
columns=_DOC_COLS,
|
|
202
204
|
build_create=build_document_create,
|
|
203
205
|
build_update=build_document_update,
|
|
206
|
+
supports_attachments=True,
|
|
204
207
|
help="Purchase bills",
|
|
205
208
|
)
|
|
206
209
|
|
|
@@ -228,8 +231,10 @@ PAYMENT = Resource(
|
|
|
228
231
|
],
|
|
229
232
|
columns=_TXN_COLS,
|
|
230
233
|
supports_toggle=False,
|
|
234
|
+
supports_attachments=True,
|
|
231
235
|
build_create=build_payment_create,
|
|
232
236
|
delete_resolver=resolve_payment_delete,
|
|
237
|
+
update_resolver=resolve_payment_update,
|
|
233
238
|
help="Payments / transactions (income & expense)",
|
|
234
239
|
)
|
|
235
240
|
|
|
@@ -10,6 +10,8 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import datetime as _dt
|
|
12
12
|
import json
|
|
13
|
+
import mimetypes
|
|
14
|
+
import os
|
|
13
15
|
from dataclasses import dataclass, field
|
|
14
16
|
from typing import Any, Callable
|
|
15
17
|
|
|
@@ -50,6 +52,7 @@ class Resource:
|
|
|
50
52
|
columns: list[tuple[str, str]] = field(default_factory=list) # (header, dotted path)
|
|
51
53
|
search_default: str | None = None # always-applied search filter
|
|
52
54
|
supports_toggle: bool = True # enable/disable verbs
|
|
55
|
+
supports_attachments: bool = False # --attachment upload + attachments/download verbs
|
|
53
56
|
help: str = ""
|
|
54
57
|
|
|
55
58
|
# hooks (override for documents/payments)
|
|
@@ -57,6 +60,9 @@ class Resource:
|
|
|
57
60
|
build_update: Callable[["Resource", Client, Any, dict], dict] | None = None
|
|
58
61
|
# returns (path, type_scope) for delete; lets payments use the nested route
|
|
59
62
|
delete_resolver: Callable[["Resource", Client, str], "tuple[str, str | None]"] | None = None
|
|
63
|
+
# returns (path, type_scope) for update; lets document-linked payments use
|
|
64
|
+
# the nested route (the flat /transactions route 400s on any document_id)
|
|
65
|
+
update_resolver: Callable[["Resource", Client, str, dict], "tuple[str, str | None]"] | None = None
|
|
60
66
|
|
|
61
67
|
def contact_scope(self) -> str:
|
|
62
68
|
"""ACL scope of the contact tied to a document resource."""
|
|
@@ -95,6 +101,73 @@ def load_data_arg(value: str | None) -> dict:
|
|
|
95
101
|
return json.loads(value)
|
|
96
102
|
|
|
97
103
|
|
|
104
|
+
# --------------------------------------------------------------------------
|
|
105
|
+
# multipart / attachment helpers
|
|
106
|
+
# --------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
# Mirror Akaunting's default config('filesystems.mimes') so we can give a clear
|
|
109
|
+
# client-side error instead of a generic 422. Overridable server-side via
|
|
110
|
+
# FILESYSTEM_MIMES, but pdf/jpeg/jpg/png is the stock allow-list.
|
|
111
|
+
_DEFAULT_ATTACHMENT_EXTS = {"pdf", "jpeg", "jpg", "png"}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def flatten_form(body: dict, *, exclude: tuple[str, ...] = ()) -> list[tuple[str, str]]:
|
|
115
|
+
"""Flatten a nested JSON body into PHP-style multipart form pairs.
|
|
116
|
+
|
|
117
|
+
``{"items": [{"name": "W", "tax_id": [1, 2]}]}`` becomes
|
|
118
|
+
``[("items[0][name]", "W"), ("items[0][tax_id][0]", "1"), ...]`` so a
|
|
119
|
+
multipart upload carries the same structure the JSON surface would. Keys in
|
|
120
|
+
``exclude`` (and any reserved ``__…__`` routing keys) are skipped. ``None``
|
|
121
|
+
values and booleans are coerced the way the API expects (``1``/``0``)."""
|
|
122
|
+
out: list[tuple[str, str]] = []
|
|
123
|
+
|
|
124
|
+
def emit(key: str, value: Any) -> None:
|
|
125
|
+
if value is None:
|
|
126
|
+
return
|
|
127
|
+
if isinstance(value, bool):
|
|
128
|
+
out.append((key, "1" if value else "0"))
|
|
129
|
+
elif isinstance(value, dict):
|
|
130
|
+
for k, v in value.items():
|
|
131
|
+
emit(f"{key}[{k}]", v)
|
|
132
|
+
elif isinstance(value, (list, tuple)):
|
|
133
|
+
for i, v in enumerate(value):
|
|
134
|
+
emit(f"{key}[{i}]", v)
|
|
135
|
+
else:
|
|
136
|
+
out.append((key, str(value)))
|
|
137
|
+
|
|
138
|
+
for k, v in body.items():
|
|
139
|
+
if k in exclude or (k.startswith("__") and k.endswith("__")):
|
|
140
|
+
continue
|
|
141
|
+
emit(k, v)
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_attachments(paths: list[str] | None, *, field: str = "attachment[]"
|
|
146
|
+
) -> list[tuple[str, tuple[str, bytes, str]]]:
|
|
147
|
+
"""Read each ``--attachment`` path into a requests ``files`` tuple.
|
|
148
|
+
|
|
149
|
+
``field`` is the multipart key. Almost every Akaunting route loops over the
|
|
150
|
+
array field ``attachment[]``; the sole exception is the nested transaction
|
|
151
|
+
*update* job (``UpdateBankingDocumentTransaction``), which reads a single
|
|
152
|
+
``attachment`` file and crashes on an array — callers pass ``field="attachment"``
|
|
153
|
+
for that route."""
|
|
154
|
+
files: list[tuple[str, tuple[str, bytes, str]]] = []
|
|
155
|
+
for path in paths or []:
|
|
156
|
+
if not os.path.isfile(path):
|
|
157
|
+
raise ValueError(f"attachment not found: {path}")
|
|
158
|
+
ext = os.path.splitext(path)[1].lstrip(".").lower()
|
|
159
|
+
if ext not in _DEFAULT_ATTACHMENT_EXTS:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"attachment {path!r}: extension {ext!r} not allowed "
|
|
162
|
+
f"(default allowed: {', '.join(sorted(_DEFAULT_ATTACHMENT_EXTS))})"
|
|
163
|
+
)
|
|
164
|
+
with open(path, "rb") as fh:
|
|
165
|
+
content = fh.read()
|
|
166
|
+
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
|
167
|
+
files.append((field, (os.path.basename(path), content, mime)))
|
|
168
|
+
return files
|
|
169
|
+
|
|
170
|
+
|
|
98
171
|
def now_dt() -> str:
|
|
99
172
|
return _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
100
173
|
|
|
@@ -444,6 +517,19 @@ def resolve_payment_delete(res: Resource, client: Client, ident: str) -> "tuple[
|
|
|
444
517
|
return f"transactions/{ident}", None
|
|
445
518
|
|
|
446
519
|
|
|
520
|
+
def resolve_payment_update(res: Resource, client: Client, ident: str,
|
|
521
|
+
current: dict) -> "tuple[str, str | None]":
|
|
522
|
+
"""A payment linked to a document must be updated via the nested route
|
|
523
|
+
PUT /documents/{doc}/transactions/{id}; the flat /transactions route 400s on
|
|
524
|
+
any request carrying document_id. Mirrors :func:`resolve_payment_delete` but
|
|
525
|
+
reuses the already-fetched ``current`` record to avoid a second GET."""
|
|
526
|
+
doc_id = current.get("document_id")
|
|
527
|
+
if doc_id:
|
|
528
|
+
scope = "invoice" if current.get("type") == "income" else "bill"
|
|
529
|
+
return f"documents/{doc_id}/transactions/{ident}", scope
|
|
530
|
+
return f"transactions/{ident}", None
|
|
531
|
+
|
|
532
|
+
|
|
447
533
|
def build_transfer_create(res: Resource, client: Client, ns: Any) -> dict:
|
|
448
534
|
body = body_from_fields(res, ns, for_update=False)
|
|
449
535
|
# Transfers validate transferred_at as date-only (Y-m-d), unlike transactions.
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
"""Generic command handlers shared by every resource."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
from .client import Client
|
|
8
|
-
from .output import emit
|
|
9
|
-
from .resources import Resource, body_from_fields
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def cmd_list(res: Resource, client: Client, ns: Any) -> int:
|
|
13
|
-
search = ns.search
|
|
14
|
-
if res.search_default:
|
|
15
|
-
search = f"{res.search_default} {search}".strip() if search else res.search_default
|
|
16
|
-
rows = client.list(
|
|
17
|
-
res.endpoint,
|
|
18
|
-
type_scope=res.type_scope,
|
|
19
|
-
search=search or None,
|
|
20
|
-
all_pages=ns.all,
|
|
21
|
-
limit=ns.limit,
|
|
22
|
-
)
|
|
23
|
-
cols = [c[1] for c in res.columns]
|
|
24
|
-
heads = [c[0] for c in res.columns]
|
|
25
|
-
emit(rows, as_json=ns.json, columns=cols if not ns.json else None, headers=heads)
|
|
26
|
-
return 0
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def cmd_get(res: Resource, client: Client, ns: Any) -> int:
|
|
30
|
-
row = client.show(res.endpoint, ns.id, type_scope=res.type_scope)
|
|
31
|
-
emit(row, as_json=True)
|
|
32
|
-
return 0
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def cmd_create(res: Resource, client: Client, ns: Any) -> int:
|
|
36
|
-
if res.build_create:
|
|
37
|
-
body = res.build_create(res, client, ns)
|
|
38
|
-
else:
|
|
39
|
-
body = body_from_fields(res, ns, for_update=False)
|
|
40
|
-
_require(res, body)
|
|
41
|
-
# A builder may override the target route via reserved keys (e.g. paying a
|
|
42
|
-
# document goes to documents/{id}/transactions).
|
|
43
|
-
endpoint = body.pop("__endpoint__", res.endpoint)
|
|
44
|
-
type_scope = body.pop("__type_scope__", res.type_scope)
|
|
45
|
-
payload = client.post(endpoint, body, type_scope=type_scope)
|
|
46
|
-
data = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
47
|
-
emit(data, as_json=True)
|
|
48
|
-
return 0
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def cmd_update(res: Resource, client: Client, ns: Any) -> int:
|
|
52
|
-
if res.build_update:
|
|
53
|
-
current = client.show(res.endpoint, ns.id, type_scope=res.type_scope)
|
|
54
|
-
body = res.build_update(res, client, ns, current)
|
|
55
|
-
else:
|
|
56
|
-
# PUT is a full replace in Akaunting, so merge changes onto the current
|
|
57
|
-
# record to satisfy required-field validation.
|
|
58
|
-
current = client.show(res.endpoint, ns.id, type_scope=res.type_scope)
|
|
59
|
-
body = body_from_fields(res, ns, for_update=True, current=current)
|
|
60
|
-
payload = client.put(f"{res.endpoint}/{ns.id}", body, type_scope=res.type_scope)
|
|
61
|
-
data = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
62
|
-
emit(data, as_json=True)
|
|
63
|
-
return 0
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def cmd_delete(res: Resource, client: Client, ns: Any) -> int:
|
|
67
|
-
if res.delete_resolver:
|
|
68
|
-
path, type_scope = res.delete_resolver(res, client, str(ns.id))
|
|
69
|
-
else:
|
|
70
|
-
path, type_scope = f"{res.endpoint}/{ns.id}", res.type_scope
|
|
71
|
-
client.delete(path, type_scope=type_scope)
|
|
72
|
-
print(f"deleted {res.noun} {ns.id}")
|
|
73
|
-
return 0
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def cmd_toggle(res: Resource, client: Client, ns: Any, enable: bool) -> int:
|
|
77
|
-
# Akaunting exposes GET enable/disable endpoints
|
|
78
|
-
action = "enable" if enable else "disable"
|
|
79
|
-
payload = client.get(f"{res.endpoint}/{ns.id}/{action}", type_scope=res.type_scope)
|
|
80
|
-
data = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
81
|
-
emit(data, as_json=True)
|
|
82
|
-
return 0
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _require(res: Resource, body: dict) -> None:
|
|
86
|
-
missing = [fld.dest for fld in res.fields if fld.required and fld.dest not in body]
|
|
87
|
-
if missing:
|
|
88
|
-
raise ValueError(f"missing required field(s): {', '.join(missing)}")
|
|
File without changes
|
|
File without changes
|