xync-db 0.1.4__tar.gz → 0.2.0__tar.gz

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.
Files changed (35) hide show
  1. {xync_db-0.1.4 → xync_db-0.2.0}/PKG-INFO +1 -1
  2. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/migrations/models/0002_triggers.py +3 -5
  3. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/models/__init__.py +231 -28
  4. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/typs/db/ad.py +1 -1
  5. {xync_db-0.1.4 → xync_db-0.2.0}/.gitignore +0 -0
  6. {xync_db-0.1.4 → xync_db-0.2.0}/README.md +0 -0
  7. {xync_db-0.1.4 → xync_db-0.2.0}/pyproject.toml +0 -0
  8. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/__init__.py +0 -0
  9. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/config.py +0 -0
  10. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/enums.py +0 -0
  11. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/exceptions.py +0 -0
  12. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/graph.py +0 -0
  13. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/i18n.py +0 -0
  14. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/logo.png +0 -0
  15. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/migrations/CLAUDE.md +0 -0
  16. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/migrations/__init__.py +0 -0
  17. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/migrations/models/0001_initial.py +0 -0
  18. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/migrations/models/0003_files_seed.py +0 -0
  19. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/migrations/models/0004_all_seed.py +0 -0
  20. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/migrations/models/__init__.py +0 -0
  21. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/migrations/models/seed/__init__.py +0 -0
  22. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/migrations/models/seed/a_actor_seed.py +0 -0
  23. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/migrations/models/seed/a_tg_seed.py +0 -0
  24. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/migrations/models/seed/b_htx_seed.py +0 -0
  25. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/migrations/models/seed/xync_files.sql +0 -0
  26. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/models/CLAUDE.md +0 -0
  27. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/py.typed +0 -0
  28. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/shared.py +0 -0
  29. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/typs/__init__.py +0 -0
  30. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/typs/db/__init__.py +0 -0
  31. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/typs/db/actor.py +0 -0
  32. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/typs/db/common.py +0 -0
  33. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/typs/db/cred.py +0 -0
  34. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/typs/db/order.py +0 -0
  35. {xync_db-0.1.4 → xync_db-0.2.0}/src/xync_db/typs/db/pm.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xync-db
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Xync DB schema: Tortoise ORM models, enums, pydantic DTOs.
5
5
  Author-email: Mike Artemiev <mixartemev@gmail.com>
6
6
  License: LicenseRef-EULA
@@ -237,11 +237,9 @@ CREATE OR REPLACE FUNCTION cred_del() returns trigger as $cred_del_trg$
237
237
  DECLARE
238
238
  cxs jsonb;
239
239
  BEGIN
240
- IF OLD.ovr_pm_id = 0 THEN
241
- SELECT jsonb_agg(jsonb_build_array(ex_id, exid)) INTO cxs FROM credex WHERE cred_id = OLD.id;
242
- IF cxs IS NOT NULL THEN
243
- PERFORM pg_notify('creds_del', jsonb_build_array(OLD.person_id, cxs)::varchar);
244
- END IF;
240
+ SELECT jsonb_agg(jsonb_build_array(ex_id, exid)) INTO cxs FROM credex WHERE cred_id = OLD.id;
241
+ IF cxs IS NOT NULL THEN
242
+ PERFORM pg_notify('creds_del', jsonb_build_array(OLD.person_id, cxs)::varchar);
245
243
  END IF;
246
244
  RETURN OLD;
247
245
  END
@@ -4,6 +4,7 @@ import logging
4
4
  import re
5
5
  import struct
6
6
  import sys
7
+ from collections.abc import Collection
7
8
  from datetime import datetime
8
9
  from decimal import Decimal
9
10
  from time import time
@@ -40,6 +41,7 @@ from tortoise.functions import Sum
40
41
  from tortoise.migrations.constraints import CheckConstraint
41
42
  from tortoise.signals import pre_save
42
43
  from tortoise.timezone import now
44
+ from tortoise.transactions import in_transaction
43
45
 
44
46
  # noinspection PyUnusedImports
45
47
  from x_auth.models import ( # noqa: F401
@@ -1163,6 +1165,64 @@ class Ad(Model, TsTrait):
1163
1165
  "price",
1164
1166
  }
1165
1167
 
