Move working documents to its own area, rename dashboard
This commit is contained in:
+66
-11
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user