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.
@@ -1,12 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akt-cli
3
- Version: 0.1.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
- [![PyPI](https://img.shields.io/pypi/v/akt-cli.svg)](https://pypi.org/project/akt-cli/)
22
- [![CI](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml)
23
- [![codecov](https://codecov.io/gh/AsyncAlchemist/akt-cli/graph/badge.svg)](https://codecov.io/gh/AsyncAlchemist/akt-cli)
24
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
29
+ [![PyPI Version](https://img.shields.io/pypi/v/akt-cli.svg?style=flat-square)](https://pypi.org/project/akt-cli/)
30
+ [![Tests](https://img.shields.io/github/actions/workflow/status/AsyncAlchemist/akt-cli/ci.yml?branch=main&label=tests&style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml)
31
+ [![Integration](https://img.shields.io/github/actions/workflow/status/AsyncAlchemist/akt-cli/release.yml?label=integration&style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/release.yml)
32
+ [![Publish](https://img.shields.io/github/actions/workflow/status/AsyncAlchemist/akt-cli/publish.yml?label=publish&style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/publish.yml)
33
+ [![Codecov](https://codecov.io/gh/AsyncAlchemist/akt-cli/graph/badge.svg)](https://codecov.io/github/AsyncAlchemist/akt-cli)
25
34
 
26
- `akt` drives an [Akaunting](https://akaunting.com) instance entirely from the
27
- command line: full create / read / update / delete for customers, vendors,
28
- items, invoices, bills, payments, accounts, categories, taxes, currencies and
29
- transfers — plus a `raw` escape hatch for any other API endpoint.
35
+ [![GitHub Release](https://img.shields.io/github/v/release/AsyncAlchemist/akt-cli?style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/releases)
36
+ [![Downloads](https://img.shields.io/pypi/dm/akt-cli.svg?style=flat-square&label=downloads)](https://pypi.org/project/akt-cli/)
37
+ [![Python Version](https://img.shields.io/badge/python-3.12%20%7C%203.13%20%7C%203.14-blue.svg?style=flat-square)](https://pypi.org/project/akt-cli/)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg?style=flat-square)](LICENSE)
30
39
 
31
- Built and tested against Akaunting **3.1.x**; it should work with any 3.x
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
- [![PyPI](https://img.shields.io/pypi/v/akt-cli.svg)](https://pypi.org/project/akt-cli/)
4
- [![CI](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml)
5
- [![codecov](https://codecov.io/gh/AsyncAlchemist/akt-cli/graph/badge.svg)](https://codecov.io/gh/AsyncAlchemist/akt-cli)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+ [![PyPI Version](https://img.shields.io/pypi/v/akt-cli.svg?style=flat-square)](https://pypi.org/project/akt-cli/)
9
+ [![Tests](https://img.shields.io/github/actions/workflow/status/AsyncAlchemist/akt-cli/ci.yml?branch=main&label=tests&style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml)
10
+ [![Integration](https://img.shields.io/github/actions/workflow/status/AsyncAlchemist/akt-cli/release.yml?label=integration&style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/release.yml)
11
+ [![Publish](https://img.shields.io/github/actions/workflow/status/AsyncAlchemist/akt-cli/publish.yml?label=publish&style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/publish.yml)
12
+ [![Codecov](https://codecov.io/gh/AsyncAlchemist/akt-cli/graph/badge.svg)](https://codecov.io/github/AsyncAlchemist/akt-cli)
7
13
 
8
- `akt` drives an [Akaunting](https://akaunting.com) instance entirely from the
9
- command line: full create / read / update / delete for customers, vendors,
10
- items, invoices, bills, payments, accounts, categories, taxes, currencies and
11
- transfers — plus a `raw` escape hatch for any other API endpoint.
14
+ [![GitHub Release](https://img.shields.io/github/v/release/AsyncAlchemist/akt-cli?style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/releases)
15
+ [![Downloads](https://img.shields.io/pypi/dm/akt-cli.svg?style=flat-square&label=downloads)](https://pypi.org/project/akt-cli/)
16
+ [![Python Version](https://img.shields.io/badge/python-3.12%20%7C%203.13%20%7C%203.14-blue.svg?style=flat-square)](https://pypi.org/project/akt-cli/)
17
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg?style=flat-square)](LICENSE)
12
18
 
13
- Built and tested against Akaunting **3.1.x**; it should work with any 3.x
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.1.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
  ]
@@ -3,4 +3,4 @@
3
3
  from .cli import main
4
4
 
5
5
  __all__ = ["main"]
6
- __version__ = "0.1.0"
6
+ __version__ = "0.2.0"
@@ -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.1 (+akaunting-cli)",
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
- if json_body is not None:
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