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:
2026-06-17 21:55:04 +12:00
parent 7db95e2027
commit 3f8279af10
24 changed files with 3820 additions and 37 deletions
+204
View File
@@ -347,3 +347,207 @@ def test_internal_user_can_change_own_password(access_app_and_db):
json={"email": admin.email, "password": "new-personal-password"},
)
assert new_login.status_code == 200
# --- Admin user management --------------------------------------------------
def _admin_headers(db: Session) -> dict[str, str]:
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
return {"Authorization": f"Bearer {_token_for(admin)}"}
def test_manage_users_create_update_password_delete(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
full_access_role = db.query(Role).filter_by(name="Full Access").one()
created = client.post(
"/api/access/users",
json={"email": "new.user@hunterstockfeeds.com", "name": "New User", "role_id": full_access_role.id},
headers=headers,
)
assert created.status_code == 201
body = created.json()
assert body["email"] == "new.user@hunterstockfeeds.com"
assert body["role"] == "Full Access"
assert body["is_protected"] is False
user_id = body["id"]
operations_role = db.query(Role).filter_by(name="Operations").one()
updated = client.patch(
f"/api/access/users/{user_id}",
json={"name": "Renamed", "role_id": operations_role.id, "is_active": False},
headers=headers,
)
assert updated.status_code == 200
assert updated.json()["name"] == "Renamed"
assert updated.json()["role"] == "Operations"
assert updated.json()["is_active"] is False
pw = client.post(
f"/api/access/users/{user_id}/password",
json={"new_password": "brand-new-pass"},
headers=headers,
)
assert pw.status_code == 200
db.expire_all()
target = db.query(User).filter_by(id=user_id).one()
assert verify_password("brand-new-pass", target.password_hash)
deleted = client.delete(f"/api/access/users/{user_id}", headers=headers)
assert deleted.status_code == 204
assert db.query(User).filter_by(id=user_id).one_or_none() is None
def test_create_user_rejects_duplicate_email(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
response = client.post(
"/api/access/users",
json={"email": "admin@hunterstockfeeds.com", "name": "Dup"},
headers=headers,
)
assert response.status_code == 409
def test_manage_users_requires_permission(access_app_and_db):
client, db = access_app_and_db
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
headers = {"Authorization": f"Bearer {_token_for(ops)}"}
response = client.post(
"/api/access/users",
json={"email": "x@hunterstockfeeds.com", "name": "X"},
headers=headers,
)
assert response.status_code == 403
def test_cannot_deactivate_or_delete_self(access_app_and_db):
client, db = access_app_and_db
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
headers = {"Authorization": f"Bearer {_token_for(admin)}"}
deactivate = client.patch(
f"/api/access/users/{admin.id}", json={"is_active": False}, headers=headers
)
assert deactivate.status_code == 400
delete = client.delete(f"/api/access/users/{admin.id}", headers=headers)
assert delete.status_code == 400
def test_lean_users_cannot_be_deleted(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
lean_role = db.query(Role).filter_by(name="lean").one()
lean_user = User(email="owner@hunterstockfeeds.com", name="Owner", role_id=lean_role.id, is_active=True)
db.add(lean_user)
db.commit()
listed = client.get("/api/access/users", headers=headers)
assert listed.status_code == 200
owner_row = next(row for row in listed.json() if row["id"] == lean_user.id)
assert owner_row["is_protected"] is True
response = client.delete(f"/api/access/users/{lean_user.id}", headers=headers)
assert response.status_code == 403
assert db.query(User).filter_by(id=lean_user.id).one_or_none() is not None
def test_assignable_roles_lists_all_roles(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
response = client.get("/api/access/assignable-roles", headers=headers)
assert response.status_code == 200
names = {row["name"] for row in response.json()}
assert names == set(ROLE_DEFINITIONS.keys())
def test_role_management_lists_modules_and_roles_for_admin(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
modules = client.get("/api/access/role-modules", headers=headers)
assert modules.status_code == 200
module_keys = {row["key"] for row in modules.json()}
assert {"dashboard", "mix_calculator", "roles", "settings"} <= module_keys
roles = client.get("/api/access/roles", headers=headers)
assert roles.status_code == 200
admin_role = next(row for row in roles.json() if row["name"] == "Admin")
assert admin_role["is_protected"] is True
assert admin_role["module_permissions"]["ordering"] == "manage"
assert admin_role["module_permissions"]["roles"] == "manage"
def test_role_management_is_blocked_for_non_admin_non_lean_roles(access_app_and_db):
client, db = access_app_and_db
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
headers = {"Authorization": f"Bearer {_token_for(ops)}"}
response = client.get("/api/access/roles", headers=headers)
assert response.status_code == 403
assert "lean and admin" in response.json()["detail"]
def test_role_management_create_update_delete_custom_role(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
created = client.post(
"/api/access/roles",
json={
"name": "Reporting Viewer",
"description": "Can review dashboards and reporting inputs",
"module_permissions": {
"dashboard": "view",
"products": "view",
"settings": "view",
},
},
headers=headers,
)
assert created.status_code == 201
created_body = created.json()
assert created_body["module_permissions"]["dashboard"] == "view"
assert created_body["module_permissions"]["products"] == "view"
assert created_body["module_permissions"]["settings"] == "view"
role_id = created_body["id"]
updated = client.patch(
f"/api/access/roles/{role_id}",
json={
"description": "Can review and edit product data",
"module_permissions": {
"dashboard": "view",
"products": "edit",
"settings": "view",
},
},
headers=headers,
)
assert updated.status_code == 200
updated_body = updated.json()
assert updated_body["module_permissions"]["products"] == "edit"
assert "edit_products" in updated_body["permissions"]
deleted = client.delete(f"/api/access/roles/{role_id}", headers=headers)
assert deleted.status_code == 204
assert db.query(Role).filter_by(id=role_id).one_or_none() is None
def test_protected_or_assigned_roles_cannot_be_deleted(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
admin_role = db.query(Role).filter_by(name="Admin").one()
protected = client.delete(f"/api/access/roles/{admin_role.id}", headers=headers)
assert protected.status_code == 403
full_access_role = db.query(Role).filter_by(name="Full Access").one()
assigned = client.delete(f"/api/access/roles/{full_access_role.id}", headers=headers)
assert assigned.status_code == 400
+88
View File
@@ -0,0 +1,88 @@
"""The change log records who edited a mix/ingredient and what changed.
Covers `record_change` / `diff_fields` / `list_changes`: edits are stored with a
field-level before/after diff and read back newest-first per entity.
"""
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.api.deps import AuthSession
from app.db.session import Base
from app.services.change_log import (
ENTITY_INGREDIENT,
ENTITY_MIX,
diff_fields,
list_changes,
record_change,
)
TENANT = "hunter-premium-produce"
LABELS = {"name": "Name", "kg_per_unit": "Kg per unit", "status": "Status"}
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
def _actor() -> AuthSession:
return AuthSession(role="internal", email="lara@hunter.test", name="Lara", tenant_id=TENANT, client_role="admin")
def test_diff_fields_only_emits_changed_keys():
deltas = diff_fields({"name": "Maize", "kg_per_unit": 25.0}, {"name": "Maize", "kg_per_unit": 30.0}, LABELS)
assert len(deltas) == 1
assert deltas[0]["field"] == "kg_per_unit"
assert deltas[0]["before"] == "25"
assert deltas[0]["after"] == "30"
def test_records_and_lists_changes_newest_first():
db = _session()
session = _actor()
record_change(
db,
session=session,
entity_type=ENTITY_INGREDIENT,
entity_id=7,
action="created",
summary="Created ingredient “Maize”",
)
record_change(
db,
session=session,
entity_type=ENTITY_INGREDIENT,
entity_id=7,
action="updated",
summary="Updated Kg per unit",
changes=diff_fields({"kg_per_unit": 25.0}, {"kg_per_unit": 30.0}, LABELS),
)
# A different entity must not leak into entity 7's history.
record_change(db, session=session, entity_type=ENTITY_MIX, entity_id=7, action="created", summary="Created mix")
db.commit()
events = list_changes(db, tenant_id=TENANT, entity_type=ENTITY_INGREDIENT, entity_id=7)
assert [event.action for event in events] == ["updated", "created"]
assert events[0].actor_name == "Lara"
assert events[0].actor_role == "admin"
assert events[0].changes[0]["label"] == "Kg per unit"
def test_changes_are_tenant_scoped():
db = _session()
record_change(
db,
session=AuthSession(role="internal", email="x@y.test", name="X", tenant_id="other-tenant"),
entity_type=ENTITY_MIX,
entity_id=1,
action="created",
summary="Created mix",
)
db.commit()
assert list_changes(db, tenant_id=TENANT, entity_type=ENTITY_MIX, entity_id=1) == []
+54
View File
@@ -266,6 +266,60 @@ def test_upload_import_keeps_blank_destination_flags_false():
assert entry.job_number is None
def test_upload_import_detects_month_first_dates_consistently():
# The pasted sheet is US month-first (M/D/Y). "9/23/2025" is unambiguous, so
# the ambiguous "12/9/2025" must follow the same convention: 9 December, not
# 12 September (which the old day-first-by-default parser produced).
db = _session()
csv_bytes = (
"Date,Product,Item ID,Quantity,Type,Bag Size,Packed By\n"
"12/9/2025,Whole Wheat Cleaned & Graded 20kg,373022,156,bags,20,Jake\n"
"9/23/2025,Steam Rolled Barley 20kg,568240,34,bags,20,jake\n"
"9/24/2025,Stock Mix 20kg,540725,153,bags,20,jake\n"
).encode("utf-8")
result = import_entries_from_file(
db,
filename="throughput-import.csv",
content=csv_bytes,
tenant_id="test-tenant",
created_by="tester@example.com",
)
assert result["entries_imported"] == 3
dates = {
e.product_name_snapshot: e.production_date
for e in db.scalars(select(ProductionThroughput)).all()
}
assert dates["Whole Wheat Cleaned & Graded 20kg"] == date(2025, 12, 9)
assert dates["Steam Rolled Barley 20kg"] == date(2025, 9, 23)
assert dates["Stock Mix 20kg"] == date(2025, 9, 24)
def test_upload_import_keeps_day_first_dates_for_australian_sheets():
# A genuinely day-first file (23/9/2025 proves D/M/Y) must stay day-first, so
# 12/9/2025 reads as 12 September.
db = _session()
csv_bytes = (
"Date,Product,Quantity,Type,Bag Size\n"
"23/9/2025,Stock Mix 20kg,10,bags,20\n"
"12/9/2025,Stock Mix 20kg,10,bags,20\n"
).encode("utf-8")
import_entries_from_file(
db,
filename="throughput-import.csv",
content=csv_bytes,
tenant_id="test-tenant",
created_by="tester@example.com",
)
produced = sorted(
e.production_date for e in db.scalars(select(ProductionThroughput)).all()
)
assert produced == [date(2025, 9, 12), date(2025, 9, 23)]
def test_upload_import_does_not_treat_unknown_destination_text_as_true():
db = _session()
csv_bytes = (