iclub-api 0.1.0__py3-none-any.whl

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.
iclub_api/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Unofficial iClub Python SDK. No relationship to the iClub operators."""
2
+ from .client import IClub
3
+ from .transport import IClubError, AuthenticationError, TransportError, SignalRTransport
4
+
5
+ __all__ = ["IClub", "IClubError", "AuthenticationError", "TransportError", "SignalRTransport"]
6
+ __version__ = "0.1.0"
iclub_api/browser.py ADDED
@@ -0,0 +1,193 @@
1
+ """Optional Playwright support for functions not covered by the observed hubs."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import re
6
+ from datetime import datetime
7
+ from typing import Any
8
+ from .transport import BASE_URL, IClubError
9
+
10
+
11
+ def _visible_error(page):
12
+ return page.locator("[role='alert'], .mat-mdc-snack-bar-label, .mat-mdc-form-field-error, .toast-message").first
13
+
14
+
15
+ class IClubBrowser:
16
+ def __init__(self, *, headless: bool = True):
17
+ self.headless = headless
18
+ self._playwright: Any = None
19
+ self._browser: Any = None
20
+ self._context: Any = None
21
+ self._page: Any = None
22
+ self._qr_page: Any = None
23
+ self._lock = asyncio.Lock()
24
+
25
+ async def _start(self):
26
+ try:
27
+ from playwright.async_api import async_playwright
28
+ except ImportError as exc:
29
+ raise IClubError("Install the optional browser extra: pip install iclub_api[browser]", code="BROWSER_MISSING") from exc
30
+ self._playwright = await async_playwright().start()
31
+ self._browser = await self._playwright.chromium.launch(headless=self.headless)
32
+ self._context = await self._browser.new_context()
33
+ self._page = await self._context.new_page()
34
+ await self._page.goto(BASE_URL + "/#/zoznam-posilnovni", wait_until="domcontentloaded")
35
+ english = self._page.get_by_role("button", name=re.compile("English", re.I))
36
+ if await english.is_visible():
37
+ await english.click()
38
+ await self._page.keyboard.press("Escape")
39
+ return self._page
40
+
41
+ async def _select_gym(self, name: str):
42
+ page = self._page
43
+ picker = page.locator("input[role='combobox']").first
44
+ await picker.wait_for(state="visible", timeout=15000)
45
+ await picker.click()
46
+ option = page.get_by_role("option", name=name, exact=True)
47
+ if await option.count() != 1:
48
+ await picker.fill(name)
49
+ option = page.get_by_role("option", name=name, exact=True)
50
+ if await option.count() != 1:
51
+ raise IClubError("Gym option is missing or ambiguous", code="GYM_AMBIGUOUS")
52
+ await option.first.wait_for(state="visible", timeout=15000)
53
+ await option.first.click()
54
+ choose = page.locator("button, [role='button']").filter(has_text=re.compile("Choose a sport ground|Vybrať športovisko|Open|Otvoriť", re.I)).first
55
+ if await choose.is_visible():
56
+ await choose.click()
57
+ try:
58
+ await page.locator("input[type='password']").first.wait_for(state="visible", timeout=20000)
59
+ except Exception:
60
+ # Preserve gym selection from the preceding flow; no fallback if it was not established.
61
+ raise IClubError("Gym authentication page did not load", code="BROWSER_GYM_NAVIGATION_FAILED")
62
+ return page
63
+
64
+ async def login(self, email: str, password: str, gym_name: str):
65
+ if self._context:
66
+ await self.close()
67
+ page = await self._start()
68
+ await self._select_gym(gym_name)
69
+ await page.locator("input[formcontrolname='Email'], input[formcontrolname='email'], input[type='email'], input[placeholder='Email']").first.fill(email)
70
+ password_input = page.locator("input[type='password']").first
71
+ await password_input.fill(password)
72
+ login = page.get_by_role("button", name=re.compile(r"^(Login|Prihlásiť)$", re.I)).last
73
+ if await login.is_visible():
74
+ await login.click()
75
+ else:
76
+ await password_input.press("Enter")
77
+ try:
78
+ await page.wait_for_url(re.compile("#/hlavny-panel"), timeout=20000)
79
+ except Exception as exc:
80
+ raise IClubError("iClub browser login was rejected or page changed", code="BROWSER_LOGIN_FAILED") from exc
81
+
82
+ async def get_live_qr_code(self) -> dict[str, Any]:
83
+ if self._context is None:
84
+ raise IClubError("Browser not authenticated", code="BROWSER_NOT_AUTHENTICATED")
85
+ async with self._lock:
86
+ if self._qr_page is None or self._qr_page.is_closed():
87
+ self._qr_page = await self._context.new_page()
88
+ await self._qr_page.goto(BASE_URL + "/#/hlavny-panel", wait_until="domcontentloaded")
89
+ await self._qr_page.locator(".qrcode canvas").first.wait_for(state="visible", timeout=15000)
90
+ data = await self._qr_page.locator(".qrcode canvas").first.evaluate("canvas => canvas.toDataURL('image/png')")
91
+ return {"qrCodeDataUrl": data}
92
+
93
+ async def reset_qr_page(self):
94
+ if self._qr_page and not self._qr_page.is_closed():
95
+ await self._qr_page.close()
96
+ self._qr_page = None
97
+
98
+ async def prepare_credit_deposit(self, amount: float) -> dict[str, str]:
99
+ if not self._page:
100
+ raise IClubError("Browser not authenticated", code="BROWSER_NOT_AUTHENTICATED")
101
+ async with self._lock:
102
+ page = self._page
103
+ await page.goto(BASE_URL + "/#/profil", wait_until="domcontentloaded")
104
+ previous = page.url
105
+ pages = set(self._context.pages)
106
+ trigger = page.locator("a,button,[role='button']").filter(has_text=re.compile(r"Add credit|Dobitie kreditu|Dobitie|Credit", re.I)).first
107
+ if not await trigger.is_visible():
108
+ raise IClubError("Account credit top-up button not available", code="TOPUP_UNAVAILABLE")
109
+ await trigger.click()
110
+ await page.wait_for_timeout(1000)
111
+ popup = next((p for p in self._context.pages if p not in pages and not p.is_closed()), None)
112
+ target = popup or page
113
+ amount_input = target.locator("input[type='number'], input[inputmode='decimal'], input[formcontrolname*='amount' i], input[placeholder*='amount' i]").first
114
+ if await amount_input.is_visible():
115
+ await amount_input.fill(str(amount))
116
+ cont = target.locator("button, [role='button'], a").filter(has_text=re.compile("Continue|Next|Pay|Proceed|Top up|Dobitie|Add credit", re.I)).first
117
+ if await cont.is_visible():
118
+ await cont.click()
119
+ await target.wait_for_timeout(1200)
120
+ url = target.url
121
+ if url == previous:
122
+ raise IClubError("No payment redirect was detected", code="TOPUP_FLOW_UNAVAILABLE")
123
+ return {"message": "Opened official iClub top-up flow; no payment made by SDK", "redirectUrl": url}
124
+
125
+ async def reserve_event(self, *, name: str, start_date: str, trainer: str | None = None) -> dict[str, Any]:
126
+ """Fail closed unless calendar DOM identifies exactly one matching event."""
127
+ if not self._page:
128
+ raise IClubError("Browser not authenticated", code="BROWSER_NOT_AUTHENTICATED")
129
+ if not name or not start_date:
130
+ raise ValueError("Both event name and start_date are required")
131
+ async with self._lock:
132
+ page = self._page
133
+ await page.goto(BASE_URL + "/#/rezervacie/rozvrh", wait_until="domcontentloaded")
134
+ await page.locator(".fc-event, [class*='fc-event']").first.wait_for(timeout=10000)
135
+ expected = datetime.fromisoformat(start_date.replace("Z", "+00:00")).strftime("%H:%M")
136
+ events = page.locator(".fc-event")
137
+ matches = []
138
+ for i in range(await events.count()):
139
+ element = events.nth(i)
140
+ if not await element.is_visible():
141
+ continue
142
+ value = " ".join((await element.inner_text()).split())
143
+ if name.casefold() in value.casefold() and expected in value and (not trainer or trainer.casefold() in value.casefold()):
144
+ matches.append(element)
145
+ if len(matches) != 1:
146
+ raise IClubError(f"Expected one matching calendar event, found {len(matches)}; booking cancelled", code="BOOKING_EVENT_AMBIGUOUS")
147
+ await matches[0].click()
148
+ dialog = page.get_by_role("dialog")
149
+ await dialog.wait_for(timeout=10000)
150
+ buttons = dialog.get_by_role("button")
151
+ eligible = []
152
+ for i in range(await buttons.count()):
153
+ b = buttons.nth(i)
154
+ label = " ".join((await b.inner_text()).split())
155
+ if await b.is_enabled() and re.search(r"reserve|book|rezerv", label, re.I) and not re.search(r"cancel|zruš", label, re.I):
156
+ eligible.append((b, label))
157
+ if len(eligible) != 1:
158
+ raise IClubError("No unique explicit reservation action; booking cancelled", code="BOOKING_UNAVAILABLE")
159
+ await eligible[0][0].click()
160
+ return {"message": "Reservation action submitted", "action": eligible[0][1], "confirmed": False}
161
+
162
+ async def register(self, *, gym_name: str, gender: str, first_name: str, last_name: str, phone: str, email: str,
163
+ accept_privacy_policy: bool, accept_terms: bool, agree_newsletter: bool = False):
164
+ if not accept_privacy_policy or not accept_terms:
165
+ raise ValueError("Required consent must be explicitly granted")
166
+ if gender not in ("man", "woman"):
167
+ raise ValueError("gender must be 'man' or 'woman'")
168
+ page = await self._start()
169
+ await self._select_gym(gym_name)
170
+ await page.get_by_text(re.compile("Sign Up", re.I)).last.click()
171
+ await page.wait_for_url(re.compile("#/ucet/registracia"), timeout=20000)
172
+ await page.locator("#mat-radio-1-input" if gender == "woman" else "#mat-radio-0-input").check()
173
+ await page.locator("input[placeholder='Name']").fill(first_name)
174
+ await page.locator("input[placeholder='Surname']").fill(last_name)
175
+ await page.locator("input[placeholder='0912 123 456']").fill(phone)
176
+ await page.locator("input[placeholder='Email']").fill(email)
177
+ checkboxes = page.locator("input[type='checkbox']")
178
+ await checkboxes.nth(0).check()
179
+ if agree_newsletter:
180
+ await checkboxes.nth(1).check()
181
+ await checkboxes.nth(2).check()
182
+ await page.get_by_role("button", name=re.compile("^Sign Up$", re.I)).last.click()
183
+ await page.wait_for_url(re.compile("#/ucet/prihlasenie"), timeout=20000)
184
+ return {"message": "Registration submitted. Verify email if requested by iClub."}
185
+
186
+ async def close(self):
187
+ if self._context:
188
+ await self._context.close()
189
+ if self._browser:
190
+ await self._browser.close()
191
+ if self._playwright:
192
+ await self._playwright.stop()
193
+ self._context = self._browser = self._playwright = self._page = self._qr_page = None