Scoped DB role + own theme colour (port log E17)

- Split database.py into a runtime engine (scoped `kds` DB role, used for
  all request handling) and a migration engine (privileged `kitchen` role,
  used only at startup to create KDS's own tables and ALTER kitchen_settings)
  — KDS previously shared kitchen's full-access DB credential wholesale
- Dispose both engines on shutdown (main.py)
- Fix theme colour clash: KDS was accidentally seeded with kitchen's teal
  (#0d9488) instead of its own colour — now #ea580c (orange), regenerated
  PWA icons to match

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-06 14:45:01 +00:00
parent 870027faca
commit 23889315f5
7 changed files with 4446 additions and 47 deletions

View file

@ -2,16 +2,39 @@ import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
DATABASE_URL = os.getenv(
def _to_asyncpg(url: str) -> str:
if url.startswith("postgresql://"):
return url.replace("postgresql://", "postgresql+asyncpg://", 1)
return url
# Runtime connection — the scoped `kds` DB role, used for every request via
# get_db(). Only has SELECT/INSERT/UPDATE/DELETE on kds_tickets,
# kds_course_bumps and the specific kitchen_settings columns KDS needs (see
# deploy_kds grants) — it cannot read the rest of kitchen_db.
DATABASE_URL = _to_asyncpg(os.getenv(
"DATABASE_URL", "DATABASE_URL",
"postgresql+asyncpg://kitchen:kitchen_secret@localhost:5432/kitchen_gp" "postgresql://kitchen:kitchen_secret@localhost:5432/kitchen_gp"
))
# Migration connection — the privileged `kitchen` DB role. Used ONLY at
# startup to create KDS's own tables and ALTER kitchen_settings (adding KDS
# columns). Never used for request handling. Falls back to DATABASE_URL for
# local/dev convenience, but production must set this separately once the
# `kds` role is scoped down (see port log E17/task: KDS DB role split).
MIGRATION_DATABASE_URL = _to_asyncpg(
os.getenv("MIGRATION_DATABASE_URL") or os.getenv(
"DATABASE_URL", "postgresql://kitchen:kitchen_secret@localhost:5432/kitchen_gp"
)
) )
# Convert standard postgres URL to asyncpg format # `engine` stays the name existing migration files (`from database import
if DATABASE_URL.startswith("postgresql://"): # engine`) already import — no changes needed there. It's bound to the
DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1) # privileged connection.
engine = create_async_engine(MIGRATION_DATABASE_URL, echo=False)
engine = create_async_engine( runtime_engine = create_async_engine(
DATABASE_URL, DATABASE_URL,
echo=False, echo=False,
pool_size=10, # Default is 5 pool_size=10, # Default is 5
@ -20,7 +43,7 @@ engine = create_async_engine(
) )
AsyncSessionLocal = async_sessionmaker( AsyncSessionLocal = async_sessionmaker(
engine, runtime_engine,
class_=AsyncSession, class_=AsyncSession,
expire_on_commit=False expire_on_commit=False
) )

View file

@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from database import engine, Base from database import engine, runtime_engine, Base
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -34,7 +34,9 @@ async def _run(name, coro):
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Create any SQLAlchemy-mapped KDS tables (idempotent) # Create any SQLAlchemy-mapped KDS tables (idempotent). Uses the
# privileged migration engine (`kitchen` role) — the scoped `kds` role
# used for request handling can't CREATE TABLE.
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
@ -51,6 +53,7 @@ async def lifespan(app: FastAPI):
await stop_signalr_listener() await stop_signalr_listener()
await engine.dispose() await engine.dispose()
await runtime_engine.dispose()
app = FastAPI( app = FastAPI(

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before After
Before After

View file

@ -20,7 +20,7 @@
--kds-sent: #6b7280; --kds-sent: #6b7280;
/* App primary — teal (shared with kitchen for recipe images etc.) */ /* App primary — teal (shared with kitchen for recipe images etc.) */
--app-primary: #0d9488; --app-primary: #ea580c; /* kds orange — distinct from kitchen's teal, D5 */
} }
*, *::before, *::after { *, *::before, *::after {

View file

@ -14,8 +14,8 @@ export default defineConfig({
start_url: '/kds/', start_url: '/kds/',
scope: '/kds/', scope: '/kds/',
display: 'standalone', display: 'standalone',
theme_color: '#0d9488', theme_color: '#ea580c',
background_color: '#0d9488', background_color: '#ea580c',
icons: [ icons: [
{ src: '/kds/icons/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/kds/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/kds/icons/icon-512.png', sizes: '512x512', type: 'image/png' }, { src: '/kds/icons/icon-512.png', sizes: '512x512', type: 'image/png' },