binaaz-python-sdk 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.
binaaz_sdk/errors.py ADDED
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class BinaAzError(Exception):
7
+ def __init__(self, message: str) -> None:
8
+ super().__init__(message)
9
+ self.message = message
10
+
11
+
12
+ class ConfigurationError(BinaAzError):
13
+ pass
14
+
15
+
16
+ class InvalidRequestError(BinaAzError, ValueError):
17
+ pass
18
+
19
+
20
+ class TransportError(BinaAzError):
21
+ def __init__(self, message: str, *, path: str = "") -> None:
22
+ super().__init__(message)
23
+ self.path = path
24
+
25
+
26
+ class NotSentError(TransportError):
27
+ pass
28
+
29
+
30
+ class OutcomeUnknownError(TransportError):
31
+ def __init__(self, message: str, *, status_code: Optional[int] = None, **kwargs: Any) -> None:
32
+ super().__init__(message, **kwargs)
33
+ self.status_code = status_code
34
+
35
+
36
+ class APIError(BinaAzError):
37
+ def __init__(self, message: str, *, status_code: int = 0, body: Any = None, path: str = "") -> None:
38
+ super().__init__(message)
39
+ self.status_code = status_code
40
+ self.body = body
41
+ self.path = path
42
+
43
+ def __str__(self) -> str:
44
+ return f"[{self.status_code}] {self.message}" if self.status_code else self.message
45
+
46
+
47
+ class BlockedError(APIError):
48
+ pass
49
+
50
+
51
+ class BadRequestError(APIError):
52
+ pass
53
+
54
+
55
+ class AuthenticationError(APIError):
56
+ pass
57
+
58
+
59
+ class NotFoundError(APIError):
60
+ pass
61
+
62
+
63
+ class RateLimitError(APIError):
64
+ def __init__(self, message: str, *, retry_after: Optional[float] = None, **kwargs: Any) -> None:
65
+ super().__init__(message, **kwargs)
66
+ self.retry_after = retry_after
67
+
68
+
69
+ class ServerError(APIError):
70
+ pass
71
+
72
+
73
+ class GraphQLError(BadRequestError):
74
+ def __init__(self, message: str, *, errors: Any = None, **kwargs: Any) -> None:
75
+ super().__init__(message, **kwargs)
76
+ self.errors = list(errors or [])
77
+
78
+
79
+ class ItemError(BadRequestError):
80
+ def __init__(self, message: str, *, status: Optional[str] = None, errors: Any = None,
81
+ locked_until: Optional[str] = None, **kwargs: Any) -> None:
82
+ super().__init__(message, **kwargs)
83
+ self.status = status
84
+ self.errors = list(errors or [])
85
+ self.locked_until = locked_until
86
+
87
+
88
+ class NotSupportedError(BinaAzError):
89
+ pass
90
+
91
+
92
+ class CodeRejectedError(BadRequestError):
93
+ pass
94
+
95
+
96
+ def for_status(status: int) -> type[APIError]:
97
+ if status in (401, 403, 419):
98
+ return AuthenticationError
99
+ if status == 404:
100
+ return NotFoundError
101
+ if status == 429:
102
+ return RateLimitError
103
+ if status >= 500:
104
+ return ServerError
105
+ if status >= 400:
106
+ return BadRequestError
107
+ return APIError
binaaz_sdk/messages.py ADDED
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from importlib import resources
5
+ from pathlib import Path
6
+ from typing import Any, Union
7
+
8
+ import yaml
9
+
10
+ PathLike = Union[str, Path]
11
+
12
+ _PACKAGE_FILE = "messages.yaml"
13
+ _catalog: dict[str, str] = {}
14
+
15
+
16
+ def _flatten(node: Mapping[str, Any], prefix: str = "") -> dict[str, str]:
17
+ flat: dict[str, str] = {}
18
+ for key, value in node.items():
19
+ path = f"{prefix}{key}"
20
+ if isinstance(value, Mapping):
21
+ flat.update(_flatten(value, f"{path}."))
22
+ else:
23
+ flat[path] = str(value)
24
+ return flat
25
+
26
+
27
+ def _parse(raw: str) -> dict[str, str]:
28
+ data = yaml.safe_load(raw) or {}
29
+ if not isinstance(data, Mapping):
30
+ raise ValueError("messages file must contain a mapping")
31
+ return _flatten(data)
32
+
33
+
34
+ def _read_packaged() -> dict[str, str]:
35
+ raw = resources.files(__package__).joinpath(_PACKAGE_FILE).read_text(encoding="utf-8")
36
+ return _parse(raw)
37
+
38
+
39
+ def catalog() -> Mapping[str, str]:
40
+ if not _catalog:
41
+ _catalog.update(_read_packaged())
42
+ return _catalog
43
+
44
+
45
+ def use(source: PathLike | Mapping[str, Any]) -> None:
46
+ overrides = _flatten(source) if isinstance(source, Mapping) else _parse(Path(source).read_text(encoding="utf-8"))
47
+ unknown = set(overrides) - set(_read_packaged())
48
+ if unknown:
49
+ raise KeyError(", ".join(sorted(unknown)))
50
+ catalog()
51
+ _catalog.update(overrides)
52
+
53
+
54
+ def reset() -> None:
55
+ _catalog.clear()
56
+
57
+
58
+ def text(key: str, **params: Any) -> str:
59
+ template = catalog()[key]
60
+ return template.format(**params) if params else template
@@ -0,0 +1,62 @@
1
+ settings:
2
+ description: Settings for the bina.az client. Values come from code, BINAAZ_* environment variables or a config file.
3
+ cookie: Signed in bina.az session, as a Cookie header value. Empty until you sign in or pass one
4
+ base_url: bina.az address. The GraphQL endpoint lives under it
5
+ photos_url: Photo service address
6
+ login_url: Address of the SMS login service bina.az uses
7
+ return_to: Page the SMS login service is told to return to
8
+ endpoints: Path of every call, relative to its service address
9
+ language: Language sent to the SMS login service
10
+ timeout: Seconds to wait for an answer
11
+ connect_timeout: Seconds to wait for a connection
12
+ max_retries: Extra attempts for calls that are safe to repeat
13
+ code_attempts: Attempts for an SMS login call whose answer has no known error, the login service drops some answers
14
+ backoff_base: First wait between retries, in seconds
15
+ backoff_cap: Longest wait between retries, in seconds
16
+ user_agent: User-Agent header
17
+ allow_insecure_http: Allow plain http addresses outside localhost, for a test server on a private network only
18
+
19
+ value:
20
+ empty: "{field} cannot be empty"
21
+ too_long: "{field} is longer than {limit} characters"
22
+ range: "{field} must be a whole number from {low} to {high}, got {value!r}"
23
+ choice: "{field} must be one of {choices}, got {value!r}"
24
+ phone: "{value!r} is not an Azerbaijani mobile number such as 050 123 45 67"
25
+ id: "{field} must be a bina.az id, got {value!r}"
26
+ photos: "bina.az takes {low} to {high} photos on a listing, got {count}"
27
+ photo_file: "photo must be bytes, a file object or a path, got {value!r}"
28
+ confirm: "{action} costs money. Pass confirm=True to go ahead"
29
+ cookie: Cookie is empty or not in a known format
30
+
31
+ error:
32
+ base_url: "{field} must be an absolute http or https address, got {url}"
33
+ insecure: "Refusing to send a session over plain http to {url}. Use https, or set allow_insecure_http for a private test server."
34
+ connect: "Could not reach {host}, nothing was sent: {error}"
35
+ timeout: "{host} did not answer: {error}"
36
+ unknown_outcome: "{action} may or may not have gone through ({reason}). Check the account before trying again."
37
+ body: "{host} sent an answer that is not JSON: {body}"
38
+ status: "{host} answered with HTTP {status}"
39
+ blocked: "{host} answered with a Cloudflare challenge page. This client does not solve challenges; the site has to let your address through."
40
+ session: bina.az did not accept the session. Sign in again
41
+ no_profile: bina.az returned no profile, the session is not signed in
42
+ no_cookie: bina.az accepted the sign in but set no session cookie
43
+ login: "bina.az did not accept the sign in: {detail}"
44
+ graphql: "bina.az refused the request: {detail}"
45
+ validation: "bina.az rejected the listing: {detail}"
46
+ not_company: "Listing management needs a bina.az company profile, this session is a {kind}"
47
+ not_found: "bina.az item {item_id} was not found for this account"
48
+ photo_id: bina.az did not return a photo id
49
+ photo_size: The photo is too large for bina.az
50
+ code_service: "The SMS login service did not answer after {attempts} attempts ({last}). Try again in a moment."
51
+ code_limit: "Too many SMS codes or wrong codes for this number. Wait before asking again: {detail}"
52
+ code_phone: "The SMS login service refused the number: {detail}"
53
+ code_rejected: "The code was not accepted: {detail}"
54
+ code_reused: "The code was not accepted: {detail}. It may have been used by an earlier attempt, ask for a new one."
55
+ no_token: The SMS login service returned no access token
56
+ status_restricted: bina.az wants the account's phone number confirmed before posting
57
+ status_unverified: The bina.az account is not verified yet
58
+ status_invalid: bina.az rejected the listing data
59
+ status_failure: bina.az could not create the listing
60
+ status_locked: "The listing is locked on bina.az, for example under moderation{until}"
61
+ status_forbidden: This bina.az account can not edit the listing
62
+ status_not_found: The listing was not found on bina.az
binaaz_sdk/models.py ADDED
@@ -0,0 +1,365 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from decimal import Decimal, InvalidOperation
6
+ from typing import Any, Mapping, Optional
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
9
+ from pydantic.alias_generators import to_camel
10
+
11
+ from .enums import PHOTOS_MAX, BuildingType, Category, ContactType, ItemState
12
+
13
+ ORIGIN = "https://bina.az"
14
+
15
+
16
+ def _pick(body: Mapping[str, Any], *keys: str) -> Any:
17
+ for key in keys:
18
+ value = body.get(key)
19
+ if value is not None and value != "":
20
+ return value
21
+ return None
22
+
23
+
24
+ def _str(body: Mapping[str, Any], *keys: str) -> Optional[str]:
25
+ value = _pick(body, *keys)
26
+ return None if value is None else str(value)
27
+
28
+
29
+ def _int(body: Mapping[str, Any], *keys: str) -> Optional[int]:
30
+ value = _pick(body, *keys)
31
+ if value is None or isinstance(value, bool):
32
+ return None
33
+ try:
34
+ return int(value)
35
+ except (TypeError, ValueError):
36
+ return None
37
+
38
+
39
+ def _money(value: Any) -> Optional[Decimal]:
40
+ if value is None or isinstance(value, bool):
41
+ return None
42
+ try:
43
+ result = Decimal(str(value))
44
+ except (InvalidOperation, ValueError):
45
+ return None
46
+ return result if result.is_finite() else None
47
+
48
+
49
+ def _when(body: Mapping[str, Any], *keys: str) -> Optional[datetime]:
50
+ value = _pick(body, *keys)
51
+ if not isinstance(value, str):
52
+ return None
53
+ try:
54
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
55
+ except ValueError:
56
+ return None
57
+
58
+
59
+ def _map(value: Any) -> Mapping[str, Any]:
60
+ return value if isinstance(value, Mapping) else {}
61
+
62
+
63
+ def _list(value: Any) -> list[Any]:
64
+ return list(value) if isinstance(value, (list, tuple)) else []
65
+
66
+
67
+ def site_url(path: Optional[str], origin: str = ORIGIN) -> Optional[str]:
68
+ if not path:
69
+ return None
70
+ return path if path.startswith("http") else origin.rstrip("/") + path
71
+
72
+
73
+ class _Camel(BaseModel):
74
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra="forbid")
75
+
76
+
77
+ class Area(_Camel):
78
+ value: float = Field(gt=0)
79
+ land: Optional[float] = Field(default=None, gt=0)
80
+
81
+
82
+ class Floor(_Camel):
83
+ value: float
84
+ total: float = Field(gt=0)
85
+
86
+ @model_validator(mode="after")
87
+ def _check(self) -> Floor:
88
+ if self.value > self.total:
89
+ raise ValueError("floor.value cannot exceed floor.total")
90
+ return self
91
+
92
+
93
+ class Location(_Camel):
94
+ address: str = Field(min_length=1, max_length=500)
95
+ city_id: str
96
+ latitude: float = Field(ge=-90, le=90)
97
+ longitude: float = Field(ge=-180, le=180)
98
+ region_id: Optional[str] = None
99
+ township_id: Optional[str] = None
100
+ landmark_ids: Optional[list[str]] = None
101
+
102
+
103
+ class Contact(_Camel):
104
+ type: ContactType
105
+ name: Optional[str] = Field(default=None, max_length=100)
106
+ email: Optional[str] = Field(default=None, max_length=255)
107
+
108
+
109
+ class Listing(_Camel):
110
+ category_id: Category
111
+ is_leased: bool
112
+ price: int = Field(gt=0)
113
+ area: Area
114
+ location: Location
115
+ contact: Contact
116
+ photo_ids: list[str] = Field(default_factory=list, max_length=PHOTOS_MAX)
117
+ description: Optional[str] = Field(default=None, max_length=5000)
118
+ rooms: Optional[int] = Field(default=None, ge=1, le=50)
119
+ floor: Optional[Floor] = None
120
+ has_repair: Optional[bool] = None
121
+ has_mortgage: Optional[bool] = None
122
+ has_bill_of_sale: Optional[bool] = None
123
+ paid_daily: Optional[bool] = None
124
+ building_type: Optional[BuildingType] = None
125
+
126
+ @model_validator(mode="after")
127
+ def _check(self) -> Listing:
128
+ if self.paid_daily and not self.is_leased:
129
+ raise ValueError("paid_daily needs is_leased=True")
130
+ if self.building_type and self.category_id != Category.OFFICE:
131
+ raise ValueError("building_type is only valid for offices (category 7)")
132
+ return self
133
+
134
+ def _dump(self, **kw: Any) -> dict[str, Any]:
135
+ data = self.model_dump(mode="json", by_alias=True, exclude_none=True, **kw)
136
+ if "categoryId" in data:
137
+ data["categoryId"] = str(data["categoryId"])
138
+ return data
139
+
140
+ def to_create_input(self) -> dict[str, Any]:
141
+ return self._dump()
142
+
143
+ def to_update_input(self, item_id: str) -> dict[str, Any]:
144
+ data = self._dump(exclude={"category_id", "is_leased", "contact", "building_type"})
145
+ if self.contact.name:
146
+ data["contactName"] = self.contact.name
147
+ return {"itemId": str(item_id), **data}
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class Profile:
152
+ kind: str
153
+ id: Optional[str] = None
154
+ name: Optional[str] = None
155
+ email: Optional[str] = None
156
+ phone: Optional[str] = None
157
+ contact_name: Optional[str] = None
158
+ path: Optional[str] = None
159
+ agency: Optional[str] = None
160
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
161
+
162
+ @property
163
+ def is_company(self) -> bool:
164
+ return self.kind == "CompanyProfile"
165
+
166
+ @classmethod
167
+ def from_api(cls, body: Mapping[str, Any]) -> Profile:
168
+ return cls(
169
+ kind=_str(body, "__typename") or "",
170
+ id=_str(body, "id"),
171
+ name=_str(body, "name"),
172
+ email=_str(body, "email"),
173
+ phone=_str(body, "phone"),
174
+ contact_name=_str(body, "contactName"),
175
+ path=_str(body, "path"),
176
+ agency=_str(_map(body.get("agency")), "name"),
177
+ raw=dict(body),
178
+ )
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class Price:
183
+ total: Optional[Decimal]
184
+ currency: str
185
+
186
+ @classmethod
187
+ def from_api(cls, body: Any) -> Optional[Price]:
188
+ data = _map(body)
189
+ if not data:
190
+ return None
191
+ return cls(_money(data.get("total")), _str(data, "currency") or "AZN")
192
+
193
+
194
+ @dataclass(frozen=True)
195
+ class Item:
196
+ id: str
197
+ state: ItemState
198
+ site_state: Optional[str] = None
199
+ title: Optional[str] = None
200
+ path: Optional[str] = None
201
+ url: Optional[str] = None
202
+ price: Optional[Price] = None
203
+ expires_at: Optional[datetime] = None
204
+ updated_at: Optional[datetime] = None
205
+ thumbnail: Optional[str] = None
206
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
207
+
208
+ @classmethod
209
+ def from_api(cls, body: Mapping[str, Any]) -> Item:
210
+ photos = [p for p in _list(body.get("photos")) if isinstance(p, Mapping)]
211
+ return cls(
212
+ id=_str(body, "id") or "",
213
+ state=ItemState.from_site(body.get("state")),
214
+ site_state=_str(body, "state"),
215
+ title=_str(body, "title"),
216
+ path=_str(body, "path"),
217
+ url=site_url(_str(body, "path")),
218
+ price=Price.from_api(body.get("price")),
219
+ expires_at=_when(body, "expiresAt"),
220
+ updated_at=_when(body, "updatedAt"),
221
+ thumbnail=_str(photos[0], "thumbnail") if photos else None,
222
+ raw=dict(body),
223
+ )
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class ItemPage:
228
+ items: tuple[Item, ...]
229
+ total: Optional[int]
230
+ next_cursor: Optional[str]
231
+
232
+ @property
233
+ def has_more(self) -> bool:
234
+ return self.next_cursor is not None
235
+
236
+ @classmethod
237
+ def from_api(cls, body: Mapping[str, Any]) -> ItemPage:
238
+ info = _map(body.get("pageInfo"))
239
+ nodes = tuple(Item.from_api(n) for n in _list(body.get("nodes")) if isinstance(n, Mapping))
240
+ cursor = _str(info, "endCursor") if info.get("hasNextPage") else None
241
+ return cls(nodes, _int(body, "totalCount"), cursor)
242
+
243
+
244
+ @dataclass(frozen=True)
245
+ class ItemResult:
246
+ id: Optional[str]
247
+ state: Optional[ItemState]
248
+ site_state: Optional[str] = None
249
+ path: Optional[str] = None
250
+ url: Optional[str] = None
251
+ status: Optional[str] = None
252
+ locked_until: Optional[str] = None
253
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
254
+
255
+ @classmethod
256
+ def from_api(cls, body: Mapping[str, Any]) -> ItemResult:
257
+ item = _map(body.get("item"))
258
+ site_state = _str(item, "state")
259
+ return cls(
260
+ id=_str(item, "id"),
261
+ state=ItemState.from_site(site_state) if site_state else None,
262
+ site_state=site_state,
263
+ path=_str(item, "path"),
264
+ url=site_url(_str(item, "path")),
265
+ status=_str(body, "status"),
266
+ locked_until=_str(body, "lockedUntil"),
267
+ raw=dict(body),
268
+ )
269
+
270
+
271
+ @dataclass(frozen=True)
272
+ class Photo:
273
+ id: str
274
+ thumbnail_url: Optional[str] = None
275
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
276
+
277
+ @classmethod
278
+ def from_api(cls, body: Mapping[str, Any]) -> Photo:
279
+ return cls(_str(body, "id") or "", _str(body, "thumbnail_url", "thumbnailUrl", "thumbnail"), dict(body))
280
+
281
+
282
+ @dataclass(frozen=True)
283
+ class Place:
284
+ id: str
285
+ name: str
286
+
287
+ @classmethod
288
+ def from_api(cls, body: Mapping[str, Any]) -> Place:
289
+ return cls(_str(body, "id") or "", _str(body, "name") or "")
290
+
291
+
292
+ @dataclass(frozen=True)
293
+ class CategoryNode:
294
+ id: str
295
+ name: str
296
+ children: tuple[Place, ...] = ()
297
+
298
+ @classmethod
299
+ def from_api(cls, body: Mapping[str, Any]) -> CategoryNode:
300
+ kids = tuple(Place.from_api(c) for c in _list(body.get("children")) if isinstance(c, Mapping))
301
+ return cls(_str(body, "id") or "", _str(body, "name") or "", kids)
302
+
303
+
304
+ @dataclass(frozen=True)
305
+ class ServiceOption:
306
+ id: str
307
+ name: str
308
+ price: Optional[Decimal]
309
+ price_with_discount: Optional[Decimal]
310
+
311
+ @classmethod
312
+ def from_api(cls, body: Mapping[str, Any]) -> ServiceOption:
313
+ return cls(_str(body, "id") or "", _str(body, "name") or "", _money(body.get("price")),
314
+ _money(body.get("priceWithDiscount")))
315
+
316
+
317
+ @dataclass(frozen=True)
318
+ class PaymentMethod:
319
+ id: str
320
+ name: str
321
+ balance: Optional[Decimal]
322
+
323
+ @classmethod
324
+ def from_api(cls, body: Mapping[str, Any]) -> PaymentMethod:
325
+ return cls(_str(body, "id") or "", _str(body, "name") or "", _money(body.get("balance")))
326
+
327
+
328
+ @dataclass(frozen=True)
329
+ class PaidService:
330
+ id: str
331
+ title: str
332
+ options: tuple[ServiceOption, ...] = ()
333
+ payment_methods: tuple[PaymentMethod, ...] = ()
334
+
335
+ @classmethod
336
+ def from_api(cls, service: Mapping[str, Any], details: Mapping[str, Any]) -> PaidService:
337
+ options = tuple(ServiceOption.from_api(o) for o in _list(details.get("paidServiceOptions"))
338
+ if isinstance(o, Mapping))
339
+ methods = tuple(PaymentMethod.from_api(m) for m in _list(details.get("paymentMethods"))
340
+ if isinstance(m, Mapping))
341
+ return cls(_str(service, "id") or "", _str(service, "title") or "", options, methods)
342
+
343
+
344
+ @dataclass(frozen=True)
345
+ class Payment:
346
+ status: Optional[str]
347
+ redirect_url: Optional[str]
348
+ payment_id: Optional[str]
349
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
350
+
351
+ @classmethod
352
+ def from_api(cls, body: Mapping[str, Any]) -> Payment:
353
+ return cls(_str(body, "status"), _str(body, "redirectUrl"), _str(body, "paymentId"), dict(body))
354
+
355
+
356
+ @dataclass(frozen=True)
357
+ class CodeSent:
358
+ phone: str
359
+ code_requests_left: Optional[int]
360
+ retried: bool = False
361
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
362
+
363
+ @property
364
+ def more_than_one_sms(self) -> bool:
365
+ return self.retried
binaaz_sdk/py.typed ADDED
File without changes
binaaz_sdk/queries.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ PROFILE = (
4
+ "query Profile { profile { __typename "
5
+ "... on CompanyProfile { id name email phone contactName agency { name } } "
6
+ "... on Business { __typename ... on Agency { id name path } ... on Residence { id name path } } } }"
7
+ )
8
+
9
+ ITEM_FIELDS = "id title state path expiresAt updatedAt price { total currency } photos { thumbnail }"
10
+
11
+ ITEM_EDIT = f"query ItemEdit($id: ID!) {{ itemEdit(itemId: $id) {{ item {{ {ITEM_FIELDS} }} }} }}"
12
+
13
+ MY_ITEMS = (
14
+ "query MyItems($state: ProfileItemTabStatesEnum, $first: Int, $after: String) { profile { __typename "
15
+ "... on CompanyProfile { items(state: $state, first: $first, after: $after) { totalCount "
16
+ f"pageInfo {{ hasNextPage endCursor }} nodes {{ {ITEM_FIELDS} }} }} }} }} }}"
17
+ )
18
+
19
+ _ERRORS = "errors { code message path }"
20
+ _ITEM_RESULT = f"item {{ id path state }} {_ERRORS}"
21
+
22
+ CREATE_ITEM = (f"mutation CreateItem($input: ItemCreateInput!) "
23
+ f"{{ createItem(input: $input) {{ status {_ITEM_RESULT} }} }}")
24
+ UPDATE_ITEM = (f"mutation UpdateItem($input: ItemUpdateInput!) "
25
+ f"{{ updateItem(input: $input) {{ status lockedUntil {_ITEM_RESULT} }} }}")
26
+ DELETE_ITEM = (f"mutation DeleteItem($input: ItemDeleteInput!) "
27
+ f"{{ deleteItem(input: $input) {{ item {{ id state }} {_ERRORS} }} }}")
28
+ PROLONG_ITEM = (f"mutation ProlongItem($input: ItemProlongInput!) "
29
+ f"{{ prolongItem(input: $input) {{ {_ITEM_RESULT} }} }}")
30
+ PAY_FOR_ITEM = (f"mutation PayForItem($input: ItemPayInput!) "
31
+ f"{{ payForItem(input: $input) {{ item {{ id state }} {_ERRORS} }} }}")
32
+ CREATE_PAYMENT = (f"mutation CreatePayment($input: PaymentCreateInput!) "
33
+ f"{{ createPayment(input: $input) {{ status redirectUrl paymentId {_ERRORS} }} }}")
34
+
35
+ PAID_SERVICES = "query PaidServices($ids: [ID!]!) { paidServices(targetIds: $ids, targetType: ITEM) { id title } }"
36
+
37
+ PAID_SERVICE_DETAILS = (
38
+ "query PaidServiceDetails($sid: ID!, $ids: [ID!]!) { "
39
+ "paidServiceOptions(paidServiceId: $sid, targetIds: $ids) "
40
+ "{ id name price priceWithDiscount(paymentMethodId: \"wallet\") } "
41
+ "paymentMethods(paidServiceId: $sid, targetIds: $ids, targetType: ITEM) { id name balance } }"
42
+ )
43
+
44
+ SIGN_IN = (
45
+ "mutation SignIn($input: ProfileSignInInput!) { signIn(input: $input) { "
46
+ "profile { __typename ... on CompanyProfile { id name } } errors { message path } } }"
47
+ )
48
+
49
+ CATEGORIES = "{ categories { id name children { id name } } }"
50
+ CITIES = "{ cities { id name } }"
51
+ REGIONS = "query($c: ID!) { regions(cityId: $c) { id name } }"
52
+ TOWNSHIPS = "query($r: ID!) { townships(regionId: $r) { id name } }"
53
+ LANDMARKS = ("query($c: ID!, $lat: Float!, $lng: Float!) "
54
+ "{ landmarks(cityId: $c, latitude: $lat, longitude: $lng) { id name } }")