tweaks
This commit is contained in:
+14
-14
@@ -109,15 +109,11 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio
|
||||
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.
|
||||
Authenticates against the per-user password hash stored on ``users``.
|
||||
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()
|
||||
user = db.scalar(
|
||||
select(User)
|
||||
@@ -127,6 +123,12 @@ def login(payload: LoginRequest, response: Response, request: Request, db: Sessi
|
||||
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")
|
||||
if not (
|
||||
verify_password(payload.password, user.password_hash)
|
||||
or (user.password_hash is None and 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")
|
||||
|
||||
session = _serialize_session(user, include_token=True)
|
||||
if session.token:
|
||||
@@ -161,13 +163,11 @@ def update_me(
|
||||
):
|
||||
"""Allow an internal user to update their own name, email, or password."""
|
||||
if payload.new_password:
|
||||
# Require current password verification before allowing a password change.
|
||||
# Users who have never set a personal password must supply the shared
|
||||
# admin password as the current credential.
|
||||
current_ok = (
|
||||
verify_password(payload.current_password or "", user.password_hash)
|
||||
if user.password_hash
|
||||
else (payload.current_password or "") == settings.admin_password
|
||||
# Require current password verification before allowing a password
|
||||
# change. Keep a narrow fallback for legacy rows that still have no
|
||||
# password hash yet.
|
||||
current_ok = verify_password(payload.current_password or "", user.password_hash) or (
|
||||
user.password_hash is None and (payload.current_password or "") == settings.admin_password
|
||||
)
|
||||
if not current_ok:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.services.mix_calculator_service import (
|
||||
update_mix_calculator_session,
|
||||
)
|
||||
from app.services.mix_calculator_pdf import MixCalculatorPdfUnavailableError, build_mix_calculator_pdf
|
||||
from app.services.mix_calculator_filenames import mix_calculator_pdf_filename
|
||||
from app.services.mix_calculator_filenames import mix_calculator_pdf_filename, mix_calculator_preview_pdf_filename
|
||||
|
||||
router = APIRouter(prefix="/api/mix-calculator", tags=["mix-calculator"])
|
||||
|
||||
@@ -56,6 +56,28 @@ def preview_mix_calculator_session(
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/preview/pdf")
|
||||
def preview_mix_calculator_session_pdf(
|
||||
payload: MixCalculatorSessionCreate,
|
||||
session: AuthSession = Depends(require_client_module_access("mix_calculator", "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
preview = calculate_mix_calculator_preview(db, tenant_id=session.tenant_id or "", payload=payload)
|
||||
pdf_bytes = build_mix_calculator_pdf(preview)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except MixCalculatorPdfUnavailableError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
||||
|
||||
filename = mix_calculator_preview_pdf_filename(MixCalculatorPreviewRead.model_validate(preview))
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=MixCalculatorSessionRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_saved_mix_calculator_session(
|
||||
payload: MixCalculatorSessionCreate,
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import AuthSession, require_client_module_access
|
||||
from app.db.session import get_db
|
||||
from app.models.throughput import ProductionThroughput, ThroughputProduct
|
||||
from app.schemas.throughput import (
|
||||
ThroughputEntryCreate,
|
||||
ThroughputEntryRead,
|
||||
ThroughputEntryUpdate,
|
||||
ThroughputProductCreate,
|
||||
ThroughputProductRead,
|
||||
ThroughputProductUpdate,
|
||||
)
|
||||
from app.services.throughput_service import (
|
||||
calculate_kg,
|
||||
normalise_staff_name,
|
||||
serialize_entry,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/throughput", tags=["operations-throughput"])
|
||||
|
||||
MODULE_KEY = "operations_throughput"
|
||||
|
||||
|
||||
@router.get("/products", response_model=list[ThroughputProductRead])
|
||||
def list_products(
|
||||
include_inactive: bool = Query(default=False),
|
||||
session: AuthSession = Depends(require_client_module_access(MODULE_KEY)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(ThroughputProduct).where(ThroughputProduct.tenant_id == session.tenant_id)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(ThroughputProduct.active.is_(True))
|
||||
stmt = stmt.order_by(ThroughputProduct.name)
|
||||
return db.scalars(stmt).all()
|
||||
|
||||
|
||||
@router.post("/products", response_model=ThroughputProductRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_product(
|
||||
payload: ThroughputProductCreate,
|
||||
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if payload.item_id:
|
||||
existing = db.scalar(
|
||||
select(ThroughputProduct).where(
|
||||
ThroughputProduct.tenant_id == session.tenant_id,
|
||||
ThroughputProduct.item_id == payload.item_id,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail="A product with this item_id already exists")
|
||||
|
||||
product = ThroughputProduct(
|
||||
tenant_id=session.tenant_id,
|
||||
item_id=payload.item_id,
|
||||
name=payload.name,
|
||||
default_bag_size=payload.default_bag_size,
|
||||
is_bulka_default=payload.is_bulka_default,
|
||||
active=payload.active,
|
||||
is_stock_item=payload.is_stock_item,
|
||||
notes=payload.notes,
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@router.patch("/products/{product_id}", response_model=ThroughputProductRead)
|
||||
def update_product(
|
||||
product_id: int,
|
||||
payload: ThroughputProductUpdate,
|
||||
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
product = db.scalar(
|
||||
select(ThroughputProduct).where(
|
||||
ThroughputProduct.id == product_id,
|
||||
ThroughputProduct.tenant_id == session.tenant_id,
|
||||
)
|
||||
)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(product, field, value)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@router.get("/entries", response_model=list[ThroughputEntryRead])
|
||||
def list_entries(
|
||||
date_from: date | None = Query(default=None),
|
||||
date_to: date | None = Query(default=None),
|
||||
product_id: int | None = Query(default=None),
|
||||
staff_name: str | None = Query(default=None),
|
||||
quantity_type: str | None = Query(default=None),
|
||||
limit: int = Query(default=200, ge=1, le=1000),
|
||||
session: AuthSession = Depends(require_client_module_access(MODULE_KEY)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(ProductionThroughput).where(ProductionThroughput.tenant_id == session.tenant_id)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(ProductionThroughput.production_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(ProductionThroughput.production_date <= date_to)
|
||||
if product_id is not None:
|
||||
stmt = stmt.where(ProductionThroughput.product_id == product_id)
|
||||
if staff_name:
|
||||
stmt = stmt.where(ProductionThroughput.staff_name == staff_name.strip())
|
||||
if quantity_type in {"bags", "kg"}:
|
||||
stmt = stmt.where(ProductionThroughput.quantity_type == quantity_type)
|
||||
stmt = stmt.order_by(ProductionThroughput.production_date.desc(), ProductionThroughput.id.desc()).limit(limit)
|
||||
return [serialize_entry(entry) for entry in db.scalars(stmt).all()]
|
||||
|
||||
|
||||
@router.post("/entries", response_model=ThroughputEntryRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_entry(
|
||||
payload: ThroughputEntryCreate,
|
||||
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
product = None
|
||||
if payload.product_id is not None:
|
||||
product = db.scalar(
|
||||
select(ThroughputProduct).where(
|
||||
ThroughputProduct.id == payload.product_id,
|
||||
ThroughputProduct.tenant_id == session.tenant_id,
|
||||
)
|
||||
)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=400, detail="product_id does not match an existing product")
|
||||
|
||||
snapshot = payload.product_name_snapshot or (product.name if product else None)
|
||||
if not snapshot:
|
||||
raise HTTPException(status_code=400, detail="product_name_snapshot or product_id is required")
|
||||
|
||||
bag_size = payload.bag_size
|
||||
if bag_size is None and product is not None:
|
||||
bag_size = product.default_bag_size
|
||||
|
||||
if payload.quantity_type == "bags" and (bag_size is None or bag_size <= 0):
|
||||
raise HTTPException(status_code=400, detail="bag_size is required when quantity_type is 'bags'")
|
||||
|
||||
calculated = calculate_kg(payload.quantity, payload.quantity_type, bag_size)
|
||||
|
||||
entry = ProductionThroughput(
|
||||
tenant_id=session.tenant_id,
|
||||
production_date=payload.production_date,
|
||||
product_id=product.id if product else None,
|
||||
product_name_snapshot=snapshot,
|
||||
bag_size=bag_size,
|
||||
scales_checked=payload.scales_checked,
|
||||
label_correct=payload.label_correct,
|
||||
bag_sealed=payload.bag_sealed,
|
||||
pallet_good_condition=payload.pallet_good_condition,
|
||||
sample_box_no=payload.sample_box_no,
|
||||
test_weight_1=payload.test_weight_1,
|
||||
test_weight_2=payload.test_weight_2,
|
||||
test_weight_3=payload.test_weight_3,
|
||||
test_weight_4=payload.test_weight_4,
|
||||
test_weight_5=payload.test_weight_5,
|
||||
quantity=payload.quantity,
|
||||
quantity_type=payload.quantity_type,
|
||||
calculated_kg=calculated,
|
||||
staff_name=normalise_staff_name(payload.staff_name),
|
||||
notes=payload.notes,
|
||||
created_by=session.email,
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return serialize_entry(entry)
|
||||
|
||||
|
||||
@router.get("/entries/{entry_id}", response_model=ThroughputEntryRead)
|
||||
def get_entry(
|
||||
entry_id: int,
|
||||
session: AuthSession = Depends(require_client_module_access(MODULE_KEY)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
entry = db.scalar(
|
||||
select(ProductionThroughput).where(
|
||||
ProductionThroughput.id == entry_id,
|
||||
ProductionThroughput.tenant_id == session.tenant_id,
|
||||
)
|
||||
)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
return serialize_entry(entry)
|
||||
|
||||
|
||||
@router.patch("/entries/{entry_id}", response_model=ThroughputEntryRead)
|
||||
def update_entry(
|
||||
entry_id: int,
|
||||
payload: ThroughputEntryUpdate,
|
||||
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
entry = db.scalar(
|
||||
select(ProductionThroughput).where(
|
||||
ProductionThroughput.id == entry_id,
|
||||
ProductionThroughput.tenant_id == session.tenant_id,
|
||||
)
|
||||
)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "staff_name" in data:
|
||||
data["staff_name"] = normalise_staff_name(data["staff_name"])
|
||||
for field, value in data.items():
|
||||
setattr(entry, field, value)
|
||||
|
||||
entry.calculated_kg = calculate_kg(entry.quantity, entry.quantity_type, entry.bag_size)
|
||||
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return serialize_entry(entry)
|
||||
|
||||
|
||||
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_entry(
|
||||
entry_id: int,
|
||||
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "manage")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
entry = db.scalar(
|
||||
select(ProductionThroughput).where(
|
||||
ProductionThroughput.id == entry_id,
|
||||
ProductionThroughput.tenant_id == session.tenant_id,
|
||||
)
|
||||
)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
return None
|
||||
Reference in New Issue
Block a user