tradedangerous 11.5.3__py3-none-any.whl → 12.0.1__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.

Potentially problematic release.


This version of tradedangerous might be problematic. Click here for more details.

Files changed (47) hide show
  1. tradedangerous/cache.py +567 -395
  2. tradedangerous/cli.py +2 -2
  3. tradedangerous/commands/TEMPLATE.py +25 -26
  4. tradedangerous/commands/__init__.py +8 -16
  5. tradedangerous/commands/buildcache_cmd.py +40 -10
  6. tradedangerous/commands/buy_cmd.py +57 -46
  7. tradedangerous/commands/commandenv.py +0 -2
  8. tradedangerous/commands/export_cmd.py +78 -50
  9. tradedangerous/commands/import_cmd.py +67 -31
  10. tradedangerous/commands/market_cmd.py +52 -19
  11. tradedangerous/commands/olddata_cmd.py +120 -107
  12. tradedangerous/commands/rares_cmd.py +122 -110
  13. tradedangerous/commands/run_cmd.py +118 -66
  14. tradedangerous/commands/sell_cmd.py +52 -45
  15. tradedangerous/commands/shipvendor_cmd.py +49 -234
  16. tradedangerous/commands/station_cmd.py +55 -485
  17. tradedangerous/commands/update_cmd.py +56 -420
  18. tradedangerous/csvexport.py +173 -162
  19. tradedangerous/db/__init__.py +27 -0
  20. tradedangerous/db/adapter.py +191 -0
  21. tradedangerous/db/config.py +95 -0
  22. tradedangerous/db/engine.py +246 -0
  23. tradedangerous/db/lifecycle.py +332 -0
  24. tradedangerous/db/locks.py +208 -0
  25. tradedangerous/db/orm_models.py +455 -0
  26. tradedangerous/db/paths.py +112 -0
  27. tradedangerous/db/utils.py +661 -0
  28. tradedangerous/gui.py +2 -2
  29. tradedangerous/plugins/eddblink_plug.py +387 -251
  30. tradedangerous/plugins/spansh_plug.py +2488 -821
  31. tradedangerous/prices.py +124 -142
  32. tradedangerous/templates/TradeDangerous.sql +6 -6
  33. tradedangerous/tradecalc.py +1227 -1109
  34. tradedangerous/tradedb.py +533 -384
  35. tradedangerous/tradeenv.py +12 -1
  36. tradedangerous/version.py +1 -1
  37. {tradedangerous-11.5.3.dist-info → tradedangerous-12.0.1.dist-info}/METADATA +11 -7
  38. {tradedangerous-11.5.3.dist-info → tradedangerous-12.0.1.dist-info}/RECORD +42 -38
  39. {tradedangerous-11.5.3.dist-info → tradedangerous-12.0.1.dist-info}/WHEEL +1 -1
  40. tradedangerous/commands/update_gui.py +0 -721
  41. tradedangerous/jsonprices.py +0 -254
  42. tradedangerous/plugins/edapi_plug.py +0 -1071
  43. tradedangerous/plugins/journal_plug.py +0 -537
  44. tradedangerous/plugins/netlog_plug.py +0 -316
  45. {tradedangerous-11.5.3.dist-info → tradedangerous-12.0.1.dist-info}/entry_points.txt +0 -0
  46. {tradedangerous-11.5.3.dist-info → tradedangerous-12.0.1.dist-info/licenses}/LICENSE +0 -0
  47. {tradedangerous-11.5.3.dist-info → tradedangerous-12.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,208 @@
1
+ # tradedangerous/db/locks.py
2
+ # -----------------------------------------------------------------------------
3
+ # Advisory lock helpers (MariaDB/MySQL) — per-station serialization
4
+ #
5
+ # SQLite compatibility:
6
+ # - On SQLite (or any unsupported dialect), all helpers become NO-OPs and
7
+ # behave as if the lock was immediately acquired (yield True). This lets
8
+ # shared code run unchanged across backends.
9
+ #
10
+ # Usage (both writers must use the SAME key format):
11
+ # from tradedangerous.db.locks import station_advisory_lock
12
+ #
13
+ # with sa_session_local(session_factory) as s:
14
+ # # (optional) set isolation once per process elsewhere:
15
+ # # s.execute(text("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED")); s.commit()
16
+ # with station_advisory_lock(s, station_id, timeout_seconds=0.2, max_retries=4) as got:
17
+ # if not got:
18
+ # # processor: defer/requeue work for this station and continue
19
+ # return
20
+ # with s.begin():
21
+ # # do per-station writes here...
22
+ # pass
23
+ # -----------------------------------------------------------------------------
24
+
25
+ from __future__ import annotations
26
+
27
+ import time
28
+ from contextlib import contextmanager
29
+ from typing import Iterator
30
+
31
+ from sqlalchemy import text
32
+ from sqlalchemy.orm import Session
33
+
34
+ __all__ = [
35
+ "station_advisory_lock",
36
+ "acquire_station_lock",
37
+ "release_station_lock",
38
+ "station_lock_key",
39
+ ]
40
+
41
+ # Precompiled SQL (MySQL/MariaDB only)
42
+ _SQL_GET_LOCK = text("SELECT GET_LOCK(:k, :t)")
43
+ _SQL_RELEASE_LOCK = text("SELECT RELEASE_LOCK(:k)")
44
+
45
+ def _is_lock_supported(session: Session) -> bool:
46
+ """
47
+ Return True if the current SQLAlchemy session is bound to a backend that
48
+ supports advisory locks via GET_LOCK/RELEASE_LOCK (MySQL/MariaDB).
49
+ """
50
+ try:
51
+ name = (session.get_bind().dialect.name or "").lower()
52
+ except Exception:
53
+ name = ""
54
+ return name in ("mysql", "mariadb")
55
+
56
+ def _ensure_read_committed(session: Session) -> None:
57
+ """
58
+ Ensure the session is using READ COMMITTED for subsequent transactions.
59
+ - Applies only to MySQL/MariaDB.
60
+ - No-ops on SQLite/others.
61
+ - Only sets it if NOT already inside a transaction (affects next txn).
62
+ """
63
+ if not _is_lock_supported(session):
64
+ return
65
+ try:
66
+ # Only set if we're not already in a transaction; otherwise it would
67
+ # affect the next transaction, not the current one.
68
+ if not session.in_transaction():
69
+ session.execute(text("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED"))
70
+ # No explicit commit needed; this is a session-level setting.
71
+ except Exception:
72
+ # Best-effort; if this fails we just proceed with the default isolation.
73
+ pass
74
+
75
+ def station_lock_key(station_id: int) -> str:
76
+ """
77
+ Return the advisory lock key used by both writers for the same station.
78
+ Keep this format identical in all writers (processor + Spansh).
79
+ """
80
+ return f"td.station.{int(station_id)}"
81
+
82
+ def acquire_station_lock(session: Session, station_id: int, timeout_seconds: float) -> bool:
83
+ """
84
+ Try to acquire the advisory lock for a station on THIS DB connection.
85
+
86
+ Returns:
87
+ True -> acquired within timeout (or NO-OP True on unsupported dialects)
88
+ False -> timed out (lock held elsewhere)
89
+
90
+ Notes:
91
+ - Advisory locks are per-connection. Use the same Session for acquire,
92
+ the critical section, and release.
93
+ - On SQLite/unsupported dialects, this is a NO-OP that returns True.
94
+ """
95
+ if not _is_lock_supported(session):
96
+ return True # NO-OP on SQLite/unsupported backends
97
+
98
+ key = station_lock_key(station_id)
99
+ row = session.execute(_SQL_GET_LOCK, {"k": key, "t": float(timeout_seconds)}).first()
100
+ # MariaDB/MySQL GET_LOCK returns 1 (acquired), 0 (timeout), or NULL (error)
101
+ return bool(row and row[0] == 1)
102
+
103
+ def release_station_lock(session: Session, station_id: int) -> None:
104
+ """
105
+ Release the advisory lock for a station on THIS DB connection.
106
+ Safe to call in finally; releasing a non-held lock is harmless.
107
+
108
+ On SQLite/unsupported dialects, this is a NO-OP.
109
+ """
110
+ if not _is_lock_supported(session):
111
+ return # NO-OP on SQLite/unsupported backends
112
+
113
+ key = station_lock_key(station_id)
114
+ try:
115
+ session.execute(_SQL_RELEASE_LOCK, {"k": key})
116
+ except Exception:
117
+ # Intentionally swallow — RELEASE_LOCK may return 0/NULL if not held.
118
+ pass
119
+
120
+ @contextmanager
121
+ def station_advisory_lock(
122
+ session: Session,
123
+ station_id: int,
124
+ timeout_seconds: float = 0.2,
125
+ max_retries: int = 4,
126
+ backoff_start_seconds: float = 0.05,
127
+ ) -> Iterator[bool]:
128
+ """
129
+ Context manager to acquire/retry/release a per-station advisory lock.
130
+
131
+ Resilience improvement:
132
+ - If no transaction is active on the Session, this helper will OPEN ONE,
133
+ so the lock is taken on the same physical connection the ensuing DML uses.
134
+ In that case, it will COMMIT on normal exit, or ROLLBACK if an exception
135
+ bubbles out of the context block.
136
+ - If a transaction is already active, this helper does NOT touch txn
137
+ boundaries; caller remains responsible for commit/rollback.
138
+
139
+ Yields:
140
+ acquired (bool): True if acquired within retry policy;
141
+ True immediately on unsupported dialects (NO-OP);
142
+ False if not acquired on supported backends.
143
+ """
144
+ # Fast-path NO-OP for SQLite/unsupported dialects
145
+ if not _is_lock_supported(session):
146
+ try:
147
+ yield True
148
+ finally:
149
+ pass
150
+ return
151
+
152
+ # If we can still influence the next txn, prefer READ COMMITTED for shorter waits.
153
+ _ensure_read_committed(session)
154
+
155
+ # Pin a connection if caller hasn't already begun a transaction.
156
+ started_txn = False
157
+ txn_ctx = None
158
+ if not session.in_transaction():
159
+ txn_ctx = session.begin()
160
+ started_txn = True
161
+
162
+ got = False
163
+ try:
164
+ # Attempt with bounded retries + exponential backoff.
165
+ attempt = 0
166
+ while attempt < max_retries:
167
+ if acquire_station_lock(session, station_id, timeout_seconds):
168
+ got = True
169
+ break
170
+ time.sleep(backoff_start_seconds * (2 ** attempt))
171
+ attempt += 1
172
+
173
+ # Hand control to caller
174
+ yield got
175
+
176
+ # If we created the transaction and no exception occurred, commit it.
177
+ if started_txn and got:
178
+ try:
179
+ session.commit()
180
+ except Exception:
181
+ # If commit fails, make sure to roll back so we don't leak an open txn.
182
+ session.rollback()
183
+ raise
184
+ except Exception:
185
+ # If we created the transaction and an exception escaped the block, roll it back.
186
+ if started_txn and session.in_transaction():
187
+ try:
188
+ session.rollback()
189
+ except Exception:
190
+ # Swallow secondary rollback failures; original exception should propagate.
191
+ pass
192
+ raise
193
+ finally:
194
+ # Always release the advisory lock if we acquired it.
195
+ if got:
196
+ try:
197
+ release_station_lock(session, station_id)
198
+ except Exception:
199
+ # Lock releases are best-effort; don't mask user exceptions.
200
+ pass
201
+
202
+ # If we opened a txn context object (older SA versions), ensure it's closed.
203
+ # (Harmless if already committed/rolled back above.)
204
+ if started_txn and txn_ctx is not None:
205
+ try:
206
+ txn_ctx.close()
207
+ except Exception:
208
+ pass
@@ -0,0 +1,455 @@
1
+ # tradedangerous/db/orm_models.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from sqlalchemy import (
7
+ MetaData,
8
+ ForeignKey,
9
+ Integer,
10
+ BigInteger,
11
+ String,
12
+ CHAR,
13
+ Enum,
14
+ Index,
15
+ UniqueConstraint,
16
+ CheckConstraint,
17
+ text,
18
+ Column,
19
+ DateTime,
20
+ )
21
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, object_session
22
+ from sqlalchemy.sql import expression
23
+ from sqlalchemy.ext.compiler import compiles
24
+ from sqlalchemy.types import TypeDecorator
25
+
26
+
27
+ # ---- Dialect-aware time utilities (moved before model usage) ----
28
+ class now6(expression.FunctionElement):
29
+ """CURRENT_TIMESTAMP with microseconds on MySQL/MariaDB; plain CURRENT_TIMESTAMP elsewhere."""
30
+ type = DateTime()
31
+ inherit_cache = True
32
+
33
+
34
+ @compiles(now6, "mysql")
35
+ @compiles(now6, "mariadb")
36
+ def _mysql_now6(element, compiler, **kw):
37
+ return "CURRENT_TIMESTAMP(6)"
38
+
39
+
40
+ @compiles(now6)
41
+ def _default_now(element, compiler, **kw):
42
+ return "CURRENT_TIMESTAMP"
43
+
44
+
45
+ class DateTime6(TypeDecorator):
46
+ """DATETIME that is DATETIME(6) on MySQL/MariaDB, generic DateTime elsewhere."""
47
+ impl = DateTime
48
+ cache_ok = True
49
+
50
+ def load_dialect_impl(self, dialect):
51
+ if dialect.name in ("mysql", "mariadb"):
52
+ from sqlalchemy.dialects.mysql import DATETIME as _MYSQL_DATETIME
53
+ return dialect.type_descriptor(_MYSQL_DATETIME(fsp=6))
54
+ return dialect.type_descriptor(DateTime())
55
+
56
+
57
+ # ---------- Dialect Helpers --------
58
+ class CIString(TypeDecorator):
59
+ """
60
+ Case-insensitive string type.
61
+ - SQLite → uses NOCASE collation
62
+ - MySQL/MariaDB → uses utf8mb4_unicode_ci
63
+ - Others → plain String
64
+ """
65
+ impl = String
66
+ cache_ok = True
67
+
68
+ def __init__(self, length, **kwargs):
69
+ super().__init__(length=length, **kwargs)
70
+
71
+ def load_dialect_impl(self, dialect):
72
+ if dialect.name == "sqlite":
73
+ return dialect.type_descriptor(String(self.impl.length, collation="NOCASE"))
74
+ elif dialect.name in ("mysql", "mariadb"):
75
+ return dialect.type_descriptor(String(self.impl.length, collation="utf8mb4_unicode_ci"))
76
+ else:
77
+ return dialect.type_descriptor(String(self.impl.length))
78
+
79
+
80
+ # ---------- Naming & Base ----------
81
+ naming_convention = {
82
+ "ix": "ix_%(table_name)s__%(column_0_N_name)s",
83
+ "uq": "uq_%(table_name)s__%(column_0_N_name)s",
84
+ "ck": "ck_%(table_name)s__%(column_0_name)s", # use column name, not constraint_name
85
+ "fk": "fk_%(table_name)s__%(column_0_N_name)s__%(referred_table_name)s",
86
+ "pk": "pk_%(table_name)s",
87
+ }
88
+ metadata = MetaData(naming_convention=naming_convention)
89
+
90
+
91
+ class Base(DeclarativeBase):
92
+ metadata = metadata
93
+
94
+ # ---------- Enums ----------
95
+ TriState = Enum(
96
+ "Y",
97
+ "N",
98
+ "?",
99
+ native_enum=False,
100
+ create_constraint=True,
101
+ validate_strings=True,
102
+ )
103
+
104
+ PadSize = Enum(
105
+ "S",
106
+ "M",
107
+ "L",
108
+ "?",
109
+ native_enum=False,
110
+ create_constraint=True,
111
+ validate_strings=True,
112
+ )
113
+
114
+
115
+ # ---------- Core Domain ----------
116
+ class Added(Base):
117
+ __tablename__ = "Added"
118
+
119
+ added_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
120
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False, unique=True)
121
+
122
+ # Relationships
123
+ systems: Mapped[list["System"]] = relationship(back_populates="added")
124
+
125
+
126
+ class System(Base):
127
+ __tablename__ = "System"
128
+
129
+ system_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
130
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
131
+ pos_x: Mapped[float] = mapped_column(nullable=False)
132
+ pos_y: Mapped[float] = mapped_column(nullable=False)
133
+ pos_z: Mapped[float] = mapped_column(nullable=False)
134
+ added_id: Mapped[int | None] = mapped_column(
135
+ ForeignKey("Added.added_id", onupdate="CASCADE", ondelete="CASCADE")
136
+ )
137
+ modified: Mapped[str] = mapped_column(
138
+ DateTime6(),
139
+ server_default=now6(),
140
+ onupdate=now6(),
141
+ nullable=False,
142
+ )
143
+
144
+ # Relationships
145
+ added: Mapped[Optional["Added"]] = relationship(back_populates="systems")
146
+ stations: Mapped[list["Station"]] = relationship(back_populates="system", cascade="all, delete-orphan")
147
+
148
+ __table_args__ = (
149
+ Index("idx_system_by_pos", "pos_x", "pos_y", "pos_z", "system_id"),
150
+ Index("idx_system_by_name", "name"),
151
+ )
152
+
153
+
154
+
155
+
156
+ class Station(Base):
157
+ __tablename__ = "Station"
158
+
159
+ station_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
160
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
161
+
162
+ # type widened; cascade semantics unchanged (DELETE only)
163
+ system_id: Mapped[int] = mapped_column(
164
+ BigInteger,
165
+ ForeignKey("System.system_id", ondelete="CASCADE"),
166
+ nullable=False,
167
+ )
168
+
169
+ ls_from_star: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
170
+
171
+ blackmarket: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
172
+ max_pad_size: Mapped[str] = mapped_column(PadSize, nullable=False, server_default=text("'?'"))
173
+ market: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
174
+ shipyard: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
175
+ outfitting: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
176
+ rearm: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
177
+ refuel: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
178
+ repair: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
179
+ planetary: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
180
+
181
+ type_id: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
182
+ modified: Mapped[str] = mapped_column(DateTime6(), server_default=now6(), onupdate=now6(), nullable=False)
183
+
184
+ # Relationships
185
+ system: Mapped["System"] = relationship(back_populates="stations")
186
+ items: Mapped[list["StationItem"]] = relationship(back_populates="station", cascade="all, delete-orphan")
187
+ ship_vendors: Mapped[list["ShipVendor"]] = relationship(back_populates="station", cascade="all, delete-orphan")
188
+ upgrade_vendors: Mapped[list["UpgradeVendor"]] = relationship(back_populates="station", cascade="all, delete-orphan")
189
+
190
+ __table_args__ = (
191
+ Index("idx_station_by_system", "system_id"),
192
+ Index("idx_station_by_name", "name"),
193
+ )
194
+
195
+
196
+
197
+
198
+ class Category(Base):
199
+ __tablename__ = "Category"
200
+
201
+ category_id: Mapped[int] = mapped_column(Integer, primary_key=True)
202
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
203
+
204
+ # Relationships
205
+ items: Mapped[list["Item"]] = relationship(back_populates="category")
206
+
207
+ __table_args__ = (Index("idx_category_by_name", "name"),)
208
+
209
+
210
+ class Item(Base):
211
+ __tablename__ = "Item"
212
+
213
+ item_id: Mapped[int] = mapped_column(Integer, primary_key=True)
214
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
215
+ category_id: Mapped[int] = mapped_column(
216
+ ForeignKey("Category.category_id", onupdate="CASCADE", ondelete="CASCADE"),
217
+ nullable=False,
218
+ )
219
+ ui_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
220
+ avg_price: Mapped[int | None] = mapped_column(Integer)
221
+ fdev_id: Mapped[int | None] = mapped_column(Integer)
222
+
223
+ # Relationships
224
+ category: Mapped["Category"] = relationship(back_populates="items")
225
+ stations: Mapped[list["StationItem"]] = relationship(back_populates="item", cascade="all, delete-orphan")
226
+
227
+ __table_args__ = (
228
+ Index("idx_item_by_fdevid", "fdev_id"),
229
+ Index("idx_item_by_category", "category_id"),
230
+ )
231
+
232
+
233
+
234
+ class StationItem(Base):
235
+ __tablename__ = "StationItem"
236
+
237
+ station_id: Mapped[int] = mapped_column(
238
+ BigInteger,
239
+ ForeignKey("Station.station_id", ondelete="CASCADE", onupdate="CASCADE"),
240
+ primary_key=True,
241
+ )
242
+ item_id: Mapped[int] = mapped_column(
243
+ ForeignKey("Item.item_id", ondelete="CASCADE", onupdate="CASCADE"),
244
+ primary_key=True,
245
+ )
246
+ demand_price: Mapped[int] = mapped_column(Integer, nullable=False)
247
+ demand_units: Mapped[int] = mapped_column(Integer, nullable=False)
248
+ demand_level: Mapped[int] = mapped_column(Integer, nullable=False)
249
+ supply_price: Mapped[int] = mapped_column(Integer, nullable=False)
250
+ supply_units: Mapped[int] = mapped_column(Integer, nullable=False)
251
+ supply_level: Mapped[int] = mapped_column(Integer, nullable=False)
252
+ modified: Mapped[str] = mapped_column(DateTime6(), server_default=now6(), onupdate=now6(), nullable=False)
253
+ from_live: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
254
+
255
+ # Relationships
256
+ station: Mapped["Station"] = relationship(back_populates="items")
257
+ item: Mapped["Item"] = relationship(back_populates="stations")
258
+
259
+ __table_args__ = (
260
+ Index("si_mod_stn_itm", "modified", "station_id", "item_id"),
261
+ Index("si_itm_dmdpr", "item_id", "demand_price", sqlite_where=text("demand_price > 0")),
262
+ Index("si_itm_suppr", "item_id", "supply_price", sqlite_where=text("supply_price > 0")),
263
+ {"sqlite_with_rowid": False},
264
+ )
265
+
266
+
267
+
268
+
269
+ class Ship(Base):
270
+ __tablename__ = "Ship"
271
+
272
+ ship_id: Mapped[int] = mapped_column(Integer, primary_key=True)
273
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
274
+ cost: Mapped[int | None] = mapped_column(Integer)
275
+
276
+ # Relationships
277
+ vendors: Mapped[list["ShipVendor"]] = relationship(back_populates="ship")
278
+
279
+
280
+ class ShipVendor(Base):
281
+ __tablename__ = "ShipVendor"
282
+
283
+ ship_id: Mapped[int] = mapped_column(
284
+ ForeignKey("Ship.ship_id", ondelete="CASCADE", onupdate="CASCADE"),
285
+ primary_key=True,
286
+ )
287
+ station_id: Mapped[int] = mapped_column(
288
+ BigInteger,
289
+ ForeignKey("Station.station_id", ondelete="CASCADE", onupdate="CASCADE"),
290
+ primary_key=True,
291
+ )
292
+ modified: Mapped[str] = mapped_column(DateTime6(), server_default=now6(), onupdate=now6(), nullable=False)
293
+
294
+ # Relationships
295
+ ship: Mapped["Ship"] = relationship(back_populates="vendors")
296
+ station: Mapped["Station"] = relationship(back_populates="ship_vendors")
297
+
298
+ __table_args__ = (Index("idx_shipvendor_by_station", "station_id"),)
299
+
300
+
301
+ class Upgrade(Base):
302
+ __tablename__ = "Upgrade"
303
+
304
+ upgrade_id: Mapped[int] = mapped_column(Integer, primary_key=True)
305
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
306
+ class_: Mapped[int] = mapped_column("class", Integer, nullable=False)
307
+ rating: Mapped[str] = mapped_column(CHAR(1), nullable=False)
308
+ ship: Mapped[str | None] = mapped_column(CIString(128))
309
+
310
+ # Relationships
311
+ vendors: Mapped[list["UpgradeVendor"]] = relationship(back_populates="upgrade")
312
+
313
+
314
+ class UpgradeVendor(Base):
315
+ __tablename__ = "UpgradeVendor"
316
+
317
+ upgrade_id: Mapped[int] = mapped_column(
318
+ ForeignKey("Upgrade.upgrade_id", ondelete="CASCADE", onupdate="CASCADE"),
319
+ primary_key=True,
320
+ )
321
+ station_id: Mapped[int] = mapped_column(
322
+ BigInteger,
323
+ ForeignKey("Station.station_id", ondelete="CASCADE", onupdate="CASCADE"),
324
+ primary_key=True,
325
+ )
326
+ modified: Mapped[str] = mapped_column(DateTime6(), nullable=False, server_default=now6(), onupdate=now6())
327
+
328
+ # Relationships
329
+ upgrade: Mapped["Upgrade"] = relationship(back_populates="vendors")
330
+ station: Mapped["Station"] = relationship(back_populates="upgrade_vendors")
331
+
332
+ __table_args__ = (Index("idx_vendor_by_station_id", "station_id"),)
333
+
334
+
335
+ class RareItem(Base):
336
+ __tablename__ = "RareItem"
337
+
338
+ rare_id: Mapped[int] = mapped_column(Integer, primary_key=True)
339
+ station_id: Mapped[int] = mapped_column(
340
+ BigInteger,
341
+ ForeignKey("Station.station_id", ondelete="CASCADE", onupdate="CASCADE"),
342
+ nullable=False,
343
+ )
344
+ category_id: Mapped[int] = mapped_column(
345
+ ForeignKey("Category.category_id", onupdate="CASCADE", ondelete="CASCADE"),
346
+ nullable=False,
347
+ )
348
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
349
+ cost: Mapped[int | None] = mapped_column(Integer)
350
+ max_allocation: Mapped[int | None] = mapped_column(Integer)
351
+ illegal: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
352
+ suppressed: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
353
+
354
+ __table_args__ = (UniqueConstraint("name", name="uq_rareitem_name"),)
355
+
356
+
357
+
358
+
359
+ class FDevShipyard(Base):
360
+ __tablename__ = "FDevShipyard"
361
+
362
+ id = Column(Integer, primary_key=True, unique=True, nullable=False)
363
+ symbol = Column(CIString(128))
364
+ name = Column(CIString(128))
365
+ entitlement = Column(String(50))
366
+
367
+
368
+ class FDevOutfitting(Base):
369
+ __tablename__ = "FDevOutfitting"
370
+
371
+ id = Column(Integer, primary_key=True, unique=True, nullable=False)
372
+ symbol = Column(CIString(128))
373
+ category = Column(String(10))
374
+ name = Column(CIString(128))
375
+ mount = Column(String(20))
376
+ guidance = Column(String(20))
377
+ ship = Column(CIString(128))
378
+ class_ = Column("class", String(1), nullable=False)
379
+ rating = Column(String(1), nullable=False)
380
+ entitlement = Column(String(50))
381
+
382
+ __table_args__ = (
383
+ CheckConstraint(
384
+ "category IN ('hardpoint','internal','standard','utility')",
385
+ name="ck_fdo_category",
386
+ ),
387
+ CheckConstraint(
388
+ "(mount IN ('Fixed','Gimballed','Turreted')) OR (mount IS NULL)",
389
+ name="ck_fdo_mount",
390
+ ),
391
+ CheckConstraint(
392
+ "(guidance IN ('Dumbfire','Seeker','Swarm')) OR (guidance IS NULL)",
393
+ name="ck_fdo_guidance",
394
+ ),
395
+ )
396
+
397
+
398
+ # ---------- Control & Staging ----------
399
+ class ExportControl(Base):
400
+ """
401
+ Singleton control row for hybrid export/watermarking.
402
+ - id: always 1
403
+ - last_full_dump_time: watermark
404
+ - last_reset_key: optional cursor for chunked from_live resets
405
+ """
406
+ __tablename__ = "ExportControl"
407
+
408
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default=text("1"))
409
+ last_full_dump_time: Mapped[str] = mapped_column(DateTime6(), nullable=False)
410
+ last_reset_key: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
411
+
412
+
413
+ class StationItemStaging(Base):
414
+ """
415
+ Staging table for bulk loads (no FKs). Same columns as StationItem.
416
+ """
417
+ __tablename__ = "StationItem_staging"
418
+
419
+ station_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
420
+ item_id: Mapped[int] = mapped_column(Integer, primary_key=True)
421
+ demand_price: Mapped[int] = mapped_column(Integer, nullable=False)
422
+ demand_units: Mapped[int] = mapped_column(Integer, nullable=False)
423
+ demand_level: Mapped[int] = mapped_column(Integer, nullable=False)
424
+ supply_price: Mapped[int] = mapped_column(Integer, nullable=False)
425
+ supply_units: Mapped[int] = mapped_column(Integer, nullable=False)
426
+ supply_level: Mapped[int] = mapped_column(Integer, nullable=False)
427
+ modified: Mapped[str] = mapped_column(DateTime6(), server_default=now6(), onupdate=now6(), nullable=False)
428
+ from_live: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
429
+
430
+ __table_args__ = (Index("idx_sistaging_stn_itm", "station_id", "item_id"),)
431
+
432
+
433
+
434
+
435
+ __all__ = [
436
+ # Base
437
+ "Base",
438
+ # Core
439
+ "Added",
440
+ "System",
441
+ "Station",
442
+ "Category",
443
+ "Item",
444
+ "StationItem",
445
+ "Ship",
446
+ "ShipVendor",
447
+ "Upgrade",
448
+ "UpgradeVendor",
449
+ "RareItem",
450
+ "FDevShipyard",
451
+ "FDevOutfitting",
452
+ # Control & staging
453
+ "ExportControl",
454
+ "StationItemStaging",
455
+ ]