django-oura 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. django_oura-0.1.0/.github/workflows/ci.yml +51 -0
  2. django_oura-0.1.0/.github/workflows/pre-commit.yml +22 -0
  3. django_oura-0.1.0/.gitignore +63 -0
  4. django_oura-0.1.0/.pre-commit-config.yaml +18 -0
  5. django_oura-0.1.0/.python-version +1 -0
  6. django_oura-0.1.0/CLAUDE.md +85 -0
  7. django_oura-0.1.0/PKG-INFO +193 -0
  8. django_oura-0.1.0/README.md +162 -0
  9. django_oura-0.1.0/demo/__init__.py +0 -0
  10. django_oura-0.1.0/demo/admin.py +30 -0
  11. django_oura-0.1.0/demo/settings.py +88 -0
  12. django_oura-0.1.0/demo/templates/demo/home.html +105 -0
  13. django_oura-0.1.0/demo/templates/registration/login.html +32 -0
  14. django_oura-0.1.0/demo/urls.py +20 -0
  15. django_oura-0.1.0/demo/views.py +64 -0
  16. django_oura-0.1.0/docs/oura/api.md +74 -0
  17. django_oura-0.1.0/docs/oura/authentication.md +65 -0
  18. django_oura-0.1.0/docs/oura/webhooks.md +90 -0
  19. django_oura-0.1.0/manage.py +16 -0
  20. django_oura-0.1.0/oura/__init__.py +0 -0
  21. django_oura-0.1.0/oura/admin.py +17 -0
  22. django_oura-0.1.0/oura/apps.py +7 -0
  23. django_oura-0.1.0/oura/client.py +224 -0
  24. django_oura-0.1.0/oura/constants.py +74 -0
  25. django_oura-0.1.0/oura/ingest.py +404 -0
  26. django_oura-0.1.0/oura/management/__init__.py +0 -0
  27. django_oura-0.1.0/oura/management/commands/__init__.py +0 -0
  28. django_oura-0.1.0/oura/management/commands/create_oura_webhook_subscription.py +70 -0
  29. django_oura-0.1.0/oura/management/commands/delete_oura_webhook_subscription.py +23 -0
  30. django_oura-0.1.0/oura/management/commands/list_oura_webhook_subscriptions.py +20 -0
  31. django_oura-0.1.0/oura/management/commands/sync_oura.py +169 -0
  32. django_oura-0.1.0/oura/migrations/0001_initial.py +61 -0
  33. django_oura-0.1.0/oura/migrations/__init__.py +0 -0
  34. django_oura-0.1.0/oura/models.py +59 -0
  35. django_oura-0.1.0/oura/oauth.py +228 -0
  36. django_oura-0.1.0/oura/py.typed +0 -0
  37. django_oura-0.1.0/oura/schemas.py +51 -0
  38. django_oura-0.1.0/oura/signals.py +24 -0
  39. django_oura-0.1.0/oura/urls.py +12 -0
  40. django_oura-0.1.0/oura/views.py +142 -0
  41. django_oura-0.1.0/oura/webhooks.py +211 -0
  42. django_oura-0.1.0/pyproject.toml +58 -0
  43. django_oura-0.1.0/tests/__init__.py +0 -0
  44. django_oura-0.1.0/tests/conftest.py +24 -0
  45. django_oura-0.1.0/tests/settings.py +53 -0
  46. django_oura-0.1.0/tests/test_client.py +161 -0
  47. django_oura-0.1.0/tests/test_command_sync.py +118 -0
  48. django_oura-0.1.0/tests/test_ingest.py +314 -0
  49. django_oura-0.1.0/tests/test_models.py +50 -0
  50. django_oura-0.1.0/tests/test_oauth.py +190 -0
  51. django_oura-0.1.0/tests/test_views.py +237 -0
  52. django_oura-0.1.0/tests/test_webhooks.py +166 -0
  53. django_oura-0.1.0/tests/urls.py +5 -0
  54. django_oura-0.1.0/uv.lock +526 -0
