v1.3 - client and admin scaffolding

This commit is contained in:
2026-04-25 22:51:36 +12:00
parent bc211ffcc8
commit 8cf9bfb441
54 changed files with 8882 additions and 1248 deletions
+48 -11
View File
@@ -1,7 +1,13 @@
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_admin_session, require_client_session
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
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -11,18 +17,49 @@ class LoginRequest(BaseModel):
password: str
class LoginResponse(BaseModel):
class SessionResponse(BaseModel):
name: str
email: str
role: str
tenant_id: str | None = None
token: str
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest):
if payload.email.strip().lower() != settings.operator_email.lower() or payload.password != settings.operator_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
return {
"name": settings.operator_name,
"email": settings.operator_email,
"role": "operator",
}
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)
@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:
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))
if client_account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client account is not configured")
return _build_session_response(
name=settings.client_name,
email=settings.client_email,
role="client",
tenant_id=client_account.tenant_id,
)
@router.post("/admin/login", response_model=SessionResponse)
def admin_login(payload: LoginRequest):
if payload.email.strip().lower() != settings.admin_email.lower() or payload.password != settings.admin_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin email or password")
return _build_session_response(name=settings.admin_name, email=settings.admin_email, role="admin")
@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)
@router.get("/admin/session", response_model=SessionResponse)
def read_admin_session(session: AuthSession = Depends(require_admin_session)):
return _build_session_response(name=session.name, email=session.email, role=session.role)
+84
View File
@@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import require_admin_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
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))
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)
if client is None:
raise HTTPException(status_code=404, detail="Client account not found")
return serialize_client_account(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)]
@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),
):
client = _get_client_or_404(db, payload.client_account_id)
user = ClientUser(tenant_id=client.tenant_id, **payload.model_dump())
db.add(user)
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, payload.client_account_id)
@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")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(user, field, value)
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)
@router.patch("/features/{feature_id}", response_model=ClientAccessRead)
def update_client_feature(
feature_id: int,
payload: ClientFeatureUpdate,
db: Session = Depends(get_db),
_: object = Depends(require_admin_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")
feature.enabled = payload.enabled
db.commit()
return _read_client_account(db, feature.client_account_id)
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from dataclasses import dataclass
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.security import verify_token
bearer_scheme = HTTPBearer(auto_error=False)
@dataclass(frozen=True)
class AuthSession:
role: str
email: str
name: str
tenant_id: str | None = None
def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme)) -> AuthSession:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
payload = verify_token(credentials.credentials)
return AuthSession(
role=str(payload.get("role", "")),
email=str(payload.get("email", "")),
name=str(payload.get("name", "")),
tenant_id=payload.get("tenant_id"),
)
def require_client_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
if session.role != "client":
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")
return session
def require_admin_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
if session.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return session
+52 -19
View File
@@ -2,6 +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.db.session import get_db
from app.models.mix import Mix, MixIngredient
from app.models.raw_material import RawMaterial
@@ -12,14 +13,15 @@ router = APIRouter(prefix="/api/mixes", tags=["mixes"])
@router.get("", response_model=list[MixRead])
def list_mixes(db: Session = Depends(get_db)):
mixes = db.scalars(select(Mix).order_by(Mix.name)).all()
def list_mixes(session: AuthSession = Depends(require_client_session), 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, db: Session = Depends(get_db)):
def create_mix(payload: MixCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
mix = Mix(
tenant_id=session.tenant_id,
client_name=payload.client_name,
name=payload.name,
status=payload.status,
@@ -29,10 +31,16 @@ def create_mix(payload: MixCreate, db: Session = Depends(get_db)):
db.add(mix)
db.flush()
for ingredient in payload.ingredients:
if db.scalar(select(RawMaterial).where(RawMaterial.id == ingredient.raw_material_id)) is None:
if db.scalar(
select(RawMaterial).where(
RawMaterial.id == ingredient.raw_material_id,
RawMaterial.tenant_id == session.tenant_id,
)
) is None:
raise HTTPException(status_code=404, detail=f"Raw material {ingredient.raw_material_id} not found")
db.add(
MixIngredient(
tenant_id=session.tenant_id,
mix_id=mix.id,
raw_material_id=ingredient.raw_material_id,
quantity_kg=ingredient.quantity_kg,
@@ -44,15 +52,15 @@ def create_mix(payload: MixCreate, db: Session = Depends(get_db)):
@router.get("/{mix_id}", response_model=MixRead)
def get_mix(mix_id: int, db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None:
def get_mix(mix_id: int, session: AuthSession = Depends(require_client_session), 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, db: Session = Depends(get_db)):
mix = db.scalar(select(Mix).where(Mix.id == mix_id))
def update_mix(mix_id: int, payload: MixUpdate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items():
@@ -62,19 +70,39 @@ def update_mix(mix_id: int, payload: MixUpdate, db: Session = Depends(get_db)):
@router.post("/{mix_id}/ingredients", response_model=MixRead, status_code=status.HTTP_201_CREATED)
def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None:
def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, session: AuthSession = Depends(require_client_session), 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)) is None:
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Raw material not found")
db.add(MixIngredient(mix_id=mix_id, raw_material_id=payload.raw_material_id, quantity_kg=payload.quantity_kg, notes=payload.notes))
db.add(
MixIngredient(
tenant_id=session.tenant_id,
mix_id=mix_id,
raw_material_id=payload.raw_material_id,
quantity_kg=payload.quantity_kg,
notes=payload.notes,
)
)
db.commit()
return calculate_mix_cost(db, mix_id)
@router.patch("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead)
def update_mix_ingredient(mix_id: int, ingredient_id: int, payload: MixIngredientUpdate, db: Session = Depends(get_db)):
ingredient = db.scalar(select(MixIngredient).where(MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_id))
def update_mix_ingredient(
mix_id: int,
ingredient_id: int,
payload: MixIngredientUpdate,
session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(MixIngredient).where(
MixIngredient.id == ingredient_id,
MixIngredient.mix_id == mix_id,
MixIngredient.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
for field, value in payload.model_dump(exclude_unset=True).items():
@@ -84,8 +112,14 @@ def update_mix_ingredient(mix_id: int, ingredient_id: int, payload: MixIngredien
@router.delete("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead)
def delete_mix_ingredient(mix_id: int, ingredient_id: int, db: Session = Depends(get_db)):
ingredient = db.scalar(select(MixIngredient).where(MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_id))
def delete_mix_ingredient(mix_id: int, ingredient_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
ingredient = db.scalar(
select(MixIngredient).where(
MixIngredient.id == ingredient_id,
MixIngredient.mix_id == mix_id,
MixIngredient.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
db.delete(ingredient)
@@ -94,8 +128,7 @@ def delete_mix_ingredient(mix_id: int, ingredient_id: int, db: Session = Depends
@router.get("/{mix_id}/cost-breakdown", response_model=MixRead)
def get_mix_cost_breakdown(mix_id: int, db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None:
def get_mix_cost_breakdown(mix_id: int, session: AuthSession = Depends(require_client_session), 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)
+19 -13
View File
@@ -2,37 +2,39 @@ 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.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product
from app.models.raw_material import RawMaterial
from app.models.scenario import Scenario
from app.services.client_access_service import build_client_access_export, list_client_accounts
from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, serialize_raw_material
router = APIRouter(prefix="/api/powerbi", tags=["powerbi"])
@router.get("/raw-material-costs")
def raw_material_costs(db: Session = Depends(get_db)):
materials = db.scalars(select(RawMaterial).order_by(RawMaterial.name)).all()
def raw_material_costs(session: AuthSession = Depends(require_client_session), 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(db: Session = Depends(get_db)):
mixes = db.scalars(select(Mix).order_by(Mix.name)).all()
def mix_costs(session: AuthSession = Depends(require_client_session), 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(db: Session = Depends(get_db)):
products = db.scalars(select(Product).order_by(Product.name)).all()
def product_costs(session: AuthSession = Depends(require_client_session), 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(db: Session = Depends(get_db)):
scenarios = db.scalars(select(Scenario).order_by(Scenario.created_at.desc())).all()
def scenario_results(session: AuthSession = Depends(require_client_session), 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 [
{
"scenario_id": scenario.id,
@@ -45,20 +47,24 @@ def scenario_results(db: Session = Depends(get_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))
@router.get("/data-quality-issues")
def data_quality_issues(db: Session = Depends(get_db)):
def data_quality_issues(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
issues: list[dict] = []
for mix in db.scalars(select(Mix)).all():
for mix in db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id)).all():
result = calculate_mix_cost(db, mix.id)
for warning in result["warnings"]:
issues.append({"entity_type": "mix", "entity_id": mix.id, "entity_name": mix.name, "warning": warning})
for product in db.scalars(select(Product)).all():
for product in db.scalars(select(Product).where(Product.tenant_id == session.tenant_id)).all():
result = calculate_product_cost(db, product.id)
for warning in result["warnings"]:
issues.append({"entity_type": "product", "entity_id": product.id, "entity_name": product.name, "warning": warning})
for material in db.scalars(select(RawMaterial)).all():
for material in db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == session.tenant_id)).all():
serialized = serialize_raw_material(material)
if serialized["current_price"] is None:
issues.append({"entity_type": "raw_material", "entity_id": material.id, "entity_name": material.name, "warning": "No active price"})
return issues
+17 -13
View File
@@ -2,6 +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.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product
@@ -33,16 +34,16 @@ def _serialize_product(product: Product) -> dict:
@router.get("", response_model=list[ProductRead])
def list_products(db: Session = Depends(get_db)):
products = db.scalars(select(Product).order_by(Product.name)).all()
def list_products(session: AuthSession = Depends(require_client_session), 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, db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == payload.mix_id)) is None:
def create_product(payload: ProductCreate, session: AuthSession = Depends(require_client_session), 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(**payload.model_dump())
product = Product(tenant_id=session.tenant_id, **payload.model_dump())
db.add(product)
db.commit()
db.refresh(product)
@@ -50,19 +51,19 @@ def create_product(payload: ProductCreate, db: Session = Depends(get_db)):
@router.get("/{product_id}", response_model=ProductRead)
def get_product(product_id: int, db: Session = Depends(get_db)):
product = db.scalar(select(Product).where(Product.id == product_id))
def get_product(product_id: int, session: AuthSession = Depends(require_client_session), 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")
return _serialize_product(product)
@router.patch("/{product_id}", response_model=ProductRead)
def update_product(product_id: int, payload: ProductUpdate, db: Session = Depends(get_db)):
product = db.scalar(select(Product).where(Product.id == product_id))
def update_product(product_id: int, payload: ProductUpdate, session: AuthSession = Depends(require_client_session), 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")
if payload.mix_id is not None and db.scalar(select(Mix.id).where(Mix.id == payload.mix_id)) is None:
if payload.mix_id is not None and 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")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(product, field, value)
@@ -72,7 +73,9 @@ def update_product(product_id: int, payload: ProductUpdate, db: Session = Depend
@router.get("/{product_id}/cost-breakdown", response_model=ProductCostBreakdown)
def get_product_cost_breakdown(product_id: int, db: Session = Depends(get_db)):
def get_product_cost_breakdown(product_id: int, session: AuthSession = Depends(require_client_session), 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:
return calculate_product_cost(db, product_id)
except ValueError as exc:
@@ -80,9 +83,10 @@ def get_product_cost_breakdown(product_id: int, db: Session = Depends(get_db)):
@router.get("/{product_id}/price-output", response_model=ProductCostBreakdown)
def get_product_price_output(product_id: int, db: Session = Depends(get_db)):
def get_product_price_output(product_id: int, session: AuthSession = Depends(require_client_session), 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:
return calculate_product_cost(db, product_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+38 -12
View File
@@ -2,6 +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.db.session import get_db
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.schemas.raw_material import (
@@ -33,14 +34,20 @@ def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> d
@router.get("", response_model=list[RawMaterialRead])
def list_raw_materials(db: Session = Depends(get_db)):
materials = db.scalars(select(RawMaterial).options(selectinload(RawMaterial.price_versions)).order_by(RawMaterial.name)).all()
def list_raw_materials(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
materials = db.scalars(
select(RawMaterial)
.where(RawMaterial.tenant_id == session.tenant_id)
.options(selectinload(RawMaterial.price_versions))
.order_by(RawMaterial.name)
).all()
return [serialize_raw_material(material) for material in materials]
@router.post("", response_model=RawMaterialRead, status_code=status.HTTP_201_CREATED)
def create_raw_material(payload: RawMaterialCreate, db: Session = Depends(get_db)):
def create_raw_material(payload: RawMaterialCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
material = RawMaterial(
tenant_id=session.tenant_id,
name=payload.name,
supplier=payload.supplier,
unit_of_measure=payload.unit_of_measure,
@@ -50,6 +57,7 @@ def create_raw_material(payload: RawMaterialCreate, db: Session = Depends(get_db
)
material.price_versions.append(
RawMaterialPriceVersion(
tenant_id=session.tenant_id,
market_value=payload.initial_price.market_value,
waste_percentage=payload.initial_price.waste_percentage,
effective_date=payload.initial_price.effective_date,
@@ -64,9 +72,11 @@ def create_raw_material(payload: RawMaterialCreate, db: Session = Depends(get_db
@router.get("/{raw_material_id}", response_model=RawMaterialRead)
def get_raw_material(raw_material_id: int, db: Session = Depends(get_db)):
def get_raw_material(raw_material_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
material = db.scalar(
select(RawMaterial).where(RawMaterial.id == raw_material_id).options(selectinload(RawMaterial.price_versions))
select(RawMaterial)
.where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)
.options(selectinload(RawMaterial.price_versions))
)
if material is None:
raise HTTPException(status_code=404, detail="Raw material not found")
@@ -74,9 +84,16 @@ def get_raw_material(raw_material_id: int, db: Session = Depends(get_db)):
@router.patch("/{raw_material_id}", response_model=RawMaterialRead)
def update_raw_material(raw_material_id: int, payload: RawMaterialUpdate, db: Session = Depends(get_db)):
def update_raw_material(
raw_material_id: int,
payload: RawMaterialUpdate,
session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db),
):
material = db.scalar(
select(RawMaterial).where(RawMaterial.id == raw_material_id).options(selectinload(RawMaterial.price_versions))
select(RawMaterial)
.where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)
.options(selectinload(RawMaterial.price_versions))
)
if material is None:
raise HTTPException(status_code=404, detail="Raw material not found")
@@ -88,11 +105,17 @@ def update_raw_material(raw_material_id: int, payload: RawMaterialUpdate, db: Se
@router.post("/{raw_material_id}/prices", response_model=RawMaterialPriceVersionRead, status_code=status.HTTP_201_CREATED)
def add_price_version(raw_material_id: int, payload: RawMaterialPriceVersionCreate, db: Session = Depends(get_db)):
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id))
def add_price_version(
raw_material_id: int,
payload: RawMaterialPriceVersionCreate,
session: AuthSession = Depends(require_client_session),
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")
price = RawMaterialPriceVersion(
tenant_id=session.tenant_id,
raw_material_id=raw_material_id,
market_value=payload.market_value,
waste_percentage=payload.waste_percentage,
@@ -107,13 +130,16 @@ def add_price_version(raw_material_id: int, payload: RawMaterialPriceVersionCrea
@router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead])
def get_price_history(raw_material_id: int, db: Session = Depends(get_db)):
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id))
def get_price_history(raw_material_id: int, session: AuthSession = Depends(require_client_session), 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")
prices = db.scalars(
select(RawMaterialPriceVersion)
.where(RawMaterialPriceVersion.raw_material_id == raw_material_id)
.where(
RawMaterialPriceVersion.raw_material_id == raw_material_id,
RawMaterialPriceVersion.tenant_id == session.tenant_id,
)
.order_by(RawMaterialPriceVersion.effective_date.desc())
).all()
items = []
+18 -16
View File
@@ -2,6 +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.db.session import get_db
from app.models.scenario import CostingResult, Scenario
from app.schemas.scenario import ScenarioCreate, ScenarioRead, ScenarioRunResponse
@@ -11,13 +12,13 @@ router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
@router.get("", response_model=list[ScenarioRead])
def list_scenarios(db: Session = Depends(get_db)):
return db.scalars(select(Scenario).order_by(Scenario.created_at.desc())).all()
def list_scenarios(session: AuthSession = Depends(require_client_session), 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, db: Session = Depends(get_db)):
scenario = Scenario(name=payload.name, description=payload.description, overrides=payload.overrides)
def create_scenario(payload: ScenarioCreate, session: AuthSession = Depends(require_client_session), 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()
db.refresh(scenario)
@@ -25,16 +26,16 @@ def create_scenario(payload: ScenarioCreate, db: Session = Depends(get_db)):
@router.get("/{scenario_id}", response_model=ScenarioRead)
def get_scenario(scenario_id: int, db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
def get_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), 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")
return scenario
@router.post("/{scenario_id}/run", response_model=ScenarioRunResponse)
def run_scenario_endpoint(scenario_id: int, db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
def run_scenario_endpoint(scenario_id: int, session: AuthSession = Depends(require_client_session), 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")
results = run_scenario(db, scenario)
@@ -43,11 +44,13 @@ def run_scenario_endpoint(scenario_id: int, db: Session = Depends(get_db)):
@router.get("/{scenario_id}/results")
def get_scenario_results(scenario_id: int, db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
def get_scenario_results(scenario_id: int, session: AuthSession = Depends(require_client_session), 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")
results = db.scalars(select(CostingResult).where(CostingResult.scenario_id == scenario_id)).all()
results = db.scalars(
select(CostingResult).where(CostingResult.scenario_id == scenario_id, CostingResult.tenant_id == session.tenant_id)
).all()
return [
{
"product_id": result.product_id,
@@ -62,8 +65,8 @@ def get_scenario_results(scenario_id: int, db: Session = Depends(get_db)):
@router.post("/{scenario_id}/approve", response_model=ScenarioRead)
def approve_scenario(scenario_id: int, db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
def approve_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), 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")
scenario.status = "approved"
@@ -73,12 +76,11 @@ def approve_scenario(scenario_id: int, db: Session = Depends(get_db)):
@router.post("/{scenario_id}/reject", response_model=ScenarioRead)
def reject_scenario(scenario_id: int, db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
def reject_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), 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")
scenario.status = "rejected"
db.commit()
db.refresh(scenario)
return scenario