v0.1.11 - Editor
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user