v0.1.27
Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live. Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator). Fix: Security headers on all API responses (hardening) Add: New mix button available on the Mix Editor. Add: New ingredient button available on the Ingredient Editor
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""Recording and reading editor change history.
|
||||
|
||||
The Mix Editor and Ingredients Editor write a row here on every create/edit so
|
||||
each mix and ingredient carries an auditable history (who changed what, when).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import AuthSession
|
||||
from app.models.change_event import EditorChangeEvent
|
||||
|
||||
# Entity types — these match the History buttons on the two editors.
|
||||
ENTITY_MIX = "mix"
|
||||
ENTITY_INGREDIENT = "ingredient"
|
||||
|
||||
|
||||
def _stringify(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return "Yes" if value else "No"
|
||||
if isinstance(value, float):
|
||||
# Trim trailing zeros so 12.50 reads as 12.5 and 12.0 as 12.
|
||||
text = f"{value:.4f}".rstrip("0").rstrip(".")
|
||||
return text or "0"
|
||||
return str(value)
|
||||
|
||||
|
||||
def diff_fields(before: dict[str, Any], after: dict[str, Any], labels: dict[str, str]) -> list[dict[str, Any]]:
|
||||
"""Build a list of ``{field, label, before, after}`` deltas for changed fields.
|
||||
|
||||
Only keys present in ``labels`` are considered, and only those whose value
|
||||
actually changed are emitted.
|
||||
"""
|
||||
deltas: list[dict[str, Any]] = []
|
||||
for field, label in labels.items():
|
||||
if field not in after:
|
||||
continue
|
||||
old = before.get(field)
|
||||
new = after.get(field)
|
||||
if old == new:
|
||||
continue
|
||||
deltas.append({"field": field, "label": label, "before": _stringify(old), "after": _stringify(new)})
|
||||
return deltas
|
||||
|
||||
|
||||
def record_change(
|
||||
db: Session,
|
||||
*,
|
||||
session: AuthSession,
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
action: str,
|
||||
summary: str,
|
||||
changes: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""Append a change event. Caller is responsible for committing the session."""
|
||||
db.add(
|
||||
EditorChangeEvent(
|
||||
tenant_id=session.tenant_id or "",
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
actor_name=session.name or session.email or "Unknown",
|
||||
actor_email=session.email or "",
|
||||
actor_role=session.client_role or session.role,
|
||||
summary=summary,
|
||||
changes=changes or [],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def list_changes(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
limit: int = 200,
|
||||
) -> list[EditorChangeEvent]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(EditorChangeEvent)
|
||||
.where(
|
||||
EditorChangeEvent.tenant_id == tenant_id,
|
||||
EditorChangeEvent.entity_type == entity_type,
|
||||
EditorChangeEvent.entity_id == entity_id,
|
||||
)
|
||||
.order_by(EditorChangeEvent.created_at.desc(), EditorChangeEvent.id.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
@@ -4,6 +4,7 @@ import csv
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
@@ -149,7 +150,17 @@ def _coerce_text(value: object) -> str | None:
|
||||
return text
|
||||
|
||||
|
||||
def _coerce_date(value: object) -> date | None:
|
||||
# Default slash-date preference. The app is Australian, so an ambiguous
|
||||
# "x/y/z" is read day-first unless a column is detected as month-first.
|
||||
_DAY_FIRST_FORMATS = ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y")
|
||||
_MONTH_FIRST_FORMATS = ("%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y")
|
||||
|
||||
_SLASH_DATE_RE = re.compile(r"^\s*(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})\s*$")
|
||||
|
||||
|
||||
def _coerce_date(
|
||||
value: object, formats: tuple[str, ...] = _DAY_FIRST_FORMATS
|
||||
) -> date | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
@@ -159,7 +170,7 @@ def _coerce_date(value: object) -> date | None:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"):
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(text, fmt).date()
|
||||
except ValueError:
|
||||
@@ -167,6 +178,32 @@ def _coerce_date(value: object) -> date | None:
|
||||
return None
|
||||
|
||||
|
||||
def _detect_slash_date_formats(values: Iterable[object]) -> tuple[str, ...]:
|
||||
"""Inspect every slash/dash date in a column and decide whether the file is
|
||||
day-first (D/M/Y) or month-first (M/D/Y), so all rows parse consistently.
|
||||
|
||||
A first component > 12 proves day-first; a second component > 12 proves
|
||||
month-first. If only month-first evidence exists we switch to M/D/Y;
|
||||
otherwise we keep the Australian day-first default.
|
||||
"""
|
||||
day_first = False
|
||||
month_first = False
|
||||
for value in values:
|
||||
if value is None or isinstance(value, (datetime, date)):
|
||||
continue
|
||||
match = _SLASH_DATE_RE.match(str(value))
|
||||
if not match:
|
||||
continue
|
||||
first, second = int(match.group(1)), int(match.group(2))
|
||||
if first > 12:
|
||||
day_first = True
|
||||
elif second > 12:
|
||||
month_first = True
|
||||
if month_first and not day_first:
|
||||
return _MONTH_FIRST_FORMATS
|
||||
return _DAY_FIRST_FORMATS
|
||||
|
||||
|
||||
def _infer_bulka_default(name: str, bag_size: float | None) -> bool:
|
||||
lowered = name.lower()
|
||||
if "bulka" in lowered:
|
||||
@@ -571,6 +608,10 @@ def import_entries_from_file(
|
||||
return None
|
||||
return row[idx]
|
||||
|
||||
# Decide the slash-date order once for the whole file so ambiguous values
|
||||
# like "12/9/2025" follow the same convention as the unambiguous ones.
|
||||
date_formats = _detect_slash_date_formats(cell(row, "date") for row in data_rows)
|
||||
|
||||
# Index existing products for matching (by item_id and by lower-cased name).
|
||||
by_item: dict[str, ThroughputProduct] = {}
|
||||
by_name: dict[str, ThroughputProduct] = {}
|
||||
@@ -596,7 +637,7 @@ def import_entries_from_file(
|
||||
if not row or all(value is None or str(value).strip() == "" for value in row):
|
||||
continue
|
||||
|
||||
production_date = _coerce_date(cell(row, "date"))
|
||||
production_date = _coerce_date(cell(row, "date"), date_formats)
|
||||
product_name = _coerce_text(cell(row, "product"))
|
||||
quantity = _coerce_float(cell(row, "quantity"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user