Commit Graph

25 Commits

Author SHA1 Message Date
Bastian Wagner
c4e986e3f8 feat: add resumable per-user sync pipeline 2026-08-15 15:53:27 +02:00
Bastian Wagner
1d5bbdb2a2 feat: add durable sync state transitions
Add state-transition methods to ActivityRepository for advancing activity
stages (mark_downloaded, mark_converted, mark_imported, mark_duplicate,
mark_failed) with proper retention of last_completed_stage on failure.
Add list_pending_for_user to filter activities for processing. Implement
SyncRunRepository for creating and finalizing sync runs with counts and
summary errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 15:33:46 +02:00
Bastian Wagner
2f65c0178c fix: address final review findings for service clients plan
Fixes 9 numbered findings + 7 minor fixes from the whole-plan review of
the MyWhoosh/Garmin service clients (Plan 3):

Garmin uploader (app/garmin/uploader.py):
- Detect Garmin-rejected imports (failures without successes) and raise
  new GarminImportRejected instead of reporting them as successful.
- Reclassify 429/rate-limit/500 login failures as transient instead of
  falling through to permanent auth errors; unrecognized login failures
  are now treated as transient (retryable) rather than GarminAuthError.
- Mirror the auth-token check from the login branch into the import
  branch so 401-at-import-time raises GarminAuthError instead of
  propagating raw.
- Add common GarminError base class, hoist transient-token tuple to a
  shared module constant, check response.status_code==409 before the
  duplicate substring fallback, and create the tokenstore dir 0o700.

MyWhoosh client (app/mywhoosh/client.py):
- Add optional max_pages bound to list_activities pagination.
- Add aclose()/__aenter__/__aexit__ so the client's own httpx.AsyncClient
  gets closed, while never closing an injected client.
- Guard the two remaining unguarded JSON-decode paths (login body,
  download-fit metadata) so malformed bodies raise
  MyWhooshIntegrationError instead of raw ValueError/AttributeError.
- Row-level malformation (missing id/activityFileId, unparseable
  startDatetime) is now skipped rather than aborting the whole page;
  envelope-shape failures still raise. id/activityFileId checks use
  explicit None/"" comparisons instead of Python falsiness.
- Replace asserts in _authenticated_post with explicit exceptions;
  restrict the reauth retry to 401 only, treat 403 as immediately
  terminal; naive startDatetime values are now treated as already-UTC
  instead of host-local.

MyWhoosh tokenstore (app/mywhoosh/tokenstore.py):
- load() now treats any corrupt/malformed token file (bad JSON, missing
  keys, OS errors) as "absent" instead of raising, so a bad cache no
  longer permanently wedges a user.

pyproject.toml:
- Tighten garminconnect pin to >=0.3.10,<1 (import_activity requires
  0.3.10+).

Adds/updates tests across tests/mywhoosh/ and tests/garmin/ covering
all of the above, including a fake client that wraps GarminUploadBlocked
in a plain RuntimeError to mirror the real garminconnect library's MFA
error wrapping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 15:23:14 +02:00
Bastian Wagner
b48008c16e feat: import FIT activities into Garmin
Implement GarminUploader adapter around garminconnect library:
- Import activities using import_activity() not upload_activity()
- Treat duplicate activity responses as terminal success
- Raise GarminUploadBlocked for MFA to allow UI code collection
- Distinguish auth, transient, and other errors appropriately
- Helper functions for duplicate detection and activity ID extraction

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 15:02:37 +02:00
Bastian Wagner
21074a35da feat: fetch MyWhoosh activities and FIT files 2026-08-15 14:52:03 +02:00
Bastian Wagner
01d2266c8e feat: add MyWhoosh API login 2026-08-15 14:45:19 +02:00
Bastian Wagner
a28d06ffdd feat: add MyWhoosh token persistence 2026-08-15 14:38:50 +02:00
Bastian Wagner
ef9ca9eeca fix: harden FIT patcher error boundary and verify patched metadata
Final-review fixes for Plan 2 (fit-rewriter). Every failure mode below now
surfaces as FitFormatError so Plan 3 can classify invalid FIT input as a
non-retryable activity error (spec 10.4).

- Range-check numeric values against the field's declared size before
  struct.pack, so an oversized serial number or a 1-byte product field
  raises FitFormatError instead of leaking a raw struct.error.
- Reject zero-size field definitions during parsing. A zero-size
  device_info field 0 read back as device_index == 0 via
  int.from_bytes(b"", ...), which could have let a paired sensor be
  rewritten as an Edge 1030 Plus (spec 10.2).
