Move working documents to its own area, rename dashboard

This commit is contained in:
2026-04-29 01:21:16 +12:00
parent 7e9663fa06
commit 761ebb050d
32 changed files with 1779 additions and 526 deletions
+66 -11
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -8,6 +8,7 @@ from app.core.config import settings
from app.core.security import issue_token
from app.db.session import get_db
from app.models.client_access import ClientAccount
from app.services.client_access_service import get_client_user_by_email, module_access_map
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -22,28 +23,70 @@ class SessionResponse(BaseModel):
email: str
role: str
tenant_id: str | None = None
client_role: str | None = None
user_id: int | None = None
client_account_id: int | None = None
module_permissions: dict[str, str] = Field(default_factory=dict)
token: str
def _build_session_response(*, name: str, email: str, role: str, tenant_id: str | None = None) -> SessionResponse:
token = issue_token({"name": name, "email": email, "role": role, "tenant_id": tenant_id})
return SessionResponse(name=name, email=email, role=role, tenant_id=tenant_id, token=token)
def _build_session_response(
*,
name: str,
email: str,
role: str,
tenant_id: str | None = None,
client_role: str | None = None,
user_id: int | None = None,
client_account_id: int | None = None,
module_permissions: dict[str, str] | None = None,
) -> SessionResponse:
token = issue_token(
{
"name": name,
"email": email,
"role": role,
"tenant_id": tenant_id,
"client_role": client_role,
"user_id": user_id,
"client_account_id": client_account_id,
}
)
return SessionResponse(
name=name,
email=email,
role=role,
tenant_id=tenant_id,
client_role=client_role,
user_id=user_id,
client_account_id=client_account_id,
module_permissions=module_permissions or {},
token=token,
)
@router.post("/client/login", response_model=SessionResponse)
def client_login(payload: LoginRequest, db: Session = Depends(get_db)):
if payload.email.strip().lower() != settings.client_email.lower() or payload.password != settings.client_password:
if payload.password != settings.client_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password")
client_account = db.scalar(select(ClientAccount).where(ClientAccount.tenant_id == settings.client_tenant_id))
user = get_client_user_by_email(db, email=payload.email.strip().lower())
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password")
client_account = db.scalar(select(ClientAccount).where(ClientAccount.id == user.client_account_id))
if client_account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client account is not configured")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client account is not configured for this user")
return _build_session_response(
name=settings.client_name,
email=settings.client_email,
name=user.full_name,
email=user.email,
role="client",
tenant_id=client_account.tenant_id,
client_role=user.role,
user_id=user.id,
client_account_id=client_account.id,
module_permissions=module_access_map(user),
)
@@ -56,8 +99,20 @@ def admin_login(payload: LoginRequest):
@router.get("/client/session", response_model=SessionResponse)
def read_client_session(session: AuthSession = Depends(require_client_session)):
return _build_session_response(name=session.name, email=session.email, role=session.role, tenant_id=session.tenant_id)
def read_client_session(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
user = get_client_user_by_email(db, email=session.email, tenant_id=session.tenant_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client user session is no longer available")
return _build_session_response(
name=user.full_name,
email=user.email,
role=session.role,
tenant_id=session.tenant_id,
client_role=user.role,
user_id=user.id,
client_account_id=user.client_account_id,
module_permissions=module_access_map(user),
)
@router.get("/admin/session", response_model=SessionResponse)
+168 -22
View File
@@ -1,45 +1,111 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, selectinload
from app.api.deps import require_admin_session
from app.api.deps import AuthSession, require_client_access_manager_session
from app.db.session import get_db
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
from app.schemas.client_access import ClientAccessRead, ClientFeatureUpdate, ClientUserCreate, ClientUserUpdate
from app.services.client_access_service import list_client_accounts, serialize_client_account
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.schemas.client_access import (
ClientAccessRead,
ClientFeatureUpdate,
ClientUserCreate,
ClientUserModulePermissionUpdate,
ClientUserUpdate,
)
from app.services.client_access_service import (
ACCESS_LEVEL_ORDER,
MODULE_INDEX,
default_access_level_for_role,
ensure_user_module_permissions,
list_client_accounts,
record_audit_event,
serialize_client_account,
)
router = APIRouter(prefix="/api/client-access", tags=["client-access"])
def _get_client_or_404(db: Session, client_id: int) -> ClientAccount:
client = db.scalar(select(ClientAccount).where(ClientAccount.id == client_id))
def _authorized_client_scope(db: Session, session: AuthSession) -> list[ClientAccount]:
clients = list_client_accounts(db)
if session.role == "admin":
return clients
return [client for client in clients if client.id == session.client_account_id]
def _get_client_or_404(db: Session, client_id: int, session: AuthSession) -> ClientAccount:
clients = _authorized_client_scope(db, session)
client = next((client for client in clients if client.id == client_id), None)
if client is None:
raise HTTPException(status_code=404, detail="Client account not found")
return client
def _read_client_account(db: Session, client_id: int) -> dict:
client = next((item for item in list_client_accounts(db) if item.id == client_id), None)
def _get_user_or_404(db: Session, user_id: int, session: AuthSession) -> ClientUser:
user = db.scalar(
select(ClientUser)
.where(ClientUser.id == user_id)
.options(selectinload(ClientUser.module_permissions))
)
if user is None:
raise HTTPException(status_code=404, detail="Client user not found")
_get_client_or_404(db, user.client_account_id, session)
return user
def _read_client_account(db: Session, client_id: int, session: AuthSession) -> dict:
client = next((item for item in _authorized_client_scope(db, session) if item.id == client_id), None)
if client is None:
raise HTTPException(status_code=404, detail="Client account not found")
return serialize_client_account(client)
def _actor_metadata(session: AuthSession) -> dict[str, str]:
if session.role == "admin":
return {
"actor_type": "lean_admin",
"actor_name": session.name,
"actor_email": session.email,
"actor_role": "admin",
}
return {
"actor_type": "client_superadmin",
"actor_name": session.name,
"actor_email": session.email,
"actor_role": session.client_role or "client",
}
@router.get("", response_model=list[ClientAccessRead])
def get_client_access(db: Session = Depends(get_db), _: object = Depends(require_admin_session)):
return [serialize_client_account(client) for client in list_client_accounts(db)]
def get_client_access(
db: Session = Depends(get_db),
session: AuthSession = Depends(require_client_access_manager_session),
):
return [serialize_client_account(client) for client in _authorized_client_scope(db, session)]
@router.post("/users", response_model=ClientAccessRead, status_code=status.HTTP_201_CREATED)
def create_client_user(
payload: ClientUserCreate,
db: Session = Depends(get_db),
_: object = Depends(require_admin_session),
session: AuthSession = Depends(require_client_access_manager_session),
):
client = _get_client_or_404(db, payload.client_account_id)
client = _get_client_or_404(db, payload.client_account_id, session)
user = ClientUser(tenant_id=client.tenant_id, **payload.model_dump())
db.add(user)
db.flush()
ensure_user_module_permissions(db, user)
record_audit_event(
db,
tenant_id=client.tenant_id,
client_account_id=client.id,
action="user.created",
target_type="client_user",
target_id=user.id,
module_key="client_access",
summary=f"{user.full_name} was created with the {user.role} role.",
**_actor_metadata(session),
)
try:
db.commit()
@@ -47,25 +113,93 @@ def create_client_user(
db.rollback()
raise HTTPException(status_code=409, detail="A user with that email already exists for this client") from exc
return _read_client_account(db, payload.client_account_id)
return _read_client_account(db, payload.client_account_id, session)
@router.patch("/users/{user_id}", response_model=ClientAccessRead)
def update_client_user(user_id: int, payload: ClientUserUpdate, db: Session = Depends(get_db), _: object = Depends(require_admin_session)):
user = db.scalar(select(ClientUser).where(ClientUser.id == user_id))
if user is None:
raise HTTPException(status_code=404, detail="Client user not found")
def update_client_user(
user_id: int,
payload: ClientUserUpdate,
db: Session = Depends(get_db),
session: AuthSession = Depends(require_client_access_manager_session),
):
user = _get_user_or_404(db, user_id, session)
changes = payload.model_dump(exclude_unset=True)
original_role = user.role
for field, value in payload.model_dump(exclude_unset=True).items():
for field, value in changes.items():
setattr(user, field, value)
if "role" in changes and changes["role"] != original_role:
for permission in user.module_permissions:
permission.access_level = default_access_level_for_role(user.role, permission.module_key)
record_audit_event(
db,
tenant_id=user.tenant_id,
client_account_id=user.client_account_id,
action="user.updated",
target_type="client_user",
target_id=user.id,
module_key="client_access",
summary=f"{user.full_name} access was updated.",
**_actor_metadata(session),
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="A user with that email already exists for this client") from exc
return _read_client_account(db, user.client_account_id)
return _read_client_account(db, user.client_account_id, session)
@router.patch("/users/{user_id}/module-permissions/{module_key}", response_model=ClientAccessRead)
def update_client_user_module_permission(
user_id: int,
module_key: str,
payload: ClientUserModulePermissionUpdate,
db: Session = Depends(get_db),
session: AuthSession = Depends(require_client_access_manager_session),
):
user = _get_user_or_404(db, user_id, session)
if payload.access_level not in ACCESS_LEVEL_ORDER:
raise HTTPException(status_code=422, detail="Invalid access level")
permission = db.scalar(
select(ClientUserModulePermission).where(
ClientUserModulePermission.client_user_id == user.id,
ClientUserModulePermission.module_key == module_key,
)
)
if permission is None:
if module_key not in MODULE_INDEX:
raise HTTPException(status_code=404, detail="Module permission not found")
permission = ClientUserModulePermission(
tenant_id=user.tenant_id,
client_account_id=user.client_account_id,
client_user_id=user.id,
module_key=module_key,
access_level=default_access_level_for_role(user.role, module_key),
)
db.add(permission)
db.flush()
permission.access_level = payload.access_level
module_name = MODULE_INDEX.get(module_key, {}).get("module_name", module_key.replace("_", " ").title())
record_audit_event(
db,
tenant_id=user.tenant_id,
client_account_id=user.client_account_id,
action="module_permission.updated",
target_type="client_user_module_permission",
target_id=permission.id,
module_key=module_key,
summary=f"{user.full_name} now has {payload.access_level} access to {module_name}.",
**_actor_metadata(session),
)
db.commit()
return _read_client_account(db, user.client_account_id, session)
@router.patch("/features/{feature_id}", response_model=ClientAccessRead)
@@ -73,12 +207,24 @@ def update_client_feature(
feature_id: int,
payload: ClientFeatureUpdate,
db: Session = Depends(get_db),
_: object = Depends(require_admin_session),
session: AuthSession = Depends(require_client_access_manager_session),
):
feature = db.scalar(select(ClientFeatureAccess).where(ClientFeatureAccess.id == feature_id))
if feature is None:
raise HTTPException(status_code=404, detail="Client feature not found")
_get_client_or_404(db, feature.client_account_id, session)
feature.enabled = payload.enabled
record_audit_event(
db,
tenant_id=feature.tenant_id,
client_account_id=feature.client_account_id,
action="feature.updated",
target_type="client_feature",
target_id=feature.id,
module_key=feature.feature_key,
summary=f"{feature.feature_name} was {'enabled' if payload.enabled else 'disabled'}.",
**_actor_metadata(session),
)
db.commit()
return _read_client_account(db, feature.client_account_id)
return _read_client_account(db, feature.client_account_id, session)
+92
View File
@@ -4,8 +4,13 @@ from dataclasses import dataclass
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.security import verify_token
from app.db.session import get_db
from app.models.client_access import ClientFeatureAccess, ClientUser
from app.services.client_access_service import has_access_level, module_access_map
bearer_scheme = HTTPBearer(auto_error=False)
@@ -16,6 +21,10 @@ class AuthSession:
email: str
name: str
tenant_id: str | None = None
client_role: str | None = None
user_id: int | None = None
client_account_id: int | None = None
module_permissions: dict[str, str] | None = None
def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme)) -> AuthSession:
@@ -28,6 +37,10 @@ def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(
email=str(payload.get("email", "")),
name=str(payload.get("name", "")),
tenant_id=payload.get("tenant_id"),
client_role=payload.get("client_role"),
user_id=payload.get("user_id"),
client_account_id=payload.get("client_account_id"),
module_permissions={},
)
@@ -36,6 +49,8 @@ def require_client_session(session: AuthSession = Depends(get_auth_session)) ->
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access required")
if not session.tenant_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client tenant is missing")
if not session.user_id or not session.client_account_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client user context is missing")
return session
@@ -43,3 +58,80 @@ def require_admin_session(session: AuthSession = Depends(get_auth_session)) -> A
if session.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return session
def load_current_client_user(db: Session, session: AuthSession) -> ClientUser:
user = db.scalar(
select(ClientUser)
.where(
ClientUser.id == session.user_id,
ClientUser.client_account_id == session.client_account_id,
ClientUser.tenant_id == session.tenant_id,
)
.options(selectinload(ClientUser.module_permissions))
)
if user is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client user access is no longer valid")
if user.status == "suspended":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client user access is suspended")
return user
def require_client_module_access(module_key: str, minimum_level: str = "view"):
def dependency(
session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db),
) -> AuthSession:
user = load_current_client_user(db, session)
feature = db.scalar(
select(ClientFeatureAccess).where(
ClientFeatureAccess.client_account_id == user.client_account_id,
ClientFeatureAccess.tenant_id == user.tenant_id,
ClientFeatureAccess.feature_key == module_key,
)
)
if feature is not None and not feature.enabled:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} is disabled for this client")
permissions = module_access_map(user)
if not has_access_level(permissions.get(module_key), minimum_level):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} access is not permitted")
return AuthSession(
role=session.role,
email=session.email,
name=session.name,
tenant_id=session.tenant_id,
client_role=user.role,
user_id=user.id,
client_account_id=user.client_account_id,
module_permissions=permissions,
)
return dependency
def require_client_access_manager_session(
session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db),
) -> AuthSession:
if session.role == "admin":
return session
if session.role != "client":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires admin or superadmin access")
user = load_current_client_user(db, require_client_session(session))
permissions = module_access_map(user)
if user.role != "superadmin" or not has_access_level(permissions.get("client_access"), "manage"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin client access is required")
return AuthSession(
role=session.role,
email=session.email,
name=session.name,
tenant_id=session.tenant_id,
client_role=user.role,
user_id=user.id,
client_account_id=user.client_account_id,
module_permissions=permissions,
)
+9 -9
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_session
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.mix import Mix, MixIngredient
from app.models.raw_material import RawMaterial
@@ -13,13 +13,13 @@ router = APIRouter(prefix="/api/mixes", tags=["mixes"])
@router.get("", response_model=list[MixRead])
def list_mixes(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def list_mixes(session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)):
mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)).all()
return [calculate_mix_cost(db, mix.id) for mix in mixes]
@router.post("", response_model=MixRead, status_code=status.HTTP_201_CREATED)
def create_mix(payload: MixCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def create_mix(payload: MixCreate, session: AuthSession = Depends(require_client_module_access("mix_master", "edit")), db: Session = Depends(get_db)):
mix = Mix(
tenant_id=session.tenant_id,
client_name=payload.client_name,
@@ -52,14 +52,14 @@ def create_mix(payload: MixCreate, session: AuthSession = Depends(require_client
@router.get("/{mix_id}", response_model=MixRead)
def get_mix(mix_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def get_mix(mix_id: int, session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found")
return calculate_mix_cost(db, mix_id)
@router.patch("/{mix_id}", response_model=MixRead)
def update_mix(mix_id: int, payload: MixUpdate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def update_mix(mix_id: int, payload: MixUpdate, session: AuthSession = Depends(require_client_module_access("mix_master", "edit")), 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")
@@ -70,7 +70,7 @@ def update_mix(mix_id: int, payload: MixUpdate, session: AuthSession = Depends(r
@router.post("/{mix_id}/ingredients", response_model=MixRead, status_code=status.HTTP_201_CREATED)
def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, session: AuthSession = Depends(require_client_module_access("mix_master", "edit")), db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) 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:
@@ -93,7 +93,7 @@ def update_mix_ingredient(
mix_id: int,
ingredient_id: int,
payload: MixIngredientUpdate,
session: AuthSession = Depends(require_client_session),
session: AuthSession = Depends(require_client_module_access("mix_master", "edit")),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
@@ -112,7 +112,7 @@ def update_mix_ingredient(
@router.delete("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead)
def delete_mix_ingredient(mix_id: int, ingredient_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def delete_mix_ingredient(mix_id: int, ingredient_id: int, session: AuthSession = Depends(require_client_module_access("mix_master", "edit")), db: Session = Depends(get_db)):
ingredient = db.scalar(
select(MixIngredient).where(
MixIngredient.id == ingredient_id,
@@ -128,7 +128,7 @@ def delete_mix_ingredient(mix_id: int, ingredient_id: int, session: AuthSession
@router.get("/{mix_id}/cost-breakdown", response_model=MixRead)
def get_mix_cost_breakdown(mix_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def get_mix_cost_breakdown(mix_id: int, session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found")
return calculate_mix_cost(db, mix_id)
+14 -8
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_admin_session, require_client_session
from app.api.deps import AuthSession, require_client_access_manager_session, require_client_module_access
from app.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product
@@ -15,25 +15,25 @@ router = APIRouter(prefix="/api/powerbi", tags=["powerbi"])
@router.get("/raw-material-costs")
def raw_material_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def raw_material_costs(session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)):
materials = db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == session.tenant_id).order_by(RawMaterial.name)).all()
return [serialize_raw_material(material) for material in materials]
@router.get("/mix-costs")
def mix_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def mix_costs(session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)):
mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)).all()
return [calculate_mix_cost(db, mix.id) for mix in mixes]
@router.get("/product-costs")
def product_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def product_costs(session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)):
products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name)).all()
return [calculate_product_cost(db, product.id) for product in products]
@router.get("/scenario-results")
def scenario_results(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def scenario_results(session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)):
scenarios = db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all()
return [
{
@@ -48,12 +48,18 @@ def scenario_results(session: AuthSession = Depends(require_client_session), db:
@router.get("/client-access")
def client_access_export(_: AuthSession = Depends(require_admin_session), db: Session = Depends(get_db)):
return build_client_access_export(list_client_accounts(db))
def client_access_export(
session: AuthSession = Depends(require_client_access_manager_session),
db: Session = Depends(get_db),
):
clients = list_client_accounts(db)
if session.role == "client":
clients = [client for client in clients if client.id == session.client_account_id]
return build_client_access_export(clients)
@router.get("/data-quality-issues")
def data_quality_issues(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def data_quality_issues(session: AuthSession = Depends(require_client_module_access("dashboard")), db: Session = Depends(get_db)):
issues: list[dict] = []
for mix in db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id)).all():
result = calculate_mix_cost(db, mix.id)
+7 -7
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_session
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product
@@ -34,13 +34,13 @@ def _serialize_product(product: Product) -> dict:
@router.get("", response_model=list[ProductRead])
def list_products(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def list_products(session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)):
products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name)).all()
return [_serialize_product(product) for product in products]
@router.post("", response_model=ProductRead, status_code=status.HTTP_201_CREATED)
def create_product(payload: ProductCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def create_product(payload: ProductCreate, session: AuthSession = Depends(require_client_module_access("products", "edit")), db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found")
product = Product(tenant_id=session.tenant_id, **payload.model_dump())
@@ -51,7 +51,7 @@ def create_product(payload: ProductCreate, session: AuthSession = Depends(requir
@router.get("/{product_id}", response_model=ProductRead)
def get_product(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def get_product(product_id: int, session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)):
product = db.scalar(select(Product).where(Product.id == product_id, Product.tenant_id == session.tenant_id))
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
@@ -59,7 +59,7 @@ def get_product(product_id: int, session: AuthSession = Depends(require_client_s
@router.patch("/{product_id}", response_model=ProductRead)
def update_product(product_id: int, payload: ProductUpdate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def update_product(product_id: int, payload: ProductUpdate, session: AuthSession = Depends(require_client_module_access("products", "edit")), db: Session = Depends(get_db)):
product = db.scalar(select(Product).where(Product.id == product_id, Product.tenant_id == session.tenant_id))
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
@@ -73,7 +73,7 @@ def update_product(product_id: int, payload: ProductUpdate, session: AuthSession
@router.get("/{product_id}/cost-breakdown", response_model=ProductCostBreakdown)
def get_product_cost_breakdown(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def get_product_cost_breakdown(product_id: int, session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)):
if db.scalar(select(Product.id).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Product not found")
try:
@@ -83,7 +83,7 @@ def get_product_cost_breakdown(product_id: int, session: AuthSession = Depends(r
@router.get("/{product_id}/price-output", response_model=ProductCostBreakdown)
def get_product_price_output(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def get_product_price_output(product_id: int, session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)):
if db.scalar(select(Product.id).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Product not found")
try:
+7 -7
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import AuthSession, require_client_session
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.schemas.raw_material import (
@@ -34,7 +34,7 @@ def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> d
@router.get("", response_model=list[RawMaterialRead])
def list_raw_materials(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def list_raw_materials(session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)):
materials = db.scalars(
select(RawMaterial)
.where(RawMaterial.tenant_id == session.tenant_id)
@@ -45,7 +45,7 @@ def list_raw_materials(session: AuthSession = Depends(require_client_session), d
@router.post("", response_model=RawMaterialRead, status_code=status.HTTP_201_CREATED)
def create_raw_material(payload: RawMaterialCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def create_raw_material(payload: RawMaterialCreate, session: AuthSession = Depends(require_client_module_access("raw_materials", "edit")), db: Session = Depends(get_db)):
material = RawMaterial(
tenant_id=session.tenant_id,
name=payload.name,
@@ -72,7 +72,7 @@ def create_raw_material(payload: RawMaterialCreate, session: AuthSession = Depen
@router.get("/{raw_material_id}", response_model=RawMaterialRead)
def get_raw_material(raw_material_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def get_raw_material(raw_material_id: int, session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)):
material = db.scalar(
select(RawMaterial)
.where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)
@@ -87,7 +87,7 @@ def get_raw_material(raw_material_id: int, session: AuthSession = Depends(requir
def update_raw_material(
raw_material_id: int,
payload: RawMaterialUpdate,
session: AuthSession = Depends(require_client_session),
session: AuthSession = Depends(require_client_module_access("raw_materials", "edit")),
db: Session = Depends(get_db),
):
material = db.scalar(
@@ -108,7 +108,7 @@ def update_raw_material(
def add_price_version(
raw_material_id: int,
payload: RawMaterialPriceVersionCreate,
session: AuthSession = Depends(require_client_session),
session: AuthSession = Depends(require_client_module_access("raw_materials", "edit")),
db: Session = Depends(get_db),
):
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id))
@@ -130,7 +130,7 @@ def add_price_version(
@router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead])
def get_price_history(raw_material_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def get_price_history(raw_material_id: int, session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)):
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id))
if material is None:
raise HTTPException(status_code=404, detail="Raw material not found")
+8 -8
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_session
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.scenario import CostingResult, Scenario
from app.schemas.scenario import ScenarioCreate, ScenarioRead, ScenarioRunResponse
@@ -12,12 +12,12 @@ router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
@router.get("", response_model=list[ScenarioRead])
def list_scenarios(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def list_scenarios(session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)):
return db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all()
@router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED)
def create_scenario(payload: ScenarioCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def create_scenario(payload: ScenarioCreate, session: AuthSession = Depends(require_client_module_access("scenarios", "edit")), db: Session = Depends(get_db)):
scenario = Scenario(tenant_id=session.tenant_id, name=payload.name, description=payload.description, overrides=payload.overrides)
db.add(scenario)
db.commit()
@@ -26,7 +26,7 @@ def create_scenario(payload: ScenarioCreate, session: AuthSession = Depends(requ
@router.get("/{scenario_id}", response_model=ScenarioRead)
def get_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def get_scenario(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found")
@@ -34,7 +34,7 @@ def get_scenario(scenario_id: int, session: AuthSession = Depends(require_client
@router.post("/{scenario_id}/run", response_model=ScenarioRunResponse)
def run_scenario_endpoint(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def run_scenario_endpoint(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios", "edit")), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found")
@@ -44,7 +44,7 @@ def run_scenario_endpoint(scenario_id: int, session: AuthSession = Depends(requi
@router.get("/{scenario_id}/results")
def get_scenario_results(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def get_scenario_results(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found")
@@ -65,7 +65,7 @@ def get_scenario_results(scenario_id: int, session: AuthSession = Depends(requir
@router.post("/{scenario_id}/approve", response_model=ScenarioRead)
def approve_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def approve_scenario(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios", "edit")), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found")
@@ -76,7 +76,7 @@ def approve_scenario(scenario_id: int, session: AuthSession = Depends(require_cl
@router.post("/{scenario_id}/reject", response_model=ScenarioRead)
def reject_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
def reject_scenario(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios", "edit")), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found")
+30
View File
@@ -9,6 +9,8 @@ from sqlalchemy.engine import Engine
TENANT_TABLES = {
"client_users": None,
"client_feature_access": None,
"client_user_module_permissions": None,
"client_access_audit_events": None,
"raw_materials": None,
"raw_material_price_versions": None,
"mixes": None,
@@ -123,6 +125,34 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]:
"""
),
),
(
"client_user_module_permissions",
text(
"""
UPDATE client_user_module_permissions
SET tenant_id = (
SELECT client_users.tenant_id
FROM client_users
WHERE client_users.id = client_user_module_permissions.client_user_id
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
),
(
"client_access_audit_events",
text(
"""
UPDATE client_access_audit_events
SET tenant_id = (
SELECT client_accounts.tenant_id
FROM client_accounts
WHERE client_accounts.id = client_access_audit_events.client_account_id
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
),
(
"raw_materials",
text(
+3 -1
View File
@@ -1,5 +1,5 @@
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
@@ -7,8 +7,10 @@ from app.models.scenario import CostingResult, Scenario
__all__ = [
"ClientAccount",
"ClientAccessAuditEvent",
"ClientFeatureAccess",
"ClientUser",
"ClientUserModulePermission",
"CostingResult",
"FreightCostRule",
"Mix",
+46
View File
@@ -30,6 +30,11 @@ class ClientAccount(Base):
cascade="all, delete-orphan",
order_by="ClientFeatureAccess.feature_group, ClientFeatureAccess.feature_name",
)
audit_events: Mapped[list["ClientAccessAuditEvent"]] = relationship(
back_populates="client_account",
cascade="all, delete-orphan",
order_by="ClientAccessAuditEvent.created_at.desc()",
)
class ClientUser(Base):
@@ -48,6 +53,11 @@ class ClientUser(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
client_account: Mapped[ClientAccount] = relationship(back_populates="users")
module_permissions: Mapped[list["ClientUserModulePermission"]] = relationship(
back_populates="client_user",
cascade="all, delete-orphan",
order_by="ClientUserModulePermission.module_key",
)
class ClientFeatureAccess(Base):
@@ -66,3 +76,39 @@ class ClientFeatureAccess(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
client_account: Mapped[ClientAccount] = relationship(back_populates="features")
class ClientUserModulePermission(Base):
__tablename__ = "client_user_module_permissions"
__table_args__ = (UniqueConstraint("client_user_id", "module_key", name="uq_client_user_module_permission"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
client_user_id: Mapped[int] = mapped_column(ForeignKey("client_users.id"), index=True)
module_key: Mapped[str] = mapped_column(String(64))
access_level: Mapped[str] = mapped_column(String(32), default="none")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
client_user: Mapped[ClientUser] = relationship(back_populates="module_permissions")
class ClientAccessAuditEvent(Base):
__tablename__ = "client_access_audit_events"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
actor_type: Mapped[str] = mapped_column(String(32), default="client")
actor_name: Mapped[str] = mapped_column(String(255))
actor_email: Mapped[str] = mapped_column(String(255))
actor_role: Mapped[str] = mapped_column(String(64))
action: Mapped[str] = mapped_column(String(128))
target_type: Mapped[str] = mapped_column(String(64))
target_id: Mapped[int | None] = mapped_column(nullable=True)
module_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
summary: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
client_account: Mapped[ClientAccount] = relationship(back_populates="audit_events")
+37
View File
@@ -24,6 +24,10 @@ class ClientFeatureUpdate(BaseModel):
enabled: bool
class ClientUserModulePermissionUpdate(BaseModel):
access_level: str
class ClientUserRead(BaseModel):
id: int
client_account_id: int
@@ -34,6 +38,7 @@ class ClientUserRead(BaseModel):
is_new_user: bool
last_login_at: datetime | None
created_at: datetime
module_permissions: list["ClientUserModulePermissionRead"]
class ClientFeatureRead(BaseModel):
@@ -48,6 +53,34 @@ class ClientFeatureRead(BaseModel):
created_at: datetime
class ClientUserModulePermissionRead(BaseModel):
id: int
client_account_id: int
client_user_id: int
module_key: str
module_name: str
module_group: str
description: str | None
access_level: str
updated_at: datetime
created_at: datetime
class ClientAccessAuditEventRead(BaseModel):
id: int
client_account_id: int
actor_type: str
actor_name: str
actor_email: str
actor_role: str
action: str
target_type: str
target_id: int | None
module_key: str | None
summary: str
created_at: datetime
class ClientAccessRead(BaseModel):
id: int
tenant_id: str
@@ -63,3 +96,7 @@ class ClientAccessRead(BaseModel):
new_user_count: int
enabled_feature_count: int
total_feature_count: int
audit_history: list[ClientAccessAuditEventRead]
ClientUserRead.model_rebuild()
+31 -14
View File
@@ -4,20 +4,11 @@ from sqlalchemy import select
from app.db.session import Base, SessionLocal, engine
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
CLIENT_FEATURES = [
("dashboard", "Dashboard", "workspace", "Top-level operational dashboard"),
("raw_materials", "Raw Materials", "costing", "Maintain live material costs and versions"),
("mix_master", "Mix Master", "costing", "Create and maintain mix worksheets"),
("products", "Products", "pricing", "Review finished product pricing"),
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
]
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role
def seed_client_access(db):
@@ -51,7 +42,7 @@ def seed_client_access(db):
tenant_id=specialty.tenant_id,
full_name="Amelia Hart",
email="operator@example.com",
role="admin",
role="superadmin",
status="active",
is_new_user=False,
last_login_at=datetime(2026, 4, 24, 11, 30),
@@ -81,13 +72,13 @@ def seed_client_access(db):
)
enabled_feature_map = {
"hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "products", "scenarios", "powerbi_export"},
"hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "products", "scenarios", "powerbi_export", "client_access"},
"loft-grains": {"dashboard", "products", "powerbi_export"},
}
for client in (specialty, loft):
enabled_keys = enabled_feature_map[client.tenant_id]
for feature_key, feature_name, feature_group, description in CLIENT_FEATURES:
for feature_key, feature_name, feature_group, description in MODULE_CATALOG:
client.features.append(
ClientFeatureAccess(
tenant_id=client.tenant_id,
@@ -99,6 +90,32 @@ def seed_client_access(db):
)
)
for user in client.users:
for module_key, _, _, _ in MODULE_CATALOG:
user.module_permissions.append(
ClientUserModulePermission(
tenant_id=client.tenant_id,
client_account_id=client.id,
module_key=module_key,
access_level=default_access_level_for_role(user.role, module_key),
)
)
specialty.audit_events.append(
ClientAccessAuditEvent(
tenant_id=specialty.tenant_id,
actor_type="seed",
actor_name="Lean 101 Seeder",
actor_email="system@lean101.local",
actor_role="system",
action="client_access.seeded",
target_type="client_account",
target_id=specialty.id,
module_key="client_access",
summary="Initial client access controls, module permissions, and feature flags were seeded.",
)
)
def seed_costing_workspace(db):
existing = db.scalar(select(RawMaterial.id))
+191 -2
View File
@@ -5,21 +5,107 @@ from datetime import datetime
from sqlalchemy import Select, select
from sqlalchemy.orm import Session, selectinload
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
from app.models.client_access import (
ClientAccessAuditEvent,
ClientAccount,
ClientFeatureAccess,
ClientUser,
ClientUserModulePermission,
)
MODULE_CATALOG = (
("dashboard", "Dashboard", "workspace", "Top-level operational dashboard"),
("raw_materials", "Raw Materials", "costing", "Maintain live material costs and versions"),
("mix_master", "Mix Master", "costing", "Create and maintain mix worksheets"),
("products", "Products", "pricing", "Review finished product pricing"),
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
("client_access", "Client Access", "administration", "Manage user access, module permissions, and audit history"),
)
MODULE_INDEX = {
key: {"module_name": name, "module_group": group, "description": description}
for key, name, group, description in MODULE_CATALOG
}
ACCESS_LEVEL_ORDER = {"none": 0, "view": 1, "edit": 2, "manage": 3}
def client_access_query() -> Select[tuple[ClientAccount]]:
return (
select(ClientAccount)
.options(selectinload(ClientAccount.users), selectinload(ClientAccount.features))
.options(
selectinload(ClientAccount.users).selectinload(ClientUser.module_permissions),
selectinload(ClientAccount.features),
selectinload(ClientAccount.audit_events),
)
.order_by(ClientAccount.name)
)
def list_client_accounts(db: Session) -> list[ClientAccount]:
ensure_client_user_module_permissions(db)
return db.scalars(client_access_query()).all()
def get_client_user_by_email(db: Session, *, email: str, tenant_id: str | None = None) -> ClientUser | None:
statement = select(ClientUser).where(ClientUser.email == email)
if tenant_id:
statement = statement.where(ClientUser.tenant_id == tenant_id)
return db.scalar(
statement.options(selectinload(ClientUser.module_permissions)).order_by(ClientUser.id.desc())
)
def module_access_map(user: ClientUser) -> dict[str, str]:
return {permission.module_key: permission.access_level for permission in user.module_permissions}
def has_access_level(access_level: str | None, minimum_level: str) -> bool:
return ACCESS_LEVEL_ORDER.get(access_level or "none", 0) >= ACCESS_LEVEL_ORDER.get(minimum_level, 0)
def default_access_level_for_role(role: str, module_key: str) -> str:
normalized = role.strip().lower()
if normalized == "superadmin":
return "manage" if module_key == "client_access" else "edit"
if normalized == "admin":
return "edit" if module_key != "client_access" else "none"
if normalized == "operator":
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "products", "scenarios"} else "none"
if normalized == "viewer":
return "view" if module_key in {"dashboard", "products", "powerbi_export"} else "none"
return "none"
def ensure_user_module_permissions(db: Session, user: ClientUser) -> bool:
existing = {permission.module_key for permission in user.module_permissions}
created = False
for module_key, _, _, _ in MODULE_CATALOG:
if module_key in existing:
continue
db.add(
ClientUserModulePermission(
tenant_id=user.tenant_id,
client_account_id=user.client_account_id,
client_user_id=user.id,
module_key=module_key,
access_level=default_access_level_for_role(user.role, module_key),
)
)
created = True
return created
def ensure_client_user_module_permissions(db: Session) -> None:
users = db.scalars(select(ClientUser).options(selectinload(ClientUser.module_permissions))).all()
changed = False
for user in users:
changed = ensure_user_module_permissions(db, user) or changed
if changed:
db.commit()
def serialize_client_user(user: ClientUser) -> dict:
return {
"id": user.id,
@@ -31,6 +117,7 @@ def serialize_client_user(user: ClientUser) -> dict:
"is_new_user": user.is_new_user,
"last_login_at": user.last_login_at,
"created_at": user.created_at,
"module_permissions": [serialize_module_permission(permission) for permission in user.module_permissions],
}
@@ -48,6 +135,39 @@ def serialize_client_feature(feature: ClientFeatureAccess) -> dict:
}
def serialize_module_permission(permission: ClientUserModulePermission) -> dict:
module_info = MODULE_INDEX.get(permission.module_key, {})
return {
"id": permission.id,
"client_account_id": permission.client_account_id,
"client_user_id": permission.client_user_id,
"module_key": permission.module_key,
"module_name": module_info.get("module_name", permission.module_key.replace("_", " ").title()),
"module_group": module_info.get("module_group", "workspace"),
"description": module_info.get("description"),
"access_level": permission.access_level,
"updated_at": permission.updated_at,
"created_at": permission.created_at,
}
def serialize_audit_event(event: ClientAccessAuditEvent) -> dict:
return {
"id": event.id,
"client_account_id": event.client_account_id,
"actor_type": event.actor_type,
"actor_name": event.actor_name,
"actor_email": event.actor_email,
"actor_role": event.actor_role,
"action": event.action,
"target_type": event.target_type,
"target_id": event.target_id,
"module_key": event.module_key,
"summary": event.summary,
"created_at": event.created_at,
}
def serialize_client_account(client: ClientAccount) -> dict:
users = [serialize_client_user(user) for user in client.users]
features = [serialize_client_feature(feature) for feature in client.features]
@@ -70,6 +190,7 @@ def serialize_client_account(client: ClientAccount) -> dict:
"new_user_count": new_users,
"enabled_feature_count": enabled_features,
"total_feature_count": len(features),
"audit_history": [serialize_audit_event(event) for event in client.audit_events[:40]],
}
@@ -78,6 +199,8 @@ def build_client_access_export(clients: list[ClientAccount]) -> dict:
client_rows = []
user_rows = []
feature_rows = []
permission_rows = []
audit_rows = []
for client in serialized_clients:
client_rows.append(
@@ -111,6 +234,21 @@ def build_client_access_export(clients: list[ClientAccount]) -> dict:
}
)
for permission in user["module_permissions"]:
permission_rows.append(
{
"client_id": client["id"],
"client_name": client["name"],
"user_id": user["id"],
"user_email": user["email"],
"module_key": permission["module_key"],
"module_name": permission["module_name"],
"module_group": permission["module_group"],
"access_level": permission["access_level"],
"updated_at": permission["updated_at"],
}
)
for feature in client["features"]:
feature_rows.append(
{
@@ -125,10 +263,61 @@ def build_client_access_export(clients: list[ClientAccount]) -> dict:
}
)
for event in client["audit_history"]:
audit_rows.append(
{
"client_id": client["id"],
"client_name": client["name"],
"event_id": event["id"],
"actor_email": event["actor_email"],
"actor_role": event["actor_role"],
"action": event["action"],
"target_type": event["target_type"],
"target_id": event["target_id"],
"module_key": event["module_key"],
"summary": event["summary"],
"created_at": event["created_at"],
}
)
return {
"generated_at": datetime.utcnow(),
"client_rows": client_rows,
"user_rows": user_rows,
"feature_rows": feature_rows,
"permission_rows": permission_rows,
"audit_rows": audit_rows,
"clients": serialized_clients,
}
def record_audit_event(
db: Session,
*,
tenant_id: str,
client_account_id: int,
actor_type: str,
actor_name: str,
actor_email: str,
actor_role: str,
action: str,
target_type: str,
target_id: int | None,
module_key: str | None,
summary: str,
) -> None:
db.add(
ClientAccessAuditEvent(
tenant_id=tenant_id,
client_account_id=client_account_id,
actor_type=actor_type,
actor_name=actor_name,
actor_email=actor_email,
actor_role=actor_role,
action=action,
target_type=target_type,
target_id=target_id,
module_key=module_key,
summary=summary,
)
)
+66 -3
View File
@@ -10,11 +10,11 @@ from app.db.migrations import bootstrap_schema, sync_tenant_ids
from app.db.session import Base
from app.main import app
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.services.client_access_service import build_client_access_export, serialize_client_account
from app.services.client_access_service import build_client_access_export, ensure_user_module_permissions, serialize_client_account
from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost
@@ -101,6 +101,8 @@ def test_root_and_login_endpoints():
assert client_login_response.status_code == 200
assert client_login_response.json()["email"] == settings.client_email
assert client_login_response.json()["tenant_id"] == settings.client_tenant_id
assert client_login_response.json()["client_role"] == "superadmin"
assert client_login_response.json()["module_permissions"]["client_access"] == "manage"
admin_login_response = client.post(
"/api/auth/admin/login",
@@ -125,7 +127,7 @@ def test_client_access_export_helpers():
ClientUser(
full_name="Amelia Hart",
email="amelia.hart@specialtyfeeds.example",
role="admin",
role="superadmin",
status="active",
is_new_user=False,
),
@@ -155,6 +157,23 @@ def test_client_access_export_helpers():
]
)
db.add(client)
db.flush()
for user in client.users:
ensure_user_module_permissions(db, user)
client.audit_events.append(
ClientAccessAuditEvent(
tenant_id=client.tenant_id,
actor_type="lean_admin",
actor_name="Lean 101",
actor_email="admin@lean101.local",
actor_role="admin",
action="client_access.seeded",
target_type="client_account",
target_id=client.id,
module_key="client_access",
summary="Initial client access controls were seeded.",
)
)
db.commit()
db.refresh(client)
@@ -167,6 +186,8 @@ def test_client_access_export_helpers():
assert export["client_rows"][0]["client_code"] == "SPEC"
assert export["user_rows"][0]["client_name"] == "Specialty Feeds"
assert len(export["feature_rows"]) == 2
assert len(export["permission_rows"]) >= 1
assert len(export["audit_rows"]) == 1
def test_client_access_endpoints():
@@ -181,10 +202,52 @@ def test_client_access_endpoints():
access_response = client.get("/api/client-access", headers=headers)
assert access_response.status_code == 200
assert len(access_response.json()) >= 1
assert "audit_history" in access_response.json()[0]
assert "module_permissions" in access_response.json()[0]["users"][0]
export_response = client.get("/api/powerbi/client-access", headers=headers)
assert export_response.status_code == 200
assert "client_rows" in export_response.json()
assert "permission_rows" in export_response.json()
client_login_response = client.post(
"/api/auth/client/login",
json={"email": settings.client_email, "password": settings.client_password},
)
client_headers = {"Authorization": f"Bearer {client_login_response.json()['token']}"}
superadmin_access_response = client.get("/api/client-access", headers=client_headers)
assert superadmin_access_response.status_code == 200
assert len(superadmin_access_response.json()) == 1
def test_module_permission_blocks_client_module_access():
with TestClient(app) as client:
admin_login_response = client.post(
"/api/auth/admin/login",
json={"email": settings.admin_email, "password": settings.admin_password},
)
admin_headers = {"Authorization": f"Bearer {admin_login_response.json()['token']}"}
access_response = client.get("/api/client-access", headers=admin_headers)
first_client = access_response.json()[0]
first_user = first_client["users"][0]
permission = next(
permission for permission in first_user["module_permissions"] if permission["module_key"] == "raw_materials"
)
client.patch(
f"/api/client-access/users/{first_user['id']}/module-permissions/{permission['module_key']}",
json={"access_level": "none"},
headers=admin_headers,
)
client_login_response = client.post(
"/api/auth/client/login",
json={"email": settings.client_email, "password": settings.client_password},
)
client_headers = {"Authorization": f"Bearer {client_login_response.json()['token']}"}
raw_materials_response = client.get("/api/raw-materials", headers=client_headers)
assert raw_materials_response.status_code == 403
def test_bootstrap_schema_creates_missing_tables_and_patches_legacy_tenant_columns():