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
This commit is contained in:
+547
-19
@@ -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])
|
||||
|
||||
+208
-4
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user