Updates
This commit is contained in:
@@ -7,7 +7,7 @@ the current user has, then use those keys to hide/show navigation items.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
@@ -21,12 +21,19 @@ from app.core.access import (
|
||||
require_permission,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.http import CLIENT_AUTH_COOKIE
|
||||
from app.core.rate_limit import SlidingWindowRateLimiter, request_client_key
|
||||
from app.core.security_logging import log_security_event
|
||||
from app.core.security import hash_password, issue_token, verify_password
|
||||
from app.db.session import get_db
|
||||
from app.models.access import Permission, Role, User
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/access", tags=["access"])
|
||||
login_rate_limiter = SlidingWindowRateLimiter(
|
||||
limit=settings.login_rate_limit_attempts,
|
||||
window_seconds=settings.login_rate_limit_window_seconds,
|
||||
)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -75,7 +82,10 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio
|
||||
role_name = user.role.name if user.role else None
|
||||
token = None
|
||||
if include_token:
|
||||
token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email})
|
||||
token = issue_token(
|
||||
{"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email},
|
||||
ttl_seconds=settings.session_ttl_seconds,
|
||||
)
|
||||
# role="internal" is a marker the shared auth deps recognise so internal
|
||||
# users can hit the same routes as client-portal users without being
|
||||
# confused with them. Display name lives in role_name / client_role.
|
||||
@@ -96,14 +106,16 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio
|
||||
|
||||
|
||||
@router.post("/login", response_model=UserSession)
|
||||
def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
||||
def login(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)):
|
||||
"""Internal-user login.
|
||||
|
||||
Authenticates against a shared internal password (``ADMIN_PASSWORD``) and
|
||||
looks up the user by email. Inactive or unknown users are rejected with
|
||||
a generic 401 to avoid leaking which emails are valid.
|
||||
"""
|
||||
login_rate_limiter.hit(request_client_key(request, suffix="internal-login"))
|
||||
if payload.password != settings.admin_password:
|
||||
log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request))
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
|
||||
|
||||
email = payload.email.strip().lower()
|
||||
@@ -113,15 +125,20 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
||||
.options(selectinload(User.role).selectinload(Role.permissions))
|
||||
)
|
||||
if user is None or not user.is_active:
|
||||
log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request))
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
|
||||
|
||||
return _serialize_session(user, include_token=True)
|
||||
session = _serialize_session(user, include_token=True)
|
||||
if session.token:
|
||||
CLIENT_AUTH_COOKIE.apply(response, session.token)
|
||||
log_security_event("auth.login_succeeded", audience="internal", role=user.role.name if user.role else None, user_id=user.id)
|
||||
return session.model_copy(update={"token": None})
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserSession)
|
||||
def read_me(user: User = Depends(get_current_user)):
|
||||
"""Return the current user with permission keys for UI navigation gating."""
|
||||
return _serialize_session(user)
|
||||
return _serialize_session(user).model_copy(update={"token": None})
|
||||
|
||||
|
||||
@router.get("/me/permissions", response_model=list[str])
|
||||
@@ -181,7 +198,14 @@ def update_me(
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return _serialize_session(user, include_token=True)
|
||||
return _serialize_session(user, include_token=True).model_copy(update={"token": None})
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def logout(response: Response):
|
||||
CLIENT_AUTH_COOKIE.clear(response)
|
||||
response.status_code = status.HTTP_204_NO_CONTENT
|
||||
return None
|
||||
|
||||
|
||||
# Permission-enforced administrative endpoints. Route bodies should not check
|
||||
|
||||
+44
-9
@@ -1,16 +1,23 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
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.http import ADMIN_AUTH_COOKIE, CLIENT_AUTH_COOKIE
|
||||
from app.core.rate_limit import SlidingWindowRateLimiter, request_client_key
|
||||
from app.core.security_logging import log_security_event
|
||||
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"])
|
||||
login_rate_limiter = SlidingWindowRateLimiter(
|
||||
limit=settings.login_rate_limit_attempts,
|
||||
window_seconds=settings.login_rate_limit_window_seconds,
|
||||
)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -27,7 +34,7 @@ class SessionResponse(BaseModel):
|
||||
user_id: int | None = None
|
||||
client_account_id: int | None = None
|
||||
module_permissions: dict[str, str] = Field(default_factory=dict)
|
||||
token: str
|
||||
token: str | None = None
|
||||
|
||||
|
||||
def _build_session_response(
|
||||
@@ -50,7 +57,8 @@ def _build_session_response(
|
||||
"client_role": client_role,
|
||||
"user_id": user_id,
|
||||
"client_account_id": client_account_id,
|
||||
}
|
||||
},
|
||||
ttl_seconds=settings.session_ttl_seconds,
|
||||
)
|
||||
return SessionResponse(
|
||||
name=name,
|
||||
@@ -66,19 +74,22 @@ def _build_session_response(
|
||||
|
||||
|
||||
@router.post("/client/login", response_model=SessionResponse)
|
||||
def client_login(payload: LoginRequest, db: Session = Depends(get_db)):
|
||||
def client_login(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)):
|
||||
login_rate_limiter.hit(request_client_key(request, suffix="client-login"))
|
||||
if payload.password != settings.client_password:
|
||||
log_security_event("auth.login_failed", audience="client", ip=request_client_key(request))
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password")
|
||||
|
||||
user = get_client_user_by_email(db, email=payload.email.strip().lower())
|
||||
if user is None:
|
||||
log_security_event("auth.login_failed", audience="client", ip=request_client_key(request))
|
||||
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 for this user")
|
||||
|
||||
return _build_session_response(
|
||||
session_response = _build_session_response(
|
||||
name=user.full_name,
|
||||
email=user.email,
|
||||
role="client",
|
||||
@@ -88,14 +99,24 @@ def client_login(payload: LoginRequest, db: Session = Depends(get_db)):
|
||||
client_account_id=client_account.id,
|
||||
module_permissions=module_access_map(user),
|
||||
)
|
||||
if session_response.token:
|
||||
CLIENT_AUTH_COOKIE.apply(response, session_response.token)
|
||||
log_security_event("auth.login_succeeded", audience="client", role="client", user_id=user.id, tenant_id=client_account.tenant_id)
|
||||
return session_response.model_copy(update={"token": None})
|
||||
|
||||
|
||||
@router.post("/admin/login", response_model=SessionResponse)
|
||||
def admin_login(payload: LoginRequest):
|
||||
def admin_login(payload: LoginRequest, response: Response, request: Request):
|
||||
login_rate_limiter.hit(request_client_key(request, suffix="admin-login"))
|
||||
if payload.email.strip().lower() != settings.admin_email.lower() or payload.password != settings.admin_password:
|
||||
log_security_event("auth.login_failed", audience="admin", ip=request_client_key(request))
|
||||
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")
|
||||
session_response = _build_session_response(name=settings.admin_name, email=settings.admin_email, role="admin")
|
||||
if session_response.token:
|
||||
ADMIN_AUTH_COOKIE.apply(response, session_response.token)
|
||||
log_security_event("auth.login_succeeded", audience="admin", role="admin")
|
||||
return session_response.model_copy(update={"token": None})
|
||||
|
||||
|
||||
@router.get("/client/session", response_model=SessionResponse)
|
||||
@@ -112,9 +133,23 @@ def read_client_session(session: AuthSession = Depends(require_client_session),
|
||||
user_id=user.id,
|
||||
client_account_id=user.client_account_id,
|
||||
module_permissions=module_access_map(user),
|
||||
)
|
||||
).model_copy(update={"token": None})
|
||||
|
||||
|
||||
@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)
|
||||
return _build_session_response(name=session.name, email=session.email, role=session.role).model_copy(update={"token": None})
|
||||
|
||||
|
||||
@router.post("/client/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def client_logout(response: Response):
|
||||
CLIENT_AUTH_COOKIE.clear(response)
|
||||
response.status_code = status.HTTP_204_NO_CONTENT
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/admin/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def admin_logout(response: Response):
|
||||
ADMIN_AUTH_COOKIE.clear(response)
|
||||
response.status_code = status.HTTP_204_NO_CONTENT
|
||||
return None
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
@@ -78,10 +78,11 @@ def _actor_metadata(session: AuthSession) -> dict[str, str]:
|
||||
|
||||
@router.get("", response_model=list[ClientAccessRead])
|
||||
def get_client_access(
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
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)]
|
||||
return [serialize_client_account(client) for client in _authorized_client_scope(db, session)[:limit]]
|
||||
|
||||
|
||||
@router.post("/users", response_model=ClientAccessRead, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends
|
||||
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.mix import Mix
|
||||
from app.models.product import Product
|
||||
@@ -35,7 +35,7 @@ def _can(session: AuthSession, module_key: str) -> bool:
|
||||
|
||||
@router.get("/summary")
|
||||
def dashboard_summary(
|
||||
session: AuthSession = Depends(require_client_session),
|
||||
session: AuthSession = Depends(require_client_module_access("dashboard")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
raw_materials_summary: dict | None = None
|
||||
|
||||
+14
-7
@@ -2,8 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
@@ -13,14 +12,14 @@ from app.core.access import (
|
||||
get_user_permissions,
|
||||
permissions_to_module_map,
|
||||
)
|
||||
from app.core.http import ADMIN_AUTH_COOKIE, CLIENT_AUTH_COOKIE, get_bearer_or_cookie_token
|
||||
from app.core.security_logging import log_security_event
|
||||
from app.core.security import verify_token
|
||||
from app.db.session import get_db
|
||||
from app.models.access import Role, User
|
||||
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)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthSession:
|
||||
@@ -67,13 +66,16 @@ def _build_internal_auth_session(db: Session, payload: dict) -> AuthSession:
|
||||
|
||||
|
||||
def get_auth_session(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> AuthSession:
|
||||
if credentials is None:
|
||||
token = get_bearer_or_cookie_token(request, cookie_name=CLIENT_AUTH_COOKIE.name) or get_bearer_or_cookie_token(
|
||||
request, cookie_name=ADMIN_AUTH_COOKIE.name
|
||||
)
|
||||
if token is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||
|
||||
payload = verify_token(credentials.credentials)
|
||||
payload = verify_token(token)
|
||||
|
||||
# Internal Hunter Stock Feeds users get an auth session derived from the
|
||||
# role/permission tables rather than the client-portal ClientUser tables.
|
||||
@@ -111,6 +113,7 @@ def require_client_session(session: AuthSession = Depends(get_auth_session)) ->
|
||||
|
||||
def require_admin_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
|
||||
if session.role != "admin":
|
||||
log_security_event("authz.denied", role=session.role, required="admin")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return session
|
||||
|
||||
@@ -143,6 +146,7 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
|
||||
if session.role == "internal":
|
||||
permissions = session.module_permissions or {}
|
||||
if not has_access_level(permissions.get(module_key), minimum_level):
|
||||
log_security_event("authz.denied", role=session.role, module=module_key, access_level=minimum_level)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"{module_key} access is not permitted",
|
||||
@@ -158,10 +162,12 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
|
||||
)
|
||||
)
|
||||
if feature is not None and not feature.enabled:
|
||||
log_security_event("authz.denied", role=session.role, module=module_key, reason="feature_disabled")
|
||||
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):
|
||||
log_security_event("authz.denied", role=session.role, module=module_key, access_level=minimum_level)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} access is not permitted")
|
||||
|
||||
return AuthSession(
|
||||
@@ -190,6 +196,7 @@ def require_client_access_manager_session(
|
||||
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"):
|
||||
log_security_event("authz.denied", role=session.role, module="client_access", access_level="manage")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin client access is required")
|
||||
|
||||
return AuthSession(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import AuthSession, require_client_module_access
|
||||
@@ -37,10 +37,11 @@ def mix_calculator_options(
|
||||
|
||||
@router.get("", response_model=list[MixCalculatorSessionSummaryRead])
|
||||
def mix_calculator_sessions(
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_mix_calculator_sessions(db, auth_session=session)
|
||||
return list_mix_calculator_sessions(db, auth_session=session, limit=limit)
|
||||
|
||||
|
||||
@router.post("/preview", response_model=MixCalculatorPreviewRead)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -13,8 +13,12 @@ router = APIRouter(prefix="/api/mixes", tags=["mixes"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[MixRead])
|
||||
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()
|
||||
def list_mixes(
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
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).limit(limit)).all()
|
||||
return [calculate_mix_cost(db, mix.id) for mix in mixes]
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -23,6 +23,7 @@ def _serialize_product(product: Product) -> dict:
|
||||
"mix_name": product.mix.name if product.mix else "",
|
||||
"sale_type": product.sale_type,
|
||||
"own_bag": product.own_bag,
|
||||
"visible": product.visible,
|
||||
"unit_of_measure": product.unit_of_measure,
|
||||
"items_per_pallet": product.items_per_pallet,
|
||||
"bagging_process": product.bagging_process,
|
||||
@@ -34,8 +35,12 @@ def _serialize_product(product: Product) -> dict:
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProductRead])
|
||||
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()
|
||||
def list_products(
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
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).limit(limit)).all()
|
||||
return [_serialize_product(product) for product in products]
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
@@ -34,12 +34,17 @@ def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> d
|
||||
|
||||
|
||||
@router.get("", response_model=list[RawMaterialRead])
|
||||
def list_raw_materials(session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)):
|
||||
def list_raw_materials(
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
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)
|
||||
.options(selectinload(RawMaterial.price_versions))
|
||||
.order_by(RawMaterial.name)
|
||||
.limit(limit)
|
||||
).all()
|
||||
return [serialize_raw_material(material) for material in materials]
|
||||
|
||||
@@ -130,7 +135,12 @@ 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_module_access("raw_materials")), db: Session = Depends(get_db)):
|
||||
def get_price_history(
|
||||
raw_material_id: int,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
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")
|
||||
@@ -141,6 +151,7 @@ def get_price_history(raw_material_id: int, session: AuthSession = Depends(requi
|
||||
RawMaterialPriceVersion.tenant_id == session.tenant_id,
|
||||
)
|
||||
.order_by(RawMaterialPriceVersion.effective_date.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
items = []
|
||||
for price in prices:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -12,8 +12,12 @@ router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ScenarioRead])
|
||||
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()
|
||||
def list_scenarios(
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
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()).limit(limit)).all()
|
||||
|
||||
|
||||
@router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
Reference in New Issue
Block a user