xync-db 0.5.4__tar.gz → 0.5.5.dev10__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 (50) hide show
  1. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/PKG-INFO +1 -1
  2. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/migrations/CLAUDE.md +22 -3
  3. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/migrations/models/0001_initial.py +211 -10
  4. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/migrations/models/0002_triggers.py +140 -3
  5. xync_db-0.5.5.dev10/src/xync_db/migrations/models/0003_seed.py +59 -0
  6. xync_db-0.5.5.dev10/src/xync_db/migrations/models/seed/a_bin_seed.py +74 -0
  7. xync_db-0.5.5.dev10/src/xync_db/migrations/models/seed/a_country_seed.py +58 -0
  8. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/migrations/models/seed/a_tg_seed.py +2 -20
  9. xync_db-0.5.5.dev10/src/xync_db/migrations/models/seed/b_htx_seed.py +26 -0
  10. xync_db-0.5.5.dev10/src/xync_db/migrations/models/seed/countries.json +357 -0
  11. xync_db-0.5.5.dev10/src/xync_db/migrations/models/seed/ranges.csv +5806 -0
  12. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/models/CLAUDE.md +7 -0
  13. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/models/__init__.py +193 -3
  14. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/typs/db/pm.py +1 -1
  15. xync_db-0.5.4/src/xync_db/migrations/models/0003_files_seed.py +0 -20
  16. xync_db-0.5.4/src/xync_db/migrations/models/0004_all_seed.py +0 -32
  17. xync_db-0.5.4/src/xync_db/migrations/models/0005_credex_del.py +0 -40
  18. xync_db-0.5.4/src/xync_db/migrations/models/0006_credex_orphan.py +0 -47
  19. xync_db-0.5.4/src/xync_db/migrations/models/0007_agent_auth_notify.py +0 -41
  20. xync_db-0.5.4/src/xync_db/migrations/models/0008_ad_exid_ex_uniq.py +0 -51
  21. xync_db-0.5.4/src/xync_db/migrations/models/0009_web3_tables.py +0 -145
  22. xync_db-0.5.4/src/xync_db/migrations/models/0010_updatestate_fix.py +0 -54
  23. xync_db-0.5.4/src/xync_db/migrations/models/0011_pair_scale.py +0 -45
  24. xync_db-0.5.4/src/xync_db/migrations/models/seed/b_htx_seed.py +0 -111
  25. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/.gitignore +0 -0
  26. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/README.md +0 -0
  27. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/pyproject.toml +0 -0
  28. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/__init__.py +0 -0
  29. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/config.py +0 -0
  30. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/enums.py +0 -0
  31. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/exceptions.py +0 -0
  32. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/graph.py +0 -0
  33. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/i18n.py +0 -0
  34. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/login_store.py +0 -0
  35. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/logo.png +0 -0
  36. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/migrations/__init__.py +0 -0
  37. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/migrations/models/__init__.py +0 -0
  38. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/migrations/models/seed/__init__.py +0 -0
  39. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/migrations/models/seed/a_actor_seed.py +0 -0
  40. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/migrations/models/seed/c_net_seed.py +0 -0
  41. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/migrations/models/seed/xync_files.sql +0 -0
  42. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/py.typed +0 -0
  43. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/shared.py +0 -0
  44. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/typs/__init__.py +0 -0
  45. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/typs/db/__init__.py +0 -0
  46. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/typs/db/actor.py +0 -0
  47. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/typs/db/ad.py +0 -0
  48. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/typs/db/common.py +0 -0
  49. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/typs/db/cred.py +0 -0
  50. {xync_db-0.5.4 → xync_db-0.5.5.dev10}/src/xync_db/typs/db/order.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xync-db
3
- Version: 0.5.4
3
+ Version: 0.5.5.dev10
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
@@ -6,6 +6,19 @@ Native Tortoise ORM migrations. Config: `xync_service.config.TORTOISE_ORM`
6
6
  CLI (from `packages/xync-db/`): `uv run tortoise makemigrations` / `uv run tortoise migrate`
7
7
  (or `make makemigrations` / `make migrate` from the repo root).
8
8
 
9
+ > **Консолидировано в 3 миграции (D-037).** Прежние 0001-0012 слиты в:
10
+ > 1. **`0001_initial`** — ФИНАЛЬНАЯ схема всех таблиц (`ops.CreateModel`), включая всё, что раньше
11
+ > доливали поздние миграции: `pair.scale`+`rate` INT (было 0011), `country.ccn3` (было `code`, 0012),
12
+ > `updatestate` перестроен (0010), `net_addr.val` nullable + таблицы `coin_net`/`w3_send` (0009).
13
+ > 2. **`0002_triggers`** — ВСЕ триггеры (база + credex_del/credex_orphan/agent_upd-auth/ad_exid_ex_uniq/
14
+ > net_addr — были 0005-0009). `CREATE OR REPLACE` + порядок в `operations` дают финальную форму.
15
+ > 3. **`0003_seed`** — весь засев (files + actor/tg/country/htx/net) в порядке зависимостей.
16
+ >
17
+ > **Почему:** сид (0003) пишет через ORM (`set_pairs`→`pair.scale`) и должен идти по ПОЛНОЙ схеме;
18
+ > когда колонку добавляла миграция ПОСЛЕ сида, fresh-migrate падал «column … does not exist». Правило:
19
+ > **любое изменение схемы для засеваемой таблицы — в `0001`, а не в поздней миграции.**
20
+ > Разделы ниже описывают ИСТОРИЧЕСКОЕ содержимое (для контекста) — оно теперь внутри этих трёх файлов.
21
+
9
22
  ## models/0001_initial.py — Initial Schema
