add smarttime verification with retries
This commit is contained in:
Generated
+1
@@ -0,0 +1 @@
|
||||
timestamp_history.json
|
||||
@@ -11,6 +11,9 @@ scheduled time in the background.
|
||||
taken breaks and legally required break deductions, updated every few seconds.
|
||||
- **Manual stamping** – single button that logs in to the coeo portal and sends a
|
||||
stamp request, storing the timestamp locally.
|
||||
- **SmartTime Plus verification** – after every stamp the app opens the internal
|
||||
SmartTime Plus dashboard (headless Chromium) and confirms the "Letzte Buchung"
|
||||
actually moved. Failed transmissions are surfaced instead of silently missed.
|
||||
- **Auto-Stamp planner** – schedule one or more times for today; a background thread
|
||||
fires the stamp within a 2-minute window of the target time and cleans up missed
|
||||
entries.
|
||||
@@ -37,8 +40,12 @@ Requires **Python 3.12+** and [Poetry](https://python-poetry.org/).
|
||||
|
||||
```powershell
|
||||
poetry install
|
||||
poetry run playwright install chromium
|
||||
```
|
||||
|
||||
The `playwright install` step downloads a ~150 MB Chromium build that the
|
||||
SmartTime verifier uses in headless mode. It only needs to be run once.
|
||||
|
||||
## .env setup
|
||||
|
||||
`settings.py` uses `pydantic-settings` and expects a `.env` file in the working
|
||||
@@ -50,6 +57,17 @@ are **required**:
|
||||
USERNAME=your.user
|
||||
PASSWORD=your-password
|
||||
|
||||
# Password for the internal SmartTime Plus web app (username is USERNAME).
|
||||
# Used to verify that stamps actually reached the backend.
|
||||
SMART_TIME_PASSWORD=your-smarttime-password
|
||||
|
||||
# Optional overrides:
|
||||
# SMART_TIME_URL=https://zeiterfassung/
|
||||
# SMART_TIME_VERIFY_INITIAL_DELAY=1 # seconds to wait before the first check
|
||||
# SMART_TIME_VERIFY_GRACE_PERIOD=30 # total polling budget in seconds
|
||||
# SMART_TIME_VERIFY_POLL_INTERVAL=3 # seconds between polls
|
||||
# SMART_TIME_VERIFY_TOLERANCE_SEC=90 # allowed drift between stamp & backing time
|
||||
|
||||
# Paths (relative or absolute) to the JSON files used for local persistence.
|
||||
# The files will be created on first write.
|
||||
AUTOSTAMP_FILE=autostamps.json
|
||||
@@ -108,6 +126,25 @@ The **"Heutiger Verlauf"** section lists all of today's stamps split into
|
||||
The session cookie from step 1 is reused for step 2. HTTP 200 is treated as
|
||||
success.
|
||||
|
||||
Because a 200 from the coeo portal does **not** guarantee the stamp reached the
|
||||
downstream time-tracking system, `SmartTimeClient` (see `smarttime_client.py`)
|
||||
then uses a headless Chromium (Playwright) to log in to SmartTime Plus
|
||||
(`https://zeiterfassung/`), navigate to the dashboard, and read the
|
||||
*"Letzte Buchung"* value. If it doesn't match the sent stamp within
|
||||
`SMART_TIME_VERIFY_TOLERANCE_MIN` after `SMART_TIME_VERIFY_DELAY` seconds, the
|
||||
UI flags the stamp as **unverified**.
|
||||
|
||||
SmartTime Plus is a GWT app (all traffic is `text/x-gwt-rpc` with per-build
|
||||
strong-name hashes), so we drive the DOM instead of trying to speak the RPC
|
||||
protocol. A persistent Chromium profile in `~/.stempelbot/smarttime-profile`
|
||||
keeps session cookies between runs so subsequent verifications skip the login
|
||||
step.
|
||||
|
||||
### SmartTime Abgleich panel
|
||||
Below the daily timeline there's a **"SmartTime Abgleich"** panel with a
|
||||
*"Jetzt prüfen"* button that compares the last local stamp with the value
|
||||
reported by SmartTime Plus and highlights any mismatch.
|
||||
|
||||
## Notes
|
||||
|
||||
- Time calculations (net work, break deductions, target end time) live in
|
||||
|
||||
+13
-2
@@ -60,7 +60,18 @@
|
||||
"2026-05-22": [],
|
||||
"2026-05-27": [],
|
||||
"2026-05-29": [],
|
||||
"2026-06-08": [
|
||||
"12:12"
|
||||
"2026-06-08": [],
|
||||
"2026-06-09": [],
|
||||
"2026-06-10": [],
|
||||
"2026-06-12": [],
|
||||
"2026-06-15": [],
|
||||
"2026-06-25": [],
|
||||
"2026-06-26": [
|
||||
"18:18"
|
||||
],
|
||||
"2026-07-03": [],
|
||||
"2026-07-09": [],
|
||||
"2026-07-10": [
|
||||
"18:09"
|
||||
]
|
||||
}
|
||||
Generated
+136
-3
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "altair"
|
||||
@@ -270,6 +270,99 @@ gitdb = ">=4.0.1,<5"
|
||||
doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"]
|
||||
test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.3"
|
||||
description = "Lightweight in-process concurrent programming"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c"},
|
||||
{file = "greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04"},
|
||||
{file = "greenlet-3.5.3-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce"},
|
||||
{file = "greenlet-3.5.3-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8"},
|
||||
{file = "greenlet-3.5.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec"},
|
||||
{file = "greenlet-3.5.3-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71"},
|
||||
{file = "greenlet-3.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8"},
|
||||
{file = "greenlet-3.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702"},
|
||||
{file = "greenlet-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db"},
|
||||
{file = "greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4"},
|
||||
{file = "greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc"},
|
||||
{file = "greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6"},
|
||||
{file = "greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb"},
|
||||
{file = "greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7"},
|
||||
{file = "greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c"},
|
||||
{file = "greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8"},
|
||||
{file = "greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8"},
|
||||
{file = "greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7"},
|
||||
{file = "greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44"},
|
||||
{file = "greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2"},
|
||||
{file = "greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b"},
|
||||
{file = "greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab"},
|
||||
{file = "greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23"},
|
||||
{file = "greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861"},
|
||||
{file = "greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c"},
|
||||
{file = "greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149"},
|
||||
{file = "greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea"},
|
||||
{file = "greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c"},
|
||||
{file = "greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d"},
|
||||
{file = "greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550"},
|
||||
{file = "greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5"},
|
||||
{file = "greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3"},
|
||||
{file = "greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1"},
|
||||
{file = "greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a"},
|
||||
{file = "greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda"},
|
||||
{file = "greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb"},
|
||||
{file = "greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b"},
|
||||
{file = "greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b"},
|
||||
{file = "greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da"},
|
||||
{file = "greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf"},
|
||||
{file = "greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31"},
|
||||
{file = "greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx", "furo"]
|
||||
test = ["objgraph", "psutil", "setuptools"]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@@ -317,7 +410,7 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=22.2.0"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
referencing = ">=0.28.4"
|
||||
rpds-py = ">=0.25.0"
|
||||
|
||||
@@ -790,6 +883,28 @@ files = [
|
||||
[package.dependencies]
|
||||
pip = ">=24.2"
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.61.0"
|
||||
description = "A high-level API to automate web browsers"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0"},
|
||||
{file = "playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a"},
|
||||
{file = "playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af"},
|
||||
{file = "playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e"},
|
||||
{file = "playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c"},
|
||||
{file = "playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b"},
|
||||
{file = "playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597"},
|
||||
{file = "playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
greenlet = ">=3.1.1,<4.0.0"
|
||||
pyee = ">=13,<14"
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "6.33.4"
|
||||
@@ -1070,6 +1185,24 @@ numpy = ">=1.16.4"
|
||||
carto = ["pydeck-carto"]
|
||||
jupyter = ["ipykernel (>=5.1.2) ; python_version >= \"3.4\"", "ipython (>=5.8.0) ; python_version < \"3.4\"", "ipywidgets (>=7,<8)", "traitlets (>=4.3.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.1"
|
||||
description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"},
|
||||
{file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -1496,4 +1629,4 @@ watchmedo = ["PyYAML (>=3.10)"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "80ae69df463b08b4a4c7c4a9037c73077d1deb5760a7b53ea4bd681015328f75"
|
||||
content-hash = "113ddcce235f83c6b490f723f8337b78145b5fdaf2755ceafa35ad7d54122928"
|
||||
|
||||
@@ -10,6 +10,7 @@ python-dotenv = "^1.1.0"
|
||||
streamlit = "^1.46"
|
||||
pip-system-certs = "^5.3"
|
||||
pydantic-settings = "^2.12.0"
|
||||
playwright = "^1.61.0"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
stempelbot = "stempelbot.cli:start"
|
||||
|
||||
+59
-3
@@ -7,6 +7,7 @@ from stamp_history import StampHistory
|
||||
from client import get_coeo_client
|
||||
from autostamp import get_autostamp
|
||||
from time_calculator import TimeLogic
|
||||
from smarttime_client import get_smarttime_client
|
||||
|
||||
stamps = StampHistory.get_today_stamps()
|
||||
|
||||
@@ -120,12 +121,22 @@ with st.container(border=True):
|
||||
|
||||
# Full width button for primary action
|
||||
if st.button("Jetzt Stempeln", type="primary", use_container_width=True):
|
||||
with st.spinner("Stempel wird übermittelt..."):
|
||||
with st.spinner("Stempel wird übermittelt und mit SmartTime abgeglichen..."):
|
||||
client = get_coeo_client()
|
||||
ok, msg = client.execute_stamp()
|
||||
ok, msg, verified, snap = client.execute_stamp()
|
||||
if ok:
|
||||
StampHistory.save_stamp(datetime.now().strftime("%H:%M"))
|
||||
st.toast(msg, icon="✅") # Use toast instead of success for cleaner UI
|
||||
if verified is True:
|
||||
st.toast(msg, icon="✅")
|
||||
elif verified is False:
|
||||
st.warning(msg, icon="⚠️")
|
||||
st.caption(
|
||||
"Der Stempel wurde an coeo gesendet, ist aber (noch) "
|
||||
"nicht in SmartTime Plus angekommen. Ggf. manuell "
|
||||
"nachstempeln."
|
||||
)
|
||||
else:
|
||||
st.toast(msg, icon="ℹ️")
|
||||
sleep(1)
|
||||
st.rerun()
|
||||
else:
|
||||
@@ -213,3 +224,48 @@ def timeline_fragment():
|
||||
st.info("Noch keine Einträge.")
|
||||
|
||||
timeline_fragment()
|
||||
|
||||
|
||||
# --- UI Component: SmartTime Plus Abgleich ---
|
||||
with st.container(border=True):
|
||||
col_icon, col_text = st.columns([1, 5])
|
||||
with col_icon:
|
||||
st.markdown("# 🕵️")
|
||||
with col_text:
|
||||
st.subheader("SmartTime Abgleich")
|
||||
st.caption(
|
||||
"Vergleicht die letzte Buchung im Backend (SmartTime Plus) mit "
|
||||
"dem lokalen Stempel-Log."
|
||||
)
|
||||
|
||||
if st.button("Jetzt prüfen", use_container_width=True, key="st_check"):
|
||||
with st.spinner("SmartTime Plus wird abgefragt..."):
|
||||
try:
|
||||
snap = get_smarttime_client().get_status()
|
||||
except Exception as e:
|
||||
st.error(f"SmartTime-Abfrage fehlgeschlagen: {e}", icon="🚨")
|
||||
snap = None
|
||||
|
||||
if snap is not None:
|
||||
local_stamps = StampHistory.get_today_stamps()
|
||||
local_last = local_stamps[-1] if local_stamps else None
|
||||
remote_last = snap.last_stamp.strftime("%H:%M") if snap.last_stamp else "—"
|
||||
|
||||
c1, c2, c3 = st.columns(3)
|
||||
c1.metric("SmartTime letzte Buchung", remote_last, border=True)
|
||||
c2.metric("Lokal letzte Buchung", local_last or "—", border=True)
|
||||
c3.metric("SmartTime Status", snap.status_text or "—", border=True)
|
||||
|
||||
if local_last and snap.last_stamp:
|
||||
if local_last == remote_last:
|
||||
st.success(
|
||||
"Übereinstimmung – Backend und lokales Log sind synchron.",
|
||||
icon="✅",
|
||||
)
|
||||
else:
|
||||
st.warning(
|
||||
f"Abweichung! Lokal: {local_last}, SmartTime: {remote_last}. "
|
||||
"Prüfe, ob ein Stempel nicht angekommen ist.",
|
||||
icon="⚠️",
|
||||
)
|
||||
st.caption(f"Abgefragt: {snap.query_time.strftime('%H:%M:%S')}")
|
||||
|
||||
@@ -104,10 +104,16 @@ class AutoStampService:
|
||||
# IO Operation: Check status to avoid double stamping
|
||||
logging.info(f"🤖 Executing Auto-Stamp out at {now}")
|
||||
client = get_coeo_client()
|
||||
ok, msg = client.execute_stamp()
|
||||
ok, msg, verified, snap = client.execute_stamp()
|
||||
if ok:
|
||||
StampHistory.save_stamp(now.strftime("%H:%M"))
|
||||
self.remove_timestamp(target_dt)
|
||||
if verified is True:
|
||||
logging.info(f"✅ Auto-Stamp verified via SmartTime: {msg}")
|
||||
elif verified is False:
|
||||
logging.warning(f"⚠️ Auto-Stamp NOT verified on SmartTime: {msg}")
|
||||
else:
|
||||
logging.info(f"ℹ️ Auto-Stamp sent (verification skipped): {msg}")
|
||||
else:
|
||||
logging.info(f"❌ Auto-Stamp Failed: {msg}")
|
||||
|
||||
|
||||
+31
-10
@@ -1,7 +1,10 @@
|
||||
from functools import lru_cache
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
from stempelbot.settings import settings
|
||||
from settings import settings
|
||||
from smarttime_client import get_smarttime_client, SmartTimeStatus
|
||||
|
||||
|
||||
class CoeoClient:
|
||||
@@ -35,21 +38,39 @@ class CoeoClient:
|
||||
return response.status_code == 200, response.status_code
|
||||
except requests.RequestException as e:
|
||||
return False, str(e)
|
||||
|
||||
def execute_stamp(self):
|
||||
"""Public method to run the full workflow."""
|
||||
|
||||
def execute_stamp(self, verify: bool = True):
|
||||
"""Public method to run the full workflow.
|
||||
|
||||
Returns a 4-tuple ``(ok, message, verified, smarttime_status)``:
|
||||
ok – coeo POST returned 200
|
||||
message – human-readable summary
|
||||
verified – True/False/None (None = not attempted)
|
||||
smarttime_status – SmartTimeStatus snapshot or None
|
||||
"""
|
||||
# 1. Login
|
||||
login_success, login_msg = self._login()
|
||||
if not login_success:
|
||||
return False, f"Login failed: HTTP {login_msg}"
|
||||
return False, f"Login failed: HTTP {login_msg}", None, None
|
||||
|
||||
# 2. Stamp
|
||||
sent_at = datetime.now()
|
||||
stamp_success, stamp_msg = self._send_stamp_request()
|
||||
if stamp_success:
|
||||
return True, "Successfully stamped!"
|
||||
else:
|
||||
return False, f"Stamping failed: HTTP {stamp_msg}"
|
||||
if not stamp_success:
|
||||
return False, f"Stamping failed: HTTP {stamp_msg}", None, None
|
||||
|
||||
# 3. Verify against SmartTime Plus (the authoritative backend)
|
||||
if not verify:
|
||||
return True, "Successfully stamped (unverified)!", None, None
|
||||
try:
|
||||
st = get_smarttime_client()
|
||||
ok, msg, snap = st.verify_stamp(sent_at)
|
||||
combined = f"Stempel gesendet – {msg}"
|
||||
return True, combined, ok, snap
|
||||
except Exception as e: # verification must never break the primary flow
|
||||
return True, f"Stempel gesendet (Verifikation fehlgeschlagen: {e})", None, None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_coeo_client():
|
||||
return CoeoClient(settings.USERNAME, settings.PASSWORD)
|
||||
return CoeoClient(settings.USERNAME, settings.PASSWORD)
|
||||
|
||||
@@ -5,9 +5,22 @@ from pydantic_settings import BaseSettings
|
||||
class StempelSettings(BaseSettings):
|
||||
USERNAME: str
|
||||
PASSWORD: str
|
||||
SMART_TIME_PASSWORD: str
|
||||
AUTOSTAMP_FILE: str
|
||||
STAMPHISTORY_FILE: str
|
||||
LOG_LEVEL: str
|
||||
# Optional: URL of the SmartTime Plus instance
|
||||
SMART_TIME_URL: str = "https://zeiterfassung/"
|
||||
# Seconds to wait between submitting a coeo stamp and the FIRST SmartTime
|
||||
# check (SmartTime usually reflects the stamp within ~1 s).
|
||||
SMART_TIME_VERIFY_INITIAL_DELAY: int = 1
|
||||
# Total budget (seconds) to keep re-polling SmartTime for the stamp.
|
||||
SMART_TIME_VERIFY_GRACE_PERIOD: int = 30
|
||||
# Poll interval (seconds) between individual SmartTime checks.
|
||||
SMART_TIME_VERIFY_POLL_INTERVAL: int = 3
|
||||
# Maximum tolerated difference (seconds) between the sent stamp and the
|
||||
# "Letzte Buchung" observed on SmartTime Plus.
|
||||
SMART_TIME_VERIFY_TOLERANCE_SEC: int = 90
|
||||
|
||||
settings = StempelSettings()
|
||||
logging.basicConfig(level=settings.LOG_LEVEL)
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
SmartTime Plus verification client.
|
||||
|
||||
The internal SmartTime Plus web app (https://zeiterfassung/) is a GWT
|
||||
application whose only transport is a serialized RPC protocol tied to a
|
||||
per-build strong-name hash. Reverse-engineering that protocol is brittle,
|
||||
so this client instead drives a headless Chromium via Playwright, logs in
|
||||
with the SmartTime credentials, opens the dashboard, and scrapes the
|
||||
"Letzte Buchung" (last booking) and status fields.
|
||||
|
||||
This is used to confirm that a stamp submitted through the coeo portal
|
||||
actually arrived at the underlying time-tracking system.
|
||||
|
||||
Threading notes
|
||||
---------------
|
||||
Playwright's sync API spawns a Node.js subprocess via asyncio, which on
|
||||
Windows requires a ``ProactorEventLoop``. Streamlit executes user scripts
|
||||
on worker threads whose default loop is a ``SelectorEventLoop`` (no
|
||||
subprocess support), which causes ``NotImplementedError`` when starting
|
||||
Playwright directly.
|
||||
|
||||
To dodge this, all Playwright interaction runs inside a **single dedicated
|
||||
worker thread** (`_PlaywrightWorker`) that installs a proactor loop before
|
||||
touching Playwright. Callers from any thread submit closures to that
|
||||
worker and block on the result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import queue
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time as _time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from playwright.sync_api import (
|
||||
BrowserContext,
|
||||
Page,
|
||||
TimeoutError as PWTimeout,
|
||||
sync_playwright,
|
||||
)
|
||||
|
||||
from settings import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PROFILE_DIR = Path.home() / ".stempelbot" / "smarttime-profile"
|
||||
|
||||
# Regex to pick "Letzte Buchung 11:43" out of the dashboard text (may span
|
||||
# lines / whitespace).
|
||||
LAST_STAMP_RE = re.compile(
|
||||
r"Letzte\s+Buchung\s*[:\-]?\s*(\d{1,2}):(\d{2})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
STATUS_RE = re.compile(r"Aktueller\s+Status\s*(?:\d{2}\.\d{2}\.\d{4})?\s*([^\n]+)")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmartTimeStatus:
|
||||
"""Snapshot of the SmartTime Plus dashboard for today."""
|
||||
|
||||
last_stamp: Optional[time]
|
||||
status_text: Optional[str] # e.g. "Anwesend" / "Abwesend (Haus verlassen)"
|
||||
query_time: datetime
|
||||
|
||||
@property
|
||||
def is_present(self) -> bool:
|
||||
return bool(self.status_text and "anwesend" in self.status_text.lower()
|
||||
and "abwesend" not in self.status_text.lower())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dedicated Playwright worker thread
|
||||
# ---------------------------------------------------------------------------
|
||||
_STOP = object()
|
||||
|
||||
|
||||
class _PlaywrightWorker(threading.Thread):
|
||||
"""Owns the Playwright instance + browser. Runs jobs from a queue."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(name="SmartTimeWorker", daemon=True)
|
||||
self._jobs: "queue.Queue[tuple[Callable[[Page], Any], queue.Queue]]" = queue.Queue()
|
||||
self._ready = threading.Event()
|
||||
self._startup_error: Optional[BaseException] = None
|
||||
self._pw = None
|
||||
self._context: Optional[BrowserContext] = None
|
||||
self._page: Optional[Page] = None
|
||||
|
||||
# ---------- called from worker thread only ----------
|
||||
def _init_playwright(self) -> None:
|
||||
# On Windows we NEED a proactor loop for subprocess support.
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
except Exception:
|
||||
pass
|
||||
asyncio.set_event_loop(asyncio.ProactorEventLoop())
|
||||
|
||||
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log.info("🕵️ SmartTime worker: launching Chromium (profile=%s)", PROFILE_DIR)
|
||||
self._pw = sync_playwright().start()
|
||||
self._context = self._pw.chromium.launch_persistent_context(
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
headless=True,
|
||||
ignore_https_errors=True,
|
||||
locale="de-DE",
|
||||
args=["--ignore-certificate-errors"],
|
||||
)
|
||||
self._page = self._context.new_page()
|
||||
|
||||
def _ensure_page(self) -> Page:
|
||||
if self._page is None or self._page.is_closed():
|
||||
self._page = self._context.new_page() # type: ignore[union-attr]
|
||||
return self._page
|
||||
|
||||
def _shutdown(self) -> None:
|
||||
try:
|
||||
if self._context:
|
||||
self._context.close()
|
||||
finally:
|
||||
self._context = None
|
||||
self._page = None
|
||||
if self._pw:
|
||||
try:
|
||||
self._pw.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._pw = None
|
||||
|
||||
def run(self) -> None: # noqa: D401
|
||||
try:
|
||||
self._init_playwright()
|
||||
except BaseException as e: # capture and surface to caller
|
||||
self._startup_error = e
|
||||
self._ready.set()
|
||||
log.exception("SmartTime worker failed to start: %s", e)
|
||||
return
|
||||
self._ready.set()
|
||||
while True:
|
||||
item = self._jobs.get()
|
||||
if item is _STOP:
|
||||
break
|
||||
fn, result_q = item
|
||||
try:
|
||||
page = self._ensure_page()
|
||||
result_q.put(("ok", fn(page)))
|
||||
except BaseException as e: # send back to caller
|
||||
log.exception("SmartTime worker job failed: %s", e)
|
||||
result_q.put(("err", e))
|
||||
self._shutdown()
|
||||
|
||||
# ---------- called from other threads ----------
|
||||
def submit(self, fn: Callable[[Page], Any], timeout: float = 120.0) -> Any:
|
||||
if not self.is_alive():
|
||||
self.start()
|
||||
if not self._ready.wait(timeout=30):
|
||||
raise RuntimeError("SmartTime worker did not start in time")
|
||||
if self._startup_error is not None:
|
||||
raise RuntimeError(
|
||||
f"SmartTime worker failed to start: {self._startup_error!r}"
|
||||
) from self._startup_error
|
||||
result_q: "queue.Queue[tuple[str, Any]]" = queue.Queue()
|
||||
self._jobs.put((fn, result_q))
|
||||
kind, val = result_q.get(timeout=timeout)
|
||||
if kind == "err":
|
||||
raise val
|
||||
return val
|
||||
|
||||
def stop(self) -> None:
|
||||
self._jobs.put(_STOP) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public client
|
||||
# ---------------------------------------------------------------------------
|
||||
class SmartTimeClient:
|
||||
def __init__(self) -> None:
|
||||
self._worker = _PlaywrightWorker()
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
@staticmethod
|
||||
def _login_if_needed(page: Page) -> None:
|
||||
try:
|
||||
page.wait_for_selector(
|
||||
"input[name='useraccount'], input[name='password']",
|
||||
timeout=4000,
|
||||
)
|
||||
except PWTimeout:
|
||||
return # already logged in via persistent cookies
|
||||
log.info("🕵️ SmartTime: logging in as %s", settings.USERNAME)
|
||||
user_input = page.query_selector("input[name='useraccount']")
|
||||
pw_input = page.query_selector("input[name='password']")
|
||||
if not user_input or not pw_input:
|
||||
raise RuntimeError("SmartTime login form not found")
|
||||
user_input.fill(settings.USERNAME)
|
||||
pw_input.fill(settings.SMART_TIME_PASSWORD)
|
||||
pw_input.press("Enter")
|
||||
try:
|
||||
page.wait_for_selector(
|
||||
"input[name='useraccount']", state="detached", timeout=15_000
|
||||
)
|
||||
except PWTimeout:
|
||||
log.warning("SmartTime: login form did not disappear – auth may have failed")
|
||||
|
||||
@classmethod
|
||||
def _fetch_status(cls, page: Page) -> SmartTimeStatus:
|
||||
log.info("🕵️ SmartTime: fetching dashboard")
|
||||
page.goto(settings.SMART_TIME_URL, wait_until="domcontentloaded",
|
||||
timeout=30_000)
|
||||
cls._login_if_needed(page)
|
||||
page.evaluate("location.hash = 'dashboard_tab'")
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=15_000)
|
||||
except PWTimeout:
|
||||
pass
|
||||
deadline = _time.time() + 10
|
||||
body_text = ""
|
||||
while _time.time() < deadline:
|
||||
try:
|
||||
body_text = page.locator("body").inner_text(timeout=3000)
|
||||
except Exception:
|
||||
body_text = ""
|
||||
if "Letzte Buchung" in body_text or "Aktueller Status" in body_text:
|
||||
break
|
||||
_time.sleep(0.5)
|
||||
|
||||
last_stamp: Optional[time] = None
|
||||
m = LAST_STAMP_RE.search(body_text)
|
||||
if m:
|
||||
try:
|
||||
last_stamp = time(int(m.group(1)), int(m.group(2)))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
status_text = None
|
||||
sm = STATUS_RE.search(body_text)
|
||||
if sm:
|
||||
status_text = sm.group(1).strip()
|
||||
|
||||
snap = SmartTimeStatus(
|
||||
last_stamp=last_stamp,
|
||||
status_text=status_text,
|
||||
query_time=datetime.now(),
|
||||
)
|
||||
log.info("🕵️ SmartTime status: %s", snap)
|
||||
return snap
|
||||
|
||||
# ---------------------------------------------------------------- public
|
||||
def get_status(self) -> SmartTimeStatus:
|
||||
return self._worker.submit(self._fetch_status)
|
||||
|
||||
def verify_stamp(
|
||||
self,
|
||||
expected_at: datetime,
|
||||
*,
|
||||
initial_delay: Optional[float] = None,
|
||||
grace_period: Optional[float] = None,
|
||||
poll_interval: Optional[float] = None,
|
||||
tolerance_sec: Optional[float] = None,
|
||||
) -> tuple[bool, str, Optional[SmartTimeStatus]]:
|
||||
"""
|
||||
Poll SmartTime Plus until we see a `Letzte Buchung` matching
|
||||
``expected_at`` within ``tolerance_sec`` seconds, or the
|
||||
``grace_period`` runs out.
|
||||
"""
|
||||
initial_delay = (
|
||||
initial_delay if initial_delay is not None
|
||||
else settings.SMART_TIME_VERIFY_INITIAL_DELAY
|
||||
)
|
||||
grace_period = (
|
||||
grace_period if grace_period is not None
|
||||
else settings.SMART_TIME_VERIFY_GRACE_PERIOD
|
||||
)
|
||||
poll_interval = (
|
||||
poll_interval if poll_interval is not None
|
||||
else settings.SMART_TIME_VERIFY_POLL_INTERVAL
|
||||
)
|
||||
tolerance_sec = (
|
||||
tolerance_sec if tolerance_sec is not None
|
||||
else settings.SMART_TIME_VERIFY_TOLERANCE_SEC
|
||||
)
|
||||
|
||||
if initial_delay > 0:
|
||||
_time.sleep(initial_delay)
|
||||
|
||||
deadline = _time.time() + grace_period
|
||||
expected_dt = expected_at.replace(second=0, microsecond=0)
|
||||
snap: Optional[SmartTimeStatus] = None
|
||||
attempts = 0
|
||||
while True:
|
||||
attempts += 1
|
||||
try:
|
||||
snap = self.get_status()
|
||||
except Exception as e:
|
||||
log.exception("SmartTime verify: error fetching status: %s", e)
|
||||
else:
|
||||
if snap.last_stamp:
|
||||
observed_dt = datetime.combine(expected_dt.date(), snap.last_stamp)
|
||||
diff_sec = abs((observed_dt - expected_dt).total_seconds())
|
||||
if diff_sec <= tolerance_sec:
|
||||
return True, (
|
||||
f"Verifiziert: SmartTime zeigt Buchung um "
|
||||
f"{snap.last_stamp.strftime('%H:%M')} "
|
||||
f"(Δ {diff_sec:.0f}s, {attempts} Versuch(e))"
|
||||
), snap
|
||||
if _time.time() >= deadline:
|
||||
break
|
||||
_time.sleep(poll_interval)
|
||||
|
||||
obs = snap.last_stamp.strftime("%H:%M") if snap and snap.last_stamp else "—"
|
||||
return False, (
|
||||
f"Nicht verifiziert: erwartet {expected_dt.strftime('%H:%M')}, "
|
||||
f"SmartTime letzte Buchung: {obs} "
|
||||
f"(nach {attempts} Versuchen in {grace_period}s)"
|
||||
), snap
|
||||
|
||||
def close(self) -> None:
|
||||
self._worker.stop()
|
||||
|
||||
|
||||
# --- singleton -------------------------------------------------------------
|
||||
_singleton: Optional[SmartTimeClient] = None
|
||||
_singleton_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_smarttime_client() -> SmartTimeClient:
|
||||
global _singleton
|
||||
with _singleton_lock:
|
||||
if _singleton is None:
|
||||
_singleton = SmartTimeClient()
|
||||
return _singleton
|
||||
|
||||
+87
-1
@@ -331,6 +331,92 @@
|
||||
"12:01"
|
||||
],
|
||||
"2026-06-08": [
|
||||
"11:49"
|
||||
"9:00",
|
||||
"11:49",
|
||||
"13:33",
|
||||
"18:56"
|
||||
],
|
||||
"2026-06-09": [
|
||||
"09:33",
|
||||
"18:21"
|
||||
],
|
||||
"2026-06-10": [
|
||||
"09:09",
|
||||
"18:11",
|
||||
"20:05"
|
||||
],
|
||||
"2026-06-11": [
|
||||
"07:01",
|
||||
"17:57"
|
||||
],
|
||||
"2026-06-12": [
|
||||
"09:42",
|
||||
"19:22"
|
||||
],
|
||||
"2026-06-15": [
|
||||
"09:50",
|
||||
"17:56"
|
||||
],
|
||||
"2026-06-16": [
|
||||
"09:10",
|
||||
"17:57"
|
||||
],
|
||||
"2026-06-17": [
|
||||
"08:35",
|
||||
"19:43"
|
||||
],
|
||||
"2026-06-22": [
|
||||
"09:24",
|
||||
"17:28"
|
||||
],
|
||||
"2026-06-23": [
|
||||
"09:11",
|
||||
"20:26"
|
||||
],
|
||||
"2026-06-24": [
|
||||
"09:09",
|
||||
"17:37"
|
||||
],
|
||||
"2026-06-25": [
|
||||
"09:18",
|
||||
"16:52"
|
||||
],
|
||||
"2026-06-26": [
|
||||
"09:48"
|
||||
],
|
||||
"2026-06-30": [
|
||||
"09:30",
|
||||
"18:25"
|
||||
],
|
||||
"2026-07-02": [
|
||||
"11:42",
|
||||
"20:01"
|
||||
],
|
||||
"2026-07-03": [
|
||||
"09:29",
|
||||
"16:50"
|
||||
],
|
||||
"2026-07-06": [
|
||||
"08:13",
|
||||
"18:17"
|
||||
],
|
||||
"2026-07-07": [
|
||||
"08:47",
|
||||
"17:37"
|
||||
],
|
||||
"2026-07-08": [
|
||||
"08:32",
|
||||
"17:30"
|
||||
],
|
||||
"2026-07-09": [
|
||||
"10:00",
|
||||
"17:35"
|
||||
],
|
||||
"2026-07-10": [
|
||||
"08:29",
|
||||
"10:04",
|
||||
"11:34",
|
||||
"11:43",
|
||||
"13:21"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user