Coverage for src/bayernwerk_client/efix/client.py: 99%
70 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 13:07 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 13:07 +0000
1from __future__ import annotations
3from collections.abc import Callable
4from pathlib import Path
5from typing import Any, Self
7import httpx
9from bayernwerk_client.exceptions import ApiError, AuthenticationError
10from bayernwerk_client.tokens import TokenSet, TokenStore
12DEFAULT_BASE_URL = "https://backend.e-fix.info"
13DEFAULT_TENANT = "BAG"
14"""Tenant code for Bayernwerk (as sent by the SPA itself)."""
16EFIX_TOKEN_PATH = Path.home() / ".cache" / "bayernwerk-client" / "efix-tokens.json"
17"""Distinct from map's token path - two services must not share a cache file."""
19TokenRefresher = Callable[[TokenSet], TokenSet]
22class EfixClient:
23 """GraphQL client for backend.e-fix.info (the e-fix installer portal).
25 Unlike `MapClient` (REST), e-fix's backend is GraphQL-only: every call
26 is a `POST /graphql?tenant=<tenant>` with a `{query, operationName,
27 variables}` body and an `Authorization: Bearer <jwt>` header. Plain
28 httpx underneath - no browser involved here. Obtain the initial
29 `TokenSet` via `bayernwerk_client.efix.auth.login_interactive` (or by
30 loading one previously saved with `TokenStore`).
31 """
33 def __init__(
34 self,
35 tokens: TokenSet,
36 *,
37 base_url: str = DEFAULT_BASE_URL,
38 tenant: str = DEFAULT_TENANT,
39 on_token_expired: TokenRefresher | None = None,
40 token_store: TokenStore | None = None,
41 timeout: float = 30.0,
42 ) -> None:
43 self._tokens = tokens
44 self._on_token_expired = on_token_expired
45 self._token_store = token_store
46 self._tenant = tenant
47 self._http = httpx.Client(base_url=base_url, timeout=timeout)
49 @classmethod
50 def from_token_store(cls, token_store: TokenStore, **kwargs: Any) -> EfixClient:
51 tokens = token_store.load()
52 if tokens is None:
53 raise AuthenticationError(f"No cached tokens found at {token_store.path}")
54 return cls(tokens, token_store=token_store, **kwargs)
56 def close(self) -> None:
57 self._http.close()
59 def __enter__(self) -> Self:
60 return self
62 def __exit__(self, *exc_info: object) -> None:
63 self.close()
65 @property
66 def tokens(self) -> TokenSet:
67 return self._tokens
69 def _renew(self) -> None:
70 if self._on_token_expired is None:
71 raise AuthenticationError("Access token expired/rejected and no on_token_expired callback was configured")
72 self._tokens = self._on_token_expired(self._tokens)
73 if self._token_store is not None:
74 self._token_store.save(self._tokens)
76 def query(
77 self,
78 query: str,
79 *,
80 operation_name: str | None = None,
81 variables: dict[str, Any] | None = None,
82 ) -> Any:
83 """Run a raw GraphQL query/mutation and return its `data`.
85 Raises `ApiError` both for a non-2xx HTTP status and for a
86 GraphQL-level `errors` array (GraphQL responses are HTTP 200 even
87 when the query itself failed).
88 """
89 if self._tokens.is_expired:
90 self._renew()
92 payload = {"query": query, "operationName": operation_name, "variables": variables or {}}
93 response = self._send(payload)
95 if response.status_code == 401 and self._on_token_expired is not None:
96 self._renew()
97 response = self._send(payload)
99 if response.is_error:
100 raise ApiError(response.status_code, response.text)
102 body = response.json()
103 if body.get("errors"):
104 raise ApiError(response.status_code, str(body["errors"]))
105 return body.get("data")
107 def _send(self, payload: dict[str, Any]) -> httpx.Response:
108 headers = {"Authorization": f"Bearer {self._tokens.access_token}"}
109 return self._http.post("/graphql", params={"tenant": self._tenant}, json=payload, headers=headers)
111 # --- Installer (own company/account data) -------------------------------
112 def get_installer(self) -> Any:
113 return self.query(_GET_INSTALLER_QUERY, operation_name="getInstaller")["getInstaller"]
115 def list_installer_antraege(self) -> Any:
116 """ "Anträge" (applications) tied to this installer account."""
117 return self.query(_GET_INSTALLER_ANTRAEGE_QUERY, operation_name="getInstallerAntraege")["getInstallerAntraege"]
119 def get_user_status(self) -> Any:
120 return self.query(_USER_STATUS_QUERY)["userStatus"]
122 def list_my_registered_events(self) -> Any:
123 return self.query(_MY_REGISTERED_EVENTS_QUERY)["myRegisteredEvents"]
126_GET_INSTALLER_ANTRAEGE_QUERY = """
127query getInstallerAntraege {
128 getInstallerAntraege {
129 antragstatus
130 antragtyp
131 antragsubtyp
132 eingangsdatum
133 id
134 __typename
135 }
136}
137"""
139_GET_INSTALLER_QUERY = """
140query getInstaller {
141 getInstaller {
142 id
143 firmenname1
144 firmenname2
145 firmenname3
146 firmeninhaber
147 email
148 telefonGeschaeft
149 telefonMobil
150 fax
151 zaehlermontageStrom
152 zaehlermontageGas
153 bueroStrasse
154 bueroHausnummer
155 bueroPlz
156 bueroOrt
157 bueroOrtsteil
158 bueroLand
159 referenznummerStrom
160 zuAdressveroeff
161 zuInfoversand
162 eintragungsartStrom
163 eintragungsartGas
164 bBetriebsartStrom {
165 name
166 __typename
167 }
168 bBetriebsartGas {
169 name
170 __typename
171 }
172 statusStrom
173 statusGas
174 auswStromGueltig
175 auswGasGueltig
176 auswStromDatum
177 auswGasDatum
178 eintragungsartErgStrom
179 eintragungsartErgGas
180 gewerbeanmeldungOrt
181 gewerbeanmeldungDatum
182 hrabnummer
183 hwknummer
184 hwkdatum
185 pflegendeBu
186 registergericht
187 registerLetzteAenderung
188 geloeschtAm
189 geloeschtGasAm
190 geloeschtWasserAm
191 ausweisnrFremdStrom
192 ausweisnrFremdGas
193 bNetzbetreiberGas {
194 id
195 name
196 __typename
197 }
198 bNetzbetreiberStrom {
199 id
200 name
201 __typename
202 }
203 bHwkbereich {
204 name
205 id
206 __typename
207 }
208 __typename
209 }
210}
211"""
213_USER_STATUS_QUERY = """
214{
215 userStatus {
216 notifications {
217 articles
218 events
219 __typename
220 }
221 subscription
222 role
223 iamType
224 id
225 installerEmail
226 latestDebFormRequestDate
227 fullName
228 __typename
229 }
230}
231"""
233_MY_REGISTERED_EVENTS_QUERY = """
234{
235 myRegisteredEvents {
236 id
237 path
238 startDate
239 endDate
240 title
241 meetingLink
242 deleted
243 chargingAmount
244 eventDays {
245 date
246 startTime
247 endTime
248 __typename
249 }
250 registration {
251 participants {
252 id
253 __typename
254 }
255 __typename
256 }
257 __typename
258 }
259}
260"""