1168
+ @staticmethod
1169
+ async def _sync_pms(ad: Ad, pms: Collection[Pm] | None) -> None:
1170
+ """Синк m2m pms (clear+add), только если набор передан и отличается от текущего.
1171
+ Дедуп и diff — по `pm.id`, НЕ через set[Pm]: XyncPay-Pm имеет id=0, а Tortoise.Model.__hash__
1172
+ трактует pk=0 как unsaved (`not self.pk`) → набор с ним невозможно захэшировать."""
1173
+ if pms is None:
1174
+ return
1175
+ new = {p.id: p for p in pms} # dedup by id (Pm.id==0 → unhashable, нельзя set[Pm])
1176
+ if {p.id for p in await ad.pms} != new.keys():
1177
+ await ad.pms.clear()
1178
+ if new:
1179
+ await ad.pms.add(*new.values())
1180
+
1181
+ @classmethod
1182
+ async def upsert(
1183
+ cls,
1184
+ *,
1185
+ id: int | None = None,
1186
+ since: datetime | None = None,
1187
+ pms: Collection[Pm] | None = None,
1188
+ **fields,
1189
+ ) -> tuple[Ad, bool]:
1190
+ """ЕДИНСТВЕННЫЙ writer строки Ad (single-writer-правило, как Cred.upsert). Три режима:
1191
+ - правка по `id` (write-back exid 0→real, смена статуса) → fields пишутся через filter().update()
1192
+ (без бампа updated_at — иначе сломали бы stale-guard синка ниже);
1193
+ - синк с биржи (передан правдивый `exid`) → upsert по (exid, maker_id) со stale-guard `since`
1194
+ (DB-first: если строку правили локально ПОЗЖЕ чтения биржи — биржевой снимок устарел, не
1195
+ затираем, иначе летит «обратный» mads_upd);
1196
+ - создание локальной заготовки (exid=0 — он НЕ уникален per maker, поэтому именно create,
1197
+ а не upsert).
1198
+ `pms` (Collection[Pm] | None) — m2m: синкаем только если передан (дедуп по id внутри _sync_pms —
1199
+ НЕ set[Pm], XyncPay-Pm имеет id=0 и невхэшируем). Запись строки + clear+add pms всегда в
1200
+ ОДНОЙ транзакции (и в синке, и в create), иначе AFTER INSERT/UPDATE-триггер (ads_new/ads_upd) шлёт
1201
+ NOTIFY на коммите строки — до записи pms — и хэндлер прочитает объяву с пустым/промежуточным набором.
1202
+ Внешние id (maker/pair_side/pms/cond) резолвит вызывающий (держит ex_client-контекст: e2x_actor/
1203
+ e2x_pm); модель пишет скаляры."""
1204
+ if id is not None:
1205
+ await cls.filter(id=id).update(**fields)
1206
+ return await cls.get(id=id), False
1207
+ if fields.get("exid"):
1208
+ unq = {"exid": fields.pop("exid"), "maker_id": fields.pop("maker_id")}
1209
+ async with in_transaction():
1210
+ if since is not None:
1211
+ existing = await cls.select_for_update().get_or_none(**unq)
1212
+ if existing and existing.updated_at and existing.updated_at >= since:
1213
+ logging.info(
1214
+ f"Ad.upsert: skip stale exchange overwrite ad#{existing.id} "
1215
+ f"(db updated_at {existing.updated_at} >= exchange read {since})"
1216
+ )
1217
+ return existing, False
1218
+ ad, created = await cls.update_or_create(defaults=fields, **unq)
1219
+ await cls._sync_pms(ad, pms)
1220
+ return ad, created
1221
+ async with in_transaction(): # create + pms одной транзакцией: ads_new (AFTER INSERT) доставляется
1222
+ ad = await cls.create(**fields) # на COMMIT — к этому моменту pms уже целиком видны хэндлеру
1223
+ await cls._sync_pms(ad, pms) # новая локальная заготовка exid=0
1224
+ return ad, True
1225
+
1166
1226
  class Meta:
1167
1227
  table_description = "P2P Advertisements"
1168
1228
  unique_together = (("exid", "maker"),)
@@ -1222,6 +1282,34 @@ class MyAd(Model): # Road
1222
1282
  "ad__pair_side__pair__coin", "ad__pair_side__pair__cur", "ad__maker__ex"
1223
1283
  )
1224
1284
 
