runningtab 0.1.0.dev3__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.
- runningtab/__init__.py +3 -0
- runningtab/access.py +419 -0
- runningtab/books.py +225 -0
- runningtab/chart.py +408 -0
- runningtab/cli.py +2216 -0
- runningtab/coa.py +212 -0
- runningtab/doc.py +185 -0
- runningtab/forecast/__init__.py +1 -0
- runningtab/forecast/compute.py +224 -0
- runningtab/forecast/doctype.py +228 -0
- runningtab/forecast/model.py +251 -0
- runningtab/forecast/names.py +8 -0
- runningtab/forecast/sources.py +85 -0
- runningtab/gl_codes.py +50 -0
- runningtab/health.py +661 -0
- runningtab/mercury/__init__.py +7 -0
- runningtab/mercury/categories.py +500 -0
- runningtab/mercury/classify.py +101 -0
- runningtab/mercury/client.py +360 -0
- runningtab/mercury/ingest.py +733 -0
- runningtab/mercury/mapping.py +130 -0
- runningtab/mercury/models.py +544 -0
- runningtab/mercury/posting.py +757 -0
- runningtab/mercury/receipts.py +304 -0
- runningtab/mercury/session.py +65 -0
- runningtab/mercury/state.py +285 -0
- runningtab/mercury/vouchers.py +951 -0
- runningtab/period/__init__.py +9 -0
- runningtab/period/balances.py +283 -0
- runningtab/period/bank.py +154 -0
- runningtab/period/close.py +510 -0
- runningtab/period/crosscheck.py +257 -0
- runningtab/period/csvfmt.py +86 -0
- runningtab/period/documents.py +260 -0
- runningtab/period/fence.py +277 -0
- runningtab/period/ledger.py +205 -0
- runningtab/period/lock.py +294 -0
- runningtab/period/manifest.py +259 -0
- runningtab/period/open_items.py +84 -0
- runningtab/period/package.py +140 -0
- runningtab/period/qbo.py +521 -0
- runningtab/period/qbo_map.py +174 -0
- runningtab/period/quarter.py +90 -0
- runningtab/period/readiness.py +146 -0
- runningtab/period/record.py +247 -0
- runningtab/period/status.py +312 -0
- runningtab/site.py +409 -0
- runningtab-0.1.0.dev3.dist-info/METADATA +726 -0
- runningtab-0.1.0.dev3.dist-info/RECORD +52 -0
- runningtab-0.1.0.dev3.dist-info/WHEEL +4 -0
- runningtab-0.1.0.dev3.dist-info/entry_points.txt +2 -0
- runningtab-0.1.0.dev3.dist-info/licenses/LICENSE +202 -0
runningtab/site.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Which ERPNext site, and where its credentials come from.
|
|
2
|
+
|
|
3
|
+
Two targets, deliberately asymmetric. `test` is a disposable site whose
|
|
4
|
+
credentials come from the environment, falling back to a stored key for that
|
|
5
|
+
one site when nothing is exported, and it refuses to run against anything but
|
|
6
|
+
a host named in the books' `test_hosts`, no matter what the environment or the
|
|
7
|
+
secret says. `books` is the real ledger, and its
|
|
8
|
+
credential is fetched from Secret Manager at the moment it is used -- never
|
|
9
|
+
held during development, and never substitutable by an exported variable.
|
|
10
|
+
|
|
11
|
+
There is no default target. Reaching the ledger requires naming it.
|
|
12
|
+
|
|
13
|
+
Which hosts, which secrets and which project come from the books directory's
|
|
14
|
+
`books.toml` (`runningtab.books`), as a `Site`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import dataclasses
|
|
20
|
+
import enum
|
|
21
|
+
import os
|
|
22
|
+
from urllib.parse import urlsplit
|
|
23
|
+
|
|
24
|
+
from icedcoffee_sync.frappe.client import SiteConfig
|
|
25
|
+
|
|
26
|
+
from runningtab.mercury.client import MercuryConfig, MercuryEnv, config_for
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclasses.dataclass(frozen=True)
|
|
30
|
+
class Site:
|
|
31
|
+
"""Where the ledger and its test site are, and which Secret Manager secrets reach them.
|
|
32
|
+
|
|
33
|
+
Every secret here is a Secret Manager secret ID in `secret_project`, not a
|
|
34
|
+
credential.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# The ledger's host. Only `books_secret`'s and `books_readonly_secret`'s
|
|
38
|
+
# credentials are sent there.
|
|
39
|
+
host: str
|
|
40
|
+
# Every host `--target test` may point at. An allowlist, so an alias of the
|
|
41
|
+
# production site that shares nothing textually with its hostname is
|
|
42
|
+
# refused by default; reaching a test site means naming it here.
|
|
43
|
+
# `runningtab.books` refuses a list that names `host` itself.
|
|
44
|
+
test_hosts: frozenset[str]
|
|
45
|
+
secret_project: str
|
|
46
|
+
# The bot's credential: the one that changes the ledger.
|
|
47
|
+
books_secret: str
|
|
48
|
+
# The reader's own credential, held in its own secret so that read access
|
|
49
|
+
# can be granted to something write access is not: `coa plan` runs on every
|
|
50
|
+
# merge request, where the branch decides what code runs, and it has no
|
|
51
|
+
# business holding a key that can change the ledger.
|
|
52
|
+
books_readonly_secret: str
|
|
53
|
+
# Mercury's token, which is **read-only**: the integration never writes a
|
|
54
|
+
# Mercury record, so nothing in production should hold a credential that
|
|
55
|
+
# could.
|
|
56
|
+
mercury_secret: str
|
|
57
|
+
# A System Manager key for the test site, so anything running under
|
|
58
|
+
# application default credentials -- an agent, a scheduled job -- reaches
|
|
59
|
+
# the disposable site with nothing exported. It is a *site* API key: it
|
|
60
|
+
# reaches that one site and cannot touch another, which is why one may be
|
|
61
|
+
# stored while the Frappe Cloud team token, which can reach the books
|
|
62
|
+
# site, stays with a human.
|
|
63
|
+
test_admin_secret: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# The sandbox token stays an environment variable. It reads seeded data on a
|
|
67
|
+
# host that is not the bank, and putting it in Secret Manager would suggest the
|
|
68
|
+
# two are interchangeable.
|
|
69
|
+
MERCURY_SANDBOX_ENV = "MERCURY_SANDBOX_TOKEN"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Target(enum.StrEnum):
|
|
73
|
+
"""Which site a command acts on."""
|
|
74
|
+
|
|
75
|
+
TEST = "test"
|
|
76
|
+
BOOKS = "books"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Profile(enum.StrEnum):
|
|
80
|
+
"""Which identity on that site: the one that changes the ledger, or the one that only reads it."""
|
|
81
|
+
|
|
82
|
+
BOT = "bot"
|
|
83
|
+
READER = "reader"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _hostname(url: str) -> str:
|
|
87
|
+
"""`url`'s host, lowercased, trailing dot stripped -- the one thing that identifies a site.
|
|
88
|
+
|
|
89
|
+
A fully-qualified "ledger.example." resolves to the same site,
|
|
90
|
+
and a hostname mentioned in a path or query string is not that site.
|
|
91
|
+
"""
|
|
92
|
+
return (urlsplit(url).hostname or "").lower().rstrip(".")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def normalised_host(host: str) -> str:
|
|
96
|
+
"""A bare hostname as `_hostname` would read it out of a URL: lowercased, trailing dot stripped."""
|
|
97
|
+
return host.lower().rstrip(".")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_books_site(site: Site, url: str) -> bool:
|
|
101
|
+
"""True when `url`'s host is the books site's. Identification only; `is_test_site` is the guard."""
|
|
102
|
+
return _hostname(url) == normalised_host(site.host)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def is_test_site(site: Site, url: str) -> bool:
|
|
106
|
+
"""True when `url`'s host is in `site.test_hosts` -- the guard the test target and live fixtures share."""
|
|
107
|
+
return _hostname(url) in {normalised_host(host) for host in site.test_hosts}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class SiteCredentialError(RuntimeError):
|
|
111
|
+
"""A credential this target needs could not be obtained, for any reason.
|
|
112
|
+
|
|
113
|
+
Its own type so a command that reports rather than crashes can print what
|
|
114
|
+
went wrong instead of handing an operator a traceback. A subclass of
|
|
115
|
+
`RuntimeError`, so nothing that already catches one changes behaviour.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class MissingSiteCredentialError(SiteCredentialError):
|
|
120
|
+
"""A credential this target needs is not in the environment.
|
|
121
|
+
|
|
122
|
+
Distinct from its parent because "nobody exported this" and "Secret
|
|
123
|
+
Manager refused us" send whoever reads the message to different places.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _require(name: str) -> str:
|
|
128
|
+
value = os.environ.get(name)
|
|
129
|
+
if not value:
|
|
130
|
+
raise MissingSiteCredentialError(f"{name} is not set; export the test site's credentials first")
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _split_token(value: str, source: str) -> tuple[str, str]:
|
|
135
|
+
"""Split a Frappe credential into the key and secret it joins with a colon.
|
|
136
|
+
|
|
137
|
+
Accepts the value bare (`key:secret`) or exactly as ERPNext displays it in
|
|
138
|
+
the Authorization header (`token key:secret`), so a paste from the app
|
|
139
|
+
works without editing, wherever it was pasted. `source` names where the
|
|
140
|
+
value came from, since that is what whoever reads the message has to fix.
|
|
141
|
+
"""
|
|
142
|
+
value = value.strip().removeprefix("token ").strip()
|
|
143
|
+
key, _, secret = value.partition(":")
|
|
144
|
+
if not (key and secret):
|
|
145
|
+
raise SiteCredentialError(f"{source} is not in the expected 'key:secret' form (a leading 'token ' is allowed)")
|
|
146
|
+
return key, secret
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _fetch_books_credential(site: Site, secret_id: str) -> tuple[str, str, str]:
|
|
150
|
+
"""Read the books site's URL, key, and secret from Secret Manager.
|
|
151
|
+
|
|
152
|
+
Every way the read can fail -- an absent secret, a refused permission, an
|
|
153
|
+
unreachable API, a payload in the wrong shape -- becomes a
|
|
154
|
+
`SiteCredentialError` carrying the underlying message, so a command that
|
|
155
|
+
reports can print it instead of crashing halfway through.
|
|
156
|
+
"""
|
|
157
|
+
project = site.secret_project
|
|
158
|
+
path = f"projects/{project}/secrets/{secret_id}/versions/latest"
|
|
159
|
+
try:
|
|
160
|
+
from google.cloud import secretmanager
|
|
161
|
+
|
|
162
|
+
client = secretmanager.SecretManagerServiceClient()
|
|
163
|
+
payload = client.access_secret_version(request={"name": path}).payload.data.decode()
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
raise SiteCredentialError(f"could not read secret {secret_id} in {project}: {exc}") from exc
|
|
166
|
+
key, _, secret = payload.partition(":")
|
|
167
|
+
if not (key and secret):
|
|
168
|
+
raise SiteCredentialError(f"secret {secret_id} in {project} is not in the expected 'key:secret' form")
|
|
169
|
+
return f"https://{site.host}", key, secret
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _secret_payload(site: Site, secret_id: str) -> str:
|
|
173
|
+
"""The latest version of one secret in `site.secret_project`, decoded and stripped.
|
|
174
|
+
|
|
175
|
+
Split out from its caller so a test can stub the read alone and still
|
|
176
|
+
exercise the parsing, the empty check, and the error wrapping for real.
|
|
177
|
+
"""
|
|
178
|
+
from google.cloud import secretmanager
|
|
179
|
+
|
|
180
|
+
path = f"projects/{site.secret_project}/secrets/{secret_id}/versions/latest"
|
|
181
|
+
client = secretmanager.SecretManagerServiceClient()
|
|
182
|
+
return client.access_secret_version(request={"name": path}).payload.data.decode().strip()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _fetch_test_credential(site: Site) -> tuple[str, str]:
|
|
186
|
+
"""Read the test site's stored System Manager key from Secret Manager.
|
|
187
|
+
|
|
188
|
+
Every way the read can fail -- an absent secret, a refused permission, an
|
|
189
|
+
unreachable API -- becomes a `SiteCredentialError` naming the secret, so a
|
|
190
|
+
command that reports can print it instead of crashing partway through.
|
|
191
|
+
"""
|
|
192
|
+
secret_id, project = site.test_admin_secret, site.secret_project
|
|
193
|
+
try:
|
|
194
|
+
payload = _secret_payload(site, secret_id)
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
raise SiteCredentialError(
|
|
197
|
+
f"could not read secret {secret_id} in {project}: {exc}. "
|
|
198
|
+
f"That secret holds a System Manager API key for the test site."
|
|
199
|
+
) from exc
|
|
200
|
+
if not payload:
|
|
201
|
+
raise MissingSiteCredentialError(
|
|
202
|
+
f"secret {secret_id} in {project} is empty; it should hold the test site's '<api_key>:<api_secret>'"
|
|
203
|
+
)
|
|
204
|
+
return _split_token(payload, f"secret {secret_id} in {project}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _test_site_url(site: Site) -> str:
|
|
208
|
+
"""The allowlisted test host as a URL.
|
|
209
|
+
|
|
210
|
+
Derived from `site.test_hosts` rather than kept as a second setting: a
|
|
211
|
+
default the allowlist then refuses is a stall whose cause nobody can read.
|
|
212
|
+
A second allowlisted host makes the choice ambiguous, and saying so beats
|
|
213
|
+
picking one.
|
|
214
|
+
"""
|
|
215
|
+
if len(site.test_hosts) != 1:
|
|
216
|
+
raise SiteCredentialError(
|
|
217
|
+
f"test_hosts names {len(site.test_hosts)} hosts "
|
|
218
|
+
f"({', '.join(sorted(site.test_hosts))}); set RUNNINGTAB_TEST_URL to say which one"
|
|
219
|
+
)
|
|
220
|
+
(host,) = site.test_hosts
|
|
221
|
+
return f"https://{host}"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def store_books_credential(site: Site, key: str, secret: str, secret_id: str) -> str:
|
|
225
|
+
"""Store a freshly minted credential as the latest version of `secret_id`.
|
|
226
|
+
|
|
227
|
+
Mirrors `_fetch_books_credential`'s `"key:secret"` payload shape exactly --
|
|
228
|
+
that is the only format `site_config` knows how to read back.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
site: Which project holds the secret.
|
|
232
|
+
key: The bot's API key.
|
|
233
|
+
secret: The bot's API secret.
|
|
234
|
+
secret_id: `site.books_secret` for the bot, `site.books_readonly_secret` for the reader.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
The created version's resource name (`.../versions/<n>`).
|
|
238
|
+
"""
|
|
239
|
+
from google.cloud import secretmanager
|
|
240
|
+
|
|
241
|
+
client = secretmanager.SecretManagerServiceClient()
|
|
242
|
+
parent = f"projects/{site.secret_project}/secrets/{secret_id}"
|
|
243
|
+
payload = f"{key}:{secret}".encode()
|
|
244
|
+
response = client.add_secret_version(request={"parent": parent, "payload": {"data": payload}})
|
|
245
|
+
return response.name
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def mercury_token(site: Site, env: MercuryEnv) -> str:
|
|
249
|
+
"""The Mercury token for one deployment.
|
|
250
|
+
|
|
251
|
+
Asymmetric on purpose, the same way the ERPNext targets are: the sandbox's
|
|
252
|
+
token is an exported variable because it reads seeded data, and the real
|
|
253
|
+
one is fetched from Secret Manager at the moment it is used.
|
|
254
|
+
|
|
255
|
+
Every way this can fail is a `SiteCredentialError` carrying the underlying
|
|
256
|
+
message, so a report can print it instead of crashing: `runningtab health` has
|
|
257
|
+
five sections to print, and a traceback out of the middle of one takes the
|
|
258
|
+
other four with it. It does not claim to know *why* -- a revoked permission
|
|
259
|
+
and a secret nobody has created yet both arrive here, and the API's own
|
|
260
|
+
error says which.
|
|
261
|
+
"""
|
|
262
|
+
if env is MercuryEnv.SANDBOX:
|
|
263
|
+
if not os.environ.get(MERCURY_SANDBOX_ENV):
|
|
264
|
+
raise MissingSiteCredentialError(
|
|
265
|
+
f"{MERCURY_SANDBOX_ENV} is not set. Create an API token inside the Mercury sandbox dashboard "
|
|
266
|
+
f"(a production token does not work there) and export it."
|
|
267
|
+
)
|
|
268
|
+
return _require(MERCURY_SANDBOX_ENV)
|
|
269
|
+
secret_id, project = site.mercury_secret, site.secret_project
|
|
270
|
+
path = f"projects/{project}/secrets/{secret_id}/versions/latest"
|
|
271
|
+
try:
|
|
272
|
+
from google.cloud import secretmanager
|
|
273
|
+
|
|
274
|
+
client = secretmanager.SecretManagerServiceClient()
|
|
275
|
+
token = client.access_secret_version(request={"name": path}).payload.data.decode().strip()
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
raise SiteCredentialError(
|
|
278
|
+
f"could not read secret {secret_id} in {project}: {exc}. That secret holds the read-only Mercury token."
|
|
279
|
+
) from exc
|
|
280
|
+
if not token:
|
|
281
|
+
raise MissingSiteCredentialError(
|
|
282
|
+
f"secret {secret_id} in {project} is empty; it should hold a read-only Mercury token"
|
|
283
|
+
)
|
|
284
|
+
return token
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def mercury_config(site: Site, env: MercuryEnv) -> MercuryConfig:
|
|
288
|
+
"""Where Mercury is and which token reads it. Never defaults to production."""
|
|
289
|
+
return config_for(env, mercury_token(site, env))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# The administrator's own credential, as one variable because that is how
|
|
293
|
+
# ERPNext hands it out: a single `<api_key>:<api_secret>` token.
|
|
294
|
+
RUNNINGTAB_ADMIN_ENV = "RUNNINGTAB_ADMIN_TOKEN"
|
|
295
|
+
|
|
296
|
+
# Mercury's write token, and the one credential here deliberately kept *out* of
|
|
297
|
+
# Secret Manager. Mercury issues no category-only write scope -- its custom-token
|
|
298
|
+
# picker offers "Fetch Categories" and nothing else for categories -- so the only
|
|
299
|
+
# token that can create a category is a full Read and Write one, whose
|
|
300
|
+
# server-side boundary is "may send money" rather than "may edit categories".
|
|
301
|
+
# It is minted for one sitting at a person's keyboard, exported by hand, and
|
|
302
|
+
# revoked in the dashboard afterwards. Storing it would put a money-moving
|
|
303
|
+
# credential exactly where every read-only one lives, and Mercury would downgrade
|
|
304
|
+
# it to read-only after 45 unused days anyway.
|
|
305
|
+
MERCURY_ADMIN_ENV = "MERCURY_ADMIN_TOKEN"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def mercury_admin_token(env: MercuryEnv) -> str:
|
|
309
|
+
"""The token allowed to write a Mercury category, from the environment and nowhere else.
|
|
310
|
+
|
|
311
|
+
Sandbox reuses `MERCURY_SANDBOX_TOKEN`: it reads and writes seeded data on
|
|
312
|
+
a host that is not the bank, so it needs no ceremony. Production takes
|
|
313
|
+
`MERCURY_ADMIN_TOKEN`, which no code stores and nothing but an interactive
|
|
314
|
+
shell sets.
|
|
315
|
+
|
|
316
|
+
Raises:
|
|
317
|
+
MissingSiteCredentialError: The variable is unset, named in the message
|
|
318
|
+
along with what it is for, so the refusal lands before any request.
|
|
319
|
+
"""
|
|
320
|
+
if env is MercuryEnv.SANDBOX:
|
|
321
|
+
sandbox = os.environ.get(MERCURY_SANDBOX_ENV, "").strip()
|
|
322
|
+
if not sandbox:
|
|
323
|
+
raise MissingSiteCredentialError(
|
|
324
|
+
f"{MERCURY_SANDBOX_ENV} is not set. Create an API token inside the Mercury sandbox dashboard "
|
|
325
|
+
f"(a production token does not work there) and export it."
|
|
326
|
+
)
|
|
327
|
+
return sandbox
|
|
328
|
+
value = os.environ.get(MERCURY_ADMIN_ENV, "").strip()
|
|
329
|
+
if not value:
|
|
330
|
+
raise MissingSiteCredentialError(
|
|
331
|
+
f"{MERCURY_ADMIN_ENV} is not set. Writing a Mercury category needs a Read and Write token, "
|
|
332
|
+
f"which Mercury will only issue with an IP allowlist and which can also send money -- so it is "
|
|
333
|
+
f"never stored. Mint one in the dashboard, export it into this shell for this run, and revoke it "
|
|
334
|
+
f"afterwards."
|
|
335
|
+
)
|
|
336
|
+
return value
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def mercury_write_config(env: MercuryEnv) -> MercuryConfig:
|
|
340
|
+
"""Where Mercury is and which token may write a category there. Never defaults to production."""
|
|
341
|
+
return config_for(env, mercury_admin_token(env))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _admin_token() -> tuple[str, str]:
|
|
345
|
+
"""`RUNNINGTAB_ADMIN_TOKEN`, split into the key and secret Frappe joined with a colon."""
|
|
346
|
+
value = os.environ.get(RUNNINGTAB_ADMIN_ENV, "").strip()
|
|
347
|
+
if not value:
|
|
348
|
+
raise MissingSiteCredentialError(
|
|
349
|
+
f"{RUNNINGTAB_ADMIN_ENV} is not set; export your own System Manager API token "
|
|
350
|
+
f"(the 'key:secret' ERPNext generates for your user) first"
|
|
351
|
+
)
|
|
352
|
+
return _split_token(value, RUNNINGTAB_ADMIN_ENV)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def admin_config(site: Site, target: Target) -> SiteConfig:
|
|
356
|
+
"""A System Manager credential for provisioning the bot -- a human's token, read from the environment.
|
|
357
|
+
|
|
358
|
+
The stored books credential is the bot's own and cannot provision.
|
|
359
|
+
The test site's environment credential is already an administrator's.
|
|
360
|
+
"""
|
|
361
|
+
if target is Target.TEST:
|
|
362
|
+
return site_config(site, target)
|
|
363
|
+
key, secret = _admin_token()
|
|
364
|
+
return SiteConfig(url=f"https://{site.host}", api_key=key, api_secret=secret, timeout=60.0)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def site_config(site: Site, target: Target, *, readonly: bool = False) -> SiteConfig:
|
|
368
|
+
"""Credentials for one target. May not submit; `ingest_config` is the one caller that needs to.
|
|
369
|
+
|
|
370
|
+
`readonly` picks the reader's credential, which the server refuses every
|
|
371
|
+
write for. Only the books target has two: the test site is disposable, so
|
|
372
|
+
its one credential serves both.
|
|
373
|
+
|
|
374
|
+
The test target reads the environment first and the stored key only when
|
|
375
|
+
nothing is exported, so a developer pointed at their own bench never
|
|
376
|
+
silently reaches the shared test site instead. An exported
|
|
377
|
+
`RUNNINGTAB_TEST_URL` therefore commits to the environment: a missing half of
|
|
378
|
+
that credential is a mistake to name, not a reason to fall back.
|
|
379
|
+
"""
|
|
380
|
+
if target is Target.BOOKS:
|
|
381
|
+
secret_id = site.books_readonly_secret if readonly else site.books_secret
|
|
382
|
+
url, key, secret = _fetch_books_credential(site, secret_id)
|
|
383
|
+
return SiteConfig(url=url, api_key=key, api_secret=secret, timeout=60.0)
|
|
384
|
+
|
|
385
|
+
url = os.environ.get("RUNNINGTAB_TEST_URL", "")
|
|
386
|
+
if url:
|
|
387
|
+
key, secret = _require("RUNNINGTAB_TEST_KEY"), _require("RUNNINGTAB_TEST_SECRET")
|
|
388
|
+
else:
|
|
389
|
+
url, (key, secret) = _test_site_url(site), _fetch_test_credential(site)
|
|
390
|
+
if not is_test_site(site, url):
|
|
391
|
+
raise SiteCredentialError(
|
|
392
|
+
f"RUNNINGTAB_TEST_URL ({url}) does not name a host in books.toml's test_hosts "
|
|
393
|
+
f"({', '.join(sorted(site.test_hosts))}); the test target refuses anything not allowlisted there"
|
|
394
|
+
)
|
|
395
|
+
return SiteConfig(url=url, api_key=key, api_secret=secret, timeout=60.0)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def ingest_config(site: Site, target: Target) -> SiteConfig:
|
|
399
|
+
"""The bot's credential with submit capability, for the Mercury ingest alone.
|
|
400
|
+
|
|
401
|
+
The ingest submits the Bank Transactions it creates: a Bank Transaction
|
|
402
|
+
posts nothing to the general ledger until a person reconciles it, and the
|
|
403
|
+
Bank Reconciliation Tool only sees submitted rows. `icedcoffee-sync`'s client gates
|
|
404
|
+
every submit route behind one flag, so this is the narrowest grant it
|
|
405
|
+
offers; what keeps it to Bank Transactions is that the posting module sets
|
|
406
|
+
a `docstatus` on that one document and nothing else, and that the bot's
|
|
407
|
+
role on the server holds submit on that one doctype and nothing else.
|
|
408
|
+
"""
|
|
409
|
+
return dataclasses.replace(site_config(site, target), may_submit=True)
|