Skip to content

Commit 806b142

Browse files
Honor tx_color picker when creating site after simulation
Previously always used the palette color, ignoring the admin's manual color selection. Now uses tx_color when set (non-empty), falling back to the rotating palette when auto-color is enabled. Fixes meshtastic#53. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6ea57db commit 806b142

24 files changed

+759
-599
lines changed

TODOS.SHIP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555

5656
- [x] 30. `Literal[tuple(AVAILABLE_COLORMAPS)]` evaluated at import time (`CoveragePredictionRequest.py`) — defer
5757
- [x] 31. Deadzone scoring weights are magic numbers (`app/services/deadzone.py:284`) — extract to named constants
58-
- [ ] 32. Raw SQL throughout codebase — migrate to SQLAlchemy (deferred: requires full ORM migration)
58+
- [x] 32. Raw SQL throughout codebase — migrated to SQLAlchemy ORM
5959
- [ ] 33. No retry mechanism for failed simulations — user must re-POST (deferred: acceptable for MVP)
6060
- [ ] 34. PPM image fully loaded into memory for large radius simulations (deferred: rare edge case)
6161

app/db/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Database module for the LoRa Coverage Planner."""
22

3-
from app.db.connection import db_connection, get_db
3+
from app.db.connection import db_session, get_engine, init_engine
44
from app.db.schema import init_db
55

6-
__all__: list[str] = ["db_connection", "get_db", "init_db"]
6+
__all__: list[str] = ["db_session", "get_engine", "init_engine", "init_db"]

app/db/connection.py

Lines changed: 43 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,73 @@
1-
"""SQLite connection factory for the LoRa Coverage Planner."""
1+
"""SQLAlchemy engine and session factory for the LoRa Coverage Planner."""
22

33
from __future__ import annotations
44

55
import logging
66
import os
7-
import sqlite3
87
from collections.abc import Generator
98
from contextlib import contextmanager
109
from pathlib import Path
1110

11+
from sqlalchemy import Engine, create_engine, event
12+
from sqlalchemy.orm import Session, sessionmaker
13+
1214
logger = logging.getLogger(__name__)
1315

1416
DEFAULT_DB_PATH: str = "/data/lora-planner.db"
1517

18+
_engine: Engine | None = None
19+
_session_factory: sessionmaker[Session] | None = None
20+
21+
22+
def init_engine(db_path: str | Path | None = None) -> Engine:
23+
"""Create and configure the SQLAlchemy engine for the given database path.
1624
17-
def get_db(db_path: str | None = None) -> sqlite3.Connection:
18-
"""Return a configured SQLite connection.
25+
Sets WAL journal mode and enables foreign keys on every new connection.
26+
Initializes the global engine and session factory used by :func:`db_session`.
1927
2028
Parameters
2129
----------
2230
db_path:
2331
Filesystem path to the database file. Falls back to the ``DB_PATH``
2432
environment variable, then to :data:`DEFAULT_DB_PATH`.
25-
26-
Returns
27-
-------
28-
sqlite3.Connection
29-
A connection with WAL mode, foreign keys enabled, and
30-
``row_factory`` set to :class:`sqlite3.Row`.
3133
"""
32-
resolved_path: Path = Path(db_path or os.environ.get("DB_PATH", DEFAULT_DB_PATH))
34+
global _engine, _session_factory
3335

34-
logger.debug("Opening database connection to %s", resolved_path)
36+
resolved = Path(db_path or os.environ.get("DB_PATH", DEFAULT_DB_PATH))
37+
resolved.parent.mkdir(parents=True, exist_ok=True)
38+
logger.debug("Creating SQLAlchemy engine for %s", resolved)
3539

36-
conn: sqlite3.Connection = sqlite3.connect(str(resolved_path))
37-
conn.execute("PRAGMA journal_mode=WAL;")
38-
conn.execute("PRAGMA foreign_keys=ON;")
39-
conn.row_factory = sqlite3.Row
40+
engine = create_engine(
41+
f"sqlite:///{resolved}",
42+
connect_args={"check_same_thread": False},
43+
)
4044

