This commit is contained in:
2026-05-31 20:19:44 +12:00
parent 2f2466ecac
commit 84792c0947
59 changed files with 5412 additions and 898 deletions
+83 -3
View File
@@ -7,6 +7,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.access import router as access_router
from app.core.access import (
INTERNAL_USER_SUBJECT,
get_user_permissions,
@@ -15,7 +16,8 @@ from app.core.access import (
require_permission,
user_has_permission,
)
from app.core.security import issue_token
from app.core.config import settings
from app.core.security import issue_token, verify_password
from app.db.session import Base, get_db
from app.models.access import Permission, Role, User
from app.seed_access import PERMISSION_DEFINITIONS, ROLE_DEFINITIONS, SEED_USERS, seed_access
@@ -42,6 +44,10 @@ def test_seed_creates_roles_permissions_and_users():
assert {role.name for role in db.query(Role).all()} == set(ROLE_DEFINITIONS.keys())
assert {p.key for p in db.query(Permission).all()} == {key for key, _ in PERMISSION_DEFINITIONS}
assert {user.email for user in db.query(User).all()} == {entry["email"] for entry in SEED_USERS}
for user in db.query(User).all():
assert user.password_hash is not None
assert user.password_hash != settings.admin_password
assert verify_password(settings.admin_password, user.password_hash)
def test_seed_is_idempotent():
@@ -71,14 +77,20 @@ def test_admin_role_permissions_match_spec():
assert "edit_mixes" not in granted
def test_operations_role_is_mix_calculator_only():
def test_operations_role_is_mix_calculator_and_throughput_only():
db = _build_session()
seed_access(db)
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
granted = get_user_permissions(ops)
assert granted == {"view_mix_calculator", "use_mix_calculator", "save_mix_calculator_session"}
assert granted == {
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
"view_throughput",
"edit_throughput",
}
assert not user_has_permission(ops, "edit_raw_materials")
assert not user_has_permission(ops, "view_dashboard")
assert not user_has_permission(ops, "manage_users")
@@ -158,6 +170,22 @@ def _token_for(user: User) -> str:
return issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email})
@pytest.fixture()
def access_app_and_db():
db = _build_session()
seed_access(db)
db.commit()
app = FastAPI()
def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
app.include_router(access_router)
return TestClient(app), db
def test_route_allows_user_with_permission(app_and_db):
client, db = app_and_db
craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one()
@@ -234,3 +262,55 @@ def test_require_all_permissions(app_and_db):
denied = client.get("/needs-all", headers={"Authorization": f"Bearer {_token_for(ops)}"})
assert denied.status_code == 403
def test_internal_login_uses_user_password_hash(access_app_and_db):
client, db = access_app_and_db
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
admin.password_hash = issue_token({"not": "a password"})
db.commit()
denied = client.post(
"/api/access/login",
json={"email": admin.email, "password": settings.admin_password},
)
assert denied.status_code == 401
def test_internal_user_can_change_own_password(access_app_and_db):
client, db = access_app_and_db
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
login_response = client.post(
"/api/access/login",
json={"email": admin.email, "password": settings.admin_password},
)
assert login_response.status_code == 200
update_response = client.patch(
"/api/access/me",
json={
"current_password": settings.admin_password,
"new_password": "new-personal-password",
},
cookies=login_response.cookies,
)
assert update_response.status_code == 200
db.refresh(admin)
assert admin.password_hash is not None
assert verify_password("new-personal-password", admin.password_hash)
assert not verify_password(settings.admin_password, admin.password_hash)
old_login = client.post(
"/api/access/login",
json={"email": admin.email, "password": settings.admin_password},
)
assert old_login.status_code == 401
new_login = client.post(
"/api/access/login",
json={"email": admin.email, "password": "new-personal-password"},
)
assert new_login.status_code == 200
+106 -7
View File
@@ -1,7 +1,7 @@
from datetime import date
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, inspect, text
from sqlalchemy import create_engine, inspect, select, text
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
@@ -13,7 +13,7 @@ from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCos
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser
from app.schemas.mix_calculator import MixCalculatorSessionCreate
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.services.client_access_service import build_client_access_export, ensure_user_module_permissions, serialize_client_account
from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost, serialize_raw_material
@@ -97,12 +97,13 @@ def test_mix_and_product_cost_breakdown():
assert product_result["wholesale_price"] == 17.3268
def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags():
def test_mix_calculator_preview_prefers_product_specific_ingredients_and_warns_on_fractional_bags():
db = build_session()
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
db.add_all([maize, barley])
wheat = RawMaterial(name="Wheat", unit_of_measure="tonne", kg_per_unit=1000, status="active")
db.add_all([maize, barley, wheat])
db.flush()
mix = Mix(tenant_id="specialty-feeds", client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1)
@@ -128,6 +129,25 @@ def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags():
bagging_process="standard_bagging",
)
db.add(product)
db.flush()
db.add_all(
[
ProductIngredient(
tenant_id="specialty-feeds",
product_id=product.id,
raw_material_id=maize.id,
quantity_kg=300,
sort_order=1,
),
ProductIngredient(
tenant_id="specialty-feeds",
product_id=product.id,
raw_material_id=wheat.id,
quantity_kg=200,
sort_order=2,
),
]
)
db.commit()
preview = calculate_mix_calculator_preview(
@@ -145,8 +165,9 @@ def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags():
assert preview["batch_size_kg"] == 550
assert preview["total_bags"] == 27.5
assert preview["lines"][0]["required_kg"] == 353.5714
assert preview["lines"][1]["required_kg"] == 196.4286
assert [line["raw_material_name"] for line in preview["lines"]] == ["Maize", "Wheat"]
assert preview["lines"][0]["required_kg"] == 330
assert preview["lines"][1]["required_kg"] == 220
assert len(preview["warnings"]) == 1
assert "not a whole-bag quantity" in preview["warnings"][0]
@@ -155,7 +176,8 @@ def test_mix_calculator_options_hide_invisible_products_and_clients():
db = build_session()
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
db.add(maize)
barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
db.add_all([maize, barley])
db.flush()
visible_mix = Mix(tenant_id="hunter-premium-produce", client_name="Peckish", name="Visible Mix", status="active", version=1)
@@ -197,12 +219,89 @@ def test_mix_calculator_options_hide_invisible_products_and_clients():
),
]
)
db.flush()
visible_product = db.scalar(select(Product).where(Product.name == "Visible Product"))
assert visible_product is not None
db.add_all(
[
ProductIngredient(
tenant_id="hunter-premium-produce",
product_id=visible_product.id,
raw_material_id=maize.id,
quantity_kg=12,
sort_order=1,
),
ProductIngredient(
tenant_id="hunter-premium-produce",
product_id=visible_product.id,
raw_material_id=barley.id,
quantity_kg=8,
sort_order=2,
),
]
)
db.commit()
options = build_mix_calculator_options(db, tenant_id="hunter-premium-produce")
assert options["clients"] == ["Peckish"]
assert [product["product_name"] for product in options["products"]] == ["Visible Product"]
assert options["products"][0]["mix_total_kg"] == 20
def test_calculate_product_cost_prefers_product_specific_ingredients():
db = build_session()
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
maize.price_versions.append(RawMaterialPriceVersion(market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1)))
barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
barley.price_versions.append(RawMaterialPriceVersion(market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1)))
wheat = RawMaterial(name="Wheat", unit_of_measure="tonne", kg_per_unit=1000, status="active")
wheat.price_versions.append(RawMaterialPriceVersion(market_value=600, waste_percentage=0.01, effective_date=date(2026, 4, 1)))
db.add_all([maize, barley, wheat])
db.flush()
mix = Mix(client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1)
db.add(mix)
db.flush()
db.add_all(
[
MixIngredient(mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180),
MixIngredient(mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100),
]
)
db.add(ProcessCostRule(process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0))
db.add(PackagingCostRule(sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63))
db.add(FreightCostRule(sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45))
db.flush()
product = Product(
client_name="Specialty Feeds",
name="Specialty Pigeon Breeder 20kg",
mix_id=mix.id,
sale_type="standard",
own_bag=False,
unit_of_measure="20kg bag",
items_per_pallet=50,
bagging_process="standard_bagging",
distributor_margin=0.225,
wholesale_margin=0.18,
)
db.add(product)
db.flush()
db.add_all(
[
ProductIngredient(product_id=product.id, raw_material_id=maize.id, quantity_kg=300, sort_order=1),
ProductIngredient(product_id=product.id, raw_material_id=wheat.id, quantity_kg=200, sort_order=2),
]
)
db.commit()
product_result = calculate_product_cost(db, product.id)
assert product_result["finished_product_delivered"] == 15.192
assert product_result["distributor_price"] == 19.6026
assert product_result["wholesale_price"] == 18.5268
def test_sync_product_visibility_hides_configured_clients():
+239
View File
@@ -0,0 +1,239 @@
from datetime import date
from io import BytesIO
from openpyxl import Workbook
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from app.db.session import Base
from app.models.mix import Mix
from app.models.product import Product
from app.models.raw_material import RawMaterial
from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.seed import seed_throughput_products_from_costing
from app.services.throughput_service import (
calculate_kg,
import_names_sheet,
import_production_sheet,
normalise_staff_name,
qa_passed,
)
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
def _costing_mix(db: Session, tenant_id: str = "hunter-premium-produce") -> Mix:
raw_material = RawMaterial(
tenant_id=tenant_id,
name="Maize",
unit_of_measure="tonne",
kg_per_unit=1000,
status="active",
)
db.add(raw_material)
db.flush()
mix = Mix(tenant_id=tenant_id, client_name="Hunter", name="Maize Mix")
db.add(mix)
db.flush()
return mix
def test_calculate_kg_bags():
assert calculate_kg(10, "bags", 20) == 200.0
def test_calculate_kg_kg_ignores_bag_size():
assert calculate_kg(550, "kg", None) == 550.0
assert calculate_kg(550, "kg", 20) == 550.0
def test_calculate_kg_zero_quantity():
assert calculate_kg(0, "bags", 20) == 0.0
assert calculate_kg(None, "bags", 20) == 0.0
def test_staff_name_normalisation():
assert normalise_staff_name(" Jake ") == "Jake"
assert normalise_staff_name("jake smith") == "jake smith"
assert normalise_staff_name("") is None
assert normalise_staff_name(None) is None
def test_qa_passed_flag():
entry = ProductionThroughput(
production_date=date(2026, 1, 1),
product_name_snapshot="X",
quantity=1,
quantity_type="bags",
scales_checked=True,
label_correct=True,
bag_sealed=True,
pallet_good_condition=True,
)
assert qa_passed(entry) is True
entry.bag_sealed = False
assert qa_passed(entry) is False
def _make_workbook() -> BytesIO:
wb = Workbook()
names = wb.active
names.title = "Names"
names.append(["Name", "Item ID"])
names.append(["Whole Wheat 20kg", 1001])
names.append(["Bulka Maize", 1002])
production = wb.create_sheet("Production")
production.append(["#VALUE!", "Operations Throughput"])
production.append([None] * 8 + ["TEST WEIGHT"])
production.append([
"DATE", "GRAIN", "BAG SIZE", "SCALES", "LABEL", "SEALED", "PALLET",
"BOX", 1, 2, 3, 4, 5, "QTY", "STAFF", "NOTES",
])
production.append([date(2026, 4, 1), "Whole Wheat 20kg", 20, True, True, True, True, None, None, None, None, None, None, 100, " Jake ", None])
production.append([date(2026, 4, 1), "Bulka Maize", None, True, True, False, True, "B7", None, None, None, None, None, 1500, "Alex", "ok"])
production.append([date(2026, 4, 2), "Whole Wheat 20kg", 20, False, True, True, True, None, None, None, None, None, None, 50, "Jake", None])
buf = BytesIO()
wb.save(buf)
buf.seek(0)
return buf
def test_import_names_and_production():
from openpyxl import load_workbook
db = _session()
wb = load_workbook(_make_workbook(), data_only=True)
created, _ = import_names_sheet(db, wb, "test-tenant")
assert created == 2
imported, skipped = import_production_sheet(db, wb, "test-tenant")
assert imported == 3
assert skipped == 0
entries = db.scalars(select(ProductionThroughput).order_by(ProductionThroughput.id)).all()
bags_entry = entries[0]
assert bags_entry.quantity_type == "bags"
assert bags_entry.calculated_kg == 2000.0
assert bags_entry.staff_name == "Jake" # whitespace trimmed
bulka_entry = entries[1]
assert bulka_entry.quantity_type == "kg"
assert bulka_entry.calculated_kg == 1500.0
assert qa_passed(bulka_entry) is False # bag_sealed was False
# Product master should have absorbed default_bag_size for the wheat product
wheat = db.scalar(
select(ThroughputProduct).where(ThroughputProduct.name == "Whole Wheat 20kg")
)
assert wheat is not None
assert wheat.default_bag_size == 20
def test_product_name_snapshot_preserved_when_product_renamed():
db = _session()
product = ThroughputProduct(tenant_id="t", name="Original Name", default_bag_size=20)
db.add(product)
db.flush()
entry = ProductionThroughput(
tenant_id="t",
production_date=date(2026, 4, 1),
product_id=product.id,
product_name_snapshot=product.name,
bag_size=20,
quantity=10,
quantity_type="bags",
calculated_kg=200,
)
db.add(entry)
db.flush()
product.name = "Renamed Product"
db.flush()
reloaded = db.scalar(select(ProductionThroughput).where(ProductionThroughput.id == entry.id))
assert reloaded.product_name_snapshot == "Original Name"
def test_seed_throughput_products_from_costing_products():
db = _session()
mix = _costing_mix(db)
db.add_all(
[
Product(
tenant_id="hunter-premium-produce",
client_name="Hunter",
item_id="1001",
name="Whole Wheat 20kg",
mix_id=mix.id,
sale_type="standard",
unit_of_measure="20kg bag",
visible=True,
),
Product(
tenant_id="hunter-premium-produce",
client_name="Hunter",
item_id="1002",
name="Bulka Maize",
mix_id=mix.id,
sale_type="bulka",
unit_of_measure="tonne",
visible=False,
),
]
)
db.flush()
report = seed_throughput_products_from_costing(db)
assert report == {"created": 2, "updated": 0, "skipped": 0}
products = db.scalars(select(ThroughputProduct).order_by(ThroughputProduct.item_id)).all()
assert [product.name for product in products] == ["Whole Wheat 20kg", "Bulka Maize"]
assert products[0].default_bag_size == 20
assert products[0].is_bulka_default is False
assert products[0].active is True
assert products[1].default_bag_size is None
assert products[1].is_bulka_default is True
assert products[1].active is False
def test_seed_throughput_products_from_costing_updates_existing_by_item_id():
db = _session()
mix = _costing_mix(db)
db.add(
Product(
tenant_id="hunter-premium-produce",
client_name="Hunter",
item_id="1001",
name="Updated Wheat 25kg",
mix_id=mix.id,
sale_type="standard",
unit_of_measure="25kg bag",
visible=True,
)
)
db.add(
ThroughputProduct(
tenant_id="hunter-premium-produce",
item_id="1001",
name="Old Wheat",
default_bag_size=20,
active=False,
notes="Seeded from costing products",
)
)
db.flush()
report = seed_throughput_products_from_costing(db)
assert report == {"created": 0, "updated": 1, "skipped": 0}
products = db.scalars(select(ThroughputProduct)).all()
assert len(products) == 1
assert products[0].name == "Updated Wheat 25kg"
assert products[0].default_bag_size == 25
assert products[0].active is True