@@ -0,0 +1,51 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*"]
7
+ pull_request:
8
+ branches: [main]
9
+ workflow_dispatch:
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+
20
+ - name: Install uv
21
+ uses: astral-sh/setup-uv@v8.2.0
22
+
23
+ - name: Install Python ${{ matrix.python-version }}
24
+ run: uv python install ${{ matrix.python-version }}
25
+
26
+ - name: Install dependencies
27
+ run: uv sync --python ${{ matrix.python-version }} --group dev
28
+
29
+ - name: Run tests
30
+ run: uv run pytest tests/ -v
31
+
32
+ publish:
33
+ needs: test
34
+ runs-on: ubuntu-latest
35
+ if: startsWith(github.ref, 'refs/tags/v')
36
+ environment: pypi
37
+ permissions:
38
+ id-token: write
39
+ steps:
40
+ - uses: actions/checkout@v7
41
+
42
+ - name: Install uv
43
+ uses: astral-sh/setup-uv@v8.2.0
44
+
45
+ - name: Build package
46
+ run: uv build
47
+
48
+ - name: Publish to PyPI
49
+ uses: pypa/gh-action-pypi-publish@release/v1
50
+ with:
51
+ skip-existing: true
@@ -0,0 +1,22 @@
1
+ name: Pre-commit
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ pre-commit:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v7
14
+
15
+ - name: Install uv
16
+ uses: astral-sh/setup-uv@v8.2.0
17
+
18
+ - name: Install Python
19
+ run: uv python install
20
+
21
+ - name: Run pre-commit
22
+ uses: pre-commit/action@v3.0.1
@@ -0,0 +1,63 @@
1
+ # Byte-compiled / optimized
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+
7
+ # Distribution / packaging
8
+ .Python
9
+ build/
10
+ develop-eggs/
11
+ dist/
12
+ downloads/
13
+ eggs/
14
+ .eggs/
15
+ lib/
16
+ lib64/
17
+ parts/
18
+ sdist/
19
+ var/
20
+ wheels/
21
+ *.egg-info/
22
+ .installed.cfg
23
+ *.egg
24
+
25
+ # uv / virtualenv
26
+ .venv/
27
+ venv/
28
+ env/
29
+
30
+ # Testing / coverage
31
+ .pytest_cache/
32
+ .coverage
33
+ .coverage.*
34
+ htmlcov/
35
+ .tox/
36
+ .nox/
37
+ coverage.xml
38
+ *.cover
39
+
40
+ # Type checkers / linters
41
+ .mypy_cache/
42
+ .ruff_cache/
43
+ .pyre/
44
+ .pytype/
45
+
46
+ # Django
47
+ *.log
48
+ local_settings.py
49
+ db.sqlite3
50
+ db.sqlite3-journal
51
+ media/
52
+ staticfiles/
53
+
54
+ # Local env vars (never commit — they hold OAuth secrets)
55
+ .env
56
+ .env.*
57
+ !.env.example
58
+
59
+ # Editors / OS
60
+ .idea/
61
+ .vscode/
62
+ *.swp
63
+ .DS_Store
@@ -0,0 +1,18 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v6.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-toml
9
+ - id: check-added-large-files
10
+ - id: check-merge-conflict
11
+ - id: debug-statements
12
+
13
+ - repo: https://github.com/astral-sh/ruff-pre-commit
14
+ rev: v0.15.18
15
+ hooks:
16
+ - id: ruff
17
+ args: [--fix]
18
+ - id: ruff-format
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,85 @@
1
+ # Notes for Claude (and human contributors)
2
+
3
+ This project is a reusable Django library — small, focused, with a real
4
+ shipping cadence to PyPI. Slice-sized changes; commit messages with the
5
+ "why"; tests for every behavior; live calibration over docs guessing.
6
+
7
+ It is a sibling of `django-health/django-google-health` and
8
+ `django-health/django-garmin` and deliberately mirrors their layout,
9
+ pyproject/CI/pre-commit setup, and OAuth/client/ingest module boundaries.
10
+ When in doubt about a pattern, check what those repos do.
11
+
12
+ ## Oura API ground truth
13
+
14
+ The Oura V2 OpenAPI spec (`https://cloud.ouraring.com/v2/docs`, spec JSON at
15
+ `/v2/static/json/openapi-<version>.json`) is ground truth for routes and
16
+ payload shapes; `docs/oura/` summarizes it plus the authentication and
17
+ webhook guides. Payload shapes in `oura/ingest.py` and the test fixtures were
18
+ written against spec version 1.35 and have NOT yet been calibrated against a
19
+ live ring — when live credentials are available, verify against real
20
+ payloads and promote findings into code comments + tests, not just commit
21
+ messages.
22
+
23
+ Live-calibration entry point: run the demo (`README.md` → "Try it on your own
24
+ data"), then pull the access token from `db.sqlite3` and hit
25
+ `https://api.ouraring.com/v2/usercollection/...` directly with `httpx` before
26
+ guessing at code fixes.
27
+
28
+ Things Oura does differently from its siblings (don't "fix" them):
29
+
30
+ - Users consent PER SCOPE on the authorization page; the granted set can be
31
+ narrower than requested and only shows up on the token response.
32
+ - Refresh tokens are SINGLE-USE and rotate on every refresh — both tokens
33
+ persist together (like Garmin, unlike Google).
34
+ - Document routes filter by the calendar day the data belongs to, in the
35
+ user's LOCAL timezone (`start_date`/`end_date`, inclusive) — not upload
36
+ time. `heartrate` is the odd one out: a time series filtered by
37
+ `start_datetime`/`end_datetime`.
38
+ - Timestamps are localized (`2026-07-04T23:12:00-04:00`). Never feed them to
39
+ `RecordInput`'s string coercion — it assumes naive-UTC. `oura.ingest._utc`
40
+ parses and converts properly.
41
+ - Webhook subscriptions are app-level (`x-client-id`/`x-client-secret`
42
+ headers), one per (event_type, data_type) pair, with a synchronous GET
43
+ challenge at creation and periodic expiry/renewal.
44
+ - Webhook notifications carry only ids — the document is fetched with the
45
+ matching user's token. The HMAC signature scheme in
46
+ `oura.webhooks.compute_signature` (HMAC-SHA256 over `timestamp + body`,
47
+ uppercase hex) is from Oura's docs example and is a prime candidate for
48
+ live calibration.
49
+ - Sleep stages come as a `sleep_phase_5_min` digit string, not intervals;
50
+ the mapper collapses runs and clamps the tail to `bedtime_end`.
51
+
52
+ ## Upstream contributions to django-healthdatamodel
53
+
54
+ The sister repo `django-health/django-healthdatamodel` is where the storage
55
+ layer lives. When a change there unblocks this project, you have end-to-end
56
+ release autonomy as long as CI is green:
57
+
58
+ 1. Open the PR.
59
+ 2. Wait for CI green.
60
+ 3. Bump version in `pyproject.toml` (minor for additive, patch for fix).
61
+ 4. Squash-merge.
62
+ 5. Tag `v<X>` on `main`, push the tag — triggers PyPI publish via OIDC.
63
+ 6. Bump the floor in this repo's `pyproject.toml`, swap any local stopgaps
64
+ for the upstream API, commit + push.
65
+
66
+ Both repos publish to PyPI via trusted publishing — no manual upload.
67
+ Heads-up from the v0.7.0 release: `makemigrations` output is not
68
+ ruff-formatted — run `pre-commit run --all-files` before committing a
69
+ generated migration or the Pre-commit workflow goes red.
70
+
71
+ ## Out of scope for this project
72
+
73
+ - **Token encryption at rest.** Production deployment uses Postgres with
74
+ encryption-at-rest at the storage layer. Don't add Fernet /
75
+ django-cryptography fields to `OuraConnection`.
76
+ - **Signup view in the demo.** `createsuperuser` is the way. The demo exists
77
+ so the maintainer can test against their own Oura account; it isn't a
78
+ hosted SaaS shell.
79
+ - **Oura-specific scores (readiness, resilience, stress).** They have no
80
+ HealthKit-schema equivalent in healthdatamodel today. If they're ever
81
+ wanted, that's an upstream schema conversation first, not a local hack.
82
+ - **The legacy wellrider Oura V1 code.** It was removed upstream
83
+ (massmutual wellrider PR #1157) and targeted the sunset V1 API; it is not
84
+ a compatibility target. Its one useful legacy: basal calories = `total −
85
+ active`, which this package preserves.
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-oura
3
+ Version: 0.1.0
4
+ Summary: A Django app for the Oura API v2, backed by django-healthdatamodel.
5
+ Project-URL: Homepage, https://github.com/django-health/django-oura
6
+ Project-URL: Repository, https://github.com/django-health/django-oura
7
+ Project-URL: Issues, https://github.com/django-health/django-oura/issues
8
+ Author-email: Andy Reagan <andy@andyreagan.com>
9
+ Classifier: Environment :: Web Environment
10
+ Classifier: Framework :: Django
11
+ Classifier: Framework :: Django :: 5.0
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: django
26
+ Requires-Dist: django-healthdatamodel>=0.7.0
27
+ Requires-Dist: httpx
28
+ Requires-Dist: pydantic>=2
29
+ Requires-Dist: python-dateutil
30
+ Description-Content-Type: text/markdown
31
+
32
+ # django-oura
33
+
34
+ [![CI](https://github.com/django-health/django-oura/actions/workflows/ci.yml/badge.svg)](https://github.com/django-health/django-oura/actions/workflows/ci.yml)
35
+
36
+ A reusable Django app for the [Oura API v2](https://cloud.ouraring.com/v2/docs). Handles Oura's OAuth 2.0 flow, fetches documents from `api.ouraring.com/v2/usercollection`, receives webhook notifications, and persists everything through [`django-healthdatamodel`](https://github.com/django-health/django-healthdatamodel) so the same storage and query layer serves Apple Health, Fitbit, Google Health, Garmin, and Oura side-by-side.
37
+
38
+ > The Oura V1 API is sunset, and personal access tokens were retired in December 2025 — this package speaks only V2 with OAuth2. API applications are limited to 10 users before requiring approval from Oura.
39
+
40
+ ## Install
41
+
42
+ ```
43
+ pip install django-oura
44
+ ```
45
+
46
+ Add both this app and `django-healthdatamodel` to `INSTALLED_APPS`, then run migrations:
47
+
48
+ ```python
49
+ INSTALLED_APPS = [
50
+ ...
51
+ "healthdatamodel",
52
+ "oura",
53
+ ]
54
+ ```
55
+
56
+ ```
57
+ python manage.py migrate
58
+ ```
59
+
60
+ The model uses `settings.AUTH_USER_MODEL` so it works with any custom user model.
61
+
62
+ ## Configuration
63
+
64
+ ```python
65
+ OURA_CLIENT_ID = "..." # from https://cloud.ouraring.com/oauth/applications
66
+ OURA_CLIENT_SECRET = "..."
67
+ OURA_REDIRECT_URI = "https://your-app.example.com/oura/callback/"
68
+
69
+ # Optional:
70
+ OURA_SCOPES = ["daily", "heartrate", "workout", "spo2"] # the default set
71
+ OURA_CONNECT_SUCCESS_URL = "/" # default "/admin/"
72
+ OURA_WEBHOOK_VERIFICATION_TOKEN = "..." # only for webhooks
73
+ ```
74
+
75
+ Register the redirect URI on the Oura application — the token exchange fails on any mismatch. Users consent **per scope** on Oura's authorization page, so the granted set may be narrower than requested; whatever was actually granted is stored on `OuraConnection.scopes`.
76
+
77
+ ## URLs
78
+
79
+ ```python
80
+ urlpatterns = [
81
+ ...
82
+ path("oura/", include("oura.urls")),
83
+ ]
84
+ ```
85
+
86
+ This mounts:
87
+
88
+ - `oura/connect/` — start the OAuth flow (login required)
89
+ - `oura/callback/` — the redirect URI target
90
+ - `oura/disconnect/` — POST; revokes the token at Oura and marks the connection revoked
91
+ - `oura/notifications/` — webhook receiver (GET verification challenge + POST events)
92
+
93
+ Mobile clients that run the OAuth dance themselves can POST the resulting token dict to a project-local endpoint that calls `oura.oauth.ingest_tokens`.
94
+
95
+ ## Getting data
96
+
97
+ Oura's recommended architecture is **one historical pull when a user connects, then webhooks** — properly implemented, webhook consumers don't hit the API's 5000-requests-per-5-minutes rate limit.
98
+
99
+ **Pull.** `oura.ingest.sync_user(connection, start=..., end=...)` fetches and ingests the configured data types. Document routes filter by the calendar day the data belongs to (in the user's local timezone), so re-syncing a window picks up revisions to those days.
100
+
101
+ ```
102
+ python manage.py sync_oura # last day, all active connections
103
+ python manage.py sync_oura --user alice --days 30
104
+ python manage.py sync_oura --data-type daily_activity --data-type sleep
105
+ ```
106
+
107
+ **Webhooks.** Subscriptions are app-level, one per `(event_type, data_type)` pair, and Oura verifies your endpoint with a GET challenge at subscription time — `oura/notifications/` answers it using `OURA_WEBHOOK_VERIFICATION_TOKEN`. Create them once the endpoint is live:
108
+
109
+ ```
110
+ python manage.py create_oura_webhook_subscription \
111
+ --callback-url https://api.example.com/oura/notifications/ \
112
+ --verification-token "$OURA_WEBHOOK_VERIFICATION_TOKEN" \
113
+ --data-type daily_activity --data-type sleep --data-type workout
114
+ python manage.py list_oura_webhook_subscriptions
115
+ python manage.py delete_oura_webhook_subscription <id>
116
+ ```
117
+
118
+ Then connect a signal handler to drive ingest:
119
+
120
+ ```python
121
+ from django.dispatch import receiver
122
+ from oura.signals import notification_received
123
+ from oura.webhooks import process_notification
124
+
125
+ @receiver(notification_received)
126
+ def on_notification(sender, payload, **kwargs):
127
+ process_notification(payload) # or hand off to celery / a queue
128
+ ```
129
+
130
+ Notifications carry `{event_type, data_type, object_id, user_id}`; `process_notification` routes to a user by Oura's stable `user_id`, fetches the referenced document with that user's credentials, and ingests it. The receiver view enforces Oura's `x-oura-signature` HMAC before emitting the signal (set `OURA_WEBHOOK_REQUIRE_SIGNATURE = False` to accept unsigned POSTs). Oura expects a 2xx within 10 seconds — hand off to a queue if your processing is heavy. Subscriptions expire; renew with `oura.webhooks.renew_subscription`.
131
+
132
+ ## What gets stored
133
+
134
+ This app does **not** define `Record` / `Workout` tables — those live in `django-healthdatamodel`. The `oura.ingest` module maps Oura documents to `healthdatamodel` inputs and persists them with `source="oura"`:
135
+
136
+ | Oura data type | healthdatamodel records |
137
+ | --- | --- |
138
+ | `daily_activity` | steps, active calories, basal calories (total − active), equivalent walking distance |
139
+ | `sleep` | one sleep-stage record per `sleep_phase_5_min` run (deep/light/REM/awake) |
140
+ | `daily_spo2` | daily SpO2 average |
141
+ | `heartrate` | one heart-rate sample per 5-minute increment |
142
+ | `workout` | one `Workout` per workout |
143
+
144
+ Oura-specific scores (readiness, sleep score, resilience, stress) have no HealthKit-schema equivalent and are not ingested yet.
145
+
146
+ The only model defined here is `OuraConnection`: per-user OAuth tokens (Oura refresh tokens are single-use — both tokens are rewritten together on every refresh), granted scopes, connection status, and last sync timestamp.
147
+
148
+ ## Try it on your own data
149
+
150
+ The repo includes a runnable demo Django project at `demo/`.
151
+
152
+ ### 1. Register an Oura API application (one-time)
153
+
154
+ Create one at [cloud.ouraring.com/oauth/applications](https://cloud.ouraring.com/oauth/applications) and register the redirect URI `http://localhost:8000/oura/callback/` (exact match, trailing slash included). Unapproved applications are limited to 10 users — fine for the demo.
155
+
156
+ ### 2. Run the demo
157
+
158
+ ```
159
+ uv sync
160
+ uv run python manage.py migrate
161
+ uv run python manage.py createsuperuser
162
+ export OURA_CLIENT_ID=...
163
+ export OURA_CLIENT_SECRET=...
164
+ uv run python manage.py runserver
165
+ ```
166
+
167
+ Open <http://localhost:8000/>, sign in with the superuser you just created, then:
168
+
169
+ - Click **Connect Oura** → consent on Oura (pick your scopes) → land back on the homepage with an `OuraConnection` saved for your user.
170
+ - Open the Oura app on your phone so your ring syncs (sleep data only reaches Oura's cloud that way), then pick a window and click **Sync now**.
171
+ - Browse the resulting rows at `/admin/healthdatamodel/record/` (and `.../workout/`).
172
+
173
+ Or drive sync from the terminal:
174
+
175
+ ```
176
+ uv run python manage.py sync_oura --user <your-username> --days 30
177
+ ```
178
+
179
+ ## Documentation
180
+
181
+ Summaries of the Oura API docs live under `docs/oura/` so they're grep-able offline:
182
+
183
+ - `authentication.md` — OAuth2 endpoints, scopes, token lifetimes, revocation
184
+ - `api.md` — the v2 usercollection routes, params, pagination, payload shapes
185
+ - `webhooks.md` — subscription API, verification challenge, signatures, retries
186
+
187
+ ## Development
188
+
189
+ ```
190
+ uv sync --group dev
191
+ uv run pytest tests/ -v
192
+ uv run pre-commit run --all-files
193
+ ```
@@ -0,0 +1,162 @@
1
+ # django-oura
2
+
3
+ [![CI](https://github.com/django-health/django-oura/actions/workflows/ci.yml/badge.svg)](https://github.com/django-health/django-oura/actions/workflows/ci.yml)
4
+
5
+ A reusable Django app for the [Oura API v2](https://cloud.ouraring.com/v2/docs). Handles Oura's OAuth 2.0 flow, fetches documents from `api.ouraring.com/v2/usercollection`, receives webhook notifications, and persists everything through [`django-healthdatamodel`](https://github.com/django-health/django-healthdatamodel) so the same storage and query layer serves Apple Health, Fitbit, Google Health, Garmin, and Oura side-by-side.
6
+
7
+ > The Oura V1 API is sunset, and personal access tokens were retired in December 2025 — this package speaks only V2 with OAuth2. API applications are limited to 10 users before requiring approval from Oura.
8
+
9
+ ## Install
10
+
11
+ ```
12
+ pip install django-oura
13
+ ```
14
+
15
+ Add both this app and `django-healthdatamodel` to `INSTALLED_APPS`, then run migrations:
16
+
17
+ ```python
18
+ INSTALLED_APPS = [
19
+ ...
20
+ "healthdatamodel",
21
+ "oura",
22
+ ]
23
+ ```
24
+
25
+ ```
26
+ python manage.py migrate
27
+ ```
28
+
29
+ The model uses `settings.AUTH_USER_MODEL` so it works with any custom user model.
30
+
31
+ ## Configuration
32
+
33
+ ```python
34
+ OURA_CLIENT_ID = "..." # from https://cloud.ouraring.com/oauth/applications
35
+ OURA_CLIENT_SECRET = "..."
36
+ OURA_REDIRECT_URI = "https://your-app.example.com/oura/callback/"
37
+
38
+ # Optional:
39
+ OURA_SCOPES = ["daily", "heartrate", "workout", "spo2"] # the default set
40
+ OURA_CONNECT_SUCCESS_URL = "/" # default "/admin/"
41
+ OURA_WEBHOOK_VERIFICATION_TOKEN = "..." # only for webhooks
42
+ ```
43
+
44
+ Register the redirect URI on the Oura application — the token exchange fails on any mismatch. Users consent **per scope** on Oura's authorization page, so the granted set may be narrower than requested; whatever was actually granted is stored on `OuraConnection.scopes`.
45
+
46
+ ## URLs
47
+
48
+ ```python
49
+ urlpatterns = [
50
+ ...
51
+ path("oura/", include("oura.urls")),
52
+ ]
53
+ ```
54
+
55
+ This mounts:
56
+
57
+ - `oura/connect/` — start the OAuth flow (login required)
58
+ - `oura/callback/` — the redirect URI target
59
+ - `oura/disconnect/` — POST; revokes the token at Oura and marks the connection revoked
60
+ - `oura/notifications/` — webhook receiver (GET verification challenge + POST events)
61
+
62
+ Mobile clients that run the OAuth dance themselves can POST the resulting token dict to a project-local endpoint that calls `oura.oauth.ingest_tokens`.
63
+
64
+ ## Getting data
65
+
66
+ Oura's recommended architecture is **one historical pull when a user connects, then webhooks** — properly implemented, webhook consumers don't hit the API's 5000-requests-per-5-minutes rate limit.
67
+
68
+ **Pull.** `oura.ingest.sync_user(connection, start=..., end=...)` fetches and ingests the configured data types. Document routes filter by the calendar day the data belongs to (in the user's local timezone), so re-syncing a window picks up revisions to those days.
69
+
70
+ ```
71
+ python manage.py sync_oura # last day, all active connections
72
+ python manage.py sync_oura --user alice --days 30
73
+ python manage.py sync_oura --data-type daily_activity --data-type sleep
74
+ ```
75
+
76
+ **Webhooks.** Subscriptions are app-level, one per `(event_type, data_type)` pair, and Oura verifies your endpoint with a GET challenge at subscription time — `oura/notifications/` answers it using `OURA_WEBHOOK_VERIFICATION_TOKEN`. Create them once the endpoint is live:
77
+
78
+ ```
79
+ python manage.py create_oura_webhook_subscription \
80
+ --callback-url https://api.example.com/oura/notifications/ \
81
+ --verification-token "$OURA_WEBHOOK_VERIFICATION_TOKEN" \
82
+ --data-type daily_activity --data-type sleep --data-type workout
83
+ python manage.py list_oura_webhook_subscriptions
84
+ python manage.py delete_oura_webhook_subscription <id>
85
+ ```
86
+
87
+ Then connect a signal handler to drive ingest:
88
+
89
+ ```python
90
+ from django.dispatch import receiver
91
+ from oura.signals import notification_received
92
+ from oura.webhooks import process_notification
93
+
94
+ @receiver(notification_received)
95
+ def on_notification(sender, payload, **kwargs):
96
+ process_notification(payload) # or hand off to celery / a queue
97
+ ```
98
+
99
+ Notifications carry `{event_type, data_type, object_id, user_id}`; `process_notification` routes to a user by Oura's stable `user_id`, fetches the referenced document with that user's credentials, and ingests it. The receiver view enforces Oura's `x-oura-signature` HMAC before emitting the signal (set `OURA_WEBHOOK_REQUIRE_SIGNATURE = False` to accept unsigned POSTs). Oura expects a 2xx within 10 seconds — hand off to a queue if your processing is heavy. Subscriptions expire; renew with `oura.webhooks.renew_subscription`.
100
+
101
+ ## What gets stored
102
+
103
+ This app does **not** define `Record` / `Workout` tables — those live in `django-healthdatamodel`. The `oura.ingest` module maps Oura documents to `healthdatamodel` inputs and persists them with `source="oura"`:
104
+
105
+ | Oura data type | healthdatamodel records |
106
+ | --- | --- |
107
+ | `daily_activity` | steps, active calories, basal calories (total − active), equivalent walking distance |
108
+ | `sleep` | one sleep-stage record per `sleep_phase_5_min` run (deep/light/REM/awake) |
109
+ | `daily_spo2` | daily SpO2 average |
110
+ | `heartrate` | one heart-rate sample per 5-minute increment |
111
+ | `workout` | one `Workout` per workout |
112
+
113
+ Oura-specific scores (readiness, sleep score, resilience, stress) have no HealthKit-schema equivalent and are not ingested yet.
114
+
115
+ The only model defined here is `OuraConnection`: per-user OAuth tokens (Oura refresh tokens are single-use — both tokens are rewritten together on every refresh), granted scopes, connection status, and last sync timestamp.
116
+
117
+ ## Try it on your own data
118
+
119
+ The repo includes a runnable demo Django project at `demo/`.
120
+
121
+ ### 1. Register an Oura API application (one-time)
122
+
123
+ Create one at [cloud.ouraring.com/oauth/applications](https://cloud.ouraring.com/oauth/applications) and register the redirect URI `http://localhost:8000/oura/callback/` (exact match, trailing slash included). Unapproved applications are limited to 10 users — fine for the demo.
124
+
125
+ ### 2. Run the demo
126
+
127
+ ```
128
+ uv sync
129
+ uv run python manage.py migrate
130
+ uv run python manage.py createsuperuser
131
+ export OURA_CLIENT_ID=...
132
+ export OURA_CLIENT_SECRET=...
133
+ uv run python manage.py runserver
134
+ ```
135
+
136
+ Open <http://localhost:8000/>, sign in with the superuser you just created, then:
137
+
138
+ - Click **Connect Oura** → consent on Oura (pick your scopes) → land back on the homepage with an `OuraConnection` saved for your user.
139
+ - Open the Oura app on your phone so your ring syncs (sleep data only reaches Oura's cloud that way), then pick a window and click **Sync now**.
140
+ - Browse the resulting rows at `/admin/healthdatamodel/record/` (and `.../workout/`).
141
+
142
+ Or drive sync from the terminal:
143
+
144
+ ```
145
+ uv run python manage.py sync_oura --user <your-username> --days 30
146
+ ```
147
+
148
+ ## Documentation
149
+
150
+ Summaries of the Oura API docs live under `docs/oura/` so they're grep-able offline:
151
+
152
+ - `authentication.md` — OAuth2 endpoints, scopes, token lifetimes, revocation
153
+ - `api.md` — the v2 usercollection routes, params, pagination, payload shapes
154
+ - `webhooks.md` — subscription API, verification challenge, signatures, retries
155
+
156
+ ## Development
157
+
158
+ ```
159
+ uv sync --group dev
160
+ uv run pytest tests/ -v
161
+ uv run pre-commit run --all-files
162
+ ```
File without changes
@@ -0,0 +1,30 @@
1
+ """Wire healthdatamodel's unregistered admin classes into the demo project.
2
+
3
+ The upstream package exposes ``RecordAdmin`` / ``WorkoutAdmin`` /
4
+ ``WearableConnectionAdmin`` as classes but doesn't call ``admin.site.register``
5
+ itself — that lets each consumer decide whether to expose these in their own
6
+ admin. The demo registers them so you can browse synced data at
7
+ ``/admin/healthdatamodel/record/`` and friends.
8
+ """
9
+
10
+ from django.contrib import admin
11
+ from healthdatamodel.admin import (
12
+ DataSourceRankingAdmin,
13
+ RecordAdmin,
14
+ WearableConnectionAdmin,
15
+ WorkoutAdmin,
16
+ WorkoutMetadataEntryAdmin,
17
+ )
18
+ from healthdatamodel.models import (
19
+ DataSourceRanking,
20
+ Record,
21
+ WearableConnection,
22
+ Workout,
23
+ WorkoutMetadataEntry,
24
+ )
25
+
26
+ admin.site.register(Record, RecordAdmin)
27
+ admin.site.register(Workout, WorkoutAdmin)
28
+ admin.site.register(WorkoutMetadataEntry, WorkoutMetadataEntryAdmin)
29
+ admin.site.register(WearableConnection, WearableConnectionAdmin)
30
+ admin.site.register(DataSourceRanking, DataSourceRankingAdmin)