1285
+ @classmethod
1286
+ async def upsert(
1287
+ cls,
1288
+ *,
1289
+ id: int | None = None,
1290
+ ad_id: int | None = None,
1291
+ credexs: list[CredEx] | None = None,
1292
+ **fields,
1293
+ ) -> tuple[MyAd, bool]:
1294
+ """ЕДИНСТВЕННЫЙ writer строки MyAd (single-writer-правило, как CredEx.upsert для CredEx). Режимы:
1295
+ - правка по `id` (hex/shared_at от ad_share, blocked, target_place) → fields через filter().update();
1296
+ - upsert по `ad_id` (1:1 с Ad) → fields в defaults.
1297
+ `credexs` (list[CredEx] | None) — m2m: синкаем (clear+add), только если передан НЕПУСТОЙ список
1298
+ (пустой бы вайпнул связь — этот случай вызывающий обрабатывает сам, не передавая credexs). Запись
1299
+ строки my_ad + clear+add credexs идут в ОДНОЙ транзакции: иначе AFTER INSERT-триггер my_ad шлёт
1300
+ my_ads_new на коммите строки — до записи credexs — и my_ad_new (фронт-создание, exid=0) запостит
1301
+ объяву на биржу с пустым набором PM."""
1302
+ async with in_transaction(): # строка + credexs одной транзакцией: my_ads_new (AFTER INSERT)
1303
+ if id is not None: # доставляется на COMMIT → my_ad_new прочитает уже полные credexs, а не
1304
+ await cls.filter(id=id).update(**fields) # пустые (иначе объява улетит на биржу без PM)
1305
+ mad, created = await cls.get(id=id), False
1306
+ else:
1307
+ mad, created = await cls.update_or_create(defaults=fields, ad_id=ad_id)
1308
+ if credexs:
1309
+ await mad.credexs.clear()
1310
+ await mad.credexs.add(*credexs)
1311
+ return mad, created
1312
+
1225
1313
  @classmethod
1226
1314
  #
1227
1315
  def new(cls, coin_id: int, cur_id: int, min_fiat: int, is_hot: bool = False): ...
@@ -1407,30 +1495,66 @@ class Cred(Model):
1407
1495
  name = f", имя: {self.name}" if self.name else ""
1408
1496
  return f"`{self.detail}`{name}{xtr}"
1409
1497
 
