feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints

This commit is contained in:
ali_mohaghegh
2026-08-25 19:08:14 +03:30
parent c8a9b66395
commit 170aed66ea
67 changed files with 9774 additions and 0 deletions
View File
+51
View File
@@ -0,0 +1,51 @@
"""Application error type + standardized error envelope.
All errors are returned as: {"error": {"code", "message", "field"}}
(contracts: expert-opinion-p0.md «General Shared Contracts»).
"""
class AppError(Exception):
"""Base error carrying an HTTP status and the contract error envelope."""
def __init__(self, status_code: int, code: str, message: str, field: str | None = None):
super().__init__(message)
self.status_code = status_code
self.code = code
self.message = message
self.field = field
@property
def body(self) -> dict:
error = {"code": self.code, "message": self.message}
if self.field is not None:
error["field"] = self.field
return {"error": error}
class ValidationError(AppError):
"""400 VALIDATION_ERROR — bad input (types, business validation)."""
def __init__(self, message: str, field: str | None = None):
super().__init__(400, "VALIDATION_ERROR", message, field)
class NotFoundError(AppError):
"""404 NOT_FOUND."""
def __init__(self, message: str = "رکورد یافت نشد", field: str | None = None):
super().__init__(404, "NOT_FOUND", message, field)
class ConflictError(AppError):
"""409 CONFLICT — duplicates, invalid state transitions, protected deletes."""
def __init__(self, message: str, field: str | None = None):
super().__init__(409, "CONFLICT", message, field)
class ForbiddenError(AppError):
"""403 FORBIDDEN — role / ownership checks."""
def __init__(self, message: str, field: str | None = None):
super().__init__(403, "FORBIDDEN", message, field)
+46
View File
@@ -0,0 +1,46 @@
"""Pagination + sorting helpers."""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import InstrumentedAttribute
from app.config import settings
from app.utils.errors import ValidationError
async def paginate(query, page: int, page_size: int, db: AsyncSession):
"""Apply offset/limit to `query` and return (items, meta dict).
`page_size` is clamped to [1, MAX_PAGE_SIZE] after validation — routers
enforce the contract bounds via Query(ge=1, le=100) before calling this.
"""
total = await db.scalar(select(func.count()).select_from(query.subquery()))
items = (await db.execute(query.offset((page - 1) * page_size).limit(page_size))).scalars().all()
return items, {"total": total or 0, "page": page, "page_size": page_size}
def sort_query(stmt, model, sort: str | None, default: str = "-created_at"):
"""Apply `?sort=-created_at` (leading `-` = descending) against a model.
Only real columns of the model are accepted — anything else is a
VALIDATION_ERROR (400), never a raw SQL fragment.
"""
raw = (sort or default).strip()
descending = raw.startswith("-")
name = raw[1:] if descending else raw
column: InstrumentedAttribute | None = getattr(model, name, None)
if column is None or not isinstance(column, InstrumentedAttribute):
raise ValidationError(f"فیلد مرتب‌سازی نامعتبر است: {name}", field="sort")
return stmt.order_by(column.desc() if descending else column.asc())
def default_pagination(page: int | None, page_size: int | None) -> tuple[int, int]:
"""Contract defaults: page=1, page_size=20 (max 100) with 400 on out-of-bounds."""
p = page if page is not None else 1
ps = page_size if page_size is not None else settings.DEFAULT_PAGE_SIZE
if p < 1:
raise ValidationError("شماره صفحه باید بزرگ‌تر از صفر باشد", field="page")
if ps < 1:
raise ValidationError("اندازه صفحه باید بزرگ‌تر از صفر باشد", field="page_size")
if ps > settings.MAX_PAGE_SIZE:
raise ValidationError(f"اندازه صفحه نمی‌تواند از {settings.MAX_PAGE_SIZE} بیشتر باشد", field="page_size")
return p, ps
+14
View File
@@ -0,0 +1,14 @@
"""Placeholder auth: no middleware logic, only a header read for `actor` attribution."""
from fastapi import Request
def get_actor(request: Request) -> str | None:
"""Extract the bearer token (if any) to attribute quote_events.actor.
This is the reserved placeholder from the contract — no authentication
or authorization is enforced yet.
"""
auth = request.headers.get("Authorization")
if auth and auth.lower().startswith("bearer "):
return auth[7:].strip() or None
return None
+16
View File
@@ -0,0 +1,16 @@
"""JSON-safe serialization helpers (contract: ISO 8601 UTC with `Z`)."""
from datetime import date, datetime, timezone
def to_iso(value: datetime | None) -> str | None:
"""Timestamptz -> '2026-08-02T10:00:00Z' (UTC). Naive values are treated as UTC."""
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def to_date(value: date | None) -> str | None:
"""Date -> 'YYYY-MM-DD'."""
return value.isoformat() if value is not None else None