- Add DeviceFieldValue.is_creator so callers can tell the creator
  device_info record from sensor records instead of silently keeping
  whichever record appeared last.
- Implement the missing spec 10.4 post-patch step: read the patched
  buffer back and verify file_id 1/2/8 and creator device_info 2/4/27
  hold the target values. A field that could not be written (e.g. a
  product_name field too small for the target string) now fails the whole
  conversion rather than producing a silent partial patch. Verification
  runs before the output is written, so a half-rewritten file never lands
  on disk.
- Use the field's actual endianness in _read_field_value's fallback path.
- Add curated re-exports in app/fit/__init__.py for Plan 3.
- Document _iter_data_fields' caller invariant (validate the container
  first; end_offset is not clamped).
- Extend the preservation fixture with a product_name string field so the
  zero-filling string write path is covered by the byte-preservation
  proof, and test convert_fit_device against a 12-byte header.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 14:29:40 +02:00
Bastian Wagner
95aa8507f0 test: add big-endian round-trip coverage for FIT field patching 2026-08-15 14:15:05 +02:00
Bastian Wagner
475635936b test: verify FIT binary preservation 2026-08-15 10:49:13 +02:00
Bastian Wagner
e1e57d9b6b test: cover product_name string field write path in FIT patcher
Addresses review finding: no test previously exercised file_id field 8
or device_info field 27 (product_name), the only string-typed fields
this task patches, leaving _write_field_value's string branch
(buffer sizing, null-terminator handling) unregression-tested.
2026-08-15 10:41:20 +02:00
Bastian Wagner
5944ef364f feat: patch FIT creator as Edge 1030 Plus 2026-08-15 10:35:36 +02:00
Bastian Wagner
a47ad77071 feat: parse and validate FIT containers 2026-08-15 10:27:05 +02:00
Bastian Wagner
4599b2b71c feat: add FIT CRC calculation 2026-08-15 10:21:09 +02:00
Bastian Wagner
49aba8efb4 fix: address final review findings for foundation plan
- C1: drop module-level app singleton in app/main.py so importing the
  package no longer validates Settings or creates DATA_DIR; run uvicorn
  with --factory in the Dockerfile. pytest now collects and passes with
  no ambient env vars.
- I2: add missing app/auth, app/security, app/web __init__.py so
  setuptools discovers all five packages.
- I3: resolve the Jinja2 template directory relative to __file__ instead
  of the process CWD.
- I4: add .gitignore covering .env, data/, .venv/, caches and build
  artifacts so example deployment secrets cannot be committed.
- I5: assert UserRepository.list_enabled() excludes disabled users.
- M6: encode both operands before hmac.compare_digest in validate_csrf so
  a non-ASCII token yields 403 instead of an unhandled 500.
- M9: remove unused relationship / HealthState imports.
- M11: make session cookie https_only configurable via SESSION_HTTPS_ONLY
  (default unchanged: false).
- M13: dispose SQLAlchemy engines in the db_session and client fixtures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 10:14:26 +02:00
Bastian Wagner
5931a57f82 build: package local admin service
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 09:56:50 +02:00
Bastian Wagner
d2ac079870 fix: reject empty credential fields with 400 instead of 500
Add an explicit non-empty check before encrypting user-submitted
email/password fields in the create and update user routes, so a
request that bypasses the HTML `required` attribute gets a clean
400 instead of an unhandled ValueError from CredentialCipher.encrypt
propagating as a 500. Applies to all four credential fields on
create, and to the two email fields on update (the password-blank-
means-keep-existing behavior on update is unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 09:50:05 +02:00
Bastian Wagner
b39842fe2c feat: add encrypted sync user management
Adds full CRUD for sync users (create/list/detail/edit) behind admin
auth and CSRF protection. Passwords are encrypted at rest and never
decrypted into a template context; only emails may be decrypted for
display on the edit form. Blank password fields on edit preserve the
existing encrypted password.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 09:41:21 +02:00
Bastian Wagner
da6b94ca2f feat: add local admin authentication 2026-08-15 09:33:27 +02:00
Bastian Wagner
93232a809e feat: encrypt stored service credentials 2026-08-15 09:26:40 +02:00
Bastian Wagner
318d7c8ddb feat: add SQLite persistence models 2026-08-15 09:18:57 +02:00
Bastian Wagner
91728cba86 feat: bootstrap FastAPI configuration 2026-08-15 09:14:33 +02:00
Bastian Wagner
c7e834bf14 roadmap 2026-08-15 09:07:40 +02:00
Bastian Wagner
a2387341bd reference 2026-08-15 09:04:38 +02:00
Bastian Wagner
f6da346e18 plans + specs 2026-08-15 09:00:41 +02:00