10
23
 
11
24
  Creates all ~50 tables. Key tables and their relationships:
@@ -80,9 +93,15 @@ Loads PM logo files from `seed/xync_files.sql` if < 100 files exist. These are T
80
93
  ## models/0004_all_seed.py — Full Seed
81
94
 
82
95
  Orchestrates the seed scripts in order:
83
- 1. `a_actor_seed` — Exchanges, currencies, coins, XyncPay PM, origin user (id=0)
84
- 2. `a_tg_seed` — Telegram DCs, app config, PM name mappings (`pm_rep`), countries
85
- 3. `b_htx_seed` — HTX-specific: currencies, countries, coins, pairs (via `xync_client.ExClient`)
96
+ 1. `a_actor_seed` — Exchanges, currencies (56 base), coins, XyncPay PM, origin user (id=0)
97
+ 2. `a_tg_seed` — Telegram DCs, app config, PM name mappings (`pm_rep`)
98
+ 3. `a_country_seed` — **all** countries + their currencies from the bundled `seed/countries.json`
99
+ (generated from mledoze/countries), single source, BEFORE the exchange seeders. Sets `ccn3`
100
+ (ISO 3166-1 numeric — the old `code`/phone column, renamed in 0012), clean `short` (cca2),
101
+ canonical `name`, real `cur` (creates the ~99 non-base `Cur`); `id` baked into the file so
102
+ existing countries keep theirs, meta-aggregates hold 201/202. Idempotent. (D-036)
103
+ 4. `b_htx_seed` — HTX-specific: currencies (CurEx), coins, pairs (via `xync_client.ExClient`).
104
+ No longer seeds countries — those moved to `a_country_seed`.
86
105
 
87
106
  > **PM-dependent seeding left the migration (D-017).** PMs aren't populated at migrate
88
107
  > time (they come from `set_pms`), so anything keyed off them moved to `packages/xync-script`
@@ -35,6 +35,7 @@ from xync_db.enums import (
35
35
  TaskType,
36
36
  TransactionStatus,
37
37
  UserStatus,
38
+ W3SendStatus,
38
39
  )
39
40
 
40
41
 
@@ -121,7 +122,7 @@ class Migration(migrations.Migration):
121
122
  name="Country",
122
123
  fields=[
123
124
  ("id", UInt1Field(generated=True, primary_key=True, unique=True, db_index=True)),
124
- ("code", UInt2Field(null=True)),
125
+ ("ccn3", UInt2Field(null=True)),
125
126
  ("short", fields.CharField(null=True, max_length=3)),
126
127
  ("name", fields.CharField(null=True, unique=True, max_length=63)),
127
128
  (
@@ -424,7 +425,10 @@ class Migration(migrations.Migration):
424
425
  on_delete=OnDelete.CASCADE,
425
426
  ),
426
427
  ),
427
- ("rate", fields.BigIntField(null=True)),
428
+ # rate — 20-битная мантисса курса (пишется через best_scale); scale — 4-битная
429
+ # (−8..7) степень десятки, своя у каждой пары: real = rate / 10^scale (Pair.real).
430
+ ("rate", fields.IntField(null=True)),
431
+ ("scale", fields.SmallIntField(db_default=0)),
428
432
  ],
429
433
  options={
430
434
  "table": "pair",
@@ -503,6 +507,24 @@ class Migration(migrations.Migration):
503
507
  options={"table": "pm_group", "app": "models", "pk_attr": "id"},
504
508
  bases=["Model"],
505
509
  ),