1410
- async def set_ovr_pm(self) -> bool:
1411
- """Вывести «оверрайд-PM» по detail/extra и проставить его. XyncPay-кред → ovr_pm_id=0 (hot,
1412
- размножается по биржам); Volet/AdvCash (или его R/E/U/T/L/D-карточный паттерн) → Pm `volet`.
1413
- Идемпотентно пишет только при реальном изменении, поэтому безопасно звать на КАЖДОМ create И
1414
- update креда без зацикливания триггера `creds_upd`. Возвращает True, если ovr_pm_id поменялся."""
1415
- extra = self.extra or ""
1416
- if "XyncPay" in self.detail or "XyncPay" in extra:
1417
- ovr: int | None = 0
1418
- elif (
1419
- "volet" in self.detail.lower()
1420
- or "advcash" in self.detail.lower()
1421
- or "volet" in extra.lower()
1422
- or "advcash" in extra.lower()
1423
- or re.match(r"[REUTLD]\d{4} ?\d{4} ?\d{4}\b", self.detail)
1498
+ _UNSET: ClassVar = object() # sentinel: «поле не передано» (отличаем от явного None, который НУЛЯЕТ)
1499
+
1500
+ @staticmethod
1501
+ async def _classify_ovr_pm(detail: str, extra: str | None) -> int | None:
1502
+ """Вывести «оверрайд-PM» по detail/extra ЧИСТЫЙ расчёт, без записи. XyncPay → 0 (hot,
1503
+ размножается по биржам); Volet/AdvCash (или его R/E/U/T/L/D-карточный паттерн) → Pm `volet`;
1504
+ иначе None. Зовётся ТОЛЬКО из `upsert`, чтобы ovr_pm писался в той же записи, что и сам Cred
1505
+ не отдельным self-write/реактивным хендлером — см. single-writer-правило)."""
1506
+ extra = extra or ""
1507
+ if "XyncPay" in detail or "XyncPay" in extra:
1508
+ return 0
1509
+ low_d, low_e = detail.lower(), extra.lower()
1510
+ if (
1511
+ "volet" in low_d
1512
+ or "advcash" in low_d
1513
+ or "volet" in low_e
1514
+ or "advcash" in low_e
1515
+ or re.match(r"[REUTLD]\d{4} ?\d{4} ?\d{4}\b", detail)
1424
1516
  or re.match(r"[REUTLD]\d{4} ?\d{4} ?\d{4}\b", extra)
1425
1517
  ):
1426
- ovr = (await Pm.get(norm="volet")).id
1427
- else:
1428
- return False
1429
- if self.ovr_pm_id == ovr:
1430
- return False
1431
- self.ovr_pm_id = ovr
1432
- await self.save(update_fields=["ovr_pm_id"])
1433
- return True
1518
+ return (await Pm.get(norm="volet")).id
1519
+ return None
1520
+
1521
+ @classmethod
1522
+ async def upsert(
1523
+ cls,
1524
+ *,
1525
+ id: int | None = None,
1526
+ pmcur_id: int | None = None,
1527
+ pm_id: int | None = None,
1528
+ cur_id: int | None = None,
1529
+ person_id: int | None = None,
1530
+ detail: str,
1531
+ name: str | None = None,
1532
+ extra: str | None = _UNSET,
1533
+ qr: bytes | None = _UNSET,
1534
+ ) -> tuple[Cred, bool]:
1535
+ """ЕДИНСТВЕННЫЙ writer строки Cred (single-writer-правило). Классифицирует ovr_pm и пишет его
1536
+ В ТОЙ ЖЕ записи. Два режима:
1537
+ - правка существующего: передан `id` → обновляем detail/name(/extra/qr) + пере-классификация;
1538
+ - создание/upsert: person_id + (pmcur_id ИЛИ pm_id+cur_id) → ключ (person, pmcur, detail).
1539
+ Внешние id (pmcur/pm/cur/person) резолвит вызывающий (он держит ex_client-контекст: e2x_actor/
1540
+ e2x_pm); модель принимает готовые скаляры и только пишет. extra/qr с дефолтом-сентинелом: не
1541
+ переданы — не трогаем (иначе случайно занулили бы существующий qr при правке detail с фронта)."""
1542
+ df: dict = {
1543
+ "name": name,
1544
+ "ovr_pm_id": await cls._classify_ovr_pm(detail, None if extra is cls._UNSET else extra),
1545
+ }
1546
+ if extra is not cls._UNSET:
1547
+ df["extra"] = extra
1548
+ if qr is not cls._UNSET:
1549
+ df["qr"] = qr
1550
+ if id is not None:
1551
+ df["detail"] = detail
1552
+ cred = await cls.get(id=id)
1553
+ cred.update_from_dict(df)
1554
+ await cred.save()
1555
+ return cred, False
1556
+ pmcur_id = pmcur_id or (await PmCur.get(pm_id=pm_id, cur_id=cur_id)).id
1557
+ return await cls.update_or_create(df, pmcur_id=pmcur_id, person_id=person_id, detail=detail)
1434
1558
 
1435
1559
  class Meta:
1436
1560
  table_description = "Currency accounts"
@@ -1446,6 +1570,14 @@ class CredEx(Model):
1446
1570
 
1447
1571
  _name: ClassVar = {"exid"}
1448
1572
 
1573
+ @classmethod
1574
+ async def upsert(cls, *, cred_id: int, ex_id: int, exid: int) -> CredEx:
1575
+ """ЕДИНСТВЕННЫЙ writer CredEx (single-writer-правило): ключ (cred, ex), ставит exid. Покрывает
1576
+ и стаб exid=0 (реактивный фанаут на биржи), и реальный exid (parse в `credex_save` / ответ биржи
1577
+ в хендлере `credex_new`). Больше НИКТО не пишет CredEx напрямую."""
1578
+ credex, _ = await cls.update_or_create({"exid": exid}, cred_id=cred_id, ex_id=ex_id)
1579
+ return credex
1580
+
1449
1581
  class Meta:
1450
1582
  table_description = "Credential on Exchange"
1451
1583
  unique_together = (("ex", "exid"),)
@@ -1472,6 +1604,18 @@ class Fiat(Model):
1472
1604
  __import__(module_name)
1473
1605
  return sys.modules[module_name].FiatEpyd
1474
1606
 
1607
+ @classmethod
1608
+ async def upsert(cls, cred_id: int, amount: int, target: int | None = None, min_deposit: int | None = None) -> Fiat:
1609
+ """ЕДИНСТВЕННЫЙ writer Fiat-баланса (single-writer-правило, как Asset.upsert). Апсертит по
1610
+ cred_id (OneToOne), ставя amount (+ target/min_deposit, если переданы). Резолв cred остаётся
1611
+ у вызывающего; модель только пишет."""
1612
+ defaults = {"amount": amount}
1613
+ if target is not None:
1614
+ defaults["target"] = target
1615
+ if min_deposit is not None:
1616
+ defaults["min_deposit"] = min_deposit
1617
+ return (await cls.update_or_create(defaults, cred_id=cred_id))[0]
1618
+
1475
1619
 
1476
1620
  class Limit(Model):
1477
1621
  pmcur: ForeignKeyRelation[PmCur] = ForeignKeyField("models.PmCur", on_update=CASCADE)
@@ -1528,6 +1672,18 @@ class Asset(Model):
1528
1672
  __import__(module_name)
1529
1673
  return sys.modules[module_name].AssetEpyd
1530
1674
 
1675
+ @classmethod
1676
+ async def upsert(
1677
+ cls, actor_id: int, coin_id: int, agent_id: int, free: int, freeze: int | None = None, typ: AddrExType = None
1678
+ ) -> Asset:
1679
+ """ЕДИНСТВЕННЫЙ writer Asset-баланса (single-writer-правило). Резолвит Addr по (actor, coin),
1680
+ создаёт при отсутствии, апсертит Asset по натуральному ключу (addr, agent, typ), ставя free
1681
+ (и freeze, если передан). Резолв actor/agent остаётся у вызывающего (client держит контекст)."""
1682
+ addr, _ = await Addr.get_or_create(actor_id=actor_id, coin_id=coin_id)
1683
+ defaults = {"free": free} if freeze is None else {"free": free, "freeze": freeze}
1684
+ asset, _ = await cls.update_or_create(defaults, addr=addr, agent_id=agent_id, typ=typ or AddrExType.found)
1685
+ return asset
1686
+
1531
1687
 
1532
1688
  class PayReq(Model, TsTrait):
1533
1689
  pay_until: datetime = DatetimeSecField()
@@ -1633,6 +1789,32 @@ class Order(Model, TsTrait):
1633
1789
  )
