Deployment Script, Postgres migration, UX improvements

This commit is contained in:
2026-05-08 23:07:01 +12:00
parent 9afc3170ff
commit cfc193b713
37 changed files with 4390 additions and 2715 deletions
+28 -4
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_module_access
@@ -13,14 +13,16 @@ from app.schemas.mix_calculator import (
)
from app.services.mix_calculator_service import (
build_mix_calculator_options,
can_view_all_mix_calculator_sessions,
calculate_mix_calculator_preview,
serialize_mix_calculator_session,
create_mix_calculator_session,
get_mix_calculator_session,
update_mix_calculator_session,
list_mix_calculator_sessions,
can_view_all_mix_calculator_sessions,
serialize_mix_calculator_session,
update_mix_calculator_session,
)
from app.services.mix_calculator_pdf import MixCalculatorPdfUnavailableError, build_mix_calculator_pdf
from app.services.mix_calculator_filenames import mix_calculator_pdf_filename
router = APIRouter(prefix="/api/mix-calculator", tags=["mix-calculator"])
@@ -77,6 +79,28 @@ def read_mix_calculator_session(
return serialize_mix_calculator_session(session_record, session)
@router.get("/{session_id}/pdf")
def download_mix_calculator_session_pdf(
session_id: int,
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
db: Session = Depends(get_db),
):
session_record = get_mix_calculator_session(db, auth_session=session, session_id=session_id)
if session_record is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mix calculator session not found")
try:
pdf_bytes = build_mix_calculator_pdf(session_record)
except MixCalculatorPdfUnavailableError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
filename = mix_calculator_pdf_filename(session_record)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.patch("/{session_id}", response_model=MixCalculatorSessionRead)
def patch_mix_calculator_session(
session_id: int,
+52 -6
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections import Counter
from datetime import date, datetime
import logging
import os
from pathlib import Path
import re
@@ -22,10 +23,48 @@ from app.services.client_access_service import MODULE_CATALOG, default_access_le
TENANT_ID = "hunter-premium-produce"
WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1)
WORKBOOK_SENTINEL_ITEM_ID = "404266"
WORKBOOK_PATH = Path(__file__).resolve().parents[2] / "Input Cost Spreadsheet(1).xlsx"
WORKBOOK_FILENAME = "Input Cost Spreadsheet(1).xlsx"
logger = logging.getLogger("data_entry_app.seed")
def _workbook_candidates() -> list[Path]:
env_value = os.getenv("WORKBOOK_PATH")
env_path = env_value.strip() if isinstance(env_value, str) and env_value.strip() else None
repo_root = Path(__file__).resolve().parents[2]
cwd = Path.cwd()
candidates = [
Path(env_path) if env_path else None,
Path("/srv/lean101-clients") / WORKBOOK_FILENAME,
repo_root / WORKBOOK_FILENAME,
cwd / WORKBOOK_FILENAME,
Path("/app") / WORKBOOK_FILENAME,
Path("/") / WORKBOOK_FILENAME,
]
ordered: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
if candidate is None:
continue
key = str(candidate)
if key in seen:
continue
seen.add(key)
ordered.append(candidate)
return ordered
def _resolve_workbook_path() -> Path:
for candidate in _workbook_candidates():
if candidate.exists():
return candidate
return _workbook_candidates()[0]
WORKBOOK_PATH = _resolve_workbook_path()
def _text(value) -> str | None:
if value is None:
return None
@@ -129,9 +168,12 @@ def _build_process_key(label, grading_cost: float, bagging_cost: float, cracking
def _load_workbook():
if not WORKBOOK_PATH.exists():
raise FileNotFoundError(f"Workbook not found at {WORKBOOK_PATH}")
return load_workbook(WORKBOOK_PATH, data_only=True)
workbook_path = _resolve_workbook_path()
if not workbook_path.exists():
raise FileNotFoundError(
f"Workbook not found. Checked: {', '.join(str(path) for path in _workbook_candidates())}"
)
return load_workbook(workbook_path, data_only=True)
def _read_raw_material_rows(workbook) -> list[dict]:
@@ -684,10 +726,14 @@ def seed_costing_workspace(db):
def seed_if_empty():
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
if WORKBOOK_PATH.exists():
workbook_path = _resolve_workbook_path()
if workbook_path.exists():
seed_costing_workspace(db)
else:
logger.warning("Skipping costing workspace seed because workbook is missing at %s", WORKBOOK_PATH)
logger.warning(
"Skipping costing workspace seed because workbook is missing. Checked: %s",
", ".join(str(path) for path in _workbook_candidates()),
)
seed_client_access(db)
seed_access(db)
db.commit()
@@ -0,0 +1,10 @@
from __future__ import annotations
import re
from app.models.mix_calculator import MixCalculatorSession
def mix_calculator_pdf_filename(session_record: MixCalculatorSession) -> str:
raw = f"{session_record.session_number}_{session_record.client_name}_{session_record.product_name}.pdf"
return re.sub(r"[^\w.\-]+", "_", raw)
+295
View File
@@ -0,0 +1,295 @@
from __future__ import annotations
from io import BytesIO
from math import ceil
from app.models.mix_calculator import MixCalculatorSession
class MixCalculatorPdfUnavailableError(RuntimeError):
pass
def _fmt_number(value: float, digits: int = 2) -> str:
return f"{value:.{digits}f}"
def _fractional_bag_warning(session_record: MixCalculatorSession) -> str | None:
rounded_bags = round(session_record.total_bags)
if abs(session_record.total_bags - rounded_bags) < 1e-9:
return None
return (
f"Batch size {session_record.batch_size_kg:g}kg produces {session_record.total_bags:.2f} bags "
f"for {session_record.product_unit_of_measure}. This is not a whole-bag quantity."
)
def build_mix_calculator_pdf(session_record: MixCalculatorSession) -> bytes:
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
except ModuleNotFoundError as exc:
raise MixCalculatorPdfUnavailableError(
"PDF generation is unavailable because 'reportlab' is not installed. "
"Install backend dependencies again to enable PDF export."
) from exc
buffer = BytesIO()
document = SimpleDocTemplate(
buffer,
pagesize=A4,
leftMargin=14 * mm,
rightMargin=14 * mm,
topMargin=14 * mm,
bottomMargin=14 * mm,
title=f"{session_record.session_number} - {session_record.product_name}",
author="Lean 101 Clients",
)
styles = getSampleStyleSheet()
eyebrow = ParagraphStyle(
"Eyebrow",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=8,
leading=10,
textColor=colors.HexColor("#62736B"),
spaceAfter=5,
)
title = ParagraphStyle(
"Title",
parent=styles["Heading1"],
fontName="Helvetica-Bold",
fontSize=24,
leading=26,
textColor=colors.HexColor("#21312A"),
spaceAfter=6,
)
subtitle = ParagraphStyle(
"Subtitle",
parent=styles["BodyText"],
fontName="Helvetica",
fontSize=10,
leading=13,
textColor=colors.HexColor("#6B7A73"),
)
label = ParagraphStyle(
"Label",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=7,
leading=9,
textColor=colors.HexColor("#6B7A73"),
)
value = ParagraphStyle(
"Value",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=11,
leading=13,
textColor=colors.HexColor("#21312A"),
)
card_value = ParagraphStyle(
"CardValue",
parent=value,
fontSize=16,
leading=18,
)
body = ParagraphStyle(
"Body",
parent=styles["BodyText"],
fontName="Helvetica",
fontSize=9,
leading=12,
textColor=colors.HexColor("#304038"),
)
section_title = ParagraphStyle(
"SectionTitle",
parent=styles["Heading2"],
fontName="Helvetica-Bold",
fontSize=13,
leading=15,
textColor=colors.HexColor("#21312A"),
)
warnings = []
bag_warning = _fractional_bag_warning(session_record)
if bag_warning:
warnings.append(bag_warning)
story = [
Paragraph(f"Mix Calculator | {session_record.session_number}", eyebrow),
Paragraph(session_record.product_name, title),
Paragraph(f"{session_record.client_name} &nbsp;&middot;&nbsp; {session_record.mix_name}", subtitle),
Spacer(1, 8),
]
header_table = Table(
[
[
[
Paragraph("Mix date", label),
Paragraph(session_record.mix_date.strftime("%d %b %Y"), value),
],
[
Paragraph("Prepared by", label),
Paragraph(session_record.prepared_by_name, value),
],
[
Paragraph("Status", label),
Paragraph(session_record.status.title(), value),
],
]
],
colWidths=[60 * mm, 60 * mm, 52 * mm],
)
header_table.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("INNERGRID", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("BACKGROUND", (0, 0), (-1, -1), colors.white),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 9),
("BOTTOMPADDING", (0, 0), (-1, -1), 9),
]
)
)
story.extend([header_table, Spacer(1, 10)])
summary_table = Table(
[
[
[Paragraph("Batch size", label), Paragraph(f"{_fmt_number(session_record.batch_size_kg)}kg", card_value)],
[Paragraph("Total output", label), Paragraph(f"{_fmt_number(session_record.total_kg)}kg", card_value)],
[Paragraph("Bags", label), Paragraph(_fmt_number(session_record.total_bags), card_value)],
[Paragraph("Unit pack", label), Paragraph(f"{_fmt_number(session_record.product_unit_size_kg)}kg", card_value)],
]
],
colWidths=[43 * mm, 43 * mm, 43 * mm, 43 * mm],
)
summary_table.setStyle(
TableStyle(
[
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("INNERGRID", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F9FBFA")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
]
)
)
story.extend([summary_table, Spacer(1, 10)])
detail_table = Table(
[
[
[Paragraph("Mix source", label), Paragraph(session_record.mix_name, value), Paragraph(f"Saved against {session_record.product_unit_of_measure} units.", body)],
[Paragraph("Composition", label), Paragraph(f"{_fmt_number(sum(line.mix_percentage for line in session_record.lines))}%", value), Paragraph(f"{len(session_record.lines)} raw material{'s' if len(session_record.lines) != 1 else ''} in the blend.", body)],
[Paragraph("Estimated pages", label), Paragraph(str(max(1, ceil(len(session_record.lines) / 18))), value), Paragraph("Formatted for A4 PDF export.", body)],
]
],
colWidths=[60 * mm, 60 * mm, 52 * mm],
)
detail_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F4F8F5")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
]
)
)
story.extend([detail_table, Spacer(1, 10)])
if session_record.notes:
notes_table = Table(
[[Paragraph("Notes", label)], [Paragraph(session_record.notes.replace("\n", "<br/>"), body)]],
colWidths=[172 * mm],
)
notes_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#F4F8F5")),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
story.extend([notes_table, Spacer(1, 10)])
if warnings:
warning_rows = [[Paragraph("Warnings", label)]]
warning_rows.extend([[Paragraph(warning, body)] for warning in warnings])
warnings_table = Table(warning_rows, colWidths=[172 * mm])
warnings_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#FFF5E6")),
("BACKGROUND", (0, 1), (-1, -1), colors.HexColor("#FFF9EF")),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#E8C483")),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
story.extend([warnings_table, Spacer(1, 10)])
story.extend(
[
Paragraph("Required Raw Materials", label),
Paragraph("Blend composition", section_title),
Paragraph(f"{session_record.product_unit_of_measure} · {_fmt_number(session_record.product_unit_size_kg)}kg per unit", subtitle),
Spacer(1, 6),
]
)
table_rows = [["Raw material", "Mix %", "Required kg", "Unit"]]
for line in session_record.lines:
table_rows.append(
[
Paragraph(f"<b>{line.raw_material_name}</b>", body),
Paragraph(f"{_fmt_number(line.mix_percentage)}%", body),
Paragraph(f"{_fmt_number(line.required_kg)}kg", body),
Paragraph(line.unit, body),
]
)
composition_table = Table(table_rows, colWidths=[88 * mm, 24 * mm, 34 * mm, 26 * mm], repeatRows=1)
composition_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#EEF4F0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#4F6158")),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8),
("BOTTOMPADDING", (0, 0), (-1, 0), 8),
("TOPPADDING", (0, 0), (-1, 0), 8),
("LEFTPADDING", (0, 0), (-1, -1), 9),
("RIGHTPADDING", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.6, colors.HexColor("#DBE4DE")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#FBFCFB")]),
]
)
)
story.append(composition_table)
document.build(story)
return buffer.getvalue()