510
+ # `bank` — раньше `Bank`/`Bin`/`Card` (D-038), т.к. на него ссылается `pm.bank_id`
511
+ ops.CreateModel(
512
+ name="Bank",
513
+ fields=[
514
+ ("id", fields.IntField(generated=True, primary_key=True, unique=True, db_index=True)),
515
+ ("name", fields.CharField(unique=True, max_length=127)),
516
+ ("url", fields.CharField(null=True, max_length=255)),
517
+ ("phone", fields.CharField(null=True, max_length=31)),
518
+ ("city", fields.CharField(null=True, max_length=63)),
519
+ ],
520
+ options={
521
+ "table": "bank",
522
+ "app": "models",
523
+ "pk_attr": "id",
524
+ "table_description": "Card-issuing banks (enriched from binlist.net)",
525
+ },
526
+ bases=["Model"],
527
+ ),
506
528
  ops.CreateModel(
507
529
  name="Pm",
508
530
  fields=[
@@ -548,7 +570,18 @@ class Migration(migrations.Migration):
548
570
  ("alias", fields.CharField(null=True, max_length=63)),
549
571
  ("extra", fields.CharField(null=True, max_length=63)),
550
572
  ("ok", fields.BooleanField(db_default=True)),
551
- ("bank", fields.BooleanField(null=True)),
573
+ (
574
+ "bank",
575
+ fields.ForeignKeyField(
576
+ "models.Bank",
577
+ source_field="bank_id",
578
+ null=True,
579
+ db_constraint=True,
580
+ to_field="id",
581
+ related_name="pms",
582
+ on_delete=OnDelete.CASCADE,
583
+ ),
584
+ ),
552
585
  ("qr", fields.BooleanField(db_default=False)),
553
586
  ("fee", UInt2Field(null=True)),
554
587
  (
@@ -1758,13 +1791,100 @@ class Migration(migrations.Migration):
1758
1791
  on_delete=OnDelete.CASCADE,
1759
1792
  ),
1760
1793
  ),
1761
- ("val", fields.CharField(max_length=200)),
1794
+ # val NULLABLE: адрес ещё не заведён (web3-хендшейк, триггеры net_addr_ins/upd в 0002)
1795
+ ("val", fields.CharField(null=True, max_length=200)),
1762
1796
  ("memo", fields.CharField(null=True, max_length=54)),
1763
1797
  ("qr", fields.CharField(null=True, max_length=255)),
1764
1798
  ],
1765
1799
  options={"table": "net_addr", "app": "models", "unique_together": (("net", "addr"),), "pk_attr": "id"},
1766
1800
  bases=["Model"],
1767
1801
  ),