1634
1790
  return not is_cold
1635
1791
 
1792
+ _terminal_statuses: ClassVar = frozenset({OrderStatus.completed, OrderStatus.canceled})
1793
+
1794
+ async def set_status(self, status: OrderStatus, *, force: bool = False) -> bool:
1795
+ """ЕДИНСТВЕННЫЙ writer Order.status (single-writer-правило, как Cred/Ad.upsert).
1796
+ Идемпотентен (тот же статус → no-op, False) и защищает терминальные статусы:
1797
+ из completed/canceled никуда без force — это снимает дублированные guard'ы из всех
1798
+ listener'ов (Htx/Mexc «не откатывай completed→paid», Bybit «игнор эха canceled→paid»,
1799
+ «status != current»). Возвращает True, если статус изменён и записан в БД."""
1800
+ if self.status == status:
1801
+ return False
1802
+ if not force and self.status in self._terminal_statuses:
1803
+ logging.info(f"Order#{self.exid}: skip {self.status.name}→{status.name} (terminal, no force)")
1804
+ return False
1805
+ self.status = status
1806
+ await self.save(update_fields=["status", "updated_at"]) # updated_at auto_now
1807
+ return True
1808
+
1809
+ async def set_hot(self, user: User) -> Hot:
1810
+ """ЕДИНСТВЕННЫЙ writer Order.hot_id (single-writer-правило, как set_status).
1811
+ Привязывает ордер к последнему открытому прогреву юзера (get_last_hot) — назначение
1812
+ hot-сессии шло одинаково из 3 мест (api-хендлер на created, контроллер is-you, бот is_you):
1813
+ order.hot = await user.get_last_hot(); save(["hot_id"]). Возвращает назначенный Hot."""
1814
+ self.hot = await user.get_last_hot()
1815
+ await self.save(update_fields=["hot_id"])
1816
+ return self.hot
1817
+
1636
1818
  # когда прилетел новый ордер, и мы уже знаем юзера которому он принадлежит, и его последний hot # noqa: RUF003
