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

1from __future__ import annotations 

2 

3from collections.abc import Callable 

4from pathlib import Path 

5from typing import Any, Self 

6 

7import httpx 

8 

9from bayernwerk_client.exceptions import ApiError, AuthenticationError 

10from bayernwerk_client.tokens import TokenSet, TokenStore 

11 

12DEFAULT_BASE_URL = "https://backend.e-fix.info" 

13DEFAULT_TENANT = "BAG" 

14"""Tenant code for Bayernwerk (as sent by the SPA itself).""" 

15 

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.""" 

18 

19TokenRefresher = Callable[[TokenSet], TokenSet] 

20 

21 

22class EfixClient: 

23 """GraphQL client for backend.e-fix.info (the e-fix installer portal). 

24 

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 """ 

32 

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) 

48 

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) 

55 

56 def close(self) -> None: 

57 self._http.close() 

58 

59 def __enter__(self) -> Self: 

60 return self 

61 

62 def __exit__(self, *exc_info: object) -> None: 

63 self.close() 

64 

65 @property 

66 def tokens(self) -> TokenSet: 

67 return self._tokens 

68 

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) 

75 

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`. 

84 

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() 

91 

92 payload = {"query": query, "operationName": operation_name, "variables": variables or {}} 

93 response = self._send(payload) 

94 

95 if response.status_code == 401 and self._on_token_expired is not None: 

96 self._renew() 

97 response = self._send(payload) 

98 

99 if response.is_error: 

100 raise ApiError(response.status_code, response.text) 

101 

102 body = response.json() 

103 if body.get("errors"): 

104 raise ApiError(response.status_code, str(body["errors"])) 

105 return body.get("data") 

106 

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) 

110 

111 # --- Installer (own company/account data) ------------------------------- 

112 def get_installer(self) -> Any: 

113 return self.query(_GET_INSTALLER_QUERY, operation_name="getInstaller")["getInstaller"] 

114 

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"] 

118 

119 def get_user_status(self) -> Any: 

120 return self.query(_USER_STATUS_QUERY)["userStatus"] 

121 

122 def list_my_registered_events(self) -> Any: 

123 return self.query(_MY_REGISTERED_EVENTS_QUERY)["myRegisteredEvents"] 

124 

125 

126_GET_INSTALLER_ANTRAEGE_QUERY = """ 

127query getInstallerAntraege { 

128 getInstallerAntraege { 

129 antragstatus 

130 antragtyp 

131 antragsubtyp 

132 eingangsdatum 

133 id 

134 __typename 

135 } 

136} 

137""" 

138 

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""" 

212 

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""" 

232 

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"""