virtuagym 1.0.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.
- virtuagym-1.0.0/.env.example +5 -0
- virtuagym-1.0.0/.github/workflows/ci.yml +24 -0
- virtuagym-1.0.0/.github/workflows/publish.yml +26 -0
- virtuagym-1.0.0/.gitignore +8 -0
- virtuagym-1.0.0/LICENSE +21 -0
- virtuagym-1.0.0/PKG-INFO +176 -0
- virtuagym-1.0.0/README.md +145 -0
- virtuagym-1.0.0/pyproject.toml +62 -0
- virtuagym-1.0.0/tests/conftest.py +19 -0
- virtuagym-1.0.0/tests/test_smoke.py +181 -0
- virtuagym-1.0.0/tests/test_v1.py +347 -0
- virtuagym-1.0.0/tests/test_v3.py +336 -0
- virtuagym-1.0.0/virtuagym/__init__.py +16 -0
- virtuagym-1.0.0/virtuagym/_types.py +17 -0
- virtuagym-1.0.0/virtuagym/exceptions.py +31 -0
- virtuagym-1.0.0/virtuagym/py.typed +0 -0
- virtuagym-1.0.0/virtuagym/v1/__init__.py +0 -0
- virtuagym-1.0.0/virtuagym/v1/_core.py +170 -0
- virtuagym-1.0.0/virtuagym/v1/async_client.py +489 -0
- virtuagym-1.0.0/virtuagym/v1/client.py +581 -0
- virtuagym-1.0.0/virtuagym/v1/models.py +490 -0
- virtuagym-1.0.0/virtuagym/v3/__init__.py +0 -0
- virtuagym-1.0.0/virtuagym/v3/_core.py +70 -0
- virtuagym-1.0.0/virtuagym/v3/async_client.py +246 -0
- virtuagym-1.0.0/virtuagym/v3/client.py +294 -0
- virtuagym-1.0.0/virtuagym/v3/models.py +264 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
ci:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ['3.10', '3.12', '3.14']
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v7
|
|
16
|
+
- uses: actions/setup-python@v6
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: pip install -e .[dev]
|
|
20
|
+
- run: ruff check .
|
|
21
|
+
- run: ruff format --check .
|
|
22
|
+
- run: mypy
|
|
23
|
+
# Unit tests only — smoke tests need live credentials and are run locally.
|
|
24
|
+
- run: pytest
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ['v*']
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
# Required for PyPI trusted publishing (OIDC).
|
|
14
|
+
id-token: write
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
- uses: actions/setup-python@v6
|
|
18
|
+
with:
|
|
19
|
+
python-version: '3.12'
|
|
20
|
+
- run: pip install -e .[dev] build
|
|
21
|
+
- run: ruff check .
|
|
22
|
+
- run: mypy
|
|
23
|
+
# Unit tests only — smoke tests need live credentials and are run locally.
|
|
24
|
+
- run: pytest
|
|
25
|
+
- run: python -m build
|
|
26
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
virtuagym-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gold Development
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
virtuagym-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: virtuagym
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Typed Python client for the Virtuagym API (v1 + v3), sync + async
|
|
5
|
+
Project-URL: Homepage, https://github.com/gold-development/virtuagym-python
|
|
6
|
+
Project-URL: Issues, https://github.com/gold-development/virtuagym-python/issues
|
|
7
|
+
Author-email: Gold Development <admin@gold-development.nl>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api,client,fitness,virtuagym
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: pydantic>=2.7
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.14; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.25; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Requires-Dist: python-dotenv>=1; extra == 'dev'
|
|
28
|
+
Requires-Dist: respx>=0.22; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# virtuagym
|
|
33
|
+
|
|
34
|
+
[](https://github.com/gold-development/virtuagym-python/actions/workflows/ci.yml)
|
|
35
|
+
[](https://pypi.org/project/virtuagym/)
|
|
36
|
+
|
|
37
|
+
A typed Python client for the [Virtuagym API](https://github.com/virtuagym/api-documentation) — v1 (api key + club secret) and v3 (OAuth client credentials) — with sync and async clients.
|
|
38
|
+
|
|
39
|
+
- **Typed models** — every response is validated onto frozen [pydantic](https://docs.pydantic.dev) models: responses that don't match the schema fail loudly instead of corrupting your data.
|
|
40
|
+
- **Battle-tested against the live API** — the behavior of every endpoint (pagination cursors, envelope quirks, type inconsistencies) was verified live; see [API-FINDINGS](https://github.com/gold-development/virtuagym-node/blob/main/API-FINDINGS.md) for everything the docs don't tell you.
|
|
41
|
+
- **Pagination handled** — iterate lazily page by page, or fetch everything with one call. Duplicate rows caused by the API's inclusive cursors are deduplicated for you.
|
|
42
|
+
- **Sync and async** — `VirtuaGymClientV1`/`VirtuaGymClientV3` on httpx's sync client, `AsyncVirtuaGymClientV1`/`AsyncVirtuaGymClientV3` on its async client, identical method surfaces.
|
|
43
|
+
- Sibling packages: [Node.js/TypeScript](https://github.com/gold-development/virtuagym-node) and [PHP](https://github.com/gold-development/virtuagym-php).
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install virtuagym
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Requires Python 3.10+.
|
|
52
|
+
|
|
53
|
+
## API v1 (api key + club secret)
|
|
54
|
+
|
|
55
|
+
You need three values, all found in Virtuagym under **Business settings → Business Info → Advanced**: your API key, the "Club Key" (club secret), and your club id.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from virtuagym import VirtuaGymClientV1
|
|
59
|
+
|
|
60
|
+
client = VirtuaGymClientV1(
|
|
61
|
+
api_key="...",
|
|
62
|
+
club_secret="...",
|
|
63
|
+
club_id=12345,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Lazily, page by page — each HTTP request only happens when you ask for the next page
|
|
67
|
+
for page in client.members():
|
|
68
|
+
print(f"received {len(page)} members")
|
|
69
|
+
|
|
70
|
+
# Or collect every page into a single list
|
|
71
|
+
members = client.all_members()
|
|
72
|
+
|
|
73
|
+
# Incremental sync (timestamp in ms)
|
|
74
|
+
changed = client.all_members(sync_from=last_sync_timestamp)
|
|
75
|
+
|
|
76
|
+
# Single member with membership instances embedded
|
|
77
|
+
member = client.member(7302399, with_="memberships")
|
|
78
|
+
|
|
79
|
+
# Mutations re-fetch and return the canonical record
|
|
80
|
+
created = client.create_member(
|
|
81
|
+
{"firstname": "John", "lastname": "Doe", "email": "john@example.com"}
|
|
82
|
+
)
|
|
83
|
+
updated = client.update_member(created.member_id, {"gender": "f"})
|
|
84
|
+
upserted = client.create_or_update_member(
|
|
85
|
+
{"external_id": "1ABC234567", "firstname": "John", "lastname": "Doe"}
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The full v1 surface: `employees`, `members`, `activate_user`, `events`, `membership_instances` / `membership_definitions` / `create_membership_instance`, `event_participants` (bookings), `club_taxes`, `income_categories`, `invoices`, `visits`, `member_notes`, `member_credits` / `add_member_credits`, `assign_workout`, `bodymetrics`. Every list endpoint has a lazy iterator (`members()`) and a collector (`all_members()`). Mutation payloads are plain dicts with the API's wire-format keys.
|
|
90
|
+
|
|
91
|
+
Worth knowing (all verified live): single-resource GETs 404 with statuscode **420**; the notes endpoint is capped at the newest 500 rows; notes and credits use **seconds** for `sync_from` while most endpoints use milliseconds; credits rows have no unique id and page-boundary duplicates are deduplicated for you.
|
|
92
|
+
|
|
93
|
+
## API v3 (OAuth)
|
|
94
|
+
|
|
95
|
+
The v3 API is a separate stack behind `gateway.services.virtuagym.com`, authenticated with OAuth client credentials ([register via api@virtuagym.com](https://github.com/virtuagym/api-documentation/blob/master/V3_AUTHENTICATION.md)). Tokens are requested and renewed automatically (club-bound, ~30 min lifetime). Which v3 resources you can reach depends on the scopes Virtuagym registered for your client — without the right scope the API answers a misleading 401 `Token not valid.`.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
import time
|
|
99
|
+
|
|
100
|
+
from virtuagym import VirtuaGymClientV3
|
|
101
|
+
|
|
102
|
+
client = VirtuaGymClientV3(
|
|
103
|
+
client_id="...",
|
|
104
|
+
client_secret="...",
|
|
105
|
+
club_id=12345,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Leads — the live API serializes EVERY lead field as a string
|
|
109
|
+
leads = client.all_leads()
|
|
110
|
+
lead = client.lead(751563)
|
|
111
|
+
created = client.create_lead({"firstname": "Jane", "lastname": "Doe", "email": "jane@example.com"})
|
|
112
|
+
client.update_lead(created.lead_id, {"status_id": 12}) # Closed won
|
|
113
|
+
|
|
114
|
+
# Schedule (requires the schedule_public_api_club_<club_id> scope)
|
|
115
|
+
now = int(time.time() * 1000)
|
|
116
|
+
week = 7 * 24 * 3600 * 1000
|
|
117
|
+
|
|
118
|
+
events = client.all_events(date_start=now, date_end=now + week, event_type="appointment")
|
|
119
|
+
bookings = client.all_event_bookings(date_start=now, date_end=now + week, member_id=42)
|
|
120
|
+
|
|
121
|
+
result = client.create_booking(events[0].event_id, {"member_id": 42})
|
|
122
|
+
# Inspect result.bookings[0].reason — see BOOKING_REASON_CODES in
|
|
123
|
+
# virtuagym.v3.models. NOTE: a member without the required credit type is
|
|
124
|
+
# booked UNPAID rather than rejected — check payment_info if payment matters.
|
|
125
|
+
client.update_booking(events[0].event_id, {"member_id": 42, "presence": True})
|
|
126
|
+
client.cancel_booking(events[0].event_id, member_id=42, refund=False)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Note: occurrences of a recurring event **share the same `event_id`** and differ only in `datetime_start` — use `event_id` + `datetime_start` as the occurrence key.
|
|
130
|
+
|
|
131
|
+
## Async
|
|
132
|
+
|
|
133
|
+
Both clients have async twins with identical surfaces:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from virtuagym import AsyncVirtuaGymClientV1
|
|
137
|
+
|
|
138
|
+
client = AsyncVirtuaGymClientV1(api_key="...", club_secret="...", club_id=12345)
|
|
139
|
+
|
|
140
|
+
async for page in client.members():
|
|
141
|
+
...
|
|
142
|
+
members = await client.all_members()
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Error handling
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from virtuagym import VirtuaGymApiError, VirtuaGymV3ApiError
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
client.member(999)
|
|
152
|
+
except VirtuaGymApiError as e:
|
|
153
|
+
# v1: in-band API errors (reported with HTTP 200), real-HTTP-status
|
|
154
|
+
# errors, and envelope failures.
|
|
155
|
+
print(e.statuscode, e.statusmessage) # e.g. 420 'Not found.'
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- **`VirtuaGymApiError`** (v1) — `statuscode`, `statusmessage`, optional `errors` payload.
|
|
159
|
+
- **`VirtuaGymV3ApiError`** (v3) — real HTTP status in `http_status`, invalid field names in `fields`. On a 401 the client refreshes the token and retries once before raising.
|
|
160
|
+
- **`pydantic.ValidationError`** — the response did not match the documented schema, naming the exact offending field.
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
pip install -e .[dev]
|
|
166
|
+
pytest # unit tests (offline, mocked HTTP)
|
|
167
|
+
ruff check . && ruff format --check . && mypy
|
|
168
|
+
|
|
169
|
+
# Smoke tests against the live API (read-only):
|
|
170
|
+
cp .env.example .env # then fill in your credentials
|
|
171
|
+
pytest -m smoke
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# virtuagym
|
|
2
|
+
|
|
3
|
+
[](https://github.com/gold-development/virtuagym-python/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/virtuagym/)
|
|
5
|
+
|
|
6
|
+
A typed Python client for the [Virtuagym API](https://github.com/virtuagym/api-documentation) — v1 (api key + club secret) and v3 (OAuth client credentials) — with sync and async clients.
|
|
7
|
+
|
|
8
|
+
- **Typed models** — every response is validated onto frozen [pydantic](https://docs.pydantic.dev) models: responses that don't match the schema fail loudly instead of corrupting your data.
|
|
9
|
+
- **Battle-tested against the live API** — the behavior of every endpoint (pagination cursors, envelope quirks, type inconsistencies) was verified live; see [API-FINDINGS](https://github.com/gold-development/virtuagym-node/blob/main/API-FINDINGS.md) for everything the docs don't tell you.
|
|
10
|
+
- **Pagination handled** — iterate lazily page by page, or fetch everything with one call. Duplicate rows caused by the API's inclusive cursors are deduplicated for you.
|
|
11
|
+
- **Sync and async** — `VirtuaGymClientV1`/`VirtuaGymClientV3` on httpx's sync client, `AsyncVirtuaGymClientV1`/`AsyncVirtuaGymClientV3` on its async client, identical method surfaces.
|
|
12
|
+
- Sibling packages: [Node.js/TypeScript](https://github.com/gold-development/virtuagym-node) and [PHP](https://github.com/gold-development/virtuagym-php).
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install virtuagym
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Requires Python 3.10+.
|
|
21
|
+
|
|
22
|
+
## API v1 (api key + club secret)
|
|
23
|
+
|
|
24
|
+
You need three values, all found in Virtuagym under **Business settings → Business Info → Advanced**: your API key, the "Club Key" (club secret), and your club id.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from virtuagym import VirtuaGymClientV1
|
|
28
|
+
|
|
29
|
+
client = VirtuaGymClientV1(
|
|
30
|
+
api_key="...",
|
|
31
|
+
club_secret="...",
|
|
32
|
+
club_id=12345,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Lazily, page by page — each HTTP request only happens when you ask for the next page
|
|
36
|
+
for page in client.members():
|
|
37
|
+
print(f"received {len(page)} members")
|
|
38
|
+
|
|
39
|
+
# Or collect every page into a single list
|
|
40
|
+
members = client.all_members()
|
|
41
|
+
|
|
42
|
+
# Incremental sync (timestamp in ms)
|
|
43
|
+
changed = client.all_members(sync_from=last_sync_timestamp)
|
|
44
|
+
|
|
45
|
+
# Single member with membership instances embedded
|
|
46
|
+
member = client.member(7302399, with_="memberships")
|
|
47
|
+
|
|
48
|
+
# Mutations re-fetch and return the canonical record
|
|
49
|
+
created = client.create_member(
|
|
50
|
+
{"firstname": "John", "lastname": "Doe", "email": "john@example.com"}
|
|
51
|
+
)
|
|
52
|
+
updated = client.update_member(created.member_id, {"gender": "f"})
|
|
53
|
+
upserted = client.create_or_update_member(
|
|
54
|
+
{"external_id": "1ABC234567", "firstname": "John", "lastname": "Doe"}
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The full v1 surface: `employees`, `members`, `activate_user`, `events`, `membership_instances` / `membership_definitions` / `create_membership_instance`, `event_participants` (bookings), `club_taxes`, `income_categories`, `invoices`, `visits`, `member_notes`, `member_credits` / `add_member_credits`, `assign_workout`, `bodymetrics`. Every list endpoint has a lazy iterator (`members()`) and a collector (`all_members()`). Mutation payloads are plain dicts with the API's wire-format keys.
|
|
59
|
+
|
|
60
|
+
Worth knowing (all verified live): single-resource GETs 404 with statuscode **420**; the notes endpoint is capped at the newest 500 rows; notes and credits use **seconds** for `sync_from` while most endpoints use milliseconds; credits rows have no unique id and page-boundary duplicates are deduplicated for you.
|
|
61
|
+
|
|
62
|
+
## API v3 (OAuth)
|
|
63
|
+
|
|
64
|
+
The v3 API is a separate stack behind `gateway.services.virtuagym.com`, authenticated with OAuth client credentials ([register via api@virtuagym.com](https://github.com/virtuagym/api-documentation/blob/master/V3_AUTHENTICATION.md)). Tokens are requested and renewed automatically (club-bound, ~30 min lifetime). Which v3 resources you can reach depends on the scopes Virtuagym registered for your client — without the right scope the API answers a misleading 401 `Token not valid.`.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import time
|
|
68
|
+
|
|
69
|
+
from virtuagym import VirtuaGymClientV3
|
|
70
|
+
|
|
71
|
+
client = VirtuaGymClientV3(
|
|
72
|
+
client_id="...",
|
|
73
|
+
client_secret="...",
|
|
74
|
+
club_id=12345,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Leads — the live API serializes EVERY lead field as a string
|
|
78
|
+
leads = client.all_leads()
|
|
79
|
+
lead = client.lead(751563)
|
|
80
|
+
created = client.create_lead({"firstname": "Jane", "lastname": "Doe", "email": "jane@example.com"})
|
|
81
|
+
client.update_lead(created.lead_id, {"status_id": 12}) # Closed won
|
|
82
|
+
|
|
83
|
+
# Schedule (requires the schedule_public_api_club_<club_id> scope)
|
|
84
|
+
now = int(time.time() * 1000)
|
|
85
|
+
week = 7 * 24 * 3600 * 1000
|
|
86
|
+
|
|
87
|
+
events = client.all_events(date_start=now, date_end=now + week, event_type="appointment")
|
|
88
|
+
bookings = client.all_event_bookings(date_start=now, date_end=now + week, member_id=42)
|
|
89
|
+
|
|
90
|
+
result = client.create_booking(events[0].event_id, {"member_id": 42})
|
|
91
|
+
# Inspect result.bookings[0].reason — see BOOKING_REASON_CODES in
|
|
92
|
+
# virtuagym.v3.models. NOTE: a member without the required credit type is
|
|
93
|
+
# booked UNPAID rather than rejected — check payment_info if payment matters.
|
|
94
|
+
client.update_booking(events[0].event_id, {"member_id": 42, "presence": True})
|
|
95
|
+
client.cancel_booking(events[0].event_id, member_id=42, refund=False)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Note: occurrences of a recurring event **share the same `event_id`** and differ only in `datetime_start` — use `event_id` + `datetime_start` as the occurrence key.
|
|
99
|
+
|
|
100
|
+
## Async
|
|
101
|
+
|
|
102
|
+
Both clients have async twins with identical surfaces:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from virtuagym import AsyncVirtuaGymClientV1
|
|
106
|
+
|
|
107
|
+
client = AsyncVirtuaGymClientV1(api_key="...", club_secret="...", club_id=12345)
|
|
108
|
+
|
|
109
|
+
async for page in client.members():
|
|
110
|
+
...
|
|
111
|
+
members = await client.all_members()
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Error handling
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from virtuagym import VirtuaGymApiError, VirtuaGymV3ApiError
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
client.member(999)
|
|
121
|
+
except VirtuaGymApiError as e:
|
|
122
|
+
# v1: in-band API errors (reported with HTTP 200), real-HTTP-status
|
|
123
|
+
# errors, and envelope failures.
|
|
124
|
+
print(e.statuscode, e.statusmessage) # e.g. 420 'Not found.'
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- **`VirtuaGymApiError`** (v1) — `statuscode`, `statusmessage`, optional `errors` payload.
|
|
128
|
+
- **`VirtuaGymV3ApiError`** (v3) — real HTTP status in `http_status`, invalid field names in `fields`. On a 401 the client refreshes the token and retries once before raising.
|
|
129
|
+
- **`pydantic.ValidationError`** — the response did not match the documented schema, naming the exact offending field.
|
|
130
|
+
|
|
131
|
+
## Development
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip install -e .[dev]
|
|
135
|
+
pytest # unit tests (offline, mocked HTTP)
|
|
136
|
+
ruff check . && ruff format --check . && mypy
|
|
137
|
+
|
|
138
|
+
# Smoke tests against the live API (read-only):
|
|
139
|
+
cp .env.example .env # then fill in your credentials
|
|
140
|
+
pytest -m smoke
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "virtuagym"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Typed Python client for the Virtuagym API (v1 + v3), sync + async"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Gold Development", email = "admin@gold-development.nl" }]
|
|
14
|
+
keywords = ["virtuagym", "api", "client", "fitness"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"httpx>=0.27",
|
|
28
|
+
"pydantic>=2.7",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/gold-development/virtuagym-python"
|
|
33
|
+
Issues = "https://github.com/gold-development/virtuagym-python/issues"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = [
|
|
37
|
+
"mypy>=1.14",
|
|
38
|
+
"pytest>=8",
|
|
39
|
+
"pytest-asyncio>=0.25",
|
|
40
|
+
"python-dotenv>=1",
|
|
41
|
+
"respx>=0.22",
|
|
42
|
+
"ruff>=0.9",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["virtuagym"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
# Smoke tests hit the live API; run them explicitly with `pytest -m smoke`.
|
|
50
|
+
addopts = "-m 'not smoke'"
|
|
51
|
+
markers = ["smoke: tests against the live Virtuagym API (need .env credentials)"]
|
|
52
|
+
asyncio_mode = "auto"
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
line-length = 100
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
59
|
+
|
|
60
|
+
[tool.mypy]
|
|
61
|
+
strict = true
|
|
62
|
+
files = ["virtuagym"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
load_dotenv(Path(__file__).parent.parent / ".env")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def require_env(name: str) -> str:
|
|
11
|
+
value = os.environ.get(name)
|
|
12
|
+
if not value:
|
|
13
|
+
raise RuntimeError(f"Missing environment variable {name} — add it to your .env file")
|
|
14
|
+
return value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture()
|
|
18
|
+
def club_id() -> int:
|
|
19
|
+
return int(require_env("VIRTUAGYM_CLUB_ID"))
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Read-only smoke tests against the live Virtuagym API.
|
|
2
|
+
|
|
3
|
+
Run with ``pytest -m smoke``; requires credentials in ``.env``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from conftest import require_env
|
|
10
|
+
|
|
11
|
+
from virtuagym import AsyncVirtuaGymClientV3, VirtuaGymClientV1, VirtuaGymClientV3
|
|
12
|
+
|
|
13
|
+
pytestmark = pytest.mark.smoke
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture()
|
|
17
|
+
def v1() -> VirtuaGymClientV1:
|
|
18
|
+
return VirtuaGymClientV1(
|
|
19
|
+
api_key=require_env("VIRTUAGYM_API_KEY"),
|
|
20
|
+
club_secret=require_env("VIRTUAGYM_CLUB_SECRET"),
|
|
21
|
+
club_id=int(require_env("VIRTUAGYM_CLUB_ID")),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture()
|
|
26
|
+
def v3() -> VirtuaGymClientV3:
|
|
27
|
+
return VirtuaGymClientV3(
|
|
28
|
+
client_id=require_env("VIRTUAGYM_CLIENT_ID"),
|
|
29
|
+
client_secret=require_env("VIRTUAGYM_CLIENT_SECRET"),
|
|
30
|
+
club_id=int(require_env("VIRTUAGYM_CLUB_ID")),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_retrieves_employees(v1: VirtuaGymClientV1) -> None:
|
|
35
|
+
for employee in v1.all_employees():
|
|
36
|
+
assert employee.member_id > 0
|
|
37
|
+
assert employee.firstname
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_retrieves_all_members_without_duplicates(v1: VirtuaGymClientV1) -> None:
|
|
41
|
+
members = v1.all_members()
|
|
42
|
+
|
|
43
|
+
assert members
|
|
44
|
+
ids = [m.member_id for m in members]
|
|
45
|
+
assert len(set(ids)) == len(ids)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_retrieves_single_member_with_memberships(v1: VirtuaGymClientV1) -> None:
|
|
49
|
+
first = next(iter(v1.members()))[0]
|
|
50
|
+
|
|
51
|
+
single = v1.member(first.member_id, with_="memberships")
|
|
52
|
+
|
|
53
|
+
assert single.member_id == first.member_id
|
|
54
|
+
assert isinstance(single.memberships, list)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_retrieves_membership_instances_without_duplicates(v1: VirtuaGymClientV1) -> None:
|
|
58
|
+
instances = v1.all_membership_instances()
|
|
59
|
+
|
|
60
|
+
ids = [i.instance_id for i in instances]
|
|
61
|
+
assert len(set(ids)) == len(ids)
|
|
62
|
+
assert all(isinstance(i.active, bool) for i in instances)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_retrieves_membership_definitions_without_duplicates(v1: VirtuaGymClientV1) -> None:
|
|
66
|
+
definitions = v1.all_membership_definitions()
|
|
67
|
+
|
|
68
|
+
ids = [d.membership_id for d in definitions]
|
|
69
|
+
assert len(set(ids)) == len(ids)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_retrieves_event_participants(v1: VirtuaGymClientV1) -> None:
|
|
73
|
+
# Default window: (today - 1 month) .. (today + 1 month).
|
|
74
|
+
assert isinstance(v1.all_event_participants(), list)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_retrieves_club_taxes_and_income_categories(v1: VirtuaGymClientV1) -> None:
|
|
78
|
+
for tax in v1.club_taxes():
|
|
79
|
+
assert isinstance(tax.tax_id, str)
|
|
80
|
+
for category in v1.income_categories():
|
|
81
|
+
assert isinstance(category.income_category_id, str)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_retrieves_first_page_of_invoices(v1: VirtuaGymClientV1) -> None:
|
|
85
|
+
page = next(iter(v1.invoices()), [])
|
|
86
|
+
for invoice in page:
|
|
87
|
+
assert invoice.guid
|
|
88
|
+
assert isinstance(invoice.rows, list)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_retrieves_member_credits_without_duplicates(v1: VirtuaGymClientV1) -> None:
|
|
92
|
+
credits = v1.all_member_credits()
|
|
93
|
+
|
|
94
|
+
keys = [f"{c.member_id}|{c.service_type}" for c in credits]
|
|
95
|
+
assert len(set(keys)) == len(keys)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_retrieves_visits(v1: VirtuaGymClientV1) -> None:
|
|
99
|
+
assert isinstance(v1.all_visits(), list)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_retrieves_member_notes(v1: VirtuaGymClientV1) -> None:
|
|
103
|
+
for note in v1.member_notes():
|
|
104
|
+
assert note.note_id > 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_retrieves_all_leads_without_duplicates(v3: VirtuaGymClientV3) -> None:
|
|
108
|
+
leads = v3.all_leads()
|
|
109
|
+
|
|
110
|
+
assert leads
|
|
111
|
+
ids = [lead.lead_id for lead in leads]
|
|
112
|
+
assert len(set(ids)) == len(ids)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_paginates_leads_consistently(v3: VirtuaGymClientV3) -> None:
|
|
116
|
+
by_default = sorted(lead.lead_id for lead in v3.all_leads())
|
|
117
|
+
by_small_pages = sorted(lead.lead_id for lead in v3.all_leads(limit=10))
|
|
118
|
+
|
|
119
|
+
assert by_small_pages == by_default
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_retrieves_single_lead(v3: VirtuaGymClientV3) -> None:
|
|
123
|
+
first = next(iter(v3.leads(limit=1)))[0]
|
|
124
|
+
|
|
125
|
+
single = v3.lead(first.lead_id)
|
|
126
|
+
|
|
127
|
+
assert single.lead_id == first.lead_id
|
|
128
|
+
assert single.lead_guid == first.lead_guid
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# The schedule tests require the schedule integration scope
|
|
132
|
+
# (schedule_public_api_club_<club_id>) on the OAuth client.
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _window() -> tuple[int, int]:
|
|
136
|
+
now = int(time.time() * 1000)
|
|
137
|
+
week = 7 * 24 * 3600 * 1000
|
|
138
|
+
return now - week, now + week
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_retrieves_schedule_events_without_duplicate_occurrences(v3: VirtuaGymClientV3) -> None:
|
|
142
|
+
date_start, date_end = _window()
|
|
143
|
+
|
|
144
|
+
events = v3.all_events(date_start=date_start, date_end=date_end)
|
|
145
|
+
|
|
146
|
+
assert events
|
|
147
|
+
# event_id repeats for occurrences of recurring events; the occurrence
|
|
148
|
+
# (event_id + start time) must be unique.
|
|
149
|
+
keys = [f"{e.event_id}|{e.datetime_start}" for e in events]
|
|
150
|
+
assert len(set(keys)) == len(keys)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_retrieves_single_schedule_event(v3: VirtuaGymClientV3) -> None:
|
|
154
|
+
date_start, date_end = _window()
|
|
155
|
+
first = next(iter(v3.events(date_start=date_start, date_end=date_end)))[0]
|
|
156
|
+
|
|
157
|
+
single = v3.event(first.event_id)
|
|
158
|
+
|
|
159
|
+
assert single.event_id == first.event_id
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_retrieves_event_bookings(v3: VirtuaGymClientV3) -> None:
|
|
163
|
+
date_start, date_end = _window()
|
|
164
|
+
|
|
165
|
+
# A 204 (no bookings) yields an empty list; both are valid.
|
|
166
|
+
for event in v3.all_event_bookings(date_start=date_start, date_end=date_end):
|
|
167
|
+
assert event.event_id
|
|
168
|
+
for participant in event.participants or []:
|
|
169
|
+
assert participant.member_id > 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def test_async_v3_client_against_live_api() -> None:
|
|
173
|
+
client = AsyncVirtuaGymClientV3(
|
|
174
|
+
client_id=require_env("VIRTUAGYM_CLIENT_ID"),
|
|
175
|
+
client_secret=require_env("VIRTUAGYM_CLIENT_SECRET"),
|
|
176
|
+
club_id=int(require_env("VIRTUAGYM_CLUB_ID")),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
leads = await client.all_leads()
|
|
180
|
+
|
|
181
|
+
assert leads
|