1637
1819
  async def hot_process(self) -> OrderResult:
1638
1820
  await self.fetch_related(
@@ -1672,8 +1854,7 @@ class Order(Model, TsTrait):
1672
1854
  ) or OrderErr.maker_balance_deficit
1673
1855
  res.data = (self.amount - payment) * 0.01
1674
1856
  elif self.ad.pair_side.is_sell: # успех и мы продавец - отпускаем (ордер уже paid: покупатель оплатил)
1675
- self.status = OrderStatus.completed
1676
- await self.save(update_fields=["status"])
1857
+ await self.set_status(OrderStatus.completed)
1677
1858
  res.data = payment.amount * 0.01
1678
1859
  else: # успех и мы покупатель - ждем когда продавец отпустит
1679
1860
  res.data = OrderResult.Order.load(self)
@@ -1714,16 +1895,15 @@ class Order(Model, TsTrait):
1714
1895
  tsf = await Transfer.create(
1715
1896
  amount=tx.amount, order_id=self.id, pmid=tx.id.hex, sender_acc=buyer.username_id
1716
1897
  )
1717
- self.status = OrderStatus.paid # todo: запрещать ставить paid, если у ордера нет трансферов на сумму amount # noqa: RUF003 - intentional Russian
1718
- await self.save(update_fields=["status", "updated_at"])
1898
+ # todo: запрещать ставить paid, если у ордера нет трансферов на сумму amount # noqa: RUF003 - intentional Russian
1899
+ await self.set_status(OrderStatus.paid)
1719
1900
  return tsf
1720
1901
 
1721
1902
  raise NotImplementedError("Pay method implemented now only for XyncPay pm")
1722
1903
 
1723
1904
  async def cancel_request(self):
1724
1905
  if not (payments := await self.fetch_related("transfers")):
1725
- self.status = OrderStatus.canceled
1726
- await self.save(update_fields=["status", "updated_at"])
1906
+ await self.set_status(OrderStatus.canceled)
1727
1907
  return {"err": 0}
1728
1908
  return {"err": payments}
1729
1909
 
@@ -1993,6 +2173,29 @@ class Msg(Model):
1993
2173
  order: ForeignKeyRelation[Order] = ForeignKeyField("models.Order", "msgs", on_update=CASCADE)
1994
2174
  sent_at: datetime | None = DatetimeSecField()
1995
2175
 
2176
+ @classmethod
2177
+ async def upsert(
2178
+ cls,
2179
+ order_id: int,
2180
+ *,
2181
+ to_maker: bool,
2182
+ txt: str | None = None,
2183
+ file_id: int | None = None,
2184
+ read: bool = False,
2185
+ sent_at: datetime | None = None,
2186
+ tg_mid: int | None = None,
2187
+ ) -> tuple[Msg, bool]:
2188
+ """ЕДИНСТВЕННЫЙ writer Msg (single-writer-правило). Натуральный ключ (order, txt, file) —
2189
+ апсертит чат-сообщение ордера из всех источников: исходящее через бота, входящее по WS,
2190
+ импорт истории. Раньше 3 расходящихся пути с разными стратегиями конфликта (bot Msg.create /
2191
+ Bybit bulk_create(ignore_conflicts) / in_agent update_or_create)."""
2192
+ return await cls.update_or_create(
2193
+ {"to_maker": to_maker, "read": read, "sent_at": sent_at or now(), "tg_mid": tg_mid},
2194
+ order_id=order_id,
2195
+ txt=txt,
2196
+ file_id=file_id,
2197
+ )
2198
+
1996
2199
  # todo: required txt or file
1997
2200
  class PydanticMeta(Model.PydanticMeta):
1998
2201
  max_recursion: int = 0
@@ -66,7 +66,7 @@ class AdXIn(_Ad):
66
66
  maker_id: int
67
67
  pair_side_id: int
68
68
 
69
- pms: set[Pm]
69
+ pms: list[Pm] # НЕ set[Pm]: XyncPay-Pm имеет id=0 → Model.__hash__ трактует pk=0 как unsaved (unhashable)
70
70
 
71
71
 
72
72
  class AdXUpd(_Ad, XExidUpd, XUpd):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes