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.
@@ -0,0 +1,5 @@
1
+ VIRTUAGYM_API_KEY=
2
+ VIRTUAGYM_CLUB_ID=
3
+ VIRTUAGYM_CLUB_SECRET=
4
+ VIRTUAGYM_CLIENT_ID=
5
+ VIRTUAGYM_CLIENT_SECRET=
@@ -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
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ dist/
5
+ .env
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
@@ -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.
@@ -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
+ [![CI](https://github.com/gold-development/virtuagym-python/actions/workflows/ci.yml/badge.svg)](https://github.com/gold-development/virtuagym-python/actions/workflows/ci.yml)
35
+ [![PyPI](https://img.shields.io/pypi/v/virtuagym)](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
+ [![CI](https://github.com/gold-development/virtuagym-python/actions/workflows/ci.yml/badge.svg)](https://github.com/gold-development/virtuagym-python/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/virtuagym)](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