v0.1.28 - Version bump and editor/throughput updates
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+37
-13
@@ -86,12 +86,17 @@ def _serialize_product_formula(product: Product) -> dict:
|
||||
|
||||
|
||||
def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict:
|
||||
# Status is product-driven once a mix has products (Active = at least one
|
||||
# visible product). A mix with no products yet has nothing to fan out to, so
|
||||
# it falls back to its own `status` column — that's what lets a brand-new
|
||||
# mix read as Active instead of being stuck Inactive and hidden.
|
||||
visible = visible_count > 0 if product_count > 0 else mix.status == "active"
|
||||
return {
|
||||
"id": mix.id,
|
||||
"tenant_id": mix.tenant_id,
|
||||
"client_name": mix.client_name,
|
||||
"name": mix.name,
|
||||
"visible": visible_count > 0,
|
||||
"visible": visible,
|
||||
"product_count": product_count,
|
||||
"visible_product_count": visible_count,
|
||||
"notes": mix.notes,
|
||||
@@ -319,6 +324,9 @@ def create_editor_mix(
|
||||
client_name=payload.client_name.strip(),
|
||||
name=payload.name.strip(),
|
||||
notes=payload.notes,
|
||||
# Active by default so a freshly created mix shows under the default
|
||||
# "Active" filter rather than being hidden until it has a visible product.
|
||||
status="active",
|
||||
)
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
@@ -348,27 +356,43 @@ def update_editor_mix(
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
# `visible` is a virtual field: it fans out to the visibility of every product
|
||||
# under the mix rather than mapping to a mix column.
|
||||
# `visible` is a virtual field: for a mix with products it fans out to the
|
||||
# visibility of every product; for a product-less mix it maps to the mix's
|
||||
# own `status` column so the toggle still persists.
|
||||
visible = updates.pop("visible", None)
|
||||
|
||||
product_total = (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Product)
|
||||
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
before = {field: getattr(mix, field) for field in updates}
|
||||
if visible is not None:
|
||||
visible_before = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Product)
|
||||
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible)
|
||||
)
|
||||
before["visible"] = bool(visible_before)
|
||||
if product_total > 0:
|
||||
visible_before = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Product)
|
||||
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible)
|
||||
)
|
||||
before["visible"] = bool(visible_before)
|
||||
else:
|
||||
before["visible"] = mix.status == "active"
|
||||
|
||||
for field, value in updates.items():
|
||||
setattr(mix, field, value)
|
||||
|
||||
if visible is not None:
|
||||
for product in db.scalars(
|
||||
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
|
||||
).all():
|
||||
product.visible = visible
|
||||
if product_total > 0:
|
||||
for product in db.scalars(
|
||||
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
|
||||
).all():
|
||||
product.visible = visible
|
||||
else:
|
||||
mix.status = "active" if visible else "inactive"
|
||||
|
||||
after = dict(updates)
|
||||
if visible is not None:
|
||||
|
||||
@@ -66,9 +66,15 @@ class MigrationReport:
|
||||
created_tables: tuple[str, ...] = ()
|
||||
added_columns: tuple[str, ...] = ()
|
||||
synced_tenant_rows: dict[str, int] = field(default_factory=dict)
|
||||
resynced_sequences: tuple[str, ...] = ()
|
||||
|
||||
def has_changes(self) -> bool:
|
||||
return bool(self.created_tables or self.added_columns or self.synced_tenant_rows)
|
||||
return bool(
|
||||
self.created_tables
|
||||
or self.added_columns
|
||||
or self.synced_tenant_rows
|
||||
or self.resynced_sequences
|
||||
)
|
||||
|
||||
def summary(self) -> str:
|
||||
parts: list[str] = []
|
||||
@@ -79,6 +85,8 @@ class MigrationReport:
|
||||
if self.synced_tenant_rows:
|
||||
counts = ", ".join(f"{table}={count}" for table, count in sorted(self.synced_tenant_rows.items()))
|
||||
parts.append(f"synced tenant rows: {counts}")
|
||||
if self.resynced_sequences:
|
||||
parts.append(f"resynced sequences: {', '.join(self.resynced_sequences)}")
|
||||
return "; ".join(parts) if parts else "schema already up to date"
|
||||
|
||||
|
||||
@@ -435,7 +443,58 @@ def sync_product_visibility(engine: Engine) -> int:
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def resync_identity_sequences(engine: Engine) -> tuple[str, ...]:
|
||||
"""Realign Postgres identity sequences with each table's current MAX(id).
|
||||
|
||||
After a bulk import that carries original primary keys across (the SQLite →
|
||||
Postgres migration inserts rows with their existing ids), every table's
|
||||
sequence still points at its starting value. The next INSERT then reuses an
|
||||
id that already exists and fails with ``duplicate key value violates unique
|
||||
constraint`` — which is why creating a new mix/ingredient/product saved fine
|
||||
on SQLite but not on production Postgres.
|
||||
|
||||
This advances each ``id`` sequence to MAX(id) so the next INSERT continues
|
||||
cleanly. It is a no-op on SQLite and idempotent on Postgres, so it is safe to
|
||||
run on every startup. A per-table failure is skipped rather than aborting the
|
||||
whole boot.
|
||||
"""
|
||||
if engine.dialect.name != "postgresql":
|
||||
return ()
|
||||
|
||||
resynced: list[str] = []
|
||||
inspector = inspect(engine)
|
||||
with engine.begin() as connection:
|
||||
for table_name in inspector.get_table_names():
|
||||
if not any(column["name"] == "id" for column in inspector.get_columns(table_name)):
|
||||
continue
|
||||
try:
|
||||
sequence = connection.execute(
|
||||
text("SELECT pg_get_serial_sequence(:table, 'id')"),
|
||||
{"table": table_name},
|
||||
).scalar()
|
||||
if not sequence:
|
||||
continue
|
||||
max_id = connection.execute(text(f'SELECT MAX(id) FROM "{table_name}"')).scalar()
|
||||
if max_id is None:
|
||||
continue
|
||||
connection.execute(
|
||||
text("SELECT setval(:sequence, :value, true)"),
|
||||
{"sequence": sequence, "value": int(max_id)},
|
||||
)
|
||||
resynced.append(table_name)
|
||||
except Exception:
|
||||
# A single problematic table must not block startup; the others
|
||||
# still get realigned.
|
||||
continue
|
||||
return tuple(resynced)
|
||||
|
||||
|
||||
def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport:
|
||||
created_tables = ensure_metadata_tables(engine, metadata)
|
||||
added_columns = ensure_tenant_columns(engine) + ensure_legacy_columns(engine)
|
||||
return MigrationReport(created_tables=created_tables, added_columns=added_columns)
|
||||
resynced_sequences = resync_identity_sequences(engine)
|
||||
return MigrationReport(
|
||||
created_tables=created_tables,
|
||||
added_columns=added_columns,
|
||||
resynced_sequences=resynced_sequences,
|
||||
)
|
||||
|
||||
@@ -117,6 +117,7 @@ def ensure_database_ready() -> MigrationReport:
|
||||
**tenant_sync_report,
|
||||
**({"products_visibility": hidden_product_count} if hidden_product_count else {}),
|
||||
},
|
||||
resynced_sequences=schema_report.resynced_sequences,
|
||||
)
|
||||
logger.info("Database startup checks complete: %s", report.summary())
|
||||
_database_ready = True
|
||||
|
||||
Reference in New Issue
Block a user