add smarttime verification with retries

This commit is contained in:
Kilian Schuettler
2026-07-10 13:26:44 +02:00
parent 4d2e88deca
commit 8eeaaaa3c2
11 changed files with 724 additions and 20 deletions
Generated
+1
View File
@@ -0,0 +1 @@
timestamp_history.json
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -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
View File
@@ -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')}")
+7 -1
View File
@@ -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
View File
@@ -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)
+13
View File
@@ -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)
+339
View File
@@ -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
View File
@@ -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"
]
}