v0.1.11 - Editor

This commit is contained in:
2026-06-03 00:17:12 +12:00
parent f5a588d631
commit cf968e802b
23 changed files with 2165 additions and 655 deletions
+3 -3
View File
@@ -28,7 +28,7 @@ router = APIRouter(prefix="/api/client-access", tags=["client-access"])
def _authorized_client_scope(db: Session, session: AuthSession) -> list[ClientAccount]:
clients = list_client_accounts(db)
if session.role == "admin":
if session.role in {"admin", "internal"}:
return clients
return [client for client in clients if client.id == session.client_account_id]
@@ -61,12 +61,12 @@ def _read_client_account(db: Session, client_id: int, session: AuthSession) -> d
def _actor_metadata(session: AuthSession) -> dict[str, str]:
if session.role == "admin":
if session.role in {"admin", "internal"}:
return {
"actor_type": "lean_admin",
"actor_name": session.name,
"actor_email": session.email,
"actor_role": "admin",
"actor_role": session.client_role or "admin",
}
return {
"actor_type": "client_superadmin",
+6
View File
@@ -190,6 +190,12 @@ def require_client_access_manager_session(
) -> AuthSession:
if session.role == "admin":
return session
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get("client_access"), "manage"):
log_security_event("authz.denied", role=session.role, module="client_access", access_level="manage")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires Lean access")
return session
if session.role != "client":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires admin or superadmin access")
+137
View File
@@ -0,0 +1,137 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, joinedload
from app.api.deps import AuthSession, get_auth_session
from app.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product
from app.schemas.editor import EditorMixUpdate, EditorProductRow, EditorProductUpdate
from app.services.client_access_service import has_access_level
router = APIRouter(prefix="/api/editor", tags=["editor"])
def _serialize_row(product: Product) -> dict:
return {
"id": product.id,
"tenant_id": product.tenant_id,
"client_name": product.client_name,
"item_id": product.item_id,
"name": product.name,
"mix_id": product.mix_id,
"mix_client_name": product.mix.client_name if product.mix else "",
"mix_name": product.mix.name if product.mix else "",
"sale_type": product.sale_type,
"unit_of_measure": product.unit_of_measure,
"visible": product.visible,
"product_notes": product.notes,
"mix_notes": product.mix.notes if product.mix else None,
}
def _require_editor_session(
session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db),
) -> AuthSession:
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get("client_access"), "manage"):
raise HTTPException(status_code=403, detail="Lean access is required")
if not has_access_level(permissions.get("products"), "edit"):
raise HTTPException(status_code=403, detail="products edit access is required")
if not has_access_level(permissions.get("mix_master"), "edit"):
raise HTTPException(status_code=403, detail="mix_master edit access is required")
if not session.tenant_id:
raise HTTPException(status_code=403, detail="Internal user context is missing")
return session
raise HTTPException(status_code=403, detail="Lean access is required")
@router.get("/products", response_model=list[EditorProductRow])
def list_editor_products(
q: str | None = Query(default=None, max_length=255),
client_name: str | None = Query(default=None, max_length=255),
limit: int = Query(default=500, ge=1, le=1000),
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
statement = (
select(Product)
.where(Product.tenant_id == session.tenant_id)
.options(joinedload(Product.mix))
.join(Product.mix)
.order_by(Product.client_name, Product.name, Product.id)
.limit(limit)
)
if client_name:
statement = statement.where(Product.client_name == client_name)
if q:
term = f"%{q.strip()}%"
statement = statement.where(
or_(
Product.client_name.ilike(term),
Product.name.ilike(term),
Product.item_id.ilike(term),
Product.unit_of_measure.ilike(term),
Mix.name.ilike(term),
)
)
return [_serialize_row(product) for product in db.scalars(statement).all()]
@router.patch("/products/{product_id}", response_model=EditorProductRow)
def update_editor_product(
product_id: int,
payload: EditorProductUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
product = db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == session.tenant_id)
.options(joinedload(Product.mix))
)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
if payload.mix_id is not None:
mix = db.scalar(select(Mix).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(product, field, value)
db.commit()
db.refresh(product)
return _serialize_row(product)
@router.patch("/mixes/{mix_id}", response_model=list[EditorProductRow])
def update_editor_mix(
mix_id: int,
payload: EditorMixUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(mix, field, value)
db.commit()
products = db.scalars(
select(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
.options(joinedload(Product.mix))
.order_by(Product.client_name, Product.name, Product.id)
).all()
return [_serialize_row(product) for product in products]
+6 -4
View File
@@ -53,10 +53,12 @@ _PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = {
"edit_mixes": ("mix_master", "edit"),
"view_throughput": ("operations_throughput", "view"),
"edit_throughput": ("operations_throughput", "edit"),
# Admin-only permissions (view_users, manage_users, manage_permissions,
# view_settings, edit_settings) are intentionally excluded — they don't
# correspond to any of the legacy module keys and remain accessible only
# via the explicit `require_permission(...)` dependency.
"view_scenarios": ("scenarios", "view"),
"edit_scenarios": ("scenarios", "edit"),
"manage_client_access": ("client_access", "manage"),
# User/role/settings permissions are intentionally excluded — they don't
# correspond to legacy module keys and remain accessible only via the
# explicit `require_permission(...)` dependency.
}
_ACCESS_LEVEL_RANK = {"none": 0, "view": 1, "edit": 2, "manage": 3}
+2
View File
@@ -22,6 +22,7 @@ from app.api.access import router as access_router
from app.api.auth import router as auth_router
from app.api.client_access import router as client_access_router
from app.api.dashboard import router as dashboard_router
from app.api.editor import router as editor_router
from app.api.mix_calculator import router as mix_calculator_router
from app.api.mixes import router as mixes_router
from app.api.powerbi import router as powerbi_router
@@ -194,6 +195,7 @@ app.include_router(auth_router)
app.include_router(access_router)
app.include_router(client_access_router)
app.include_router(dashboard_router)
app.include_router(editor_router)
app.include_router(raw_materials_router)
app.include_router(mixes_router)
app.include_router(mix_calculator_router)
+38
View File
@@ -0,0 +1,38 @@
from pydantic import BaseModel, ConfigDict, Field
class EditorProductRow(BaseModel):
id: int
tenant_id: str
client_name: str
item_id: str | None
name: str
mix_id: int
mix_client_name: str
mix_name: str
sale_type: str
unit_of_measure: str
visible: bool
product_notes: str | None
mix_notes: str | None
class EditorProductUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
client_name: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255)
item_id: str | None = Field(default=None, max_length=128)
mix_id: int | None = None
sale_type: str | None = Field(default=None, min_length=1, max_length=64)
unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64)
visible: bool | None = None
notes: str | None = Field(default=None, max_length=2000)
class EditorMixUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
client_name: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255)
notes: str | None = Field(default=None, max_length=2000)
+32
View File
@@ -31,6 +31,9 @@ PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = (
("edit_mixes", "Create and edit mix master recipes"),
("view_throughput", "View operations throughput"),
("edit_throughput", "Create and edit operations throughput entries"),
("view_scenarios", "View scenario planning"),
("edit_scenarios", "Create, run, approve, and reject scenarios"),
("manage_client_access", "Manage client accounts, users, feature access, and exports"),
("view_users", "View internal users and roles"),
("manage_users", "Create, deactivate, and assign user roles"),
("manage_permissions", "Modify roles and role-permission assignments"),
@@ -50,10 +53,14 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"view_raw_materials",
"edit_raw_materials",
"view_products",
"edit_products",
"view_mixes",
"edit_mixes",
"view_throughput",
"edit_throughput",
"view_scenarios",
"edit_scenarios",
"manage_client_access",
"view_users",
"manage_users",
"manage_permissions",
@@ -88,6 +95,31 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"edit_throughput",
],
},
"lean": {
"description": "Lean owner access with unrestricted view/edit access across every workspace module.",
"permissions": [
"view_dashboard",
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
"view_raw_materials",
"edit_raw_materials",
"view_products",
"edit_products",
"view_mixes",
"edit_mixes",
"view_throughput",
"edit_throughput",
"view_scenarios",
"edit_scenarios",
"manage_client_access",
"view_users",
"manage_users",
"manage_permissions",
"view_settings",
"edit_settings",
],
},
}
+36 -3
View File
@@ -11,6 +11,7 @@ from app.api.access import router as access_router
from app.core.access import (
INTERNAL_USER_SUBJECT,
get_user_permissions,
permissions_to_module_map,
require_all_permissions,
require_any_permission,
require_permission,
@@ -72,9 +73,17 @@ def test_admin_role_permissions_match_spec():
assert granted == set(ROLE_DEFINITIONS["Admin"]["permissions"])
assert "manage_users" in granted
assert "manage_permissions" in granted
# Admin spec deliberately excludes edit_products / edit_mixes.
assert "edit_products" not in granted
assert "edit_mixes" not in granted
assert "edit_products" in granted
assert "edit_mixes" in granted
assert "view_scenarios" in granted
assert "edit_scenarios" in granted
assert "manage_client_access" in granted
modules = permissions_to_module_map(granted)
assert modules["products"] == "edit"
assert modules["mix_master"] == "edit"
assert modules["scenarios"] == "edit"
assert modules["client_access"] == "manage"
def test_operations_role_is_mix_calculator_and_throughput_only():
@@ -108,6 +117,30 @@ def test_full_access_role_can_edit_operational_data_but_not_users():
assert "manage_permissions" not in granted
def test_lean_role_has_unrestricted_workspace_permissions():
db = _build_session()
seed_access(db)
lean_role = db.query(Role).filter_by(name="lean").one()
user = User(email="lean@example.com", name="Lean User", role_id=lean_role.id, is_active=True)
db.add(user)
db.flush()
granted = get_user_permissions(user)
assert granted == set(ROLE_DEFINITIONS["lean"]["permissions"])
assert {key for key, _ in PERMISSION_DEFINITIONS} == granted
modules = permissions_to_module_map(granted)
assert modules["dashboard"] == "view"
assert modules["raw_materials"] == "edit"
assert modules["mix_master"] == "edit"
assert modules["mix_calculator"] == "edit"
assert modules["products"] == "edit"
assert modules["operations_throughput"] == "edit"
assert modules["scenarios"] == "edit"
assert modules["client_access"] == "manage"
def test_inactive_user_has_no_permissions():
db = _build_session()
seed_access(db)