From 3f8279af108fabb6246ebc8de07c55d9bfb916b9 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Wed, 17 Jun 2026 21:55:04 +1200 Subject: [PATCH] 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 --- backend/app/api/access.py | 566 +++++++++++- backend/app/api/editor.py | 212 ++++- backend/app/api/throughput.py | 16 + backend/app/db/migrations.py | 1 + backend/app/models/__init__.py | 2 + backend/app/models/change_event.py | 34 + backend/app/schemas/editor.py | 23 + backend/app/schemas/throughput.py | 4 + backend/app/services/change_log.py | 97 ++ backend/app/services/throughput_service.py | 47 +- backend/pyproject.toml | 6 +- backend/tests/test_access.py | 204 +++++ backend/tests/test_change_log.py | 88 ++ backend/tests/test_throughput.py | 54 ++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/lib/api.ts | 54 ++ .../editor/ChangeHistoryModal.svelte | 353 ++++++++ .../settings/RoleManagementPanel.svelte | 792 ++++++++++++++++ .../settings/UserManagementPanel.svelte | 851 ++++++++++++++++++ frontend/src/lib/types.ts | 87 ++ frontend/src/routes/editor/+page.svelte | 20 +- frontend/src/routes/ingredients/+page.svelte | 19 +- frontend/src/routes/settings/+page.svelte | 321 ++++++- 24 files changed, 3820 insertions(+), 37 deletions(-) create mode 100644 backend/app/models/change_event.py create mode 100644 backend/app/services/change_log.py create mode 100644 backend/tests/test_change_log.py create mode 100644 frontend/src/lib/components/editor/ChangeHistoryModal.svelte create mode 100644 frontend/src/lib/components/settings/RoleManagementPanel.svelte create mode 100644 frontend/src/lib/components/settings/UserManagementPanel.svelte diff --git a/backend/app/api/access.py b/backend/app/api/access.py index ea88bef..96fe1ba 100644 --- a/backend/app/api/access.py +++ b/backend/app/api/access.py @@ -9,7 +9,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session, selectinload from app.core.access import ( @@ -65,6 +65,9 @@ class RoleRead(BaseModel): name: str description: str | None permissions: list[str] + module_permissions: dict[str, str] + is_protected: bool = False + user_count: int = 0 class UserRead(BaseModel): @@ -73,6 +76,273 @@ class UserRead(BaseModel): name: str is_active: bool role: str | None + role_id: int | None + # True when this user can never be deleted (lean owner accounts). The UI + # uses this to disable the delete control rather than re-deriving the rule. + is_protected: bool = False + + +class AssignableRole(BaseModel): + id: int + name: str + description: str | None + + +class RoleModuleDefinition(BaseModel): + key: str + label: str + description: str + levels: list[str] + + +class CreateUserRequest(BaseModel): + email: str + name: str + role_id: int | None = None + is_active: bool = True + password: str | None = None + + +class AdminUpdateUserRequest(BaseModel): + name: str | None = None + email: str | None = None + role_id: int | None = None + is_active: bool | None = None + + +class AdminSetPasswordRequest(BaseModel): + new_password: str + + +class CreateRoleRequest(BaseModel): + name: str + description: str | None = None + module_permissions: dict[str, str] = {} + + +class UpdateRoleRequest(BaseModel): + name: str | None = None + description: str | None = None + module_permissions: dict[str, str] | None = None + + +# Lean owner accounts are permanent: they may be edited but never deleted, so a +# tenant can't accidentally lock itself out of the highest level of access. +LEAN_ROLE_NAME = "lean" +ADMIN_ROLE_NAME = "admin" +ROLE_MANAGEMENT_ALLOWED_ROLES = {LEAN_ROLE_NAME, ADMIN_ROLE_NAME} +PROTECTED_ROLE_NAMES = ROLE_MANAGEMENT_ALLOWED_ROLES + +ROLE_MODULE_DEFINITIONS: tuple[dict[str, object], ...] = ( + { + "key": "dashboard", + "label": "Dashboard", + "description": "Home dashboard visibility.", + "levels": {"none": (), "view": ("view_dashboard",)}, + }, + { + "key": "mix_calculator", + "label": "Mix Calculator", + "description": "Open the calculator and save sessions.", + "levels": { + "none": (), + "view": ("view_mix_calculator",), + "edit": ("view_mix_calculator", "use_mix_calculator", "save_mix_calculator_session"), + }, + }, + { + "key": "raw_materials", + "label": "Raw Materials", + "description": "View or edit raw materials.", + "levels": {"none": (), "view": ("view_raw_materials",), "edit": ("view_raw_materials", "edit_raw_materials")}, + }, + { + "key": "products", + "label": "Products", + "description": "View or edit finished products.", + "levels": {"none": (), "view": ("view_products",), "edit": ("view_products", "edit_products")}, + }, + { + "key": "mix_master", + "label": "Mix Master", + "description": "View or edit mix recipes.", + "levels": {"none": (), "view": ("view_mixes",), "edit": ("view_mixes", "edit_mixes")}, + }, + { + "key": "operations_throughput", + "label": "Throughput", + "description": "View or edit throughput entries.", + "levels": {"none": (), "view": ("view_throughput",), "edit": ("view_throughput", "edit_throughput")}, + }, + { + "key": "ordering", + "label": "Ordering", + "description": "Access customer ordering and ordering administration.", + "levels": { + "none": (), + "view": ("view_ordering",), + "edit": ("view_ordering", "edit_ordering"), + "manage": ("view_ordering", "edit_ordering", "manage_ordering"), + }, + }, + { + "key": "scenarios", + "label": "Scenarios", + "description": "View or run scenarios.", + "levels": {"none": (), "view": ("view_scenarios",), "edit": ("view_scenarios", "edit_scenarios")}, + }, + { + "key": "client_access", + "label": "Client Access", + "description": "Manage customer portal accounts and access.", + "levels": {"none": (), "manage": ("manage_client_access",)}, + }, + { + "key": "users", + "label": "Users", + "description": "View or manage internal users.", + "levels": {"none": (), "view": ("view_users",), "manage": ("view_users", "manage_users")}, + }, + { + "key": "roles", + "label": "Roles", + "description": "Manage roles and permission assignments.", + "levels": {"none": (), "manage": ("manage_permissions",)}, + }, + { + "key": "settings", + "label": "Settings", + "description": "Open settings and edit system configuration.", + "levels": {"none": (), "view": ("view_settings",), "edit": ("view_settings", "edit_settings")}, + }, +) + + +def _serialize_user_read(user: User) -> UserRead: + role_name = user.role.name if user.role else None + return UserRead( + id=user.id, + email=user.email, + name=user.name, + is_active=user.is_active, + role=role_name, + role_id=user.role_id, + is_protected=(role_name or "").lower() == LEAN_ROLE_NAME, + ) + + +def _role_name_lower(role: Role | None) -> str: + return (role.name if role else "").strip().lower() + + +def _is_protected_role_name(role_name: str | None) -> bool: + return (role_name or "").strip().lower() in PROTECTED_ROLE_NAMES + + +def _module_definitions_response() -> list[RoleModuleDefinition]: + return [ + RoleModuleDefinition( + key=definition["key"], + label=definition["label"], + description=definition["description"], + levels=list(definition["levels"].keys()), + ) + for definition in ROLE_MODULE_DEFINITIONS + ] + + +def _permissions_to_role_module_map(permission_keys: set[str]) -> dict[str, str]: + result: dict[str, str] = {} + for definition in ROLE_MODULE_DEFINITIONS: + selected = "none" + levels = definition["levels"] + for level, required in levels.items(): + required_keys = set(required) + if not required_keys or required_keys.issubset(permission_keys): + selected = level + result[definition["key"]] = selected + return result + + +def _role_payload_to_permission_keys(module_permissions: dict[str, str]) -> set[str]: + known_modules = {definition["key"] for definition in ROLE_MODULE_DEFINITIONS} + unknown_modules = sorted(set(module_permissions) - known_modules) + if unknown_modules: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unknown modules: {unknown_modules}", + ) + + granted: set[str] = set() + for definition in ROLE_MODULE_DEFINITIONS: + key = definition["key"] + level = module_permissions.get(key, "none") + available_levels: dict[str, tuple[str, ...]] = definition["levels"] # type: ignore[assignment] + if level not in available_levels: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid access level '{level}' for module '{key}'", + ) + granted.update(available_levels[level]) + return granted + + +def _serialize_role_read(role: Role, *, user_count: int = 0) -> RoleRead: + permission_keys = {permission.key for permission in role.permissions} + return RoleRead( + id=role.id, + name=role.name, + description=role.description, + permissions=sorted(permission_keys), + module_permissions=_permissions_to_role_module_map(permission_keys), + is_protected=_is_protected_role_name(role.name), + user_count=user_count, + ) + + +def _require_role_management_actor(user: User = Depends(get_current_user)) -> User: + if _role_name_lower(user.role) not in ROLE_MANAGEMENT_ALLOWED_ROLES: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only lean and admin accounts can manage roles", + ) + return user + + +def _load_role(db: Session, role_id: int) -> Role: + role = db.scalar( + select(Role).where(Role.id == role_id).options(selectinload(Role.permissions)) + ) + if role is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") + return role + + +def _apply_role_updates( + role: Role, + *, + name: str | None, + description: str | None, + module_permissions: dict[str, str] | None, + permissions_by_key: dict[str, Permission], +) -> None: + if name is not None: + trimmed_name = name.strip() + if not trimmed_name: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Role name cannot be empty") + role.name = trimmed_name + + if description is not None: + role.description = description.strip() or None + + if module_permissions is not None: + desired_permission_keys = _role_payload_to_permission_keys(module_permissions) + desired = {permissions_by_key[key] for key in desired_permission_keys} + current = set(role.permissions) + for permission in desired - current: + role.permissions.append(permission) + for permission in current - desired: + role.permissions.remove(permission) def _serialize_session(user: User, *, include_token: bool = False) -> UserSession: @@ -216,36 +486,294 @@ def list_users( db: Session = Depends(get_db), _: User = Depends(require_permission("view_users")), # gated by permission key ): - users = db.scalars(select(User).options(selectinload(User.role))).all() + users = db.scalars( + select(User).options(selectinload(User.role)).order_by(User.name) + ).all() + return [_serialize_user_read(user) for user in users] + + +@router.get("/assignable-roles", response_model=list[AssignableRole]) +def list_assignable_roles( + db: Session = Depends(get_db), + _: User = Depends(require_permission("manage_users")), # gated by permission key +): + """Roles that a user-manager can assign — used to populate the role picker. + + Separate from ``/roles`` (which exposes full permission sets and is gated by + ``manage_permissions``); managing users only needs the role list itself. + """ + roles = db.scalars(select(Role).order_by(Role.name)).all() return [ - UserRead( - id=user.id, - email=user.email, - name=user.name, - is_active=user.is_active, - role=user.role.name if user.role else None, - ) - for user in users + AssignableRole(id=role.id, name=role.name, description=role.description) + for role in roles ] +def _load_managed_user(db: Session, user_id: int) -> User: + user = db.scalar( + select(User).where(User.id == user_id).options(selectinload(User.role)) + ) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return user + + +def _resolve_role(db: Session, role_id: int | None) -> Role | None: + if role_id is None: + return None + role = db.scalar(select(Role).where(Role.id == role_id)) + if role is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unknown role") + return role + + +@router.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED) +def create_user( + payload: CreateUserRequest, + db: Session = Depends(get_db), + actor: User = Depends(require_permission("manage_users")), # gated by permission key +): + """Create a new internal user. + + A user with no password can still sign in with the shared internal password + until they set a personal one in their own settings. + """ + email = payload.email.strip().lower() + if not email or "@" not in email: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address") + name = payload.name.strip() + if not name: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty") + if db.scalar(select(User).where(User.email == email)): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use") + + role = _resolve_role(db, payload.role_id) + + password_hash = None + if payload.password: + if len(payload.password) < 8: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Password must be at least 8 characters", + ) + password_hash = hash_password(payload.password) + + user = User( + email=email, + name=name, + role_id=role.id if role else None, + is_active=payload.is_active, + password_hash=password_hash, + ) + db.add(user) + db.commit() + db.refresh(user) + log_security_event("users.created", audience="internal", actor_user_id=actor.id, user_id=user.id) + return _serialize_user_read(user) + + +@router.patch("/users/{user_id}", response_model=UserRead) +def update_user( + user_id: int, + payload: AdminUpdateUserRequest, + db: Session = Depends(get_db), + actor: User = Depends(require_permission("manage_users")), # gated by permission key +): + """Update another user's name, email, role, or active status.""" + user = _load_managed_user(db, user_id) + + if payload.is_active is not None: + # Guard against locking yourself out of your own management session. + if user.id == actor.id and not payload.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You cannot deactivate your own account", + ) + user.is_active = payload.is_active + + if payload.name is not None: + name = payload.name.strip() + if not name: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty") + user.name = name + + if payload.email is not None: + email = payload.email.strip().lower() + if not email or "@" not in email: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address") + existing = db.scalar(select(User).where(User.email == email, User.id != user.id)) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use") + user.email = email + + if payload.role_id is not None: + role = _resolve_role(db, payload.role_id) + user.role_id = role.id if role else None + + db.commit() + db.refresh(user) + log_security_event("users.updated", audience="internal", actor_user_id=actor.id, user_id=user.id) + return _serialize_user_read(user) + + +@router.post("/users/{user_id}/password", response_model=UserRead) +def set_user_password( + user_id: int, + payload: AdminSetPasswordRequest, + db: Session = Depends(get_db), + actor: User = Depends(require_permission("manage_users")), # gated by permission key +): + """Set (reset) another user's password without their current password.""" + user = _load_managed_user(db, user_id) + if len(payload.new_password) < 8: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="New password must be at least 8 characters", + ) + user.password_hash = hash_password(payload.new_password) + db.commit() + db.refresh(user) + log_security_event("users.password_reset", audience="internal", actor_user_id=actor.id, user_id=user.id) + return _serialize_user_read(user) + + +@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user( + user_id: int, + response: Response, + db: Session = Depends(get_db), + actor: User = Depends(require_permission("manage_users")), # gated by permission key +): + """Delete a user. Lean owner accounts and your own account are protected.""" + user = _load_managed_user(db, user_id) + if user.id == actor.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You cannot delete your own account", + ) + if (user.role.name if user.role else "").lower() == LEAN_ROLE_NAME: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Lean accounts cannot be deleted", + ) + db.delete(user) + db.commit() + log_security_event("users.deleted", audience="internal", actor_user_id=actor.id, user_id=user_id) + response.status_code = status.HTTP_204_NO_CONTENT + return None + + @router.get("/roles", response_model=list[RoleRead]) def list_roles( db: Session = Depends(get_db), - _: User = Depends(require_permission("manage_permissions")), # gated by permission key + _: User = Depends(_require_role_management_actor), ): + user_counts = dict( + db.execute(select(User.role_id, func.count(User.id)).group_by(User.role_id)).all() + ) roles = db.scalars( select(Role).options(selectinload(Role.permissions)).order_by(Role.name) ).all() - return [ - RoleRead( - id=role.id, - name=role.name, - description=role.description, - permissions=sorted(p.key for p in role.permissions), + return [_serialize_role_read(role, user_count=user_counts.get(role.id, 0)) for role in roles] + + +@router.get("/role-modules", response_model=list[RoleModuleDefinition]) +def list_role_modules(_: User = Depends(_require_role_management_actor)): + return _module_definitions_response() + + +@router.post("/roles", response_model=RoleRead, status_code=status.HTTP_201_CREATED) +def create_role( + payload: CreateRoleRequest, + db: Session = Depends(get_db), + actor: User = Depends(_require_role_management_actor), +): + name = payload.name.strip() + if not name: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Role name cannot be empty") + existing = db.scalar(select(Role).where(func.lower(Role.name) == name.lower())) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Role name already exists") + + permissions_by_key = {permission.key: permission for permission in db.scalars(select(Permission)).all()} + role = Role(name=name, description=None) + db.add(role) + db.flush() + _apply_role_updates( + role, + name=name, + description=payload.description, + module_permissions=payload.module_permissions, + permissions_by_key=permissions_by_key, + ) + db.commit() + db.refresh(role) + log_security_event("roles.created", audience="internal", actor_user_id=actor.id, role_id=role.id) + return _serialize_role_read(role, user_count=0) + + +@router.patch("/roles/{role_id}", response_model=RoleRead) +def update_role( + role_id: int, + payload: UpdateRoleRequest, + db: Session = Depends(get_db), + actor: User = Depends(_require_role_management_actor), +): + role = _load_role(db, role_id) + original_name = role.name + protected = _is_protected_role_name(original_name) + requested_name = payload.name.strip() if payload.name is not None else role.name + if protected and requested_name.lower() != original_name.lower(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Lean and admin roles cannot be renamed", ) - for role in roles - ] + if payload.name is not None: + duplicate = db.scalar( + select(Role).where(func.lower(Role.name) == requested_name.lower(), Role.id != role.id) + ) + if duplicate: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Role name already exists") + + permissions_by_key = {permission.key: permission for permission in db.scalars(select(Permission)).all()} + _apply_role_updates( + role, + name=payload.name, + description=payload.description, + module_permissions=payload.module_permissions, + permissions_by_key=permissions_by_key, + ) + db.commit() + db.refresh(role) + user_count = db.scalar(select(func.count(User.id)).where(User.role_id == role.id)) or 0 + log_security_event("roles.updated", audience="internal", actor_user_id=actor.id, role_id=role.id) + return _serialize_role_read(role, user_count=user_count) + + +@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_role( + role_id: int, + response: Response, + db: Session = Depends(get_db), + actor: User = Depends(_require_role_management_actor), +): + role = _load_role(db, role_id) + if _is_protected_role_name(role.name): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Lean and admin roles cannot be deleted", + ) + assigned_users = db.scalar(select(func.count(User.id)).where(User.role_id == role.id)) or 0 + if assigned_users > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Reassign users before deleting this role", + ) + db.delete(role) + db.commit() + log_security_event("roles.deleted", audience="internal", actor_user_id=actor.id, role_id=role_id) + response.status_code = status.HTTP_204_NO_CONTENT + return None @router.get("/permissions", response_model=list[str]) diff --git a/backend/app/api/editor.py b/backend/app/api/editor.py index eca13db..5843311 100644 --- a/backend/app/api/editor.py +++ b/backend/app/api/editor.py @@ -8,7 +8,9 @@ from app.db.session import get_db from app.models.mix import Mix, MixIngredient from app.models.product import Product, ProductIngredient from app.models.raw_material import RawMaterial +from app.models.change_event import EditorChangeEvent from app.schemas.editor import ( + EditorChangeEventRead, EditorIngredientCreate, EditorIngredientRow, EditorIngredientUpdate, @@ -25,6 +27,14 @@ from app.schemas.editor import ( EditorProductRow, EditorProductUpdate, EditorResolvedMixFormula, + EditorResolvedMixIngredient, +) +from app.services.change_log import ( + ENTITY_INGREDIENT, + ENTITY_MIX, + diff_fields, + list_changes, + record_change, ) from app.services.client_access_service import has_access_level from app.services.costing_engine import calculate_raw_material_cost, get_active_price @@ -126,6 +136,50 @@ def _serialize_mix_formula(mix: Mix) -> dict: } +def _serialize_change_event(event: EditorChangeEvent) -> dict: + return { + "id": event.id, + "entity_type": event.entity_type, + "entity_id": event.entity_id, + "action": event.action, + "actor_name": event.actor_name, + "actor_email": event.actor_email, + "actor_role": event.actor_role, + "summary": event.summary, + "changes": event.changes or [], + "created_at": event.created_at, + } + + +def _format_kg(value: float) -> str: + text = f"{value:.4f}".rstrip("0").rstrip(".") + return f"{text or '0'} kg" + + +def _formula_deltas( + before: list[EditorResolvedMixIngredient] | list, + after: list[EditorResolvedMixIngredient] | list, +) -> list[dict]: + """Per-ingredient before/after deltas between two resolved formulas.""" + before_map = {row.raw_material_name: row.quantity_kg for row in before} + after_map = {row.raw_material_name: row.quantity_kg for row in after} + deltas: list[dict] = [] + for name in sorted(set(before_map) | set(after_map)): + old = before_map.get(name) + new = after_map.get(name) + if old == new: + continue + deltas.append( + { + "field": name, + "label": name, + "before": _format_kg(old) if old is not None else None, + "after": _format_kg(new) if new is not None else None, + } + ) + return deltas + + def _load_editor_mix_formula(db: Session, *, mix_id: int, tenant_id: str) -> Mix | None: return db.scalar( select(Mix) @@ -267,6 +321,15 @@ def create_editor_mix( notes=payload.notes, ) db.add(mix) + db.flush() + record_change( + db, + session=session, + entity_type=ENTITY_MIX, + entity_id=mix.id, + action="created", + summary=f"Created mix “{mix.name}” for {mix.client_name}", + ) db.commit() db.refresh(mix) # A brand-new mix has no products yet, so it reads as Inactive (no visible products). @@ -288,6 +351,16 @@ def update_editor_mix( # `visible` is a virtual field: it fans out to the visibility of every product # under the mix rather than mapping to a mix column. visible = updates.pop("visible", None) + + before = {field: getattr(mix, field) for field in updates} + if visible is not None: + visible_before = db.scalar( + select(func.count()) + .select_from(Product) + .where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible) + ) + before["visible"] = bool(visible_before) + for field, value in updates.items(): setattr(mix, field, value) @@ -297,6 +370,25 @@ def update_editor_mix( ).all(): product.visible = visible + after = dict(updates) + if visible is not None: + after["visible"] = visible + deltas = diff_fields( + before, + after, + {"name": "Mix name", "client_name": "Client", "notes": "Notes", "visible": "Status (active)"}, + ) + if deltas: + record_change( + db, + session=session, + entity_type=ENTITY_MIX, + entity_id=mix.id, + action="updated", + summary=f"Updated {', '.join(delta['label'] for delta in deltas)}", + changes=deltas, + ) + db.commit() counts = _mix_product_counts(db, session.tenant_id or "") @@ -326,7 +418,10 @@ def add_editor_mix_ingredient( mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") if mix is None: raise HTTPException(status_code=404, detail="Mix not found") - if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None: + raw_material = db.scalar( + select(RawMaterial).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id) + ) + if raw_material is None: raise HTTPException(status_code=404, detail="Raw material not found") db.add( @@ -338,6 +433,15 @@ def add_editor_mix_ingredient( notes=payload.notes, ) ) + record_change( + db, + session=session, + entity_type=ENTITY_MIX, + entity_id=mix_id, + action="ingredient_added", + summary=f"Added {raw_material.name} ({_format_kg(payload.quantity_kg)})", + changes=[{"field": raw_material.name, "label": raw_material.name, "before": None, "after": _format_kg(payload.quantity_kg)}], + ) try: db.commit() except IntegrityError as exc: @@ -367,8 +471,22 @@ def update_editor_mix_ingredient( ) if ingredient is None: raise HTTPException(status_code=404, detail="Ingredient not found") - for field, value in payload.model_dump(exclude_unset=True).items(): + raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}" + updates = payload.model_dump(exclude_unset=True) + before = {field: getattr(ingredient, field) for field in updates} + for field, value in updates.items(): setattr(ingredient, field, value) + deltas = diff_fields(before, updates, {"quantity_kg": f"{raw_material_name} quantity", "notes": f"{raw_material_name} notes"}) + if deltas: + record_change( + db, + session=session, + entity_type=ENTITY_MIX, + entity_id=mix_id, + action="ingredient_updated", + summary=f"Updated {raw_material_name}", + changes=deltas, + ) db.commit() mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") @@ -393,7 +511,18 @@ def delete_editor_mix_ingredient( ) if ingredient is None: raise HTTPException(status_code=404, detail="Ingredient not found") + raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}" + removed_kg = ingredient.quantity_kg db.delete(ingredient) + record_change( + db, + session=session, + entity_type=ENTITY_MIX, + entity_id=mix_id, + action="ingredient_removed", + summary=f"Removed {raw_material_name}", + changes=[{"field": raw_material_name, "label": raw_material_name, "before": _format_kg(removed_kg), "after": None}], + ) db.commit() mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") @@ -437,6 +566,9 @@ def replace_editor_mix_formula( if mix is None: raise HTTPException(status_code=404, detail="Mix not found") + # Snapshot the formula as it stands so we can diff it against the saved one. + before_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix) + raw_ids = [row.raw_material_id for row in payload.rows] if len(set(raw_ids)) != len(raw_ids): raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix") @@ -483,9 +615,35 @@ def replace_editor_mix_formula( ) ) - db.commit() + db.flush() mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id) - return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix) + after_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix) + deltas = _formula_deltas(before_formula.ingredients, after_formula.ingredients) + if deltas: + record_change( + db, + session=session, + entity_type=ENTITY_MIX, + entity_id=mix_id, + action="formula_updated", + summary=f"Updated formula ({len(deltas)} ingredient {'change' if len(deltas) == 1 else 'changes'})", + changes=deltas, + ) + db.commit() + return after_formula + + +@router.get("/mixes/{mix_id}/history", response_model=list[EditorChangeEventRead]) +def get_editor_mix_history( + mix_id: int, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + tenant_id = session.tenant_id or "" + if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == tenant_id)) is None: + raise HTTPException(status_code=404, detail="Mix not found") + events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_MIX, entity_id=mix_id) + return [_serialize_change_event(event) for event in events] @router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead) @@ -662,6 +820,15 @@ def create_editor_ingredient( ) db.add(material) try: + db.flush() + record_change( + db, + session=session, + entity_type=ENTITY_INGREDIENT, + entity_id=material.id, + action="created", + summary=f"Created ingredient “{material.name}”", + ) db.commit() except IntegrityError as exc: db.rollback() @@ -692,8 +859,32 @@ def update_editor_ingredient( updates["supplier"] = (updates["supplier"] or "").strip() or None if "unit_of_measure" in updates and updates["unit_of_measure"] is not None: updates["unit_of_measure"] = updates["unit_of_measure"].strip() + before = {field: getattr(material, field) for field in updates} for field, value in updates.items(): setattr(material, field, value) + deltas = diff_fields( + before, + updates, + { + "name": "Name", + "supplier": "Supplier", + "unit_of_measure": "Unit of measure", + "kg_per_unit": "Kg per unit", + "status": "Status", + "rounding_decimals": "Rounding (dp)", + "notes": "Notes", + }, + ) + if deltas: + record_change( + db, + session=session, + entity_type=ENTITY_INGREDIENT, + entity_id=material.id, + action="updated", + summary=f"Updated {', '.join(delta['label'] for delta in deltas)}", + changes=deltas, + ) try: db.commit() except IntegrityError as exc: @@ -702,3 +893,16 @@ def update_editor_ingredient( db.refresh(material) usage = _ingredient_usage_counts(db, tenant_id) return _serialize_ingredient(material, usage.get(material.id, 0)) + + +@router.get("/ingredients/{ingredient_id}/history", response_model=list[EditorChangeEventRead]) +def get_editor_ingredient_history( + ingredient_id: int, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + tenant_id = session.tenant_id or "" + if db.scalar(select(RawMaterial.id).where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)) is None: + raise HTTPException(status_code=404, detail="Ingredient not found") + events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_INGREDIENT, entity_id=ingredient_id) + return [_serialize_change_event(event) for event in events] diff --git a/backend/app/api/throughput.py b/backend/app/api/throughput.py index ae708c2..cea2e4c 100644 --- a/backend/app/api/throughput.py +++ b/backend/app/api/throughput.py @@ -10,6 +10,7 @@ from app.api.deps import AuthSession, require_client_module_access from app.db.session import get_db from app.models.throughput import ProductionThroughput, ThroughputProduct from app.schemas.throughput import ( + ThroughputDeleteAllResult, ThroughputEntryCreate, ThroughputEntryRead, ThroughputEntryUpdate, @@ -216,6 +217,21 @@ def import_entries( return result +@router.delete("/entries", response_model=ThroughputDeleteAllResult) +def delete_all_entries( + # Clearing the log is part of correcting a bad import, so it sits at the same + # "edit" level as deleting a single entry. It is scoped to the caller's + # tenant, so one client can never wipe another's data. + session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")), + db: Session = Depends(get_db), +): + deleted = db.query(ProductionThroughput).filter( + ProductionThroughput.tenant_id == session.tenant_id + ).delete(synchronize_session=False) + db.commit() + return ThroughputDeleteAllResult(entries_deleted=deleted) + + @router.get("/entries/{entry_id}", response_model=ThroughputEntryRead) def get_entry( entry_id: int, diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index 0fc2fbd..d5b334d 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -37,6 +37,7 @@ TENANT_TABLES = { "product_cost_freight_inputs": None, "scenarios": None, "costing_results": None, + "editor_change_events": None, "process_cost_rules": None, "packaging_cost_rules": None, "freight_cost_rules": None, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0223d0b..6698860 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,6 @@ from app.models.access import Permission, Role, User, role_permissions from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule +from app.models.change_event import EditorChangeEvent from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine from app.models.mix import Mix, MixIngredient @@ -40,6 +41,7 @@ __all__ = [ "ClientUser", "ClientUserModulePermission", "CostingResult", + "EditorChangeEvent", "CustomerPriceAssignment", "CustomerProductPrice", "CustomerProductVisibility", diff --git a/backend/app/models/change_event.py b/backend/app/models/change_event.py new file mode 100644 index 0000000..01eed02 --- /dev/null +++ b/backend/app/models/change_event.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base + + +class EditorChangeEvent(Base): + """An audit row recording an edit to a mix or an ingredient. + + Written by the editor API whenever a mix or raw material (ingredient) is + created or changed, and read back per-entity by the History buttons on the + Mix Editor and Ingredients Editor. `changes` holds a list of + ``{"field", "label", "before", "after"}`` field deltas so the UI can show a + readable before/after for each edit. + """ + + __tablename__ = "editor_change_events" + + id: Mapped[int] = mapped_column(primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) + # "mix" or "ingredient" — the surface the History button lives on. + entity_type: Mapped[str] = mapped_column(String(32), index=True) + entity_id: Mapped[int] = mapped_column(Integer, index=True) + action: Mapped[str] = mapped_column(String(48)) + actor_name: Mapped[str] = mapped_column(String(255), default="") + actor_email: Mapped[str] = mapped_column(String(255), default="") + actor_role: Mapped[str | None] = mapped_column(String(64), nullable=True) + summary: Mapped[str] = mapped_column(Text, default="") + changes: Mapped[list | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) diff --git a/backend/app/schemas/editor.py b/backend/app/schemas/editor.py index c0b339a..120b7f6 100644 --- a/backend/app/schemas/editor.py +++ b/backend/app/schemas/editor.py @@ -219,3 +219,26 @@ class EditorIngredientUpdate(BaseModel): status: str | None = Field(default=None, max_length=32) rounding_decimals: int | None = Field(default=None, ge=0, le=6) notes: str | None = Field(default=None, max_length=2000) + + +# --- Change history ---------------------------------------------------------- + + +class EditorChangeFieldDelta(BaseModel): + field: str + label: str + before: str | None = None + after: str | None = None + + +class EditorChangeEventRead(BaseModel): + id: int + entity_type: str + entity_id: int + action: str + actor_name: str + actor_email: str + actor_role: str | None + summary: str + changes: list[EditorChangeFieldDelta] + created_at: datetime diff --git a/backend/app/schemas/throughput.py b/backend/app/schemas/throughput.py index c1220be..dd09a66 100644 --- a/backend/app/schemas/throughput.py +++ b/backend/app/schemas/throughput.py @@ -124,6 +124,10 @@ class ThroughputImportResult(BaseModel): errors: list[str] = Field(default_factory=list) +class ThroughputDeleteAllResult(BaseModel): + entries_deleted: int + + class ThroughputEntryRead(BaseModel): id: int tenant_id: str diff --git a/backend/app/services/change_log.py b/backend/app/services/change_log.py new file mode 100644 index 0000000..cf071b4 --- /dev/null +++ b/backend/app/services/change_log.py @@ -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() + ) diff --git a/backend/app/services/throughput_service.py b/backend/app/services/throughput_service.py index bc8f582..b0bf04a 100644 --- a/backend/app/services/throughput_service.py +++ b/backend/app/services/throughput_service.py @@ -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")) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a0f23a7..2936be1 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -3,9 +3,9 @@ requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "data-entry-app-backend" -version = "0.1.19" -description = "Costing platform MVP backend" +name = "hunter-backend" +version = "0.1.27" +description = "Costing platform MVP backend (API for Hunter)" requires-python = ">=3.11" dependencies = [ "fastapi>=0.115,<1.0", diff --git a/backend/tests/test_access.py b/backend/tests/test_access.py index 825df34..afd7427 100644 --- a/backend/tests/test_access.py +++ b/backend/tests/test_access.py @@ -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 diff --git a/backend/tests/test_change_log.py b/backend/tests/test_change_log.py new file mode 100644 index 0000000..cd8680f --- /dev/null +++ b/backend/tests/test_change_log.py @@ -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) == [] diff --git a/backend/tests/test_throughput.py b/backend/tests/test_throughput.py index 1457a85..386614c 100644 --- a/backend/tests/test_throughput.py +++ b/backend/tests/test_throughput.py @@ -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 = ( diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d47b45f..a0f41bc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hunter-app", - "version": "0.1.26", + "version": "0.1.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hunter-app", - "version": "0.1.26", + "version": "0.1.27", "dependencies": { "@fontsource/inter": "^5.2.8", "lucide-svelte": "^1.0.1" diff --git a/frontend/package.json b/frontend/package.json index 90e4a43..c6bf368 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.26", + "version": "0.1.27", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 76cbb6e..0616104 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -7,6 +7,14 @@ import type { ClientUserCreateInput, ClientUserModulePermission, ClientUserUpdateInput, + InternalUser, + InternalRoleOption, + InternalRole, + InternalRoleCreateInput, + InternalRoleModuleDefinition, + InternalRoleUpdateInput, + InternalUserCreateInput, + InternalUserUpdateInput, LoginResponse, EditorMixCreateInput, EditorMixUpdateInput, @@ -17,6 +25,7 @@ import type { EditorIngredientRow, EditorIngredientCreateInput, EditorIngredientUpdateInput, + EditorChangeEvent, EditorProductFormula, EditorProductRow, EditorProductUpdateInput, @@ -49,6 +58,7 @@ import type { XeroContactList, XeroContactLinkRow, Scenario, + ThroughputDeleteAllResult, ThroughputEntry, ThroughputEntryCreateInput, ThroughputEntryUpdateInput, @@ -453,6 +463,10 @@ export const api = { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), + editorMixHistory: (mixId: number) => + request(`/api/editor/mixes/${mixId}/history`, {}, 'client'), + editorIngredientHistory: (ingredientId: number) => + request(`/api/editor/ingredients/${ingredientId}/history`, {}, 'client'), productCosts: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/product-costs', 'client', fetcher), productCostingItems: (fetcher?: ApiFetch) => @@ -505,6 +519,8 @@ export const api = { formData.append('file', file); return uploadFile('/api/throughput/import', formData, 'client'); }, + deleteAllThroughputEntries: () => + request('/api/throughput/entries', { method: 'DELETE' }, 'client'), createThroughputProduct: (payload: ThroughputProductCreateInput) => request('/api/throughput/products', { method: 'POST', @@ -541,6 +557,44 @@ export const api = { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), + // --- Internal user management (lean/admin: manage_users) ------------------ + accessUsers: (fetcher?: ApiFetch) => + request('/api/access/users', { method: 'GET' }, 'client', fetcher), + accessAssignableRoles: (fetcher?: ApiFetch) => + request('/api/access/assignable-roles', { method: 'GET' }, 'client', fetcher), + accessRoles: (fetcher?: ApiFetch) => + request('/api/access/roles', { method: 'GET' }, 'client', fetcher), + accessRoleModules: (fetcher?: ApiFetch) => + request('/api/access/role-modules', { method: 'GET' }, 'client', fetcher), + createAccessRole: (payload: InternalRoleCreateInput) => + request('/api/access/roles', { + method: 'POST', + body: JSON.stringify(payload) + }, 'client'), + updateAccessRole: (roleId: number, payload: InternalRoleUpdateInput) => + request(`/api/access/roles/${roleId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'client'), + deleteAccessRole: (roleId: number) => + request(`/api/access/roles/${roleId}`, { method: 'DELETE' }, 'client'), + createAccessUser: (payload: InternalUserCreateInput) => + request('/api/access/users', { + method: 'POST', + body: JSON.stringify(payload) + }, 'client'), + updateAccessUser: (userId: number, payload: InternalUserUpdateInput) => + request(`/api/access/users/${userId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'client'), + setAccessUserPassword: (userId: number, newPassword: string) => + request(`/api/access/users/${userId}/password`, { + method: 'POST', + body: JSON.stringify({ new_password: newPassword }) + }, 'client'), + deleteAccessUser: (userId: number) => + request(`/api/access/users/${userId}`, { method: 'DELETE' }, 'client'), adminLogin: (email: string, password: string) => request('/api/auth/admin/login', { method: 'POST', diff --git a/frontend/src/lib/components/editor/ChangeHistoryModal.svelte b/frontend/src/lib/components/editor/ChangeHistoryModal.svelte new file mode 100644 index 0000000..6e23937 --- /dev/null +++ b/frontend/src/lib/components/editor/ChangeHistoryModal.svelte @@ -0,0 +1,353 @@ + + + + + diff --git a/frontend/src/lib/components/settings/RoleManagementPanel.svelte b/frontend/src/lib/components/settings/RoleManagementPanel.svelte new file mode 100644 index 0000000..f427f3d --- /dev/null +++ b/frontend/src/lib/components/settings/RoleManagementPanel.svelte @@ -0,0 +1,792 @@ + + +
+
+
+

Roles

+

Define which modules each role can open, edit, or manage.

+
+ +
+ + {#if loading} +

Loading roles…

+ {:else if loadError} +

{loadError}

+ {:else} +
+ + + + + + + + + + + {#each roles as role (role.id)} + + + + + + + {/each} + +
RoleAssigned usersModule accessActions
+
+
+ {role.name} + {#if role.is_protected} + + Protected + + {/if} +
+ {#if role.description} +

{role.description}

+ {/if} +
+
{role.user_count}{summary(role) || 'No module access'} +
+ + +
+
+
+ {/if} +
+ +{#if formOpen} + +{/if} + +{#if deleteRole} + +{/if} + + diff --git a/frontend/src/lib/components/settings/UserManagementPanel.svelte b/frontend/src/lib/components/settings/UserManagementPanel.svelte new file mode 100644 index 0000000..621873b --- /dev/null +++ b/frontend/src/lib/components/settings/UserManagementPanel.svelte @@ -0,0 +1,851 @@ + + +
+
+
+

Users

+

Manage who can sign in to the workspace, their role, and their access.

+
+ +
+ + {#if loading} +

Loading users…

+ {:else if loadError} +

{loadError}

+ {:else} +
+ + + + + + + + + + + + {#each users as user (user.id)} + + + + + + + + {/each} + +
NameEmailRoleAccessActions
+ {user.name} + {#if user.id === currentUserId}You{/if} + {#if user.is_protected} + + Lean + + {/if} + {user.role ?? '—'} + + +
+ + + +
+
+
+ {/if} +
+ + +{#if formOpen} + +{/if} + + +{#if pwOpen && pwUser} + +{/if} + + +{#if deleteUser} + +{/if} + + diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index eac7da1..46d65e8 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -419,6 +419,26 @@ export type EditorIngredientCreateInput = { export type EditorIngredientUpdateInput = Partial; +export type EditorChangeFieldDelta = { + field: string; + label: string; + before: string | null; + after: string | null; +}; + +export type EditorChangeEvent = { + id: number; + entity_type: 'mix' | 'ingredient'; + entity_id: number; + action: string; + actor_name: string; + actor_email: string; + actor_role: string | null; + summary: string; + changes: EditorChangeFieldDelta[]; + created_at: string; +}; + export type Scenario = { id: number; name: string; @@ -592,6 +612,69 @@ export type LoginResponse = { role_name?: string | null; }; +// Internal Hunter Stock Feeds user (the access-control system), as returned by +// /api/access/users. Distinct from the B2B ordering ClientUser accounts. +export type InternalUser = { + id: number; + email: string; + name: string; + is_active: boolean; + role: string | null; + role_id: number | null; + // Lean owner accounts: editable but never deletable. + is_protected: boolean; +}; + +export type InternalRoleOption = { + id: number; + name: string; + description: string | null; +}; + +export type InternalRoleModuleDefinition = { + key: string; + label: string; + description: string; + levels: string[]; +}; + +export type InternalRole = { + id: number; + name: string; + description: string | null; + permissions: string[]; + module_permissions: Record; + is_protected: boolean; + user_count: number; +}; + +export type InternalRoleCreateInput = { + name: string; + description?: string | null; + module_permissions: Record; +}; + +export type InternalRoleUpdateInput = { + name?: string; + description?: string | null; + module_permissions?: Record; +}; + +export type InternalUserCreateInput = { + email: string; + name: string; + role_id?: number | null; + is_active?: boolean; + password?: string | null; +}; + +export type InternalUserUpdateInput = { + name?: string; + email?: string; + role_id?: number | null; + is_active?: boolean; +}; + export type RawMaterialCreateInput = { name: string; supplier?: string | null; @@ -716,6 +799,10 @@ export type ThroughputImportResult = { errors: string[]; }; +export type ThroughputDeleteAllResult = { + entries_deleted: number; +}; + export type ThroughputEntryListParams = { date_from?: string; date_to?: string; diff --git a/frontend/src/routes/editor/+page.svelte b/frontend/src/routes/editor/+page.svelte index 9487265..b9dbf6b 100644 --- a/frontend/src/routes/editor/+page.svelte +++ b/frontend/src/routes/editor/+page.svelte @@ -2,6 +2,7 @@ import { api } from '$lib/api'; import { toast } from '$lib/toast'; import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte'; + import ChangeHistoryModal from '$lib/components/editor/ChangeHistoryModal.svelte'; import SortHeader from '$lib/table/SortHeader.svelte'; import { TableController } from '$lib/table/table.svelte'; import type { @@ -11,7 +12,7 @@ EditorMixUpdateInput, RawMaterial } from '$lib/types'; - import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Plus, Save, Search, X } from 'lucide-svelte'; + import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, X } from 'lucide-svelte'; import { fade } from 'svelte/transition'; let { data } = $props(); @@ -42,6 +43,9 @@ // rescales every row's kg from its %. let totalReference = $state(0); + // The mix whose change history is open in the modal (null = closed). + let historyMix = $state(null); + // Inline "create new mix" form state. let creatingMix = $state(false); let newMixClient = $state(''); @@ -531,6 +535,10 @@ {expandedMixId === row.id ? 'Close ingredients' : savingKey === `mix-load:${row.id}` ? 'Loading...' : 'Ingredients'} +