1802
+ ops.CreateModel(
1803
+ name="CoinNet",
1804
+ fields=[
1805
+ ("id", fields.IntField(generated=True, primary_key=True, unique=True, db_index=True)),
1806
+ (
1807
+ "coin",
1808
+ fields.ForeignKeyField(
1809
+ "models.Coin",
1810
+ source_field="coin_id",
1811
+ db_constraint=True,
1812
+ to_field="id",
1813
+ related_name="coin_nets",
1814
+ on_delete=OnDelete.CASCADE,
1815
+ ),
1816
+ ),
1817
+ (
1818
+ "net",
1819
+ fields.ForeignKeyField(
1820
+ "models.Net",
1821
+ source_field="net_id",
1822
+ db_constraint=True,
1823
+ to_field="id",
1824
+ related_name="coin_nets",
1825
+ on_delete=OnDelete.CASCADE,
1826
+ ),
1827
+ ),
1828
+ ("contract", fields.CharField(null=True, max_length=128)),
1829
+ ("decimals", UInt1Field()),
1830
+ ],
1831
+ options={
1832
+ "table": "coin_net",
1833
+ "app": "models",
1834
+ "unique_together": (("coin", "net"),),
1835
+ "pk_attr": "id",
1836
+ "table_description": "Coin presence per web3 network",
1837
+ },
1838
+ bases=["Model"],
1839
+ ),
1840
+ ops.CreateModel(
1841
+ name="W3Send",
1842
+ fields=[
1843
+ ("id", fields.IntField(generated=True, primary_key=True, unique=True, db_index=True)),
1844
+ ("created_at", DatetimeSecField(db_default=Now(), auto_now=False, auto_now_add=True)),
1845
+ ("updated_at", DatetimeSecField(db_default=Now(), auto_now=True, auto_now_add=False)),
1846
+ (
1847
+ "sender",
1848
+ fields.ForeignKeyField(
1849
+ "models.NetAddr",
1850
+ source_field="sender_id",
1851
+ db_constraint=True,
1852
+ to_field="id",
1853
+ related_name="w3_sends_out",
1854
+ on_delete=OnDelete.CASCADE,
1855
+ ),
1856
+ ),
1857
+ (
1858
+ "receiver",
1859
+ fields.ForeignKeyField(
1860
+ "models.NetAddr",
1861
+ source_field="receiver_id",
1862
+ db_constraint=True,
1863
+ to_field="id",
1864
+ related_name="w3_sends_in",
1865
+ on_delete=OnDelete.CASCADE,
1866
+ ),
1867
+ ),
1868
+ ("amount", UInt8Field()),
1869
+ (
1870
+ "status",
1871
+ fields.IntEnumField(
1872
+ description="pending: 0\naccepted: 1\ndeclined: 2\ndone: 3",
1873
+ db_default=W3SendStatus.pending,
1874
+ enum_type=W3SendStatus,
1875
+ generated=False,
1876
+ ),
1877
+ ),
1878
+ ("tx_hash", fields.CharField(null=True, max_length=128)),
1879
+ ],
1880
+ options={
1881
+ "table": "w3_send",
1882
+ "app": "models",
1883
+ "pk_attr": "id",
1884
+ "table_description": "Outbound web3 send (NetAddr → NetAddr)",
1885
+ },
1886
+ bases=["Model", "TsTrait"],
1887
+ ),
1768
1888
  ops.CreateModel(
1769
1889
  name="PayReq",
1770
1890
  fields=[
@@ -2427,7 +2547,7 @@ class Migration(migrations.Migration):
2427
2547
  ops.CreateModel(
2428
2548
  name="UpdateState",
2429
2549
  fields=[
2430
- ("id", UInt2Field(generated=True, primary_key=True, unique=True, db_index=True)),
2550
+ ("id", fields.IntField(generated=True, primary_key=True, unique=True, db_index=True)),
2431
2551
  (
2432
2552
  "session",
2433
2553
  fields.ForeignKeyField(
@@ -2439,12 +2559,21 @@ class Migration(migrations.Migration):
2439
2559
  on_delete=OnDelete.CASCADE,
2440
2560
  ),
2441
2561
  ),
2442
- ("pts", UInt2Field()),
2443
- ("qts", UInt2Field()),
2444
- ("date", UInt2Field()),
2445
- ("seq", UInt2Field()),
2562
+ (
2563
+ "chat_id",
2564
+ fields.BigIntField(db_default=0, description="pyrogram get_channel_id(-100..), 0 = common state"),
2565
+ ),
2566
+ ("pts", UInt4Field()),
2567
+ ("qts", UInt4Field(null=True)),
2568
+ ("date", UInt4Field()),
2569
+ ("seq", UInt4Field(null=True)),
2446
2570
  ],
2447
- options={"table": "updatestate", "app": "models", "pk_attr": "id"},
2571
+ options={
2572
+ "table": "updatestate",
2573
+ "app": "models",
2574
+ "unique_together": (("session", "chat_id"),),
2575
+ "pk_attr": "id",
2576
+ },
2448
2577
  bases=["Model"],
2449
2578
  ),
2450
2579
  ops.CreateModel(
@@ -2477,6 +2606,78 @@ class Migration(migrations.Migration):
2477
2606
  options={"table": "vpn", "app": "models", "pk_attr": "id", "table_description": "VPNs"},
2478
2607
  bases=["Model"],
2479
2608
  ),
2609
+ # ── Cards / BINs (D-038; засев — `0003_seed` → a_bin_seed из binlist/data; `Bank` создан выше — на него ссылается `pm`) ──
2610
+ ops.CreateModel(
2611
+ name="Bin",
2612
+ fields=[
2613
+ ("id", fields.IntField(generated=True, primary_key=True, unique=True, db_index=True)),
2614
+ ("prefix", UInt4Field(unique=True)),
2615
+ ("length", UInt1Field(db_default=8)),
2616
+ ("card_length", UInt1Field(db_default=16)),
2617
+ ("luhn", fields.BooleanField(db_default=True)),
2618
+ ("scheme", fields.CharField(null=True, max_length=31)),
2619
+ ("typ", fields.CharField(null=True, max_length=15)),
2620
+ ("brand", fields.CharField(null=True, max_length=63)),
2621
+ ("prepaid", fields.BooleanField(null=True)),
2622
+ (
2623
+ "country",
2624
+ fields.ForeignKeyField(
2625
+ "models.Country",
2626
+ source_field="country_id",
2627
+ null=True,
2628
+ db_constraint=True,
2629
+ to_field="id",
2630
+ related_name="bins",
2631
+ on_delete=OnDelete.CASCADE,
2632
+ ),
2633
+ ),
2634
+ (
2635
+ "bank",
2636
+ fields.ForeignKeyField(
2637
+ "models.Bank",
2638
+ source_field="bank_id",
2639
+ null=True,
2640
+ db_constraint=True,
2641
+ to_field="id",
2642
+ related_name="bins",
2643
+ on_delete=OnDelete.CASCADE,
2644
+ ),
2645
+ ),
2646
+ ],
2647
+ options={
2648
+ "table": "bin",
2649
+ "app": "models",
2650
+ "pk_attr": "id",
2651
+ "table_description": "Card BIN ranges (binlist.net)",
2652
+ },
2653
+ bases=["Model"],
2654
+ ),
2655
+ ops.CreateModel(
2656
+ name="Card",
2657
+ fields=[
2658
+ ("id", fields.IntField(generated=True, primary_key=True, unique=True, db_index=True)),
2659
+ (
2660
+ "bin",
2661
+ fields.ForeignKeyField(
2662
+ "models.Bin",
2663
+ source_field="bin_id",
2664
+ db_constraint=True,
2665
+ to_field="id",
2666
+ related_name="cards",
2667
+ on_delete=OnDelete.CASCADE,
2668
+ ),
2669
+ ),
2670
+ ("num", UInt4Field()),
2671
+ ],
2672
+ options={
2673
+ "table": "card",
2674
+ "app": "models",
2675
+ "unique_together": (("bin", "num"),),
2676
+ "pk_attr": "id",
2677
+ "table_description": "Cards: number stored without BIN and without the Luhn check digit",
2678
+ },
2679
+ bases=["Model"],
2680
+ ),
2480
2681
  ops.RunSQL(
2481
2682
  "ALTER TABLE pm DROP CONSTRAINT uid_pm_norm_83aa05;"
2482
2683
  "ALTER TABLE pm ADD CONSTRAINT uid_pm_norm_83aa05 UNIQUE NULLS NOT DISTINCT (norm, country_id);"
@@ -1,5 +1,7 @@
1
- """PG triggers migration.
2
- These define the NOTIFY-emitting triggers that the service-api pg-listener consumes.
1
+ """PG triggers migration — ВСЕ триггеры БД в одном месте.
2
+
3
+ Определяют NOTIFY-триггеры, которые слушает pg-listener service-api. Раньше часть жила отдельными
4
+ миграциями (0005-0009); слиты сюда. `CREATE OR REPLACE` + порядок в `operations` дают финальную форму.
3
5
 
4
6
  Channels emitted:
5
7
  after_orders_new -- new order, no hot_id
@@ -7,10 +9,16 @@ Channels emitted:
7
9
  hot_order_upd_status -- hot order status change
8
10
  after_orders_upd_status -- non-hot order status change
9
11
  agents_upd_status -- agent status toggle
12
+ agents_upd_auth -- agent web-session (cookies/headers) changed (0007)
10
13
  actor_person_needs_def_user -- new actor needs person assignment
11
14
  after_users_new -- new user (also creates person + xync-actor)
15
+ tx_verified -- transaction reached verified
16
+ ads_upd / mads_upd -- ad row changed (diff payload)
17
+ creds_new/creds_del/creds_upd, credexs_del (0005), credexs_orphan (0006) -- cred/credex lifecycle
18
+ w3addr_wanted / w3addr_filled -- web3 address handshake (0009)
12
19
 
13
- Plus utility functions: _user_balance(uid, cid), _tx_pack(...).
20
+ Guards (RAISE, без NOTIFY): tx_before (баланс/иммутабельность/монотонность), tx_no_delete,
21
+ ad_exid_ex_uniq (0008, exid уникален per-exchange). Utility: _user_balance(uid, cid), _tx_pack(...).
14
22
 
15
23
  `actor_new` trigger: link the auto-created anonymous person + stamp its `note` (D-035).
16
24
 
@@ -263,6 +271,129 @@ $cred_del_trg$ LANGUAGE plpgsql;
263
271
  CREATE OR REPLACE TRIGGER cred_del BEFORE DELETE ON "cred" FOR EACH ROW EXECUTE FUNCTION cred_del();
264
272
  """
265
273
 
274
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
275
+ # Дополнительные триггеры (были отдельными миграциями 0005-0009; здесь слиты). Все `CREATE OR REPLACE`,
276
+ # поэтому порядок в `operations` гарантирует финальную форму: `credex_del` переопределяет дефолтный
277
+ # `plsql("credex")` DEL, а `agent_upd` — базовый выше.
278
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
279
+
280
+ # Per-CredEx delete → `credexs_del` (0005). Юзер отвязывает ОДИН кред от ОДНОЙ биржи. Дефолтный
281
+ # `plsql("credex")` DEL нёс лишь OLD.id — бесполезно после каскада. Шлём [person_id, ex_id, exid]
282
+ # (person через ещё живой родительский `cred`). ПРОПУСКАЕМ notify если родитель-cred уже удалён:
283
+ # это каскад Cred → BEFORE-DELETE `cred_del()` уже снял каждый credex в `creds_del` (иначе двойное
284
+ # удаление на бирже). Одиночное удаление credex оставляет cred живым → notify.
285
+ CREDEX_DEL_SQL = """
286
+ CREATE OR REPLACE FUNCTION credex_del() returns trigger as $credex_del_trg$
287
+ DECLARE
288
+ pid INT;
289
+ BEGIN
290
+ -- person via the parent cred; NULL ⇒ cred already cascade-deleted ⇒ cred_del() handled it ⇒ skip
291
+ SELECT person_id INTO pid FROM cred WHERE id = OLD.cred_id;
292
+ IF pid IS NOT NULL THEN
293
+ PERFORM pg_notify('credexs_del', jsonb_build_array(pid, OLD.ex_id, OLD.exid)::varchar);
294
+ END IF;
295
+ RETURN NULL;
296
+ END
297
+ $credex_del_trg$ LANGUAGE plpgsql;
298
+ CREATE OR REPLACE TRIGGER credex_del AFTER DELETE ON "credex" FOR EACH ROW EXECUTE FUNCTION credex_del();
299
+ """
300
+
301
+ # Per-CredEx exid-overwrite → `credexs_orphan` (0006). `CredEx.upsert` ключуется по (cred, ex) и
302
+ # перезаписывает `exid`. Перезапись РЕАЛЬНОГО (не 0) exid другим значением осиротит прежний кред на
303
+ # бирже (БД его больше не трекает) — не утекаем молча: BEFORE-UPDATE шлёт [person_id, ex_id, OLD.exid,
304
+ # OLD.cred_id], чтобы service-api спросил владельца, удалить ли устаревший (mirror `is_you`).
305
+ # Срабатывает только на настоящую перезапись real→другой: OLD.exid<>0 (стаб 0→real — в `credex_new`) и
306
+ # NEW.exid<>OLD.exid (пропуск no-op переписи). Перерегистрация идёт сразу (RETURN NEW); чистанка — async.
307
+ CREDEX_ORPHAN_SQL = """
308
+ CREATE OR REPLACE FUNCTION credex_orphan() returns trigger as $credex_orphan_trg$
309
+ DECLARE
310
+ pid INT;
311
+ BEGIN
312
+ IF OLD.exid <> 0 AND NEW.exid <> OLD.exid THEN
313
+ SELECT person_id INTO pid FROM cred WHERE id = OLD.cred_id;
314
+ IF pid IS NOT NULL THEN
315
+ PERFORM pg_notify('credexs_orphan', jsonb_build_array(pid, OLD.ex_id, OLD.exid, OLD.cred_id)::varchar);
316
+ END IF;
317
+ END IF;
318
+ RETURN NEW;
319
+ END
320
+ $credex_orphan_trg$ LANGUAGE plpgsql;
321
+ CREATE OR REPLACE TRIGGER credex_orphan BEFORE UPDATE ON "credex" FOR EACH ROW EXECUTE FUNCTION credex_orphan();
322
+ """
323
+
324
+ # Agent.auth change → `agents_upd_auth` (0007). ПЕРЕОПРЕДЕЛЯЕТ `agent_upd` выше: второй, независимый
325
+ # branch шлёт `agents_upd_auth` ТОЛЬКО при реальной смене auth (`OLD.auth IS DISTINCT FROM NEW.auth`) —
326
+ # service-api патчит куки/хедеры в ЖИВУЮ http-сессию агента (`BaseAgentClient.reload_auth`) без рестарта.
327
+ # Независим от status-branch (status и auth меняются вместе или порознь).
328
+ AGENT_AUTH_SQL = """
329
+ CREATE OR REPLACE FUNCTION agent_upd() returns trigger as $agent_upd_trg$
330
+ BEGIN
331
+ IF OLD.status != NEW.status THEN
332
+ PERFORM pg_notify('agents_upd_status', jsonb_build_array(row_to_json(NEW), OLD.status)::varchar);
333
+ END IF;
334
+ IF OLD.auth IS DISTINCT FROM NEW.auth THEN
335
+ PERFORM pg_notify('agents_upd_auth', row_to_json(NEW)::varchar);
336
+ END IF;
337
+ RETURN NULL;
338
+ END
339
+ $agent_upd_trg$ LANGUAGE plpgsql;
340
+ CREATE OR REPLACE TRIGGER agent_upd AFTER UPDATE ON "agent" FOR EACH ROW EXECUTE FUNCTION agent_upd();
341
+ """
342
+
343
+ # Ad.exid uniqueness per-EXCHANGE, не per-maker (0008). `unique_together(exid, maker)` не ловит РАЗНОГО
344
+ # maker на ТОЙ ЖЕ бирже с чужим exid (биржа выдаёт ad-id глобально по аккаунтам). Tortoise не выразит
345
+ # uniq через join (`maker`→`actor.ex_id` не колонка `ad`) → BEFORE INSERT/UPDATE RAISE на конфликт по
346
+ # (exid≠0, тот же actor.ex_id). Падает громко на записи (как fail-loud в `Ex.e2x_ad`).
347
+ AD_EXID_EX_UNIQ_SQL = """
348
+ CREATE OR REPLACE FUNCTION ad_exid_ex_uniq() returns trigger as $ad_exid_ex_uniq_trg$
349
+ DECLARE
350
+ new_ex_id INT;
351
+ conflict_id INT;
352
+ conflict_maker_id INT;
353
+ BEGIN
354
+ IF NEW.exid = 0 THEN
355
+ RETURN NEW;
356
+ END IF;
357
+ SELECT ex_id INTO new_ex_id FROM actor WHERE id = NEW.maker_id;
358
+ SELECT a.id, a.maker_id INTO conflict_id, conflict_maker_id
359
+ FROM ad a JOIN actor act ON act.id = a.maker_id
360
+ WHERE a.exid = NEW.exid AND act.ex_id = new_ex_id AND a.id != NEW.id
361
+ LIMIT 1;
362
+ IF conflict_id IS NOT NULL THEN
363
+ RAISE EXCEPTION 'ad.exid=% already used by ad#% (maker#%) on ex_id=% — exid must be unique per exchange, rejected write for maker#%',
364
+ NEW.exid, conflict_id, conflict_maker_id, new_ex_id, NEW.maker_id;
365
+ END IF;
366
+ RETURN NEW;
367
+ END
368
+ $ad_exid_ex_uniq_trg$ LANGUAGE plpgsql;
369
+ CREATE OR REPLACE TRIGGER ad_exid_ex_uniq BEFORE INSERT OR UPDATE ON "ad" FOR EACH ROW EXECUTE FUNCTION ad_exid_ex_uniq();
370
+ """
371
+
372
+ # web3-хендшейк адресов (0009): net_addr_ins (вставка пустого val → зовём получателя завести адрес,
373
+ # канал `w3addr_wanted`) и net_addr_upd (val NULL→real → сообщаем отправителям pending-W3Send, канал
374
+ # `w3addr_filled`). (`ALTER net_addr.val DROP NOT NULL` уехал в 0001 — теперь val создаётся nullable.)
375
+ NET_ADDR_TRIGGERS_SQL = """
376
+ CREATE OR REPLACE FUNCTION net_addr_ins() RETURNS trigger AS $net_addr_ins$
377
+ BEGIN
378
+ IF NEW.val IS NULL THEN
379
+ PERFORM pg_notify('w3addr_wanted', row_to_json(NEW)::varchar);
380
+ END IF;
381
+ RETURN NULL;
382
+ END
383
+ $net_addr_ins$ LANGUAGE plpgsql;
384
+ CREATE OR REPLACE TRIGGER net_addr_ins AFTER INSERT ON "net_addr" FOR EACH ROW EXECUTE FUNCTION net_addr_ins();
385
+
386
+ CREATE OR REPLACE FUNCTION net_addr_upd() RETURNS trigger AS $net_addr_upd$
387
+ BEGIN
388
+ IF OLD.val IS NULL AND NEW.val IS NOT NULL THEN
389
+ PERFORM pg_notify('w3addr_filled', row_to_json(NEW)::varchar);
390
+ END IF;
391
+ RETURN NULL;
392
+ END
393
+ $net_addr_upd$ LANGUAGE plpgsql;
394
+ CREATE OR REPLACE TRIGGER net_addr_upd AFTER UPDATE ON "net_addr" FOR EACH ROW EXECUTE FUNCTION net_addr_upd();
395
+ """
396
+
266
397
 
267
398
  class Migration(BaseMigration):
268
399
  dependencies: ClassVar = [("models", "0001_initial")]
@@ -275,4 +406,10 @@ class Migration(BaseMigration):
275
406
  + plsql("my_ad")
276
407
  + plsql("credex")
277
408
  ),
409
+ # порядок важен: credex_del/agent_upd переопределяют созданные выше (plsql + база)
410
+ RunSQL(CREDEX_DEL_SQL),
411
+ RunSQL(CREDEX_ORPHAN_SQL),
412
+ RunSQL(AGENT_AUTH_SQL),
413
+ RunSQL(AD_EXID_EX_UNIQ_SQL),
414
+ RunSQL(NET_ADDR_TRIGGERS_SQL),
278
415
  ]
@@ -0,0 +1,59 @@
1
+ """Полный засев БД одной миграцией — файлы (PM-логотипы) + справочники/сущности в нужном порядке.
2
+
3
+ Слияние прежних `0003_files_seed` и `0004_all_seed`. Идёт ПОСЛЕ триггеров (0002) и по ПОЛНОЙ схеме
4
+ (0001), поэтому сидеры, пишущие через ORM (напр. `set_pairs` → `pair.scale`), не спотыкаются о
5
+ недостающие колонки (раньше `pair.scale`/`country.ccn3` добавлялись миграциями ПОСЛЕ засева).
6
+
7
+ Порядок (зависимости):
8
+ 1. files — PM-логотипы из `seed/xync_files.sql` (если файлов < 100)
9
+ 2. a_actor_seed — биржи, 56 базовых валют, монеты, XyncPay PM, origin user (id=0)
10
+ 3. a_tg_seed — Telegram DC/App, нормализация имён PM (`pm_rep`)
11
+ 4. a_country_seed — ВСЕ страны + их валюты из mledoze (`seed/countries.json`), ДО биржевых
12
+ 5. a_bin_seed — BIN-диапазоны + банки из binlist/data (`seed/ranges.csv`), ПОСЛЕ стран (линк по alpha-2)
13
+ 6. b_htx_seed — HTX-специфика: валюты (CurEx), монеты, пары
14
+ 7. c_net_seed — web3-сети (Net) + недостающие нативные монеты
15
+
16
+ NB (D-017): PM-зависимый засев (`set_pms` + `refine_pms`) живёт в `packages/xync-script`, НЕ здесь —
17
+ он тянет `pyro_client` (FileClient), а миграции не должны импортировать `pyro_client`.
18
+ """
19
+
20
+ from os.path import dirname
21
+ from typing import ClassVar
22
+
23
+ from tortoise.migrations.migration import Migration as BaseMigration
24
+ from tortoise.migrations.operations import RunPython
25
+
26
+ from xync_db.migrations.models.seed import (
27
+ a_actor_seed,
28
+ a_bin_seed,
29
+ a_country_seed,
30
+ a_tg_seed,
31
+ b_htx_seed,
32
+ c_net_seed,
33
+ )
34
+ from xync_db.models import File
35
+
36
+
37
+ async def seed(apps, schema_editor):
38
+ db = schema_editor.client
39
+ # 1) файлы PM-логотипов
40
+ if await File.all().count() < 100:
41
+ with open(dirname(__file__) + "/seed/xync_files.sql") as f:
42
+ await schema_editor._run_sql(f.read())
43
+ # 2) справочники и сущности в порядке зависимостей
44
+ sql = await a_actor_seed.actor_seed(db)
45
+ sql += await a_tg_seed.tg_seed(db)
46
+ sql += await a_country_seed.country_seed(db) # страны+валюты из mledoze, ДО биржевых сидеров
47
+ sql += await a_bin_seed.bin_seed(db) # BIN-диапазоны+банки из binlist/data, ПОСЛЕ стран (линк по alpha-2)
48
+ sql += await b_htx_seed.htx_seed(db)
49
+ sql += await c_net_seed.net_seed(db)
50
+ if sql.strip():
51
+ await schema_editor._run_sql(sql)
52
+
53
+
54
+ class Migration(BaseMigration):
55
+ atomic = False
56
+ dependencies: ClassVar = [("models", "0002_triggers")]
57
+ operations: ClassVar = [
58
+ RunPython(seed),
59
+ ]
@@ -0,0 +1,74 @@
1
+ """Сидер BIN-диапазонов и банков-эмитентов из binlist/data — ПОСЛЕ стран (`a_country_seed`).
2
+
3
+ Данные — статический `ranges.csv` рядом (снимок https://github.com/binlist/data, `ranges.csv`):
4
+ колонки `iin_start,iin_end,number_length,number_luhn,scheme,brand,type,prepaid,country,bank_name,
5
+ bank_logo,bank_url,bank_phone,bank_city`. `iin_start` — BIN-префикс (6 ИЛИ 8 цифр), `iin_end` —
6
+ верх диапазона (пусто = одиночный). Диапазоны маленькие (макс. span 18), поэтому РАЗВОРАЧИВАЕМ их в
7
+ отдельные префиксы — модель `Bin` остаётся префиксной (`Bin.for_card` ищет 6- и 8-значный префикс,
8
+ `Card.from_number` режет тело по длине найденного BIN). См. D-038.
9
+
10
+ Идём после стран, т.к. `Bin.country` линкуется на уже засеянные `Country` по alpha-2 (`short`).
11
+ Банки дедуплицируем по `name` (первый вариант деталей выигрывает). Идемпотентно: guard по count +
12
+ `bulk_create(ignore_conflicts=True)`. Только линкуем существующие страны — новые не заводим
13
+ (`Country` требует `cur_id`), незнакомый alpha-2 → `country_id=NULL`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import csv
19
+ from pathlib import Path
20
+
21
+ from tortoise import BaseDBAsyncClient
22
+
23
+ from xync_db.models import Bank, Bin, Country
24
+
25
+ DATA = Path(__file__).with_name("ranges.csv")
26
+ _SEEDED_ENOUGH = 5000 # если BIN-ов уже столько — считаем засеянным, полный проход не гоняем
27
+
28
+
29
+ async def bin_seed(_: BaseDBAsyncClient) -> str:
30
+ if await Bin.all().count() >= _SEEDED_ENOUGH:
31
+ return "select 1;" # уже засеяно
32
+
33
+ rows = list(csv.DictReader(DATA.read_text(encoding="utf-8").splitlines()))
34
+
35
+ # 1) банки — по уникальному name (первый вариант деталей выигрывает)
36
+ banks: dict[str, dict] = {}
37
+ for r in rows:
38
+ if (name := r["bank_name"].strip()) and name not in banks:
39
+ banks[name] = {
40
+ "url": r["bank_url"].strip() or None,
41
+ "phone": r["bank_phone"].strip() or None,
42
+ "city": r["bank_city"].strip() or None,
43
+ }
44
+ await Bank.bulk_create([Bank(name=n, **d) for n, d in banks.items()], ignore_conflicts=True)
45
+ bank_id: dict[str, int] = {b.name: b.id for b in await Bank.all()}
46
+ country_id: dict[str, int] = {c.short: c.id for c in await Country.all() if c.short}
47
+
48
+ # 2) BIN-ы — развернуть диапазоны в отдельные префиксы
49
+ bins: list[Bin] = []
50
+ for r in rows:
51
+ start = int(r["iin_start"])
52
+ end = int(r["iin_end"]) if r["iin_end"].strip() else start
53
+ length = len(r["iin_start"])
54
+ card_length = int(r["number_length"]) if r["number_length"].strip() else 16
55
+ prepaid = True if r["prepaid"].strip().lower() == "y" else None # 'y' | пусто
56
+ common = {
57
+ "length": length,
58
+ "card_length": card_length,
59
+ "luhn": r["number_luhn"].strip().lower() != "false", # в датасете всегда пусто → True
60
+ "scheme": r["scheme"].strip() or None,
61
+ "typ": r["type"].strip() or None,
62
+ "brand": r["brand"].strip() or None,
63
+ "prepaid": prepaid,
64
+ "country_id": country_id.get(r["country"].strip()),
65
+ "bank_id": bank_id.get(r["bank_name"].strip()),
66
+ }
67
+ bins.extend(Bin(prefix=pfx, **common) for pfx in range(start, end + 1))
68
+ await Bin.bulk_create(bins, ignore_conflicts=True, batch_size=1000)
69
+
70
+ return "select 1;"
71
+
72
+
73
+ async def downgrade(_: BaseDBAsyncClient) -> str:
74
+ return ""