41-
return conn
45+
@event.listens_for(engine, "connect")
46+
def _set_sqlite_pragma(dbapi_conn, connection_record):
47+
cursor = dbapi_conn.cursor()
48+
cursor.execute("PRAGMA journal_mode=WAL")
49+
cursor.execute("PRAGMA foreign_keys=ON")
50+
cursor.close()
4251

52+
_engine = engine
53+
_session_factory = sessionmaker(bind=engine)
54+
return engine
4355

44-
@contextmanager
45-
def db_connection(db_path: str | None = None) -> Generator[sqlite3.Connection, None, None]:
46-
"""Context manager that yields a configured SQLite connection and closes it on exit.
4756

48-
Parameters
49-
----------
50-
db_path:
51-
Filesystem path to the database file. Passed through to :func:`get_db`.
57+
def get_engine() -> Engine:
58+
"""Return the global engine, raising if not yet initialized."""
59+
if _engine is None:
60+
raise RuntimeError("Database engine not initialized — call init_engine() first")
61+
return _engine
5262

53-
Yields
54-
------
55-
sqlite3.Connection
56-
"""
57-
conn = get_db(db_path)
63+
64+
@contextmanager
65+
def db_session() -> Generator[Session, None, None]:
66+
"""Context manager that yields a configured SQLAlchemy session and closes it on exit."""
67+
if _session_factory is None:
68+
raise RuntimeError("Database not initialized — call init_engine() first")
69+
session = _session_factory()
5870
try:
59-
yield conn
71+
yield session
6072
finally:
61-
conn.close()
73+
session.close()

app/db/models.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""SQLAlchemy ORM models for the LoRa Coverage Planner.
2+
3+
These models define the current schema state used for application queries.
4+
Schema evolution is handled by the migration system in ``schema.py``.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from sqlalchemy import JSON, Float, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint, text
10+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
11+
12+
13+
class Base(DeclarativeBase):
14+
pass
15+
16+
17+
class Tower(Base):
18+
__tablename__ = "towers"
19+
20+
id: Mapped[str] = mapped_column(String, primary_key=True)
21+
name: Mapped[str] = mapped_column(String, nullable=False)
22+
color: Mapped[str | None] = mapped_column(String)
23+
params: Mapped[dict] = mapped_column(JSON, nullable=False)
24+
geotiff: Mapped[bytes | None] = mapped_column(LargeBinary)
25+
created_at: Mapped[str] = mapped_column(String, nullable=False, server_default=text("(datetime('now'))"))
26+
updated_at: Mapped[str] = mapped_column(String, nullable=False, server_default=text("(datetime('now'))"))
27+
28+
29+
class Task(Base):
30+
__tablename__ = "tasks"
31+
32+
id: Mapped[str] = mapped_column(String, primary_key=True)
33+
tower_id: Mapped[str] = mapped_column(ForeignKey("towers.id", ondelete="CASCADE"), nullable=False, index=True)
34+
status: Mapped[str] = mapped_column(String, nullable=False, server_default=text("'processing'"))
35+
error: Mapped[str | None] = mapped_column(Text)
36+
created_at: Mapped[str] = mapped_column(String, nullable=False, server_default=text("(datetime('now'))"))
37+
38+
39+
class TowerPath(Base):
40+
__tablename__ = "tower_paths"
41+
__table_args__ = (
42+
UniqueConstraint("tower_a_id", "tower_b_id"),
43+
Index("idx_tower_paths_tower_a", "tower_a_id"),
44+
Index("idx_tower_paths_tower_b", "tower_b_id"),
45+
)
46+
47+
id: Mapped[str] = mapped_column(String, primary_key=True)
48+
tower_a_id: Mapped[str] = mapped_column(ForeignKey("towers.id", ondelete="CASCADE"), nullable=False)
49+
tower_b_id: Mapped[str] = mapped_column(ForeignKey("towers.id", ondelete="CASCADE"), nullable=False)
50+
path_loss_db: Mapped[float | None] = mapped_column(Float)
51+
has_los: Mapped[int | None] = mapped_column(Integer)
52+
distance_km: Mapped[float | None] = mapped_column(Float)
53+
status: Mapped[str] = mapped_column(String, nullable=False, server_default=text("'pending'"))
54+
error: Mapped[str | None] = mapped_column(Text)
55+
created_at: Mapped[str] = mapped_column(String, nullable=False, server_default=text("(datetime('now'))"))
56+
57+
tower_a: Mapped[Tower] = relationship(foreign_keys=[tower_a_id])
58+
tower_b: Mapped[Tower] = relationship(foreign_keys=[tower_b_id])
59+
60+
61+
class Simulation(Base):
62+
__tablename__ = "simulations"
63+
__table_args__ = (
64+
UniqueConstraint("tower_id", "client_hardware", "client_antenna", "terrain_model"),
65+
Index("idx_simulations_tower_id", "tower_id"),
66+
)
67+
68+
id: Mapped[str] = mapped_column(String, primary_key=True)
69+
tower_id: Mapped[str] = mapped_column(ForeignKey("towers.id", ondelete="CASCADE"), nullable=False)
70+
client_hardware: Mapped[str] = mapped_column(String, nullable=False)
71+
client_antenna: Mapped[str] = mapped_column(String, nullable=False)
72+
terrain_model: Mapped[str] = mapped_column(String, nullable=False, server_default=text("'bare_earth'"))
73+
status: Mapped[str] = mapped_column(String, nullable=False, server_default=text("'pending'"))
74+
geotiff: Mapped[bytes | None] = mapped_column(LargeBinary)
75+
error: Mapped[str | None] = mapped_column(Text)
76+
created_at: Mapped[str] = mapped_column(String, nullable=False, server_default=text("(datetime('now'))"))
77+
78+
79+
class Setting(Base):
80+
__tablename__ = "settings"
81+
82+
key: Mapped[str] = mapped_column(String, primary_key=True)
83+
value: Mapped[str] = mapped_column(Text, nullable=False)
84+
updated_at: Mapped[str] = mapped_column(String, nullable=False, server_default=text("(datetime('now'))"))

