xync-db 0.0.2.dev2__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.
@@ -0,0 +1,1781 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ import struct
4
+ import sys
5
+ from time import time
6
+ from uuid import UUID
7
+ from datetime import datetime
8
+ from decimal import Decimal
9
+
10
+ from cryptography.exceptions import InvalidSignature
11
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
12
+
13
+ from tortoise.fields import (
14
+ SmallIntField,
15
+ BigIntField,
16
+ CharField,
17
+ BooleanField,
18
+ IntEnumField,
19
+ FloatField,
20
+ JSONField,
21
+ ForeignKeyField,
22
+ OneToOneField,
23
+ ManyToManyField,
24
+ ForeignKeyRelation,
25
+ OneToOneRelation,
26
+ ForeignKeyNullableRelation,
27
+ OneToOneNullableRelation,
28
+ ManyToManyRelation,
29
+ UUIDField,
30
+ CASCADE,
31
+ BinaryField,
32
+ IntField,
33
+ )
34
+ from tortoise.fields.relational import BackwardFKRelation, BackwardOneToOneRelation
35
+ from tortoise.functions import Sum
36
+ from tortoise import Model as BaseModel, BaseDBAsyncClient, connections
37
+ from tortoise.signals import pre_save
38
+ from tortoise.timezone import now
39
+
40
+ # noinspection PyUnresolvedReferences
41
+ from x_auth.models import (
42
+ Model,
43
+ Username as Username,
44
+ User as TgUser,
45
+ Proxy as Proxy,
46
+ Dc as Dc,
47
+ Fcm as Fcm,
48
+ App as App,
49
+ Session as Session,
50
+ Peer as Peer,
51
+ UpdateState as UpdateState,
52
+ Version as Version,
53
+ Country as BaseCountry,
54
+ )
55
+ from x_model.field import UInt1Field, UInt2Field, UInt4Field, UInt8Field, UniqBinaryField, DatetimeSecField
56
+ from x_model.models import TsTrait
57
+
58
+ from xync_db.enums import (
59
+ ExType,
60
+ AdStatus,
61
+ OrderStatus,
62
+ ExAction,
63
+ ExStatus,
64
+ PersonStatus,
65
+ UserStatus,
66
+ PmType,
67
+ FileType,
68
+ AddrExType,
69
+ DepType,
70
+ Party,
71
+ Slip,
72
+ SynonymType,
73
+ AbuserType,
74
+ Boundary,
75
+ SbpStrict,
76
+ HotStatus,
77
+ TaskType,
78
+ TransactionStatus as TS,
79
+ OrderErr,
80
+ LinkType,
81
+ CoinType,
82
+ )
83
+ from xync_db.exceptions import RaiseError, PgRaiseError
84
+ from xync_db.shared import qr_gen, typ_ts_pack
85
+ from xync_db.typs import OrderResult
86
+ from xync_db.typs.db import AdNewFlatRaw
87
+
88
+
89
+ class Country(BaseCountry):
90
+ cur: ForeignKeyRelation[Cur] = ForeignKeyField("models.Cur", "countries", on_update=CASCADE)
91
+ curexs: ManyToManyRelation[CurEx]
92
+ forbidden_exs: ManyToManyRelation[Ex]
93
+ fiats: BackwardFKRelation[Fiat]
94
+
95
+
96
+ class Cur(Model):
97
+ id = UInt1Field(True)
98
+ ticker: str = CharField(3, unique=True)
99
+ scale: int = UInt1Field(default=2)
100
+
101
+ pms: ManyToManyRelation[Pm] = ManyToManyField("models.Pm", "pmcur", on_update=CASCADE)
102
+ exs: ManyToManyRelation[Ex] = ManyToManyField("models.Ex", "curex", on_update=CASCADE)
103
+ pairs: BackwardFKRelation[Pair]
104
+ countries: BackwardFKRelation[Country]
105
+ synonyms: ManyToManyRelation[Synonym]
106
+
107
+ _name = {"ticker"}
108
+
109
+ class Meta:
110
+ table_description = "Fiat currencies"
111
+
112
+
113
+ class Coin(Model):
114
+ id: int = SmallIntField(True)
115
+ ticker: str = CharField(15, unique=True)
116
+ rate: float | None = FloatField(default=0)
117
+ is_fiat: bool = BooleanField(default=False)
118
+ scale: int = UInt1Field()
119
+ typ: CoinType = IntEnumField(CoinType, default=CoinType.normal)
120
+ exs: ManyToManyRelation[Ex] = ManyToManyField("models.Ex", "coinex", on_update=CASCADE)
121
+
122
+ assets: BackwardFKRelation[Asset]
123
+ nets: BackwardFKRelation[Net]
124
+ pairs: BackwardFKRelation[Pair]
125
+ # deps: BackwardFKRelation[Dep]
126
+ # deps_reward: BackwardFKRelation[Dep]
127
+ # deps_bonus: ReverseRelation[Dep]
128
+
129
+ _name = {"ticker"}
130
+
131
+
132
+ class Net(Model):
133
+ id: int = UInt1Field(True)
134
+ name: str = CharField(63)
135
+ native_coin: ForeignKeyRelation[Coin] = ForeignKeyField("models.Coin", "nets", on_update=CASCADE)
136
+ native_coin_id: int
137
+
138
+
139
+ class Ex(Model):
140
+ id: int = UInt1Field(True)
141
+ name: str = CharField(31)
142
+ host: str | None = CharField(63, null=True, description="With no protocol 'https://'")
143
+ host_p2p: str | None = CharField(63, null=True, description="With no protocol 'https://'")
144
+ url_login: str | None = CharField(63, null=True, description="With no protocol 'https://'")
145
+ typ: ExType = IntEnumField(ExType)
146
+ status: ExStatus = IntEnumField(ExStatus, default=ExStatus.plan)
147
+ logo: str = CharField(511, default="")
148
+
149
+ ads: ManyToManyRelation[Ad]
150
+ pms: ManyToManyRelation[Pm]
151
+ curs: ManyToManyRelation[Cur]
152
+ # pmcurs: ManyToManyRelation[PmCur] = ManyToManyField("models.PmCur", through="pmcurex")
153
+ coins: ManyToManyRelation[Coin]
154
+ forbidden_countries: ManyToManyRelation[Country] = ManyToManyField(
155
+ "models.Country", related_name="forbidden_exs", on_update=CASCADE
156
+ )
157
+
158
+ actors: BackwardFKRelation[Actor]
159
+ pmexs: BackwardFKRelation[PmEx]
160
+ pm_reps: BackwardFKRelation[PmRep]
161
+ pairexs: BackwardFKRelation[PairEx]
162
+ deps: BackwardFKRelation[Dep]
163
+ stats: BackwardFKRelation[ExStat]
164
+
165
+ class Meta:
166
+ table_description = "Exchanges"
167
+ unique_together = (("name", "typ"),)
168
+
169
+ class PydanticMeta(Model.PydanticMeta):
170
+ include = "name", "logo"
171
+
172
+ def client(self, **kwargs):
173
+ module_name = f"xync_client.{self.name}.ex"
174
+ __import__(module_name)
175
+ client = sys.modules[module_name].ExClient
176
+ return client(self, **kwargs)
177
+
178
+ def etype(self):
179
+ module_name = f"xync_client.{self.name}.etype"
180
+ __import__(module_name)
181
+ return sys.modules[module_name]
182
+
183
+
184
+ class CurEx(BaseModel):
185
+ cur: ForeignKeyRelation[Cur] = ForeignKeyField("models.Cur", on_update=CASCADE)
186
+ cur_id: int
187
+ ex: ForeignKeyRelation[Ex] = ForeignKeyField("models.Ex", on_update=CASCADE)
188
+ ex_id: int
189
+ exid: str = CharField(32)
190
+ minimum: int = UInt4Field(null=True) # /10^cur.scale
191
+ scale: int = UInt1Field(null=True)
192
+
193
+ # countries: ManyToManyRelation[Country] = ManyToManyField (
194
+ # "models.Country", through="curex_country", backward_key="curexs"
195
+ # )
196
+
197
+ class Meta:
198
+ table_description = "Currency in Exchange"
199
+ unique_together = (("ex_id", "cur_id"), ("ex_id", "exid"))
200
+
201
+
202
+ class CoinEx(BaseModel):
203
+ coin: ForeignKeyRelation[Coin] = ForeignKeyField("models.Coin", on_update=CASCADE)
204
+ coin_id: int
205
+ ex: ForeignKeyRelation[Ex] = ForeignKeyField("models.Ex", on_update=CASCADE)
206
+ ex_id: int
207
+ minimum: int = BigIntField(null=True) # /10^self.scale
208
+ scale: int = UInt1Field()
209
+
210
+ exid: str = CharField(32)
211
+ p2p: bool = BooleanField(default=True)
212
+
213
+ class Meta:
214
+ table_description = "Currency in Exchange"
215
+ unique_together = (("ex_id", "coin_id"), ("ex_id", "exid"))
216
+
217
+
218
+ class Pair(Model):
219
+ id = SmallIntField(True)
220
+ coin: ForeignKeyRelation[Coin] = ForeignKeyField("models.Coin", "pairs", on_update=CASCADE)
221
+ cur: ForeignKeyRelation[Cur] = ForeignKeyField("models.Cur", "pairs", on_update=CASCADE)
222
+ rate: int | None = BigIntField(null=True)
223
+
224
+ _name = {"coin__ticker", "cur__ticker"}
225
+
226
+ class Meta:
227
+ table_description = "Coin/Currency pairs"
228
+ unique_together = (("coin_id", "cur_id"),)
229
+
230
+
231
+ class PairSide(Model): # Way
232
+ id = SmallIntField(True)
233
+ pair: ForeignKeyRelation[Pair] = ForeignKeyField("models.Pair", "pair_sides", on_update=CASCADE)
234
+ pair_id: int
235
+ is_sell: bool = BooleanField()
236
+ rate: int | None = BigIntField(null=True)
237
+
238
+ class Meta:
239
+ table = "pair_side"
240
+ unique_together = (("pair_id", "is_sell"),)
241
+
242
+
243
+ class NetAddr(Model): # Way
244
+ net: ForeignKeyRelation[Net] = ForeignKeyField("models.Net", "net_addrs", on_update=CASCADE)
245
+ net_id: int
246
+ addr: ForeignKeyRelation[Addr] = ForeignKeyField("models.Addr", "net_addrs", on_update=CASCADE)
247
+ addr_id: int
248
+ val: str = CharField(200)
249
+ memo: str | None = CharField(54, null=True)
250
+ qr: str | None = CharField(255, null=True)
251
+
252
+ class Meta:
253
+ table = "net_addr"
254
+ unique_together = (("net_id", "addr_id"),)
255
+
256
+
257
+ class PairEx(Model, TsTrait): # todo: refact to PairSideEx?
258
+ # todo: различаются ли комиссии на buy/sell по одной паре хоть на одной бирже? если да, то переделать
259
+ pair: ForeignKeyRelation[Pair] = ForeignKeyField("models.Pair", "pairexs", on_update=CASCADE)
260
+ pair_id: int
261
+ fee: int = SmallIntField(default=0) # /10_000
262
+ ex: ForeignKeyRelation[Ex] = ForeignKeyField("models.Ex", "pairs", on_update=CASCADE)
263
+ ex_id: int
264
+ pair_sides: BackwardFKRelation[PairSide]
265
+
266
+ _name = {"pair__coin__ticker", "pair__cur__ticker", "ex__name"}
267
+
268
+ class Meta:
269
+ table_description = "Pairs on Exs"
270
+ unique_together = (("pair_id", "ex_id"),)
271
+
272
+
273
+ class Person(Model, TsTrait):
274
+ status: PersonStatus = IntEnumField(PersonStatus, default=PersonStatus.DEFAULT)
275
+ name: str | None = CharField(127, null=True)
276
+ note: bool = CharField(255, null=True)
277
+ # name+user(where user is not null)=unique in raw migration!
278
+ user: ForeignKeyRelation[User] = ForeignKeyField("models.User", "persons", on_update=CASCADE, null=True)
279
+ user_id: int
280
+ creds: BackwardFKRelation[Cred]
281
+ actors: BackwardFKRelation[Actor]
282
+ pm_agents: BackwardFKRelation[PmAgent]
283
+
284
+ async def merge(self, new_person: Person):
285
+ """Вмерджить new_person в self: перенести акторов и креды, удалить new_person."""
286
+ await new_person.fetch_related("creds", "actors")
287
+ for cred in new_person.creds:
288
+ cred.person = self
289
+ await cred.save(update_fields=["person_id"])
290
+ for actor in new_person.actors:
291
+ actor.person = self
292
+ await actor.save(update_fields=["person_id"])
293
+ self.name = self.name or new_person.name
294
+ await self.save(update_fields=["name"])
295
+ # после переноса у new_person не должно остаться связей
296
+ await new_person.fetch_related("creds", "actors")
297
+ if not len(new_person.creds) + len(new_person.actors) and new_person.user_id in (self.user_id, None):
298
+ await new_person.delete()
299
+ else:
300
+ raise ValueError(
301
+ f"Merge to person#{self.id} failed: new person#{new_person.id} still has "
302
+ f"{len(new_person.creds)} creds, {len(new_person.actors)} actors, user_id={new_person.user_id}"
303
+ )
304
+
305
+
306
+ class User(TgUser, TsTrait):
307
+ status: UserStatus = IntEnumField(UserStatus, null=True)
308
+ ref: ForeignKeyNullableRelation[User] = ForeignKeyField("models.User", "proteges", on_update=CASCADE, null=True)
309
+ ref_id: int | None
310
+ bonus: int = UInt2Field(default=0)
311
+ tz: int = SmallIntField(default=3)
312
+ prv: bytes = UniqBinaryField(unique=True, null=True) # len=32
313
+ pub: bytes = UniqBinaryField(unique=True, null=True) # len=32
314
+
315
+ actors: BackwardFKRelation[Actor]
316
+ borrows: BackwardFKRelation[Credit]
317
+ contacts: BackwardFKRelation[User]
318
+ created_forums: BackwardFKRelation[Forum]
319
+ creds: BackwardFKRelation[Cred]
320
+ gmail: BackwardOneToOneRelation[Gmail]
321
+ forum: BackwardOneToOneRelation[Forum]
322
+ hots: BackwardFKRelation[Hot]
323
+ investments: BackwardFKRelation[Investment]
324
+ invite_requests: BackwardFKRelation[Invite]
325
+ invite_approvals: BackwardFKRelation[Invite]
326
+ lends: BackwardFKRelation[Credit]
327
+ limits: BackwardFKRelation[Limit]
328
+ msgs: BackwardFKRelation[Msg]
329
+ persons: BackwardFKRelation[Person]
330
+ pm_agents: BackwardFKRelation[PmAgent]
331
+ proteges: BackwardFKRelation[User]
332
+ sends: BackwardFKRelation[Transaction]
333
+ receives: BackwardFKRelation[Transaction]
334
+ validates: BackwardFKRelation[Transaction]
335
+ vpn: BackwardOneToOneRelation[Vpn]
336
+ hot_ads: ManyToManyRelation[MyAd]
337
+
338
+ # def __init__(self, **kwargs: Any) -> None:
339
+ # super().__init__(**kwargs)
340
+ # self.pm_clients = {pma.pm_id: pma.client() for pma in self.pm_agents}
341
+ # self.agent_clients = {a.id: a.client() for a in self.person.actor.agents}
342
+
343
+ async def free_assets(self):
344
+ assets = await Asset.filter(agent__actor__person__user__id=self.id).values("free", "addr__coin__rate")
345
+ return sum(asset["free"] * asset["addr__coin__rate"] for asset in assets)
346
+
347
+ async def fiats_sum(self):
348
+ fiats = await Fiat.filter(cred__person__user__id=self.id).values("amount", "cred__pmcur__cur__rate")
349
+ return sum(fiat["amount"] * Decimal(fiat["cred__pmcur__cur__rate"]) for fiat in fiats)
350
+
351
+ async def balance_sum(self) -> int:
352
+ return int(await self.free_assets()) + int(await self.fiats_sum())
353
+
354
+ async def balances(self, ctd: bool = True) -> dict[int, int | float]: # ctd - convert to decimal
355
+ scales: dict[int, int] = {1: 2, 2: 2, 3: 2}
356
+
357
+ def grab(rows):
358
+ scales.update({c: s for c, _, s in rows})
359
+ return {c: v for c, v, _ in rows}
360
+
361
+ dbt = {1: 0, 2: 0, 3: 0} | grab(
362
+ await Transaction.filter(receiver=self, status=TS.verified)
363
+ .group_by("cur_id", "cur__scale")
364
+ .annotate(debit=Sum("amount"))
365
+ .values_list("cur_id", "debit", "cur__scale")
366
+ )
367
+ crd = grab(
368
+ await Transaction.filter(sender=self, status__gte=TS.signed)
369
+ .group_by("cur_id", "cur__scale")
370
+ .annotate(credit=Sum("amount"))
371
+ .values_list("cur_id", "credit", "cur__scale")
372
+ )
373
+ top = grab(
374
+ await TopUp.filter(user=self, completed_at__isnull=False)
375
+ .group_by("cur_id", "cur__scale")
376
+ .annotate(topup=Sum("amount"))
377
+ .values_list("cur_id", "topup", "cur__scale")
378
+ )
379
+ raw = {c: dbt.get(c, 0) - crd.get(c, 0) + top.get(c, 0) for c in dbt.keys() | crd.keys() | top.keys()}
380
+ return {c: round(v * 10 ** -scales[c], scales[c]) for c, v in raw.items()} if ctd else raw
381
+
382
+ async def balance(self, cur_id: int, ts: int = None) -> int:
383
+ flt = dict(cur_id=cur_id)
384
+ if ts:
385
+ flt["ts__lte"] = ts
386
+ dbt = (
387
+ await Transaction.filter(receiver=self, status=TS.verified, **flt)
388
+ .group_by("cur_id")
389
+ .annotate(debit=Sum("amount"))
390
+ .values_list("debit", flat=True)
391
+ ) or [0]
392
+ crd = (
393
+ await Transaction.filter(sender_id=self.id or None, status__in=[TS.signed, TS.verified], **flt)
394
+ .group_by("cur_id")
395
+ .annotate(credit=Sum("amount"))
396
+ .values_list("credit", flat=True)
397
+ ) or [0]
398
+ return dbt[0] - crd[0]
399
+
400
+ def keygen(self): # moved to front
401
+ prv = Ed25519PrivateKey.generate()
402
+ self.prv = prv.private_bytes_raw()
403
+ self.pub = prv.public_key().public_bytes_raw()
404
+
405
+ async def get_validator(self, amount: int, cur_id: int, receiver_id: int) -> "User":
406
+ r = {
407
+ c: v
408
+ for c, v in await Transaction.filter( # todo: rm ` or receiver_id` - is hack for first trans
409
+ receiver_id__not_in={receiver_id, self.id or receiver_id}, status=TS.verified, cur_id=cur_id
410
+ )
411
+ .group_by("receiver_id")
412
+ .annotate(debit=Sum("amount"))
413
+ .values_list("receiver_id", "debit")
414
+ }
415
+ s = {
416
+ c: v
417
+ for c, v in await Transaction.filter(
418
+ sender_id__not_in={self.id, receiver_id}, status=TS.verified, cur_id=cur_id
419
+ )
420
+ .group_by("sender_id")
421
+ .annotate(credit=Sum("amount"))
422
+ .values_list("sender_id", "credit")
423
+ if c is not None # todo: rm `if c is not None` - is hack
424
+ }
425
+ val_ids = r.keys() | s.keys()
426
+ # noinspection PyUnboundLocalVariable
427
+ vals = {c: am for c in val_ids if c is not None and (am := r.get(c, 0) - s.get(c, 0)) > amount}
428
+ val_id, _ = sorted(vals.items(), key=lambda x: x[1], reverse=True)[0]
429
+ if val_id is None:
430
+ raise ValueError("No available validators:(")
431
+ validator = await User[val_id]
432
+ return validator
433
+
434
+ async def req(self, amount: int, cur_id: int, sender_id: int = None, ts: int = None) -> Transaction:
435
+ return await Transaction.create(
436
+ receiver=self,
437
+ sender_id=sender_id,
438
+ amount=amount,
439
+ cur_id=cur_id,
440
+ status=TS.request,
441
+ ts=ts,
442
+ )
443
+
444
+ async def send(
445
+ self, cur_id: int, rcv_id: int, amount: int, ts: int = None, sign: bytes = None
446
+ ) -> Transaction | int:
447
+ assert sign or self.prv, "sign or user.prv is required"
448
+ # sender check balance himself befor send
449
+ if (b := await self.balance(cur_id)) < amount and self.id:
450
+ return b
451
+ # creating tx
452
+ tx = Transaction(
453
+ ts=ts or int(time()),
454
+ cur_id=cur_id,
455
+ receiver_id=rcv_id,
456
+ amount=amount,
457
+ sender=self,
458
+ proof=sign,
459
+ # status=TS.signed,
460
+ )
461
+ tx.id = UUID(bytes=tx.pack)
462
+ return await tx.sign(sign)
463
+
464
+ # доступные hot объявления для юзера с его текущими балансами
465
+ async def get_hot_ads(self, ex_ids: list[int] = None) -> tuple[list[MyAd], list[MyAd], dict[int, float]]:
466
+ adq = MyAd.hot_mads_query(ex_ids).filter(ad__maker__person__user_id__not=self.id)
467
+ bads = await adq.filter(ad__pair_side__is_sell=False)
468
+
469
+ sads = []
470
+ if balances := await self.balances():
471
+ curexs = await CurEx.filter(cur_id__in=balances.keys(), ex_id__in=[4]) # todo: fix hardcode cur mins
472
+ minimums = {cx.cur_id: cx.minimum for cx in curexs}
473
+ balances = {cur_id: b for cur_id, b in balances.items() if minimums.get(cur_id) and b >= minimums[cur_id]}
474
+ sads = await adq.filter(ad__pair_side__is_sell=True, ad__pair_side__pair__cur_id__in=balances.keys()).all()
475
+
476
+ return bads, sads, balances
477
+
478
+ async def get_last_hot(self, force_create: bool = False) -> Hot:
479
+ if not force_create and (
480
+ hot := await Hot.filter(status=HotStatus.opened, user=self).order_by("-updated_at").first()
481
+ ):
482
+ await hot.update_from_dict({"updated_at": now()}).save()
483
+ return hot
484
+ return await Hot.create(user=self)
485
+
486
+ def name(self):
487
+ return f"{self.first_name} {self.last_name}".rstrip()
488
+
489
+ class PydanticMeta(Model.PydanticMeta):
490
+ max_recursion = 0
491
+ include = "role", "status"
492
+ # computed = ["balance"]
493
+
494
+ def qr_invite(self, bot_nick: str) -> bytes:
495
+ data = f"{bot_nick and 't.me/' + bot_nick + '?startapp='}{int.from_bytes(self.id.bytes + self.proof)}"
496
+ # Prepare text
497
+ typ = ("Personalized" if self.sender_id else "Opened") if self.status == TS.request else "Receipt"
498
+ text = f"{typ} {self.status.name}"
499
+ return qr_gen(data, text)
500
+
501
+
502
+ @pre_save(User) # keygen for custodian users
503
+ async def keys(_cls: type[User], user: User, _db: BaseDBAsyncClient, _updated: list[str]) -> None:
504
+ if not user.pub:
505
+ user.keygen()
506
+
507
+
508
+ class Gmail(Model):
509
+ login: str = CharField(127)
510
+ auth: dict = JSONField(default={})
511
+ token: bytes = BinaryField(null=True)
512
+ updated_at: datetime | None = DatetimeSecField(auto_now=True)
513
+
514
+ user: OneToOneRelation[User] = OneToOneField("models.User", "gmail", on_update=CASCADE)
515
+
516
+
517
+ class Forum(Model, TsTrait):
518
+ id: int = BigIntField(True)
519
+ joined: bool = BooleanField(default=False)
520
+ user: OneToOneRelation[User] = OneToOneField("models.User", "forum", on_update=CASCADE)
521
+ user_id: int
522
+ # created_by: BackwardFKRelation[User] = ForeignKeyField("models.User", "created_forums")
523
+
524
+
525
+ class Actor(Model):
526
+ exid: int = UInt8Field()
527
+ name: str = CharField(63)
528
+ ex: ForeignKeyRelation[Ex] = ForeignKeyField("models.Ex", "actors", on_update=CASCADE)
529
+ ex_id: int
530
+ person: ForeignKeyRelation[Person] = ForeignKeyField("models.Person", "actors", on_update=CASCADE)
531
+ person_id: int
532
+
533
+ agent: BackwardOneToOneRelation[Agent]
534
+ conds: BackwardFKRelation[Cond]
535
+ my_ads: BackwardFKRelation[Ad]
536
+ taken_orders: BackwardFKRelation[Order]
537
+
538
+ hots: ManyToManyRelation[Hot]
539
+
540
+ class Meta:
541
+ table_description = "Actors"
542
+ unique_together = (("ex_id", "exid"),)
543
+
544
+ async def get_credexs_by(self, pm_ids: list[int], cur_id: int) -> list[CredEx]:
545
+ return await CredEx.filter(
546
+ ex_id=self.ex_id,
547
+ cred__pmcur__pm_id__in=pm_ids,
548
+ cred__person_id=self.person_id,
549
+ cred__pmcur__cur_id=cur_id,
550
+ )
551
+
552
+ async def set_user(self, username_id: int) -> tuple[int, str, User]:
553
+ """Назначить актору персону юзера с username_id.
554
+
555
+ Логика мерджа персон:
556
+ - Если персона юзера все еще без акторов (базовая от триггера) — мерджим в неё.
557
+ - Если все персоны юзера заняты акторами, но имя совпадает — мерджим в ту персону.
558
+ - Если имена не совпадают — оставляем новую персону, и привязываем к юзеру.
559
+ """
560
+ assert isinstance(self.person, Person), "person should be fetched"
561
+ err, txt = 0, ""
562
+
563
+ user_persons: list[Person] = (
564
+ await Person.filter(user__username_id=username_id).prefetch_related("user", "actors").all()
565
+ )
566
+ if not user_persons:
567
+ raise ValueError(f"Не найдено персон юзера:{username_id}! Персона создаётся триггером при создании User")
568
+ user = user_persons[0].user
569
+
570
+ # персоне этого актора уже назначен юзер
571
+ if self.person.user_id:
572
+ if (await self.person.user).username_id == username_id:
573
+ txt = f"У персоны актора:{self.id} уже назначен этот юзер"
574
+ logging.warning(txt)
575
+ return 1, txt, user
576
+ txt = f"У персоны актора:{self.id} уже назначен другой юзер"
577
+ logging.error(txt)
578
+ return 3, txt, user
579
+
580
+ merged = False
581
+ for i, person in enumerate(user_persons):
582
+ if [a for a in person.actors if a.ex_id]:
583
+ # персона юзера уже занята актором(ами) — мерджим только если имена совпадают
584
+ if self.person.name == person.name:
585
+ await person.merge(self.person)
586
+ merged = True
587
+ elif i:
588
+ # у юзера >1 персоны без акторов — аномалия
589
+ txt = (
590
+ f"У {i + 1}-й персоны #{person.id} нет акторов (юзер #{username_id}), но персон без акторов уже > 1"
591
+ )
592
+ logging.error(txt)
593
+ return 2, txt, user
594
+ else:
595
+ # первая персона юзера без акторов (базовая от триггера) — мерджим
596
+ await person.merge(self.person)
597
+ merged = True
598
+
599
+ if not merged:
600
+ # все персоны юзера заняты акторами с другими именами — оставляем новую, привязываем к юзеру
601
+ self.person.user = user
602
+ await self.person.save(update_fields=["user_id"])
603
+
604
+ txt = f"Юзер#{username_id} подтвердил что Actor#{self.id} его"
605
+ logging.info(txt)
606
+ return err, txt, user
607
+
608
+
609
+ class Agent(Model, TsTrait):
610
+ auth: dict = JSONField(default={})
611
+ actor: OneToOneRelation[Actor] = OneToOneField("models.Actor", "agent", on_update=CASCADE)
612
+ actor_id: int
613
+ expire_at: int | None = IntField(null=True)
614
+ status: int = SmallIntField(default=0)
615
+ same_dir_ad: int = UInt1Field(default=0)
616
+
617
+ assets: BackwardFKRelation[Asset]
618
+
619
+ _name = {"actor__name"}
620
+
621
+ # def balance(self) -> int:
622
+ # return sum(asset.free * (asset.coin.rate or 0) for asset in self.assets)
623
+
624
+ # class PydanticMeta(Model.PydanticMeta):
625
+ # max_recursion = 3
626
+ # include = "id", "actor__ex", "auth", "updated_at"
627
+ # computed = ["balance"]
628
+
629
+ def client(self, ex_cl, pm_clients=None, proxy=None):
630
+ module_name = f"xync_client.{self.actor.ex.name}.agent"
631
+ __import__(module_name)
632
+ client = sys.modules[module_name].AgentClient
633
+ return client(
634
+ self,
635
+ ex_cl,
636
+ pm_clients or {},
637
+ headers=self.auth.get("headers"),
638
+ cookies=self.auth.get("cookies"),
639
+ proxy=proxy,
640
+ )
641
+
642
+ async def coins_balance(self, to_float: bool = True):
643
+ await self.fetch_related("assets__addr")
644
+ scales = dict(to_float and await CoinEx.filter(ex_id=self.actor.ex_id).values_list("coin_id", "scale") or ())
645
+ return {a.addr.coin_id: round(a.free * 10 ** -(s := scales.get(a.addr.coin_id, 0)), s) for a in self.assets if a.free}
646
+
647
+ @classmethod
648
+ async def upsert(
649
+ cls, exid: int, host: str, auth: dict, exp: int, rname: str, nick: str, email: str
650
+ ) -> tuple[int, Agent]:
651
+ err = 0
652
+ if host and (ex := await Ex.get_or_none(host=host)):
653
+ if not (actor := await Actor.get_or_none(exid=exid, ex=ex)): # такого актора еще нет
654
+ mail_tpl = email.replace("*", "_")
655
+ if person := await Person.get_or_none(name=rname):
656
+ err = 1 # уточнить что person c таким именем действительно тот
657
+ # если нашелся (и не больше 1) юзер по gmail-у
658
+ elif (gml := await Gmail.raw(f"SELECT * FROM gmail WHERE login LIKE '{mail_tpl}'")) and len(gml) == 1:
659
+ err = 2 # уточнить что почта правильная
660
+ if persons := await Person.filter(user_id=gml[0].user_id).all():
661
+ if len(persons) > 1:
662
+ err = 3 # уточнить какой именно персон нужен этому актору
663
+ person = persons[0]
664
+ if person.name != rname:
665
+ person.name = rname
666
+ await person.save(update_fields=["name"])
667
+ else:
668
+ raise ValueError(f"Это как так почта {gml[0]} с юзером есть, а персоны нет!?")
669
+ else: # если нет, то создаем пустой персон со статусом "определить юзера"
670
+ person = await Person.create(name=rname, status=PersonStatus.USER_DEF)
671
+ actor = await Actor.create(ex=ex, exid=exid, name=nick, person=person)
672
+ if ag := await Agent.get_or_none(actor=actor):
673
+ ag.auth |= auth
674
+ ag.expire_at = exp
675
+ await ag.save(update_fields=["auth", "expire_at"])
676
+ else:
677
+ ag = await Agent.create(auth=auth, actor=actor, expire_at=exp)
678
+ return err, ag
679
+ raise ValueError("Wrong ex: " + host)
680
+
681
+
682
+ class Cond(Model, TsTrait):
683
+ raw_txt: str = CharField(4095, unique=True)
684
+ last_ver: str = CharField(4095, null=True)
685
+
686
+ ads: BackwardFKRelation[Ad]
687
+ parsed: BackwardOneToOneRelation[CondParsed]
688
+ sim: BackwardOneToOneRelation[Cond]
689
+
690
+ async def sims(self):
691
+ return {self.raw_txt: await self.sim.sims()} if await self.sim else self.raw_txt
692
+
693
+
694
+ class Synonym(BaseModel):
695
+ typ: SynonymType = IntEnumField(SynonymType, db_index=True)
696
+ txt: str = CharField(255, unique=True)
697
+ boundary: Boundary = IntEnumField(Boundary, default=Boundary.no)
698
+ curs: ManyToManyRelation[Cur] = ManyToManyField(
699
+ "models.Cur", "synonym_cur", related_name="synonyms", on_update=CASCADE
700
+ )
701
+ val: int | None = UInt2Field(null=True) # SynonymType dependent (e.g., no-slavic for name, approx for ppo.)
702
+ is_re: bool = BooleanField(default=False)
703
+
704
+
705
+ class CondSim(BaseModel):
706
+ cond: OneToOneRelation[Cond] = OneToOneField("models.Cond", "sim", primary_key=True, on_update=CASCADE)
707
+ cond_id: int # new
708
+ similarity: int = UInt2Field(db_index=True) # /1000
709
+ cond_rel: ForeignKeyRelation[Cond] = ForeignKeyField("models.Cond", "sims_rel", on_update=CASCADE)
710
+ cond_rel_id: int # old
711
+
712
+ class Meta:
713
+ table = "cond_sim"
714
+
715
+
716
+ class PmGroup(Model):
717
+ id: int = SmallIntField(True)
718
+ name: str = CharField(127)
719
+ pms: BackwardFKRelation[Pm]
720
+
721
+ class Meta:
722
+ table = "pm_group"
723
+
724
+
725
+ class Pm(Model):
726
+ # name: str = CharField(63) # mv to pmex cause it diffs on each ex
727
+ norm: str | None = CharField(255)
728
+ acronym: str | None = CharField(7, null=True)
729
+ country: ForeignKeyNullableRelation[Country] = ForeignKeyField(
730
+ "models.Country", "pms", on_update=CASCADE, null=True
731
+ )
732
+ df_cur: ForeignKeyNullableRelation[Cur] = ForeignKeyField("models.Cur", "df_pms", on_update=CASCADE, null=True)
733
+ grp: ForeignKeyNullableRelation[PmGroup] = ForeignKeyField("models.PmGroup", "pms", on_update=CASCADE, null=True)
734
+ alias: str | None = CharField(63, null=True)
735
+ extra: str | None = CharField(63, null=True)
736
+ ok: bool = BooleanField(default=True)
737
+ bank: bool | None = BooleanField(null=True)
738
+ qr: bool = BooleanField(default=False)
739
+ fee: int | None = UInt2Field(null=True) # /10_000
740
+
741
+ typ: PmType | None = IntEnumField(PmType, null=True)
742
+
743
+ ads: ManyToManyRelation[Ad]
744
+ curs: ManyToManyRelation[Cur]
745
+ no_conds: ManyToManyRelation[Cond]
746
+ only_conds: ManyToManyRelation[Cond]
747
+ exs: ManyToManyRelation[Ex] = ManyToManyField("models.Ex", "pmex", on_update=CASCADE) # no need. use pmexs[.exid]
748
+ conds: BackwardFKRelation[CondParsed]
749
+ orders: BackwardFKRelation[Order]
750
+ pmcurs: BackwardFKRelation[PmCur] # no need. use curs
751
+ pmexs: BackwardFKRelation[PmEx]
752
+ agents: BackwardFKRelation[PmAgent]
753
+ topupable: BackwardFKRelation[TopUpAble]
754
+
755
+ class Meta:
756
+ table_description = "Payment methods"
757
+ unique_together = (("norm", "country_id"), ("alias", "country_id"))
758
+
759
+ # class PydanticMeta(Model.PydanticMeta):
760
+ # max_recursion = 3
761
+ # backward_relations = True
762
+ # include = "id", "name", "logo", "pmexs__sbp"
763
+
764
+ # def epyd(self):
765
+ # module_name = f"xync_client.{self.ex.name}.pyd"
766
+ # __import__(module_name)
767
+ # return sys.modules[module_name].PmEpyd
768
+
769
+
770
+ class CondParsed(Model, TsTrait):
771
+ cond: OneToOneNullableRelation[Cond] = OneToOneField("models.Cond", "parsed", on_update=CASCADE, null=True)
772
+ cond_id: int # new
773
+ to_party: Party = IntEnumField(Party, null=True)
774
+ from_party: Party = IntEnumField(Party, null=True)
775
+ ppo: int = UInt1Field(null=True) # Payments per order
776
+ slip_req: Slip = IntEnumField(Slip, null=True)
777
+ slip_send: Slip = IntEnumField(Slip, null=True)
778
+ abuser: AbuserType = IntEnumField(AbuserType, default=AbuserType.no)
779
+ slavic: bool = BooleanField(null=True)
780
+ mtl_like: bool = BooleanField(null=True)
781
+ scale: int = SmallIntField(null=True)
782
+ bank_side: bool = BooleanField(null=True) # False - except these banks, True - only this banks
783
+ sbp_strict: SbpStrict = IntEnumField(SbpStrict, default=SbpStrict.no)
784
+ contact: str | None = CharField(127, null=True)
785
+ done: bool = BooleanField(default=False, db_index=True)
786
+ banks: ManyToManyRelation[Pm] = ManyToManyField("models.Pm", "cond_banks", related_name="conds", on_update=CASCADE)
787
+
788
+ class Meta:
789
+ table = "cond_parsed"
790
+
791
+
792
+ class Ad(Model, TsTrait):
793
+ exid: int = UInt8Field(db_index=True) # todo: спарить уникальность с биржей, тк на разных биржах могут совпасть
794
+ pair_side: ForeignKeyRelation[PairSide] = ForeignKeyField("models.PairSide", "ads", on_update=CASCADE)
795
+ price: int = UInt4Field() # /10^cur.scale
796
+ premium: int = IntField(null=True) # /10_000
797
+ amount: int = UInt4Field() # /10^cur.scale
798
+ quantity: int = UInt8Field(null=True) # /10^coinex.scale
799
+ min_fiat: int = UInt4Field() # /10^cur.scale
800
+ max_fiat: int | None = UInt4Field(null=True) # /10^cur.scale
801
+ auto_msg: str | None = CharField(4095, null=True)
802
+ status: AdStatus = IntEnumField(AdStatus, default=AdStatus.active)
803
+ filtered: bool = BooleanField(default=False)
804
+
805
+ cond: ForeignKeyNullableRelation[Cond] = ForeignKeyField("models.Cond", "ads", on_update=CASCADE, null=True)
806
+ cond_id: int
807
+ maker: ForeignKeyRelation[Actor] = ForeignKeyField("models.Actor", "my_ads", on_update=CASCADE)
808
+ maker_id: int
809
+
810
+ pms: ManyToManyRelation[Pm] = ManyToManyField("models.Pm", "ad_pm", related_name="ads", on_update=CASCADE)
811
+ my_ad: BackwardOneToOneRelation[MyAd]
812
+ orders: BackwardFKRelation[Order]
813
+
814
+ _icon = "ad"
815
+ _name = {"pair_side__pairex__coin__ticker", "pair_side__pairex__cur__ticker", "pair_side__sell", "price"}
816
+
817
+ class Meta:
818
+ table_description = "P2P Advertisements"
819
+ unique_together = (("exid", "maker_id"),)
820
+
821
+ async def to_float(self, ex_id: int = None) -> Ad:
822
+ ex_id = ex_id or self.maker.ex_id
823
+ cur_scl = await CurEx.get(cur_id=self.pair_side.pair.cur_id, ex_id=ex_id).values_list('scale', flat=True)
824
+ coin_scl = await CoinEx.get(coin_id=self.pair_side.pair.coin_id, ex_id=ex_id).values_list('scale', flat=True)
825
+ cur_k = 10 ** -cur_scl
826
+ coin_k = 10 ** -coin_scl
827
+ self.amount = round(self.amount * cur_k, cur_scl)
828
+ self.price = round(self.price * cur_k, cur_scl)
829
+ self.quantity = self.quantity and round(self.quantity * coin_k, coin_scl)
830
+ return self
831
+
832
+ @classmethod
833
+ async def from_flat(cls, ad: AdNewFlatRaw, coin_scl: int = None, cur_scl: int = None) -> Ad:
834
+ cur_scl = cur_scl or await CurEx.get(exid=ad.curex_exid, ex_id=ad.ex_id).values_list('scale', flat=True)
835
+ coin_scl = coin_scl or await CoinEx.get(exid=ad.coinex_exid, ex_id=ad.ex_id).values_list('scale', flat=True)
836
+ cur_k = 10 ** cur_scl
837
+ coin_k = 10 ** coin_scl
838
+
839
+ self.amount = round(self.amount * cur_k, cur_scl)
840
+ self.price = round(self.price * cur_k, cur_scl)
841
+ self.quantity = self.quantity and round(self.quantity * coin_k, coin_scl)
842
+ return self
843
+
844
+ # def epyds(self) -> tuple[PydModel, PydModel, PydModel, PydModel, PydModel, PydModel]:
845
+ # module_name = f"xync_client.{self.maker.ex.name}.pyd"
846
+ # __import__(module_name)
847
+ # return (
848
+ # sys.modules[module_name].AdEpyd,
849
+ # sys.modules[module_name].AdFullEpyd,
850
+ # sys.modules[module_name].MyAdEpydPurchase,
851
+ # sys.modules[module_name].MyAdInEpydPurchase,
852
+ # sys.modules[module_name].MyAdEpydSale,
853
+ # sys.modules[module_name].MyAdInEpydSale,
854
+ # )
855
+
856
+
857
+ class MyAd(Model): # Road
858
+ WEB = "https://www.bybit.com/{lang}/p2p/{side}/{coin}/{cur}?share="
859
+ MOB = "https://app.bybit.com/inapp?by_dp=bybitapp://open/route?targetUrl=by-mini://p2p/home?share="
860
+
861
+ ad: OneToOneRelation[Ad] = OneToOneField("models.Ad", "my_ad", on_update=CASCADE)
862
+ ad_id: int # new
863
+ target_place: int = UInt1Field(default=1)
864
+ hex: bytes = BinaryField(null=True)
865
+ shared_at: datetime | None = DatetimeSecField(null=True)
866
+ blocked: bool = BooleanField(default=False)
867
+
868
+ pay_req: ForeignKeyNullableRelation[PayReq] = ForeignKeyField(
869
+ "models.PayReq", "maked_ads", on_update=CASCADE, null=True
870
+ )
871
+ credexs: ManyToManyRelation[CredEx] = ManyToManyField(
872
+ "models.CredEx", through="myad_cred", related_name="my_ads", on_update=CASCADE
873
+ )
874
+ hot_users: ManyToManyRelation[User] = ManyToManyField(
875
+ "models.User", "myad_user", related_name="hot_ads", on_update=CASCADE
876
+ )
877
+
878
+ class Meta:
879
+ table = "my_ad"
880
+
881
+ def get_url(self, lang: str = "en-US") -> tuple[str, str]:
882
+ # if not self.hex:
883
+ # return None
884
+ hx = self.hex.hex()
885
+ side = "buy" if self.ad.pair_side.is_sell else "sell"
886
+ coin, cur = self.ad.pair_side.pair.coin.ticker, self.ad.pair_side.pair.cur.ticker
887
+ mob_pref = self.WEB.format(lang=lang, side=side, coin=coin, cur=cur)
888
+ return mob_pref + hx, self.MOB + hx
889
+
890
+ @classmethod
891
+ # запрос для получения объяв всех агентов этой биржи для разогрева
892
+ def hot_mads_query(cls, ex_ids: list[int], active: bool = True):
893
+ fltr = dict(credexs__cred__ovr_pm_id=0, ad__maker__agent__status__gte=3)
894
+ if active:
895
+ fltr["ad__status"] = AdStatus.active
896
+ if ex_ids:
897
+ fltr["ad__maker__ex_id__in"] = ex_ids
898
+ return cls.filter(**fltr).prefetch_related("ad__pair_side__pair__coin", "ad__pair_side__pair__cur", "ad__maker")
899
+
900
+ @classmethod
901
+ #
902
+ def new(cls, coin_id: int, cur_id: int, min_fiat: int, is_hot: bool = False): ...
903
+
904
+
905
+ class Hot(Model, TsTrait):
906
+ user: ForeignKeyNullableRelation[User] = ForeignKeyField("models.User", "hots", on_update=CASCADE)
907
+ user_id: int
908
+ status: HotStatus = IntEnumField(HotStatus, default=HotStatus.opened)
909
+ msg_id: int | None = IntField(null=True)
910
+ actors: ManyToManyRelation[Actor] = ManyToManyField("models.Actor", related_name="hots", on_update=CASCADE)
911
+
912
+ orders: BackwardFKRelation[Order]
913
+
914
+ _name = {"id"}
915
+
916
+
917
+ class MyAdHotUser(Model):
918
+ user: ForeignKeyRelation[User] = ForeignKeyField("models.User", on_update=CASCADE)
919
+ user_id: int
920
+ my_ad: ForeignKeyRelation[MyAd] = ForeignKeyField("models.MyAd", on_update=CASCADE)
921
+ my_ad_id: int
922
+ updated_at: datetime | None = DatetimeSecField(auto_now=True)
923
+
924
+ class Meta:
925
+ table = "myad_user"
926
+
927
+
928
+ class Race(Model):
929
+ road: ForeignKeyRelation[MyAd] = ForeignKeyField("models.MyAd", "race", on_update=CASCADE)
930
+ road_id: int
931
+ ceil: int = UInt4Field(null=True) # /10^cur.scale
932
+ target_place: int = SmallIntField(default=1)
933
+ vm_filter: bool = BooleanField(default=True)
934
+ started: bool = BooleanField(default=True)
935
+ filter_amount: int = UInt4Field() # /10^cur.scale
936
+ updated_at: datetime | None = DatetimeSecField(auto_now=True)
937
+
938
+ stats: BackwardFKRelation[RaceStat]
939
+
940
+ # def overprice_filter(self, ads: list[BaseAd]):
941
+ # ads[0]
942
+ # # вырезаем ads с ценами выше потолка
943
+ # if ads and (self.ceil - Decimal(ads[0].price)) * k > 0:
944
+ # if int(ads[0].userId) != self.actor.exid:
945
+ # ads.pop(0)
946
+ # self.overprice_filter(ads, self.ceil, k)
947
+
948
+
949
+ class RaceStat(Model):
950
+ race: ForeignKeyRelation[Race] = ForeignKeyField("models.Race", "stats", on_update=CASCADE)
951
+ race_id: int
952
+ rivals: ManyToManyRelation[Ad] = ManyToManyField("models.Ad", "rivals", related_name="stats", on_update=CASCADE)
953
+ rival_id: int
954
+ price: int = UInt4Field() # /10^cur.scale
955
+ premium: int = SmallIntField(null=True) # /10_000
956
+ place: int = UInt1Field()
957
+ created_at: datetime | None = DatetimeSecField(auto_now_add=True)
958
+
959
+ class Meta:
960
+ table = "race_stat"
961
+
962
+
963
+ class PmRep(Model):
964
+ ex: ForeignKeyNullableRelation[Ex] = ForeignKeyField("models.Ex", "pm_reps", on_update=CASCADE, null=True)
965
+ ex_id: int
966
+ src: str | None = CharField(63, unique=True)
967
+ target: str | None = CharField(63)
968
+ used_at: datetime | None = DatetimeSecField(null=True)
969
+
970
+ class Meta:
971
+ table = "pm_rep"
972
+ # unique_together = (("src", "target"),)
973
+
974
+
975
+ class PmAgent(Model):
976
+ pm: ForeignKeyRelation[Pm] = ForeignKeyField("models.Pm", "agents", on_update=CASCADE)
977
+ pm_id: int
978
+ user: ForeignKeyRelation[User] = ForeignKeyField("models.User", "pm_agents", on_update=CASCADE)
979
+ user_id: int
980
+ auth: dict = JSONField(default={})
981
+ state: dict = JSONField(default={})
982
+ active: bool = BooleanField(null=True, default=False)
983
+
984
+ class Meta:
985
+ table = "pm_agent"
986
+ unique_together = (("pm_id", "user_id"),)
987
+
988
+ def client(self, browser=None):
989
+ module_name = f"xync_client.Pms.{self.pm.norm.capitalize()}.agent"
990
+ __import__(module_name)
991
+ kwargs = {}
992
+ if browser:
993
+ kwargs["browser"] = browser
994
+ return sys.modules[module_name].PmAgentClient(self, **kwargs)
995
+
996
+
997
+ class PmCur(Model): # for fiat with no exs tie
998
+ pm: ForeignKeyRelation[Pm] = ForeignKeyField("models.Pm", on_update=CASCADE)
999
+ pm_id: int
1000
+ cur: ForeignKeyRelation[Cur] = ForeignKeyField("models.Cur", on_update=CASCADE)
1001
+ cur_id: int
1002
+
1003
+ creds: BackwardFKRelation[Cred]
1004
+ sends: BackwardFKRelation[Transfer]
1005
+ exs: ManyToManyRelation[Ex]
1006
+
1007
+ class Meta:
1008
+ table_description = "Payment methods - Currencies"
1009
+ unique_together = (("pm_id", "cur_id"),)
1010
+
1011
+ class PydanticMeta(Model.PydanticMeta):
1012
+ max_recursion: int = 2 # default: 3
1013
+ include = "cur_id", "pm"
1014
+
1015
+
1016
+ class PmEx(BaseModel): # existence pm in ex with no cur tie
1017
+ ex: ForeignKeyRelation[Ex] = ForeignKeyField("models.Ex", "pmexs", on_update=CASCADE)
1018
+ ex_id: int
1019
+ pm: ForeignKeyRelation[Pm] = ForeignKeyField("models.Pm", "pmexs", on_update=CASCADE)
1020
+ pm_id: int
1021
+ logo: ForeignKeyNullableRelation[File] = ForeignKeyField("models.File", "pmex_logos", on_update=CASCADE, null=True)
1022
+ logo_id: int
1023
+ exid: str = CharField(63)
1024
+ name: str = CharField(255)
1025
+
1026
+ banks: BackwardFKRelation[PmExBank]
1027
+
1028
+ class Meta:
1029
+ unique_together = (("ex_id", "exid"),) # , ("ex", "pm"), ("ex", "name") # todo: tmp removed for HTX duplicates
1030
+
1031
+
1032
+ class PmExBank(BaseModel): # banks for SBP
1033
+ pmex: ForeignKeyRelation[PmEx] = ForeignKeyField("models.PmEx", "banks", on_update=CASCADE)
1034
+ pmex_id: int
1035
+ exid: str = CharField(63)
1036
+ name: str = CharField(63)
1037
+
1038
+ creds: ManyToManyRelation[Cred]
1039
+
1040
+ class Meta:
1041
+ table = "pmex_bank"
1042
+ unique_together = (("pmex", "exid"),)
1043
+
1044
+
1045
+ # class PmCurEx(BaseModel): # existence pm in ex for exact cur, with "blocked" flag
1046
+ # pmcur: ForeignKeyRelation[PmCur] = ForeignKeyField("models.PmCur")
1047
+ # pmcur_id: int
1048
+ # ex: ForeignKeyRelation[Ex] = ForeignKeyField("models.Ex")
1049
+ # ex_id: int
1050
+ # blocked: bool = BooleanField(default=False) # todo: move to curex or pmex?
1051
+ #
1052
+ # # class Meta:
1053
+ # # unique_together = (("ex_id", "pmcur_id"),)
1054
+
1055
+
1056
+ class Cred(Model):
1057
+ pmcur: ForeignKeyRelation[PmCur] = ForeignKeyField("models.PmCur", on_update=CASCADE)
1058
+ pmcur_id: int
1059
+ detail: str = CharField(255)
1060
+ name: str | None = CharField(127, null=True)
1061
+ extra: str | None = CharField(255, null=True)
1062
+ person: ForeignKeyRelation[Person] = ForeignKeyField("models.Person", "creds")
1063
+ person_id: int
1064
+ from_chat: int # todo: ForeignKeyNullableRelation[Order] = ForeignKeyField("models.Order", null=True)
1065
+
1066
+ ovr_pm: ForeignKeyRelation[Pm] = ForeignKeyField("models.Pm", on_update=CASCADE, null=True)
1067
+ ovr_pm_id: int
1068
+ banks: ManyToManyRelation[PmExBank] = ManyToManyField("models.PmExBank", related_name="creds", on_update=CASCADE)
1069
+
1070
+ fiat: BackwardOneToOneRelation[Fiat]
1071
+ receives: BackwardOneToOneRelation[Transfer]
1072
+ credexs: BackwardFKRelation[CredEx]
1073
+ orders: BackwardFKRelation[Order]
1074
+ pay_reqs: BackwardFKRelation[PayReq]
1075
+
1076
+ _name = {"detail"}
1077
+
1078
+ def repr(self):
1079
+ xtr = f" ({self.extra})" if self.extra else ""
1080
+ name = f", имя: {self.name}" if self.name else ""
1081
+ return f"`{self.detail}`{name}{xtr}"
1082
+
1083
+ class Meta:
1084
+ table_description = "Currency accounts"
1085
+ unique_together = (("person_id", "pmcur_id", "ovr_pm_id"),)
1086
+
1087
+
1088
+ class CredEx(Model):
1089
+ exid: int = UInt8Field()
1090
+ cred: ForeignKeyRelation[Cred] = ForeignKeyField("models.Cred", "credexs", on_update=CASCADE)
1091
+ cred_id: int
1092
+ ex: ForeignKeyRelation[Ex] = ForeignKeyField("models.Ex", "credexs", on_update=CASCADE)
1093
+ ex_id: int
1094
+
1095
+ _name = {"exid"}
1096
+
1097
+ class Meta:
1098
+ table_description = "Credential on Exchange"
1099
+ unique_together = (("ex_id", "exid"),)
1100
+
1101
+
1102
+ class Fiat(Model):
1103
+ cred: OneToOneRelation[Cred] = OneToOneField("models.Cred", "fiat", on_update=CASCADE)
1104
+ cred_id: int
1105
+ amount: int = UInt4Field() # /10^cur.scale
1106
+ target: int = UInt4Field(null=True) # /10^cur.scale
1107
+ min_deposit: int = UInt4Field(null=True) # /10^cur.scale
1108
+
1109
+ class Meta:
1110
+ table_description = "Currency balances"
1111
+
1112
+ class PydanticMeta(Model.PydanticMeta):
1113
+ # max_recursion: int = 2
1114
+ backward_relations = False
1115
+ include = "id", "cred__pmcur", "cred__detail", "cred__name", "amount"
1116
+
1117
+ @staticmethod
1118
+ def epyd(ex: Ex):
1119
+ module_name = f"xync_client.{ex.name}.pyd"
1120
+ __import__(module_name)
1121
+ return sys.modules[module_name].FiatEpyd
1122
+
1123
+
1124
+ class Limit(Model):
1125
+ pmcur: ForeignKeyRelation[PmCur] = ForeignKeyField("models.PmCur", on_update=CASCADE)
1126
+ pmcur_id: int
1127
+ amount: int = UInt4Field(null=True) # '$' if unit >= 0 else 'transactions count'
1128
+ unit: int = UInt1Field(default=30) # positive: $/days, 0: $/transaction, negative: transactions count / days
1129
+ # 0 - same group, 1 - to parent group, 2 - to grandparent # only for output trans, on input = None
1130
+ level: float | None = UInt1Field(default=0, null=True)
1131
+ income: bool = BooleanField(default=False)
1132
+ added_by: ForeignKeyRelation[User] = ForeignKeyField("models.User", "limits", on_update=CASCADE)
1133
+ added_by_id: int
1134
+
1135
+ _name = {"pmcur__pm__name", "pmcur__cur__ticker", "unit", "income", "amount"}
1136
+
1137
+ class Meta:
1138
+ table_description = "Currency accounts balance"
1139
+
1140
+
1141
+ class Addr(Model):
1142
+ coin: ForeignKeyRelation[Coin] = ForeignKeyField("models.Coin", "addrs", on_update=CASCADE)
1143
+ coin_id: int
1144
+ actor: ForeignKeyRelation[Actor] = ForeignKeyField("models.Actor", "addrs", on_update=CASCADE)
1145
+ actor_id: int
1146
+
1147
+ nets: ManyToManyRelation[Net] = ManyToManyField("models.Net", "net_addr", related_name="addrs", on_update=CASCADE)
1148
+
1149
+ pay_reqs: BackwardFKRelation[PayReq]
1150
+
1151
+ _name = {"coin__ticker", "free"}
1152
+
1153
+ class Meta:
1154
+ table_description = "Coin address on cex"
1155
+ unique_together = (("coin_id", "actor_id"),)
1156
+
1157
+
1158
+ class Asset(Model):
1159
+ addr: ForeignKeyRelation[Addr] = ForeignKeyField("models.Addr", "addrs", on_update=CASCADE)
1160
+ addr_id: int
1161
+ agent: ForeignKeyRelation[Agent] = ForeignKeyField("models.Agent", "assets", on_update=CASCADE)
1162
+ agent_id: int
1163
+ typ: AddrExType = IntEnumField(AddrExType, default=AddrExType.found)
1164
+ free: int = UInt8Field() # /10^coinex.scale
1165
+ freeze: int | None = UInt8Field(default=0) # /10^coinex.scale
1166
+ lock: int | None = UInt8Field(default=0) # /10^coinex.scale
1167
+ target: int | None = UInt8Field(default=0, null=True) # /10^coinex.scale
1168
+
1169
+ _name = {"asset__coin__ticker", "free"}
1170
+
1171
+ class Meta:
1172
+ table_description = "Coin balance"
1173
+ unique_together = (("addr_id", "agent_id", "typ"),)
1174
+
1175
+ def epyd(self):
1176
+ module_name = f"xync_client.{self.agent.ex.name}.pyd"
1177
+ __import__(module_name)
1178
+ return sys.modules[module_name].AssetEpyd
1179
+
1180
+
1181
+ class PayReq(Model, TsTrait):
1182
+ pay_until: datetime = DatetimeSecField()
1183
+ addr: ForeignKeyNullableRelation[Addr] = ForeignKeyField("models.Addr", "pay_reqs", on_update=CASCADE, null=True)
1184
+ addr_id: int
1185
+ cred: ForeignKeyNullableRelation[Cred] = ForeignKeyField("models.Cred", "pay_reqs", on_update=CASCADE, null=True)
1186
+ cred_id: int
1187
+ user: ForeignKeyRelation[User] = ForeignKeyField("models.User", "pay_reqs", on_update=CASCADE)
1188
+ user_id: int
1189
+ amount: float = UInt4Field()
1190
+ parts: int = UInt1Field(default=1)
1191
+ payed_at: datetime | None = DatetimeSecField(null=True)
1192
+
1193
+ maked_ads: BackwardFKRelation[MyAd]
1194
+ taken_orders: BackwardFKRelation[Order]
1195
+
1196
+ _icon = "pay"
1197
+ _name = {"ad_id"}
1198
+
1199
+ class Meta:
1200
+ table_description = "Payment request"
1201
+ unique_together = (("user_id", "cred_id", "addr_id"),)
1202
+
1203
+
1204
+ class Order(Model, TsTrait):
1205
+ exid: int = UInt8Field(unique=True) # todo: спарить уникальность с биржей, тк на разных биржах могут совпасть
1206
+ ad: ForeignKeyRelation[Ad] = ForeignKeyField("models.Ad", "orders", on_update=CASCADE)
1207
+ ad_id: int
1208
+ amount: int = UInt4Field() # /10^cur.scale
1209
+ quantity: int = UInt8Field(null=True) # /10^coinex.scale
1210
+ cred: ForeignKeyRelation[Cred] = ForeignKeyField("models.Cred", "orders", on_update=CASCADE, null=True)
1211
+ cred_id: int | None
1212
+ taker: ForeignKeyRelation[Actor] = ForeignKeyField("models.Actor", "taken_orders", on_update=CASCADE)
1213
+ taker_id: int
1214
+ payreq: ForeignKeyNullableRelation[PayReq] = ForeignKeyField(
1215
+ "models.PayReq", "taken_orders", on_update=CASCADE, null=True
1216
+ )
1217
+ payreq_id: int
1218
+ hot: ForeignKeyNullableRelation[Hot] = ForeignKeyField("models.Hot", "orders", on_update=CASCADE, null=True)
1219
+ hot_id: int
1220
+ maker_topic: int = UInt2Field(null=True) # todo: remove nullability
1221
+ taker_topic: int = UInt2Field(null=True)
1222
+ status: OrderStatus = IntEnumField(OrderStatus, default=OrderStatus.created)
1223
+ chat_parsed: bool = BooleanField(default=False)
1224
+
1225
+ hots: ManyToManyRelation[Hot]
1226
+ msgs: BackwardFKRelation[Msg]
1227
+ transfers: BackwardFKRelation[Transfer]
1228
+
1229
+ _name = {"cred__pmcur__pm__name"}
1230
+
1231
+ def ami_maker(self, actor_id: int) -> bool:
1232
+ return self.taker_id != actor_id
1233
+
1234
+ async def is_it_seller(self, actor_id: int) -> bool:
1235
+ await self.fetch_related("ad__pair_side") # todo: check and fetch if only need
1236
+ return self.ad.pair_side.is_sell == self.ami_maker(actor_id)
1237
+
1238
+ async def actors_bs(self) -> tuple[Actor, Actor]: # (buyer, seller)
1239
+ # ad_not_fetched = not isinstance(self.ad, Ad)
1240
+ # if ad_not_fetched or not isinstance(self.ad.pair_side, PairSide):
1241
+ # await self.fetch_related("ad__pair_side")
1242
+ # if ad_not_fetched or not isinstance(self.ad.maker, Actor):
1243
+ # await self.fetch_related("ad__maker")
1244
+ # if not isinstance(self.taker, Actor):
1245
+ # await self.fetch_related("taker")
1246
+ # todo: СУКАПИЗДЕЦБЛЯ! Один фетч перекрывает получений предыдущих связей фетчем!!!
1247
+ k = int(self.ad.pair_side.is_sell) * 2 - 1
1248
+ # noinspection PyTypeChecker
1249
+ return (self.taker, self.ad.maker)[::k]
1250
+
1251
+ async def users_bs(self) -> tuple[User, User]: # (buyer, seller)
1252
+ actors_bs = await self.actors_bs()
1253
+ return await User.get(persons__id=actors_bs[0].person_id), await User.get(persons__id=actors_bs[1].person_id)
1254
+
1255
+ def client(self, agent_client: BaseAgentClient = None): # noqa
1256
+ module_name = f"xync_client.{agent_client.ex_client.ex.name}.order"
1257
+ __import__(module_name)
1258
+ client = sys.modules[module_name].OrderClient
1259
+ return client(self, agent_client)
1260
+
1261
+ async def get_chat(self):
1262
+ await self.fetch_related("ad__pair_side", "msgs")
1263
+ return [
1264
+ f"{'b' if self.ad.pair_side.is_sell == m.to_maker else 's'}{m.to_maker and 't' or 'm'}: {m.txt or '<file>'}"
1265
+ for m in self.msgs
1266
+ ]
1267
+
1268
+ async def is_hot_ad(self) -> bool:
1269
+ assert isinstance(self.ad.maker, Actor), "ad__maker should be fetched"
1270
+ assert isinstance(self.ad.pms[0], Pm), "ad__pms should be fetched"
1271
+ # проверяем что это именно DUMB ордер, а не COLD # todo: fox DRY: move to schema.model.fn:is_hot_ad()
1272
+ is_cold = await Cred.exists(
1273
+ pmcur__pm_id__in=[pm.id for pm in self.ad.pms], person=self.ad.maker.person_id, ovr_pm_id=0
1274
+ )
1275
+ return not is_cold
1276
+
1277
+ # когда прилетел новый ордер, и мы уже знаем юзера которому он принадлежит, и его последний hot
1278
+ async def hot_process(self) -> OrderResult:
1279
+ await self.fetch_related(
1280
+ "taker", "hot__user", "ad__pair_side__pair__cur", "ad__maker__person__user", "transfers"
1281
+ )
1282
+ # оптимистично сразу проставляем удачный результат, если ошибки - перезапишется
1283
+ res = OrderResult(err=OrderErr.ok if self.ad.pair_side.is_sell else OrderErr.i_wait_for_release)
1284
+ if self.status in (OrderStatus.created, OrderStatus.paid):
1285
+ # нет ли у тейкера незавершенных ордеров?
1286
+ if (
1287
+ incomplete_order := await Order.filter(
1288
+ id__not=self.id,
1289
+ taker__person__user_id=self.hot.user.id,
1290
+ ad__pair_side__is_sell=False,
1291
+ status__not_in={OrderStatus.completed, OrderStatus.canceled},
1292
+ )
1293
+ .first()
1294
+ .values("exid", "status", "amount")
1295
+ ):
1296
+ res.err = OrderErr.uncompleted_orders
1297
+ res.data = OrderResult.Order.load(incomplete_order)
1298
+ return res
1299
+ # покупатель оплачивает ордер
1300
+ payment: Transfer | int = await self.pay() # -> self.status = paid
1301
+ if isinstance(payment, int): # если не хватило денег - там число=баланс
1302
+ # мы мейкер, и если это ad:sell значит покупатель - тейкер, иначе - мейкер
1303
+ res.err = self.ad.pair_side.is_sell and OrderErr.taker_balance_deficit or OrderErr.maker_balance_deficit
1304
+ res.data = (self.amount - payment) * 0.01
1305
+ elif self.ad.pair_side.is_sell: # успех и мы продавец - отпускаем
1306
+ self.status = OrderStatus.completed
1307
+ await self.save(update_fields=["status"])
1308
+ res.data = payment.amount * 0.01
1309
+ else: # успех и мы покупатель - ждем когда продавец отпустит
1310
+ res.data = OrderResult.Order.load(self)
1311
+ return res
1312
+ # статус ордера не "новый" и не "оплачен": мы не должны сюда попадать, чини!
1313
+ logging.error(f"Hot order status SHOULD NOT BE: {self.status.name}! Fix it!")
1314
+ res.data = OrderResult.Order.load(self)
1315
+ res.err = OrderErr.wrong_status_for_hot
1316
+ return res
1317
+
1318
+ async def pay(self, pm_id: int = 0) -> Transfer | int:
1319
+ if tsfs := self.transfers:
1320
+ if sum(t.amount for t in tsfs) < self.amount:
1321
+ ValueError(f"Оплачено меньше суммы ордера {self.exid}")
1322
+ if len(tsfs) > 1:
1323
+ logging.warning(f"Несколько оплат по ордеру {self.exid}")
1324
+ return tsfs[0]
1325
+ if pm_id == 0:
1326
+ buyer, seller = await self.users_bs()
1327
+
1328
+ # партнерские отчисления:
1329
+ if self.ad.maker.person.user.status == UserStatus.HOT_PARTNER:
1330
+ hspread = abs(self.ad.price - self.ad.pair_side.pair.cur.rate)
1331
+ earn = hspread * self.quantity * 0.0001
1332
+ fee = int(earn * (0.7 - self.ad.maker.person.user.bonus * 0.0001)) # 1% = 100
1333
+ b = await self.ad.maker.person.user.balance(self.ad.pair_side.pair.cur_id)
1334
+ if b < fee + (0 if self.ad.pair_side.is_sell else self.amount):
1335
+ return b
1336
+ _txf = await self.ad.maker.person.user.send(self.ad.pair_side.pair.cur_id, 0, fee)
1337
+
1338
+ tx = await buyer.send(self.ad.pair_side.pair.cur_id, seller.id, self.amount)
1339
+ if isinstance(tx, int):
1340
+ logging.error("КАК БЛЯТЬ МЫ СЮДА ПОПАЛИ? Its unreal!")
1341
+ return tx
1342
+ if not self.id:
1343
+ logging.warning("No ID order payment")
1344
+ await self.save()
1345
+ tsf = await Transfer.create(
1346
+ amount=tx.amount, order_id=self.id, pmid=tx.id.hex, sender_acc=buyer.username_id
1347
+ )
1348
+ self.status = OrderStatus.paid # todo: запрещать ставить paid, если у ордера нет трансферов на сумму amount
1349
+ await self.save(update_fields=["status", "updated_at"])
1350
+ return tsf
1351
+
1352
+ raise NotImplementedError("Pay method implemented now only for XyncPay pm")
1353
+
1354
+ async def cancel_request(self):
1355
+ if not (payments := await self.fetch_related("transfers")):
1356
+ self.status = OrderStatus.canceled
1357
+ await self.save(update_fields=["status", "updated_at"])
1358
+ return {"err": 0}
1359
+ return {"err": payments}
1360
+
1361
+ # def epyd(self): # todo: for who?
1362
+ # module_name = f"xync_client.{self.ex.name}.pyd"
1363
+ # __import__(module_name)
1364
+ # return sys.modules[module_name].OrderEpyd
1365
+
1366
+ class Meta:
1367
+ table_description = "P2P Orders"
1368
+ # unique_together = (("exid", "ad_id"),)
1369
+
1370
+ class PydanticMeta(Model.PydanticMeta):
1371
+ max_recursion: int = 0
1372
+ exclude_raw_fields: bool = False
1373
+ exclude = ("taker", "ad", "cred", "msgs")
1374
+
1375
+
1376
+ class Task(Model):
1377
+ order: ForeignKeyRelation[Order] = ForeignKeyField("models.Order", "tasks", on_update=CASCADE)
1378
+ typ: TaskType = IntEnumField(TaskType)
1379
+ data: dict = JSONField()
1380
+
1381
+
1382
+ class Transaction(Model):
1383
+ id: UUID = UUIDField(primary_key=True, default=None)
1384
+ amount: int = UInt4Field() # /10^cur.scale
1385
+ cur: ForeignKeyRelation[Cur] = ForeignKeyField("models.Cur", "transfers", on_update=CASCADE)
1386
+ cur_id: int
1387
+ sender: ForeignKeyRelation[User] = ForeignKeyField("models.User", "sends", on_update=CASCADE, null=True)
1388
+ sender_id: int | None
1389
+ receiver: ForeignKeyRelation[User] = ForeignKeyField("models.User", "receives", on_update=CASCADE)
1390
+ receiver_id: int
1391
+ # validator: ForeignKeyRelation[User] = ForeignKeyField("models.User", "validates", null=True)
1392
+ # validator_id: int
1393
+ status: TS = IntEnumField(TS)
1394
+ proof: bytes = UniqBinaryField(unique=True, null=True) # len=128
1395
+ ts: int | None = IntField(null=True, db_index=True)
1396
+
1397
+ def is_expired(self) -> bool:
1398
+ """Проверка истёк ли запрос"""
1399
+ assert not self.proof and self.ts, "It is method for ttl requests only"
1400
+ return time() > self.ts
1401
+
1402
+ def is_valid_sender(self, sender_id: int) -> bool:
1403
+ """Проверка может ли отправитель оплатить этот запрос"""
1404
+ assert self.proof is None, "It is method for requests only"
1405
+ return self.sender_id is None or self.sender_id == sender_id
1406
+
1407
+ @property
1408
+ def pack(self) -> bytes:
1409
+ """Упаковка тип+время+валюта+получатель+сумма+отправитель в 4+(1+3+5)+3=16 байт"""
1410
+
1411
+ def tt(): # тип + ts (2+30=32bit=4byte)
1412
+ """Упаковка тип+время в 4 байта"""
1413
+ typ: LinkType = LinkType.request if self.status == TS.request else LinkType.receipt
1414
+ return typ_ts_pack(typ.value, self.ts)
1415
+
1416
+ def cr(cur_id: int, rec_id: int):
1417
+ """Упаковка валюта + получатель в 1+3=4 байт"""
1418
+ rcv = struct.pack(">I", rec_id)[1:4] # последние 3 байта от 4х-байтного идшника
1419
+ return struct.pack(">B", cur_id) + rcv
1420
+
1421
+ def cra(cur_id: int, rec_id: int, amount: int):
1422
+ """Упаковка валюта + получатель + сумма в (1+3)+5=9 байт"""
1423
+ amt = struct.pack(">Q", amount)[3:8] # последние 5 байт от 8-байтной суммы (max 1_099_511_627_775)
1424
+ return cr(cur_id, rec_id) + amt
1425
+
1426
+ def ttcras():
1427
+ assert self.sender_id < 1 << 24
1428
+ sndr = struct.pack(">I", self.sender_id)[1:4] # последние 3 байта от 4х-байтного идшника
1429
+ return tt() + cra(self.cur_id, self.receiver_id, self.amount) + sndr
1430
+
1431
+ # если это запрос, то все, пруфа нет и не нужен
1432
+ proof_len = len(self.proof or b"")
1433
+ if self.status == TS.request:
1434
+ assert not proof_len, f"no proof `{self.proof}` for request"
1435
+ # return cra(
1436
+ # self.cur_id, # 1
1437
+ # self.receiver_id, # 4
1438
+ # self.amount, # 4
1439
+ # )
1440
+ # иначе это отправка
1441
+ elif not self.ts:
1442
+ raise ValueError("Time of sending is required for transaction")
1443
+ elif (self.status == TS.signed and proof_len != 64) or (self.status == TS.verified and proof_len != 128):
1444
+ raise ValueError(f"wrong proof length `{proof_len}` for `{self.status.name}` transaction")
1445
+ return ttcras()
1446
+
1447
+ async def can_be_send(self) -> bool:
1448
+ if not self.sender_id:
1449
+ return True
1450
+ assert isinstance(self.sender, User), "Sender is not fetched!"
1451
+ sender_balance = await self.sender.balance(self.cur_id, self.ts)
1452
+ is_in_db = await Transaction.exists(id=self.id, status__gte=TS.signed)
1453
+ if not (res := sender_balance >= (0 if is_in_db else self.amount)):
1454
+ for ttx in await Transaction.filter(id__not=self.id, ts=self.ts, receiver=self.sender, status=TS.signed):
1455
+ await ttx.approve()
1456
+ res = await self.can_be_send()
1457
+ return res
1458
+
1459
+ async def sign(self, front_sign: bytes = None) -> "Transaction | int":
1460
+ assert isinstance(self.sender, User), "Sender is not fetched!"
1461
+ back_sign = self.sender.prv and Ed25519PrivateKey.from_private_bytes(self.sender.prv).sign(self.pack)
1462
+ if self.sender.prv and front_sign: # if fronted sign passed, and sender has backend priv key, check the match
1463
+ if front_sign != back_sign:
1464
+ raise InvalidSignature(front_sign, "old_front_pub")
1465
+ elif not front_sign: # if fronted sign not passed, use back sign
1466
+ self.proof = back_sign
1467
+ self.status = TS.signed
1468
+ # xync verify tx
1469
+ await self.approve()
1470
+ # validator = await self.sender.get_validator(amount, cur_id, rcv_id)
1471
+ return self
1472
+
1473
+ async def approve(self, prv: bytes = None):
1474
+ assert self.status == TS.signed, "It is method for signed transfers only"
1475
+ # assert len(self.proof) == 64, "Sender did not signed yet"
1476
+ # 1. Проверяем подпись отправителя
1477
+ await self.fetch_related("sender")
1478
+ snd_pub = Ed25519PublicKey.from_public_bytes(self.sender.pub)
1479
+ # отправитель подписывал транзу когда в ней еще не было пруфа, поэтому проверяем обрезок пака до него
1480
+ snd_pub.verify(self.proof[:64], self.id.bytes)
1481
+ # 2. Проверяем что баланс отправителя не меньшы отправляемой суммы. # todo: remove origin hack
1482
+ assert await self.can_be_send(), "Less sender balance"
1483
+ # 3. Подписываем
1484
+ prv = prv or (await User[0]).prv # or (await self.receiver).prv
1485
+ pk = Ed25519PrivateKey.from_private_bytes(prv) # ключем Xync-a, если не был передан другой
1486
+ self.proof += pk.sign(
1487
+ self.id.bytes + self.proof
1488
+ ) # ид транзы + пруф отправителя и добавляем эту подпись к пруфу 64+64=128
1489
+ self.status = TS.verified
1490
+ # Только две подписи: отправителя + валидатора (128 байт)
1491
+ try:
1492
+ await self.save(**dict(update_fields=["proof", "status"]) if self._saved_in_db else {})
1493
+ except RaiseError as e:
1494
+ re = PgRaiseError(e)
1495
+ raise re
1496
+
1497
+ def check(self, vld_pub: bytes = None) -> bool:
1498
+ """Проверка доказательства"""
1499
+ if len(self.proof) != 128: # 64 + 64
1500
+ return False # wrong size
1501
+ sender_sig, vld_sig = self.proof[:64], self.proof[64:128]
1502
+ try:
1503
+ # Проверяем подпись отправителя
1504
+ sender_pub = Ed25519PublicKey.from_public_bytes(self.sender.pub)
1505
+ sender_pub.verify(sender_sig, self.id.bytes)
1506
+ # Проверяем подпись валидатора
1507
+ if vld_pub:
1508
+ vld_pub = Ed25519PublicKey.from_public_bytes(vld_pub)
1509
+ vld_pub.verify(vld_sig, self.id.bytes + sender_sig)
1510
+ return True
1511
+ except InvalidSignature as _:
1512
+ return False # wrong sign
1513
+
1514
+ def get_qr(self, bot_nick: str) -> bytes:
1515
+ data = f"{bot_nick and 't.me/' + bot_nick + '?startapp='}{int.from_bytes(self.id.bytes + self.proof)}"
1516
+ # Prepare text
1517
+ typ = ("Personalized" if self.sender_id else "Opened") if self.status == TS.request else "Receipt"
1518
+ text = f"{typ} {self.status.name}"
1519
+ return qr_gen(data, text)
1520
+
1521
+ @classmethod
1522
+ async def snapshot(cls, ts: int = None) -> dict[int, dict[int, int]]: # {user_id: {cur_id: balance}}
1523
+ conn = connections.get("default")
1524
+ r = await conn.execute_query_dict(
1525
+ """
1526
+ SELECT user_id,
1527
+ cur_id::int,
1528
+ SUM(total)::int AS balance
1529
+ FROM (SELECT receiver_id AS user_id, t.cur_id, SUM(amount) AS total
1530
+
1531
+ FROM transaction t -- deposits
1532
+ WHERE status = 2 -- verified
1533
+ AND ts <= $1
1534
+ GROUP BY receiver_id, t.cur_id
1535
+
1536
+ UNION ALL
1537
+
1538
+ SELECT sender_id AS user_id, t.cur_id, -SUM(amount) AS total
1539
+ FROM transaction t -- credits
1540
+ WHERE status in (1, 2) -- signed+
1541
+ AND sender_id IS NOT NULL -- исключаем Request-ы денег
1542
+ AND ts <= $1
1543
+ GROUP BY sender_id, t.cur_id) a
1544
+
1545
+ GROUP BY user_id, cur_id
1546
+ HAVING SUM(total) != 0
1547
+ ORDER BY user_id, cur_id;
1548
+ """,
1549
+ [ts or int(time())],
1550
+ )
1551
+ result: dict[int, dict[int, int]] = {}
1552
+ for row in r:
1553
+ result.setdefault(row["user_id"], {})[row["cur_id"]] = row["balance"]
1554
+ return result
1555
+
1556
+
1557
+ class Sign(Model):
1558
+ transaction: ForeignKeyRelation[Transaction] = ForeignKeyField("models.Transaction", "signs", on_update=CASCADE)
1559
+ transaction_id: int
1560
+ validator: ForeignKeyRelation[User] = ForeignKeyField("models.User", "approves", on_update=CASCADE)
1561
+ validator_id: int
1562
+ created_at: datetime | None = DatetimeSecField(auto_now_add=True)
1563
+
1564
+
1565
+ # class Checkpoint(Model):
1566
+ # amount: int = BigIntField() # /10^cur.scale
1567
+ # cur: ForeignKeyRelation[Cur] = ForeignKeyField("models.Cur", related_name="transfers")
1568
+ # cur_id: int
1569
+ # user: ForeignKeyRelation[User] = ForeignKeyField("models.User", "checkpoints", null=True)
1570
+ # user_id: int | None
1571
+ #
1572
+ # class Meta:
1573
+ # unique_together = (("user_id", "cur_id"),)
1574
+
1575
+
1576
+ class Transfer(Model):
1577
+ amount: int = UInt4Field() # /10^cur.scale
1578
+ order: ForeignKeyRelation[Order] = ForeignKeyField("models.Order", "transfers", on_update=CASCADE, null=True)
1579
+ order_id: int
1580
+ file: OneToOneNullableRelation["File"] = OneToOneField("models.File", "transfer", on_update=CASCADE, null=True)
1581
+ file_id: int
1582
+ pmid: str = CharField(32, unique=True, null=True)
1583
+ sender_acc: str = CharField(63, null=True)
1584
+ # pm: ForeignKeyRelation["Pm"] = ForeignKeyField("models.Pm", "transfers", on_update=CASCADE)
1585
+ # pm_id: int
1586
+ updated_at: datetime | None = DatetimeSecField(auto_now=True)
1587
+
1588
+ # class Meta:
1589
+ # unique_together = (("pmid", "pm_id"),)
1590
+
1591
+
1592
+ class Msg(Model):
1593
+ tg_mid: int = UInt4Field(null=True)
1594
+ txt: str = CharField(4095, null=True)
1595
+ read: bool = BooleanField(default=False, db_index=True)
1596
+ to_maker: bool = BooleanField()
1597
+ file: OneToOneNullableRelation["File"] = OneToOneField("models.File", "msg", on_update=CASCADE, null=True)
1598
+ order: ForeignKeyRelation[Order] = ForeignKeyField("models.Order", "msgs", on_update=CASCADE)
1599
+ sent_at: datetime | None = DatetimeSecField()
1600
+
1601
+ # todo: required txt or file
1602
+ class PydanticMeta(Model.PydanticMeta):
1603
+ max_recursion: int = 0
1604
+ exclude_raw_fields: bool = False
1605
+ exclude = ("receiver", "order")
1606
+
1607
+ class Meta:
1608
+ unique_together = (
1609
+ ("order", "txt"),
1610
+ # ("order", "sent_at"),
1611
+ )
1612
+
1613
+
1614
+ class TopUpAble(Model):
1615
+ pm: OneToOneRelation[Pm] = OneToOneField("models.Pm", "topupable", on_update=CASCADE)
1616
+ pm_id: int
1617
+ url: str = CharField(127)
1618
+ auth: dict = JSONField(default={})
1619
+ fee: int = SmallIntField(default=0) # /10_000
1620
+
1621
+
1622
+ class TopUp(Model):
1623
+ topupable: ForeignKeyRelation[TopUpAble] = ForeignKeyField("models.TopUpAble", "topup", on_update=CASCADE)
1624
+ topupable_id: int
1625
+ pmid: str = CharField(36, unique=True, null=True)
1626
+ tid: str = CharField(36, unique=True, null=True)
1627
+ amount: int = UInt4Field() # /10^cur.scale
1628
+ cur: ForeignKeyRelation[Cur] = ForeignKeyField("models.Cur", "topups", on_update=CASCADE)
1629
+ cur_id: int
1630
+ user: ForeignKeyRelation[User] = ForeignKeyField("models.User", "topups", on_update=CASCADE)
1631
+ user_id: int
1632
+ created_at: datetime | None = DatetimeSecField(auto_now_add=True)
1633
+ completed_at: datetime | None = DatetimeSecField(null=True)
1634
+
1635
+
1636
+ class Dep(Model, TsTrait):
1637
+ pid: str = CharField(31) # product_id
1638
+ apr: int = UInt2Field() # /10_000
1639
+ fee: int | None = UInt2Field(null=True) # /10_000
1640
+ apr_is_fixed: bool = BooleanField(default=False)
1641
+ duration: int | None = UInt2Field(null=True)
1642
+ early_redeem: bool | None = BooleanField(null=True)
1643
+ typ: DepType = IntEnumField(DepType)
1644
+ # mb: renewable?
1645
+ min_limit: float = UInt8Field()
1646
+ max_limit: float | None = UInt8Field(null=True)
1647
+ is_active: bool = BooleanField(default=True)
1648
+
1649
+ coin: ForeignKeyRelation[Coin] = ForeignKeyField("models.Coin", "deps", on_update=CASCADE)
1650
+ coin_id: int
1651
+ reward_coin: ForeignKeyRelation[Coin] = ForeignKeyField("models.Coin", "deps_reward", on_update=CASCADE, null=True)
1652
+ reward_coin_id: int | None = None
1653
+ bonus_coin: ForeignKeyRelation[Coin] = ForeignKeyField("models.Coin", "deps_bonus", on_update=CASCADE, null=True)
1654
+ bonus_coin_id: int | None = None
1655
+ ex: ForeignKeyRelation[Ex] = ForeignKeyField("models.Ex", "deps", on_update=CASCADE)
1656
+ ex_id: int
1657
+ investments: BackwardFKRelation[Investment]
1658
+
1659
+ _icon = "seeding"
1660
+ _name = {"pid"}
1661
+
1662
+ def repr(self):
1663
+ return (
1664
+ f"{self.coin.ticker}:{self.apr * 100:.3g}% "
1665
+ f"{f'{self.duration}d' if self.duration and self.duration > 0 else 'flex'}"
1666
+ )
1667
+
1668
+ class Meta:
1669
+ table_description = "Investment products"
1670
+ unique_together = (("pid", "typ", "ex_id"),)
1671
+
1672
+
1673
+ class Investment(Model, TsTrait):
1674
+ dep: ForeignKeyRelation[Dep] = ForeignKeyField("models.Dep", "investments", on_update=CASCADE)
1675
+ # dep_id: int
1676
+ amount: float = UInt8Field()
1677
+ is_active: bool = BooleanField(default=True)
1678
+ user: ForeignKeyRelation[User] = ForeignKeyField("models.User", "investments", on_update=CASCADE)
1679
+
1680
+ _icon = "trending-up"
1681
+ _name = {"dep__pid", "amount"}
1682
+
1683
+ def repr(self):
1684
+ return f"{self.amount:.3g} {self.dep.repr()}"
1685
+
1686
+ class Meta:
1687
+ table_description = "Investments"
1688
+
1689
+
1690
+ class ExStat(Model):
1691
+ ex: ForeignKeyRelation[Ex] = ForeignKeyField("models.Ex", "stats", on_update=CASCADE)
1692
+ ex_id: int
1693
+ action: ExAction = IntEnumField(ExAction)
1694
+ ok: bool | None = BooleanField(default=False, null=True)
1695
+ updated_at: datetime | None = DatetimeSecField(auto_now=True)
1696
+
1697
+ _icon = "test-pipe"
1698
+ _name = {"ex_id", "action", "ok"}
1699
+
1700
+ def repr(self):
1701
+ return f"{self.ex_id} {self.action.name} {self.ok}"
1702
+
1703
+ class Meta:
1704
+ table = "ex_stat"
1705
+ table_description = "Ex Stats"
1706
+ unique_together = (("action", "ex_id"),)
1707
+
1708
+ class PydanticMeta(Model.PydanticMeta):
1709
+ max_recursion: int = 2
1710
+
1711
+
1712
+ class Vpn(Model):
1713
+ user: OneToOneRelation[User] = OneToOneField("models.User", "vpn", on_update=CASCADE)
1714
+ user_id: int
1715
+ prv: str = CharField(63, unique=True)
1716
+ pub: str = CharField(63, unique=True)
1717
+ created_at: datetime | None = DatetimeSecField(auto_now_add=True)
1718
+
1719
+ _icon = "vpn"
1720
+ _name = {"pub"}
1721
+
1722
+ def repr(self):
1723
+ return self.user.username
1724
+
1725
+ class Meta:
1726
+ table_description = "VPNs"
1727
+
1728
+
1729
+ class File(Model):
1730
+ name: str = CharField(178, null=True, unique=True)
1731
+ typ: FileType = IntEnumField(FileType)
1732
+ ref: bytes = UniqBinaryField(unique=True)
1733
+ size: bytes = UInt4Field()
1734
+ created_at: datetime | None = DatetimeSecField(auto_now_add=True)
1735
+
1736
+ msg: BackwardOneToOneRelation[Msg]
1737
+ transfer: BackwardOneToOneRelation[Transfer]
1738
+ pmex_logos: BackwardFKRelation[PmEx]
1739
+
1740
+ _icon = "file"
1741
+ _name = {"name"}
1742
+
1743
+ class Meta:
1744
+ table_description = "Files"
1745
+ # Создаем индекс через raw SQL
1746
+ # indexes = [CREATEUNIQUE INDEX IF NOT EXISTS idx_bytea_unique ON file (encode(sha256(ref), 'hex'))"]
1747
+
1748
+
1749
+ class Invite(Model, TsTrait):
1750
+ ref: ForeignKeyRelation[User] = ForeignKeyField("models.User", "invite_approvals", on_update=CASCADE)
1751
+ ref_id: int
1752
+ protege: ForeignKeyRelation[User] = ForeignKeyField("models.User", "invite_requests", on_update=CASCADE)
1753
+ protege_id: int
1754
+ approved: str = BooleanField(default=False) # status
1755
+
1756
+ _icon = "invite"
1757
+ _name = {"ref__username", "protege__username", "approved"}
1758
+
1759
+ def repr(self):
1760
+ return self.protege.name
1761
+
1762
+ class Meta:
1763
+ table_description = "Invites"
1764
+
1765
+
1766
+ class Credit(Model, TsTrait):
1767
+ lender: ForeignKeyRelation[User] = ForeignKeyField("models.User", "lends", on_update=CASCADE)
1768
+ lender_id: int
1769
+ borrower: ForeignKeyRelation[User] = ForeignKeyField("models.User", "borrows", on_update=CASCADE)
1770
+ borrower_id: int
1771
+ borrower_priority: bool = BooleanField(default=True)
1772
+ amount: int = UInt4Field(default=None) # 0 - is all remain borrower balance
1773
+
1774
+ _icon = "credit"
1775
+ _name = {"lender__username", "borrower__username", "amount"}
1776
+
1777
+ def repr(self):
1778
+ return self.borrower.name
1779
+
1780
+ class Meta:
1781
+ table_description = "Credits"