Access permissions, seed permissions, security, session, api/session improved handling + speed across the site/UX improvements

This commit is contained in:
2026-05-08 00:00:56 +12:00
parent ebee72d4df
commit 1533b5aa9b
29 changed files with 1851 additions and 520 deletions
+177
View File
@@ -0,0 +1,177 @@
"""Internal-user authentication and permission introspection routes.
Frontends should call ``GET /api/access/me`` to discover which permission keys
the current user has, then use those keys to hide/show navigation items.
**Visibility is not security** — every privileged backend route must depend on
``require_permission`` (or one of its siblings) directly.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.access import (
INTERNAL_USER_SUBJECT,
INTERNAL_USER_TENANT_ID,
get_current_user,
get_user_permissions,
permissions_to_module_map,
require_permission,
)
from app.core.config import settings
from app.core.security import issue_token
from app.db.session import get_db
from app.models.access import Permission, Role, User
router = APIRouter(prefix="/api/access", tags=["access"])
class LoginRequest(BaseModel):
email: str
password: str
class UserSession(BaseModel):
# Mirrors the existing `LoginResponse` shape so the frontend's `AppSession`
# store can consume this response without a separate type. `permissions`
# is the new permission-key array; `module_permissions` is the legacy
# module→access-level map for nav gating.
user_id: int
email: str
name: str
role: str
role_name: str | None = None
is_active: bool
tenant_id: str | None = None
client_role: str | None = None
client_account_id: int | None = None
module_permissions: dict[str, str] = {}
permissions: list[str]
token: str | None = None
class RoleRead(BaseModel):
id: int
name: str
description: str | None
permissions: list[str]
class UserRead(BaseModel):
id: int
email: str
name: str
is_active: bool
role: str | None
def _serialize_session(user: User, *, include_token: bool = False) -> UserSession:
permission_set = get_user_permissions(user)
permissions = sorted(permission_set)
module_permissions = permissions_to_module_map(permission_set)
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})
# 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.
return UserSession(
user_id=user.id,
email=user.email,
name=user.name,
role="internal",
role_name=role_name,
is_active=user.is_active,
tenant_id=INTERNAL_USER_TENANT_ID,
client_role=role_name,
client_account_id=None,
module_permissions=module_permissions,
permissions=permissions,
token=token,
)
@router.post("/login", response_model=UserSession)
def login(payload: LoginRequest, 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.
"""
if payload.password != settings.admin_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
email = payload.email.strip().lower()
user = db.scalar(
select(User)
.where(User.email == email)
.options(selectinload(User.role).selectinload(Role.permissions))
)
if user is None or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
return _serialize_session(user, include_token=True)
@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)
@router.get("/me/permissions", response_model=list[str])
def read_my_permissions(user: User = Depends(get_current_user)):
return sorted(get_user_permissions(user))
# Permission-enforced administrative endpoints. Route bodies should not check
# role names — every gate is the require_permission(...) dependency.
@router.get("/users", response_model=list[UserRead])
def list_users(
db: Session = Depends(get_db),
_: User = Depends(require_permission("view_users")), # gated by permission key
):
users = db.scalars(select(User).options(selectinload(User.role))).all()
return [
UserRead(
id=user.id,
email=user.email,
name=user.name,
is_active=user.is_active,
role=user.role.name if user.role else None,
)
for user in users
]
@router.get("/roles", response_model=list[RoleRead])
def list_roles(
db: Session = Depends(get_db),
_: User = Depends(require_permission("manage_permissions")), # gated by permission key
):
roles = db.scalars(
select(Role).options(selectinload(Role.permissions)).order_by(Role.name)
).all()
return [
RoleRead(
id=role.id,
name=role.name,
description=role.description,
permissions=sorted(p.key for p in role.permissions),
)
for role in roles
]
@router.get("/permissions", response_model=list[str])
def list_permissions(
db: Session = Depends(get_db),
_: User = Depends(require_permission("manage_permissions")), # gated by permission key
):
return sorted(p.key for p in db.scalars(select(Permission)).all())
+150
View File
@@ -0,0 +1,150 @@
"""Dashboard summary endpoint.
Returns only the aggregates the homepage actually renders — counts, top items,
totals, and a trend-chart series. Replaces a Dashboard load that previously
fetched five full collections (raw materials, mixes, all product cost
breakdowns, scenarios, data-quality) and only used summaries from each.
"""
from __future__ import annotations
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.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.services.client_access_service import has_access_level
from app.services.costing_engine import (
calculate_mix_cost,
calculate_product_cost,
get_active_price,
calculate_raw_material_cost,
)
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
def _can(session: AuthSession, module_key: str) -> bool:
permissions = session.module_permissions or {}
return has_access_level(permissions.get(module_key), "view")
@router.get("/summary")
def dashboard_summary(
session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db),
):
raw_materials_summary: dict | None = None
mixes_summary: dict | None = None
products_summary: dict | None = None
raw_series: list[float] = []
mix_series: list[float] = []
product_series: list[float] = []
if _can(session, "raw_materials") or _can(session, "dashboard"):
materials = db.scalars(
select(RawMaterial)
.where(RawMaterial.tenant_id == session.tenant_id)
.options(selectinload(RawMaterial.price_versions))
).all()
total_market_value = 0.0
latest = None
latest_date = None
for material in materials:
active = get_active_price(material)
if active is None:
continue
comp = calculate_raw_material_cost(material, active)
raw_series.append(comp.cost_per_kg)
total_market_value += active.market_value
if latest_date is None or active.effective_date > latest_date:
latest_date = active.effective_date
latest = {
"id": material.id,
"name": material.name,
"market_value": active.market_value,
"cost_per_kg": comp.cost_per_kg,
"effective_date": active.effective_date.isoformat() if active.effective_date else None,
}
raw_materials_summary = {
"count": len(materials),
"total_market_value": round(total_market_value, 4),
"latest": latest,
}
if _can(session, "mix_master") or _can(session, "dashboard"):
mix_rows = db.scalars(
select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)
).all()
cost_sum = 0.0
cost_count = 0
top_mix: dict | None = None
for mix in mix_rows:
result = calculate_mix_cost(db, mix.id)
cost_per_kg = result.get("mix_cost_per_kg")
if cost_per_kg is not None:
mix_series.append(cost_per_kg)
cost_sum += cost_per_kg
cost_count += 1
if top_mix is None or (cost_per_kg or 0) > (top_mix.get("mix_cost_per_kg") or 0):
top_mix = {
"id": result["id"],
"name": result["name"],
"client_name": result["client_name"],
"ingredients_count": len(result["ingredients"]),
"total_mix_kg": result["total_mix_kg"],
"total_mix_cost": result["total_mix_cost"],
"mix_cost_per_kg": cost_per_kg,
"warnings": result["warnings"],
}
mixes_summary = {
"count": len(mix_rows),
"average_cost_per_kg": round(cost_sum / cost_count, 4) if cost_count else 0.0,
"top": top_mix,
}
if _can(session, "products") or _can(session, "dashboard"):
products = db.scalars(
select(Product).where(Product.tenant_id == session.tenant_id)
).all()
rows: list[dict] = []
for product in products:
result = calculate_product_cost(db, product.id)
finished = result.get("finished_product_delivered") or 0.0
product_series.append(finished)
rows.append(
{
"id": product.id,
"product_name": result["product_name"],
"client_name": result["client_name"],
"finished_product_delivered": finished,
"warnings": result["warnings"],
}
)
rows.sort(key=lambda row: row["finished_product_delivered"], reverse=True)
products_summary = {
"count": len(products),
"top": rows[0] if rows else None,
"top_products": rows[:4],
}
return {
"raw_materials": raw_materials_summary,
"mixes": mixes_summary,
"products": products_summary,
# Pre-computed numeric series for the homepage trend chart so the
# client doesn't need full collections to draw it.
"trend_seeds": {
"raw_material_cost_per_kg": raw_series,
"mix_cost_per_kg": mix_series,
"product_finished_delivered": product_series,
},
}
+68 -1
View File
@@ -7,8 +7,15 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.access import (
INTERNAL_USER_SUBJECT,
INTERNAL_USER_TENANT_ID,
get_user_permissions,
permissions_to_module_map,
)
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
@@ -27,11 +34,52 @@ class AuthSession:
module_permissions: dict[str, str] | None = None
def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme)) -> AuthSession:
def _build_internal_auth_session(db: Session, payload: dict) -> AuthSession:
"""Translate an internal-user token into an AuthSession the shared route
dependencies can consume. Internal users present `role="internal"` so
`require_client_module_access` can spot them and skip the ClientUser DB
lookup, deriving their module_permissions from the role-permission table.
"""
user_id = payload.get("user_id")
if not isinstance(user_id, int):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
user = db.scalar(
select(User)
.where(User.id == user_id)
.options(selectinload(User.role).selectinload(Role.permissions))
)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User no longer exists")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is inactive")
return AuthSession(
role="internal",
email=user.email,
name=user.name,
tenant_id=INTERNAL_USER_TENANT_ID,
client_role=user.role.name if user.role else None,
user_id=user.id,
client_account_id=None,
module_permissions=permissions_to_module_map(get_user_permissions(user)),
)
def get_auth_session(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> AuthSession:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
payload = verify_token(credentials.credentials)
# Internal Hunter Stock Feeds users get an auth session derived from the
# role/permission tables rather than the client-portal ClientUser tables.
if payload.get("sub") == INTERNAL_USER_SUBJECT:
return _build_internal_auth_session(db, payload)
return AuthSession(
role=str(payload.get("role", "")),
email=str(payload.get("email", "")),
@@ -45,6 +93,13 @@ def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(
def require_client_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
# Internal Hunter Stock Feeds users share the workspace with client users
# but don't have a ClientUser row, so we accept them here and let
# `require_client_module_access` enforce the per-module checks.
if session.role == "internal":
if not session.tenant_id or not session.user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Internal user context is missing")
return session
if session.role != "client":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access required")
if not session.tenant_id:
@@ -82,6 +137,18 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db),
) -> AuthSession:
# Internal users have their permissions baked into the AuthSession at
# token-resolve time; skip the ClientUser/feature DB lookups and check
# the in-memory module_permissions map directly.
if session.role == "internal":
permissions = session.module_permissions or {}
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 session
user = load_current_client_user(db, session)
feature = db.scalar(
select(ClientFeatureAccess).where(
+193
View File
@@ -0,0 +1,193 @@
"""Database-driven role/permission access control.
This module is the single source of truth for permission enforcement on
internal Hunter Stock Feeds users. Route handlers should depend on
``require_permission`` / ``require_any_permission`` / ``require_all_permissions``
rather than checking role names. To change access later, update the database
seed (``app.seed_access``) — no route code should need to change.
Fail-closed rules enforced here:
* Missing or invalid token -> 401
* Unknown user_id -> 401
* Inactive user -> 403
* No role / missing perm -> 403
"""
from __future__ import annotations
from typing import Iterable
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.access import Permission, Role, User
bearer_scheme = HTTPBearer(auto_error=False)
# Subject claim used by tokens issued for internal Hunter Stock Feeds users.
# Distinct from the existing client-portal/admin tokens so the two systems
# cannot impersonate each other.
INTERNAL_USER_SUBJECT = "internal_user"
# Default tenant internal Hunter Stock Feeds users operate against. Internal
# users see the production workspace data, so they read/write the same tenant
# the costing seed populates.
INTERNAL_USER_TENANT_ID = "hunter-premium-produce"
# Maps the internal permission keys onto the legacy module/access-level shape
# the existing client-portal routes already understand. This lets the shared
# routes (raw materials, mixes, products, etc.) accept either token type
# without having to be rewritten.
_PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = {
"view_dashboard": ("dashboard", "view"),
"view_mix_calculator": ("mix_calculator", "view"),
"use_mix_calculator": ("mix_calculator", "edit"),
"save_mix_calculator_session": ("mix_calculator", "edit"),
"view_raw_materials": ("raw_materials", "view"),
"edit_raw_materials": ("raw_materials", "edit"),
"view_products": ("products", "view"),
"edit_products": ("products", "edit"),
"view_mixes": ("mix_master", "view"),
"edit_mixes": ("mix_master", "edit"),
# Admin-only permissions (view_users, manage_users, manage_permissions,
# view_settings, edit_settings) are intentionally excluded — they don't
# correspond to any of the legacy module keys and remain accessible only
# via the explicit `require_permission(...)` dependency.
}
_ACCESS_LEVEL_RANK = {"none": 0, "view": 1, "edit": 2, "manage": 3}
def permissions_to_module_map(permission_keys: set[str]) -> dict[str, str]:
"""Translate internal permission keys into the legacy module→level map.
Higher levels (edit > view) win when multiple permissions map to the same
module key (e.g. ``edit_raw_materials`` overrides ``view_raw_materials``).
"""
result: dict[str, str] = {}
for key in permission_keys:
mapping = _PERMISSION_TO_MODULE_LEVEL.get(key)
if not mapping:
continue
module_key, level = mapping
if _ACCESS_LEVEL_RANK[level] > _ACCESS_LEVEL_RANK.get(result.get(module_key, "none"), 0):
result[module_key] = level
return result
def get_user_permissions(user: User | None) -> set[str]:
"""Return the set of permission keys granted to ``user``.
Returns an empty set for unknown users, inactive users, or users without
a role. This means downstream checks fail closed by default.
"""
if user is None or not user.is_active or user.role is None:
return set()
return {permission.key for permission in user.role.permissions}
def user_has_permission(user: User | None, permission_key: str) -> bool:
return permission_key in get_user_permissions(user)
def _load_user(db: Session, user_id: int) -> User | None:
return db.scalar(
select(User)
.where(User.id == user_id)
.options(selectinload(User.role).selectinload(Role.permissions))
)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> User:
"""Resolve the current internal user from the bearer token.
Raises 401 for missing/invalid tokens or unknown users, 403 for inactive
users.
"""
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
payload = verify_token(credentials.credentials)
if payload.get("sub") != INTERNAL_USER_SUBJECT:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
user_id = payload.get("user_id")
if not isinstance(user_id, int):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
user = _load_user(db, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User no longer exists")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is inactive")
return user
def require_permission(permission_key: str):
"""FastAPI dependency factory enforcing a single permission key."""
def dependency(user: User = Depends(get_current_user)) -> User:
if not user_has_permission(user, permission_key):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permission: {permission_key}",
)
return user
return dependency
def require_any_permission(permission_keys: Iterable[str]):
"""FastAPI dependency factory: pass if the user has any of the keys."""
keys = tuple(permission_keys)
def dependency(user: User = Depends(get_current_user)) -> User:
granted = get_user_permissions(user)
if not any(key in granted for key in keys):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires any of: {list(keys)}",
)
return user
return dependency
def require_all_permissions(permission_keys: Iterable[str]):
"""FastAPI dependency factory: pass only if the user has every key."""
keys = tuple(permission_keys)
def dependency(user: User = Depends(get_current_user)) -> User:
granted = get_user_permissions(user)
missing = [key for key in keys if key not in granted]
if missing:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permissions: {missing}",
)
return user
return dependency
__all__ = [
"INTERNAL_USER_SUBJECT",
"Permission",
"Role",
"User",
"bearer_scheme",
"get_current_user",
"get_user_permissions",
"require_all_permissions",
"require_any_permission",
"require_permission",
"user_has_permission",
]
+38
View File
@@ -48,3 +48,41 @@ def verify_token(token: str) -> dict[str, Any]:
if int(payload.get("exp", 0)) < int(time.time()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication token has expired")
return payload
# --- Password hashing ------------------------------------------------------
# PBKDF2-SHA256 with a per-password 16-byte salt and 200k iterations. Stored
# as `pbkdf2_sha256$iterations$salt_hex$hash_hex`. No external dep needed.
import os
import secrets
_PBKDF2_ITERATIONS = 200_000
_PBKDF2_ALGO = "pbkdf2_sha256"
def hash_password(password: str) -> str:
if not password:
raise ValueError("Password cannot be empty")
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITERATIONS)
return f"{_PBKDF2_ALGO}${_PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
def verify_password(password: str, encoded: str | None) -> bool:
if not encoded or not password:
return False
try:
algo, iterations_str, salt_hex, digest_hex = encoded.split("$", 3)
except ValueError:
return False
if algo != _PBKDF2_ALGO:
return False
try:
iterations = int(iterations_str)
salt = bytes.fromhex(salt_hex)
expected = bytes.fromhex(digest_hex)
except ValueError:
return False
candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(candidate, expected)
+4
View File
@@ -12,8 +12,10 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from app.api.access import router as access_router
from app.api.auth import router as auth_router
from app.api.client_access import router as client_access_router
from app.api.dashboard import router as dashboard_router
from app.api.mix_calculator import router as mix_calculator_router
from app.api.mixes import router as mixes_router
from app.api.powerbi import router as powerbi_router
@@ -72,7 +74,9 @@ app.add_middleware(
)
app.include_router(auth_router)
app.include_router(access_router)
app.include_router(client_access_router)
app.include_router(dashboard_router)
app.include_router(raw_materials_router)
app.include_router(mixes_router)
app.include_router(mix_calculator_router)
+5
View File
@@ -1,3 +1,4 @@
from app.models.access import Permission, Role, User, role_permissions
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
@@ -19,9 +20,13 @@ __all__ = [
"MixCalculatorSessionLine",
"MixIngredient",
"PackagingCostRule",
"Permission",
"ProcessCostRule",
"Product",
"RawMaterial",
"RawMaterialPriceVersion",
"Role",
"Scenario",
"User",
"role_permissions",
]
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Table, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
role_permissions = Table(
"role_permissions",
Base.metadata,
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
Column("permission_id", ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True),
)
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
permissions: Mapped[list["Permission"]] = relationship(
secondary=role_permissions,
back_populates="roles",
lazy="selectin",
)
users: Mapped[list["User"]] = relationship(back_populates="role")
class Permission(Base):
__tablename__ = "permissions"
id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(String(128), unique=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
roles: Mapped[list["Role"]] = relationship(
secondary=role_permissions,
back_populates="permissions",
)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
name: Mapped[str] = mapped_column(String(255))
role_id: Mapped[int | None] = mapped_column(ForeignKey("roles.id"), nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Per-user password hash (PBKDF2-SHA256). Null while a user has never set
# a personal password — they can still sign in with the shared internal
# password until they choose one in settings.
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
role: Mapped["Role | None"] = relationship(back_populates="users", lazy="selectin")
+2
View File
@@ -15,6 +15,7 @@ from app.models.client_access import ClientAccessAuditEvent, ClientAccount, Clie
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.seed_access import seed_access
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role
@@ -688,6 +689,7 @@ def seed_if_empty():
else:
logger.warning("Skipping costing workspace seed because workbook is missing at %s", WORKBOOK_PATH)
seed_client_access(db)
seed_access(db)
db.commit()
+164
View File
@@ -0,0 +1,164 @@
"""Idempotent seed for roles, permissions, and the Hunter Stock Feeds users.
Re-running this is safe: it upserts permissions, syncs each role's permission
set to the declared list, and creates or updates the seed users without
duplicating rows. Permission grants are the source of truth — change them
here (or in the DB) rather than in route code.
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models.access import Permission, Role, User
PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = (
("view_dashboard", "View the operational dashboard"),
("view_mix_calculator", "View the mix calculator area"),
("use_mix_calculator", "Run calculations in the mix calculator"),
("save_mix_calculator_session", "Save mix calculator sessions"),
("view_raw_materials", "View raw materials"),
("edit_raw_materials", "Create and edit raw materials"),
("view_products", "View finished products"),
("edit_products", "Create and edit finished products"),
("view_mixes", "View mix master recipes"),
("edit_mixes", "Create and edit mix master recipes"),
("view_users", "View internal users and roles"),
("manage_users", "Create, deactivate, and assign user roles"),
("manage_permissions", "Modify roles and role-permission assignments"),
("view_settings", "View system settings"),
("edit_settings", "Edit system settings"),
)
ROLE_DEFINITIONS: dict[str, dict] = {
"Admin": {
"description": "Full administrative access including user and permission management.",
"permissions": [
"view_dashboard",
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
"view_raw_materials",
"edit_raw_materials",
"view_products",
"view_mixes",
"view_users",
"manage_users",
"manage_permissions",
"view_settings",
"edit_settings",
],
},
"Operations": {
"description": "Mix calculator only — cannot edit raw materials, products, mixes, users, or settings.",
"permissions": [
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
],
},
"Full Access": {
"description": "Operational data editor — cannot manage users or permissions unless explicitly granted.",
"permissions": [
"view_dashboard",
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
"view_raw_materials",
"edit_raw_materials",
"view_products",
"edit_products",
"view_mixes",
"edit_mixes",
],
},
}
SEED_USERS: tuple[dict, ...] = (
{
"email": "admin@hunterstockfeeds.com",
"name": "Hunter Stock Feeds Admin",
"role": "Admin",
},
{
"email": "ops@hunterstockfeeds.com",
"name": "Hunter Stock Feeds Operations",
"role": "Operations",
},
{
"email": "craig@hunterstockfeeds.com",
"name": "Craig",
"role": "Full Access",
},
)
def _upsert_permissions(db: Session) -> dict[str, Permission]:
existing = {permission.key: permission for permission in db.scalars(select(Permission)).all()}
for key, description in PERMISSION_DEFINITIONS:
permission = existing.get(key)
if permission is None:
permission = Permission(key=key, description=description)
db.add(permission)
existing[key] = permission
elif permission.description != description:
permission.description = description
db.flush()
return existing
def _upsert_roles(db: Session, permissions_by_key: dict[str, Permission]) -> dict[str, Role]:
existing = {
role.name: role
for role in db.scalars(
select(Role).options(selectinload(Role.permissions))
).all()
}
for role_name, definition in ROLE_DEFINITIONS.items():
role = existing.get(role_name)
if role is None:
role = Role(name=role_name, description=definition["description"])
db.add(role)
existing[role_name] = role
elif role.description != definition["description"]:
role.description = definition["description"]
db.flush()
desired = {permissions_by_key[key] for key in definition["permissions"]}
current = set(role.permissions)
for permission in desired - current:
role.permissions.append(permission)
for permission in current - desired:
role.permissions.remove(permission)
db.flush()
return existing
def _upsert_users(db: Session, roles_by_name: dict[str, Role]) -> None:
existing = {user.email: user for user in db.scalars(select(User)).all()}
for entry in SEED_USERS:
email = entry["email"].lower()
role = roles_by_name[entry["role"]]
user = existing.get(email)
if user is None:
user = User(email=email, name=entry["name"], role_id=role.id, is_active=True)
db.add(user)
existing[email] = user
else:
user.name = entry["name"]
user.role_id = role.id
if not user.is_active:
user.is_active = True
db.flush()
def seed_access(db: Session) -> None:
"""Idempotent: roles, permissions, role-permission links, seed users."""
permissions_by_key = _upsert_permissions(db)
roles_by_name = _upsert_roles(db, permissions_by_key)
_upsert_users(db, roles_by_name)
+28 -18
View File
@@ -2,8 +2,8 @@ from __future__ import annotations
from datetime import date
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload, selectinload
from app.api.deps import AuthSession
from app.models.mix import Mix, MixIngredient
@@ -108,29 +108,39 @@ def calculate_mix_calculator_preview(
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
# Aggregate mix totals in a single query instead of loading every
# ingredient row for every product. The previous implementation was the
# main slow path on first Mix Calculator open — it streamed the entire
# tenant's recipe table just to compute one sum per product.
mix_totals_rows = db.execute(
select(MixIngredient.mix_id, func.coalesce(func.sum(MixIngredient.quantity_kg), 0.0))
.join(Mix, Mix.id == MixIngredient.mix_id)
.where(Mix.tenant_id == tenant_id)
.group_by(MixIngredient.mix_id)
).all()
mix_totals: dict[int, float] = {mix_id: round(total or 0.0, 4) for mix_id, total in mix_totals_rows}
products = db.scalars(
select(Product)
.where(Product.tenant_id == tenant_id)
.options(selectinload(Product.mix).selectinload(Mix.ingredients))
.options(joinedload(Product.mix))
.order_by(Product.client_name, Product.name)
).all()
product_rows = []
clients = sorted({product.client_name for product in products})
for product in products:
mix_total_kg = round(sum(ingredient.quantity_kg for ingredient in (product.mix.ingredients if product.mix else [])), 4)
product_rows.append(
{
"product_id": product.id,
"client_name": product.client_name,
"product_name": product.name,
"mix_id": product.mix_id,
"mix_name": product.mix.name if product.mix else "",
"unit_of_measure": product.unit_of_measure,
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
"mix_total_kg": mix_total_kg,
}
)
product_rows = [
{
"product_id": product.id,
"client_name": product.client_name,
"product_name": product.name,
"mix_id": product.mix_id,
"mix_name": product.mix.name if product.mix else "",
"unit_of_measure": product.unit_of_measure,
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
"mix_total_kg": mix_totals.get(product.mix_id, 0.0),
}
for product in products
]
return {"clients": clients, "products": product_rows}