app/db/schema.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
"""SQLite schema definitions and database initialization for the LoRa Coverage Planner."""
1+
"""SQLite schema definitions and database initialization for the LoRa Coverage Planner.
2+
3+
DDL migrations are applied via raw SQL through the SQLAlchemy engine. The ORM
4+
models in ``models.py`` describe the *current* schema state for application
5+
queries; this module handles schema *evolution*.
6+
"""
27

38
from __future__ import annotations
49

510
import logging
611
from pathlib import Path
712

8-
from app.db.connection import db_connection
13+
from sqlalchemy import text
14+
15+
from app.db.connection import init_engine
916

1017
logger = logging.getLogger(__name__)
1118

@@ -104,6 +111,9 @@
104111
def init_db(db_path: str | Path) -> None:
105112
"""Create the database file (if needed) and apply all pending schema migrations.
106113
114+
Also initializes the global SQLAlchemy engine and session factory via
115+
:func:`init_engine`, so callers can use :func:`db_session` immediately after.
116+
107117
Each migration is applied inside a single transaction and its version
108118
number is recorded in the ``schema_version`` table so it is never
109119
re-applied.
@@ -113,24 +123,22 @@ def init_db(db_path: str | Path) -> None:
113123
db_path:
114124
Filesystem path where the SQLite database should be created.
115125
"""
116-
db_path = Path(db_path)
117-
db_path.parent.mkdir(parents=True, exist_ok=True)
118-
119126
logger.info("Initializing database at %s", db_path)
127+
engine = init_engine(db_path)
120128

121-
with db_connection(str(db_path)) as conn:
129+
with engine.connect() as conn:
122130
# Bootstrap the version-tracking table (always safe to re-run)
123-
conn.execute(SCHEMA_VERSION)
131+
conn.execute(text(SCHEMA_VERSION))
124132

125-
current = conn.execute("SELECT COALESCE(MAX(version), 0) FROM schema_version").fetchone()[0]
133+
current = conn.execute(text("SELECT COALESCE(MAX(version), 0) FROM schema_version")).scalar()
126134
logger.info("Current schema version: %s", current)
127135

128136
for version, statements in MIGRATIONS:
129137
if version <= current:
130138
continue
131139
for stmt in statements:
132-
conn.execute(stmt)
133-
conn.execute("INSERT INTO schema_version (version) VALUES (?)", (version,))
140+
conn.execute(text(stmt))
141+
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": version})
134142
logger.info("Applied schema migration v%s", version)
135143

136144
conn.commit()

0 commit comments

Comments
 (0)