Deployment Script, Postgres migration, UX improvements
This commit is contained in:
@@ -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
@@ -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)
|
||||
@@ -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} · {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()
|
||||
@@ -1,10 +1,13 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: data-entry-app-backend
|
||||
Version: 0.1.2
|
||||
Version: 0.1.5
|
||||
Summary: Costing platform MVP backend
|
||||
Requires-Python: >=3.11
|
||||
Requires-Dist: fastapi<1.0,>=0.115
|
||||
Requires-Dist: openpyxl<4.0,>=3.1
|
||||
Requires-Dist: uvicorn[standard]<1.0,>=0.30
|
||||
Requires-Dist: sqlalchemy<3.0,>=2.0
|
||||
Requires-Dist: pydantic<3.0,>=2.8
|
||||
Requires-Dist: pytest<9.0,>=8.0
|
||||
Requires-Dist: psycopg[binary]<4.0,>=3.2
|
||||
Requires-Dist: reportlab<5.0,>=4.2
|
||||
|
||||
@@ -2,33 +2,80 @@ pyproject.toml
|
||||
./app/__init__.py
|
||||
./app/main.py
|
||||
./app/seed.py
|
||||
./app/seed_access.py
|
||||
./app/api/__init__.py
|
||||
./app/api/access.py
|
||||
./app/api/auth.py
|
||||
./app/api/client_access.py
|
||||
./app/api/dashboard.py
|
||||
./app/api/deps.py
|
||||
./app/api/mix_calculator.py
|
||||
./app/api/mixes.py
|
||||
./app/api/powerbi.py
|
||||
./app/api/products.py
|
||||
./app/api/raw_materials.py
|
||||
./app/api/scenarios.py
|
||||
./app/core/__init__.py
|
||||
./app/core/access.py
|
||||
./app/core/config.py
|
||||
./app/core/security.py
|
||||
./app/db/__init__.py
|
||||
./app/db/migrations.py
|
||||
./app/db/session.py
|
||||
./app/models/__init__.py
|
||||
./app/models/access.py
|
||||
./app/models/assumption.py
|
||||
./app/models/client_access.py
|
||||
./app/models/mix.py
|
||||
./app/models/mix_calculator.py
|
||||
./app/models/product.py
|
||||
./app/models/raw_material.py
|
||||
./app/models/scenario.py
|
||||
./app/schemas/__init__.py
|
||||
./app/schemas/client_access.py
|
||||
./app/schemas/mix.py
|
||||
./app/schemas/mix_calculator.py
|
||||
./app/schemas/product.py
|
||||
./app/schemas/raw_material.py
|
||||
./app/schemas/scenario.py
|
||||
./app/services/__init__.py
|
||||
./app/services/client_access_service.py
|
||||
./app/services/costing_engine.py
|
||||
./app/services/mix_calculator_filenames.py
|
||||
./app/services/mix_calculator_pdf.py
|
||||
./app/services/mix_calculator_service.py
|
||||
./app/services/scenario_engine.py
|
||||
app/__init__.py
|
||||
app/main.py
|
||||
app/seed.py
|
||||
app/api/__init__.py
|
||||
app/api/mixes.py
|
||||
app/api/powerbi.py
|
||||
app/api/products.py
|
||||
app/api/raw_materials.py
|
||||
app/api/scenarios.py
|
||||
app/core/__init__.py
|
||||
app/core/config.py
|
||||
app/db/__init__.py
|
||||
app/db/session.py
|
||||
app/models/__init__.py
|
||||
app/models/assumption.py
|
||||
app/models/mix.py
|
||||
app/models/product.py
|
||||
app/models/raw_material.py
|
||||
app/models/scenario.py
|
||||
app/schemas/__init__.py
|
||||
app/schemas/mix.py
|
||||
app/schemas/product.py
|
||||
app/schemas/raw_material.py
|
||||
app/schemas/scenario.py
|
||||
app/services/__init__.py
|
||||
app/services/costing_engine.py
|
||||
app/services/scenario_engine.py
|
||||
data_entry_app_backend.egg-info/PKG-INFO
|
||||
data_entry_app_backend.egg-info/SOURCES.txt
|
||||
data_entry_app_backend.egg-info/dependency_links.txt
|
||||
data_entry_app_backend.egg-info/requires.txt
|
||||
data_entry_app_backend.egg-info/top_level.txt
|
||||
tests/test_access.py
|
||||
tests/test_costing_engine.py
|
||||
@@ -1,5 +1,8 @@
|
||||
fastapi<1.0,>=0.115
|
||||
openpyxl<4.0,>=3.1
|
||||
uvicorn[standard]<1.0,>=0.30
|
||||
sqlalchemy<3.0,>=2.0
|
||||
pydantic<3.0,>=2.8
|
||||
pytest<9.0,>=8.0
|
||||
psycopg[binary]<4.0,>=3.2
|
||||
reportlab<5.0,>=4.2
|
||||
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"pydantic>=2.8,<3.0",
|
||||
"pytest>=8.0,<9.0",
|
||||
"psycopg[binary]>=3.2,<4.0",
|
||||
"reportlab>=4.2,<5.0",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -344,6 +344,41 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
|
||||
assert operator_detail_response.status_code == 404
|
||||
|
||||
|
||||
def test_mix_calculator_pdf_endpoint_returns_pdf():
|
||||
with TestClient(app) as client:
|
||||
superadmin_login = client.post(
|
||||
"/api/auth/client/login",
|
||||
json={"email": settings.client_email, "password": settings.client_password},
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {superadmin_login.json()['token']}"}
|
||||
|
||||
options_response = client.get("/api/mix-calculator/options", headers=headers)
|
||||
seeded_product = next(
|
||||
product for product in options_response.json()["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg"
|
||||
)
|
||||
|
||||
create_response = client.post(
|
||||
"/api/mix-calculator",
|
||||
json={
|
||||
"mix_date": "2026-04-29",
|
||||
"client_name": seeded_product["client_name"],
|
||||
"product_id": seeded_product["product_id"],
|
||||
"batch_size_kg": 560,
|
||||
"prepared_by_name": "Amelia Hart",
|
||||
"notes": "Morning production run",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
created = create_response.json()
|
||||
|
||||
pdf_response = client.get(f"/api/mix-calculator/{created['id']}/pdf", headers=headers)
|
||||
|
||||
assert pdf_response.status_code == 200
|
||||
assert pdf_response.headers["content-type"] == "application/pdf"
|
||||
assert "attachment;" in pdf_response.headers["content-disposition"]
|
||||
assert pdf_response.content.startswith(b"%PDF")
|
||||
|
||||
|
||||
def test_module_permission_blocks_client_module_access():
|
||||
with TestClient(app) as client:
|
||||
admin_login_response = client.post(
|
||||
|
||||
Reference in New Issue
Block a user