API: Standardized JSON list responses with Pydantic schemas and Pagination; add sort_by/sort_dir validation with whitelists; consistent JSON 401 for /api/*; structured logging for sorting/pagination; add pydantic dep; add Docker smoke script and README docs.
This commit is contained in:
33
README.md
33
README.md
@@ -82,6 +82,39 @@ curl http://localhost:8000/health
|
|||||||
|
|
||||||
4. Access the API at `http://localhost:8000`
|
4. Access the API at `http://localhost:8000`
|
||||||
|
|
||||||
|
## API JSON Lists and Sorting
|
||||||
|
|
||||||
|
The following list endpoints return standardized JSON with a shared `pagination` envelope and Pydantic models:
|
||||||
|
|
||||||
|
- `GET /api/rolodex` → items: `ClientOut[]`
|
||||||
|
- `GET /api/files` → items: `CaseOut[]`
|
||||||
|
- `GET /api/ledger` → items: `TransactionOut[]`
|
||||||
|
|
||||||
|
Common query params:
|
||||||
|
- `page` (>=1), `page_size` (1..100 or 200 for ledger)
|
||||||
|
- `sort_by` (endpoint-specific whitelist)
|
||||||
|
- `sort_dir` (`asc` | `desc`)
|
||||||
|
|
||||||
|
If `sort_by` is invalid or `sort_dir` is not one of `asc|desc`, the API returns `400` with details. Dates are ISO-8601 strings, and nulls are preserved as `null`.
|
||||||
|
|
||||||
|
Authentication: Unauthenticated requests to `/api/*` return a JSON `401` with `{ "detail": "Unauthorized" }`.
|
||||||
|
|
||||||
|
### Sorting whitelists
|
||||||
|
- `/api/rolodex`: `id, rolodex_id, last_name, first_name, company, created_at`
|
||||||
|
- `/api/files`: `file_no, status, case_type, description, open_date, close_date, created_at, client_last_name, client_first_name, client_company, id`
|
||||||
|
- `/api/ledger`: `transaction_date, item_no, id, amount, billed, t_code, t_type_l, employee_number, case_file_no, case_id`
|
||||||
|
|
||||||
|
## Docker smoke script
|
||||||
|
|
||||||
|
A simple curl-based smoke script is available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose exec delphi-db bash -lc "bash scripts/smoke.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: For authenticated API calls, log in at `/login` via the browser to create a session cookie, then copy your session cookie to a `cookies.txt` file for curl usage.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
BIN
app/__pycache__/schemas.cpython-313.pyc
Normal file
BIN
app/__pycache__/schemas.cpython-313.pyc
Normal file
Binary file not shown.
376
app/main.py
376
app/main.py
@@ -16,7 +16,7 @@ from typing import Optional, List, Dict, Any
|
|||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
from fastapi import FastAPI, Depends, Request, Query, HTTPException, UploadFile, File, Form
|
from fastapi import FastAPI, Depends, Request, Query, HTTPException, UploadFile, File, Form
|
||||||
from fastapi.responses import RedirectResponse, Response
|
from fastapi.responses import RedirectResponse, Response, JSONResponse
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -32,6 +32,16 @@ from .database import create_tables, get_db, get_database_url
|
|||||||
from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog
|
from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog
|
||||||
from .auth import authenticate_user, get_current_user_from_session
|
from .auth import authenticate_user, get_current_user_from_session
|
||||||
from .logging_config import setup_logging
|
from .logging_config import setup_logging
|
||||||
|
from .schemas import (
|
||||||
|
ClientOut,
|
||||||
|
PhoneOut,
|
||||||
|
CaseOut,
|
||||||
|
TransactionOut,
|
||||||
|
Pagination,
|
||||||
|
RolodexListResponse,
|
||||||
|
FilesListResponse,
|
||||||
|
LedgerListResponse,
|
||||||
|
)
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -73,6 +83,9 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
# Enforce authentication for other paths
|
# Enforce authentication for other paths
|
||||||
if not request.session.get("user_id"):
|
if not request.session.get("user_id"):
|
||||||
|
# Return JSON 401 for API routes, redirect for HTML routes
|
||||||
|
if path.startswith("/api/"):
|
||||||
|
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
|
||||||
return RedirectResponse(url="/login", status_code=302)
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
@@ -2185,3 +2198,364 @@ async def phone_book_report(
|
|||||||
"report_phone_book.html",
|
"report_phone_book.html",
|
||||||
{"request": request, "user": user, "clients": clients, "q": q, "client_ids": client_ids or []},
|
{"request": request, "user": user, "clients": clients, "q": q, "client_ids": client_ids or []},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# JSON API: list/filter endpoints
|
||||||
|
# ------------------------------
|
||||||
|
|
||||||
|
def _apply_sorting(query, sort_by: str | None, sort_dir: str, allowed_map: dict[str, Any], default_order: list[Any]):
|
||||||
|
"""Apply validated sorting to a SQLAlchemy query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Base SQLAlchemy query object
|
||||||
|
sort_by: Optional requested sort field
|
||||||
|
sort_dir: 'asc' or 'desc'
|
||||||
|
allowed_map: Map of allowed sort_by -> SQLAlchemy column or list of columns
|
||||||
|
default_order: Fallback order_by list when sort_by is not provided
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(query, applied_sort_by, applied_sort_dir)
|
||||||
|
"""
|
||||||
|
if not sort_by:
|
||||||
|
for col in default_order:
|
||||||
|
query = query.order_by(col)
|
||||||
|
return query, None, sort_dir
|
||||||
|
|
||||||
|
column_expr = allowed_map.get(sort_by)
|
||||||
|
if column_expr is None:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid sort_by: '{sort_by}'. Allowed: {sorted(list(allowed_map.keys()))}")
|
||||||
|
|
||||||
|
def _order(expr):
|
||||||
|
return expr.asc().nulls_last() if sort_dir == "asc" else expr.desc().nulls_last()
|
||||||
|
|
||||||
|
if isinstance(column_expr, (list, tuple)):
|
||||||
|
for expr in column_expr:
|
||||||
|
query = query.order_by(_order(expr))
|
||||||
|
else:
|
||||||
|
query = query.order_by(_order(column_expr))
|
||||||
|
|
||||||
|
return query, sort_by, sort_dir
|
||||||
|
|
||||||
|
@app.get("/api/rolodex", response_model=RolodexListResponse)
|
||||||
|
async def api_list_rolodex(
|
||||||
|
request: Request,
|
||||||
|
q: str | None = Query(None, description="Search by first/last/company contains"),
|
||||||
|
phone: str | None = Query(None, description="Phone number contains"),
|
||||||
|
rolodex_id: str | None = Query(None, description="Legacy Rolodex ID contains"),
|
||||||
|
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="Results per page"),
|
||||||
|
sort_by: str | None = Query(None, description="Sort field: id, rolodex_id, last_name, first_name, company, created_at"),
|
||||||
|
sort_dir: str = Query("asc", description="Sort direction: asc or desc"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> RolodexListResponse:
|
||||||
|
"""Return paginated clients with simple filters as JSON."""
|
||||||
|
user = get_current_user_from_session(request.session)
|
||||||
|
if not user:
|
||||||
|
# Middleware ensures JSON 401 for /api/*, keep explicit for clarity
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
query = db.query(Client).options(joinedload(Client.phones))
|
||||||
|
|
||||||
|
if q:
|
||||||
|
like = f"%{q}%"
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Client.first_name.ilike(like),
|
||||||
|
Client.last_name.ilike(like),
|
||||||
|
Client.company.ilike(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if phone:
|
||||||
|
query = query.filter(Client.phones.any(Phone.phone_number.ilike(f"%{phone}%")))
|
||||||
|
if rolodex_id:
|
||||||
|
query = query.filter(Client.rolodex_id.ilike(f"%{rolodex_id}%"))
|
||||||
|
|
||||||
|
# Sorting
|
||||||
|
sort_dir_norm = (sort_dir or "").lower()
|
||||||
|
if sort_dir_norm not in ("asc", "desc"):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid sort_dir. Allowed: 'asc' or 'desc'")
|
||||||
|
|
||||||
|
allowed_sort = {
|
||||||
|
"id": Client.id,
|
||||||
|
"rolodex_id": Client.rolodex_id,
|
||||||
|
"last_name": Client.last_name,
|
||||||
|
"first_name": Client.first_name,
|
||||||
|
"company": Client.company,
|
||||||
|
"created_at": Client.created_at,
|
||||||
|
}
|
||||||
|
default_order = [Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last(), Client.id.asc()]
|
||||||
|
query, applied_sort_by, applied_sort_dir = _apply_sorting(query, sort_by, sort_dir_norm, allowed_sort, default_order)
|
||||||
|
|
||||||
|
total: int = query.count()
|
||||||
|
total_pages: int = (total + page_size - 1) // page_size if total > 0 else 1
|
||||||
|
if page > total_pages:
|
||||||
|
page = total_pages
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
clients = query.offset(offset).limit(page_size).all()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"api_rolodex_list",
|
||||||
|
query=q,
|
||||||
|
phone=phone,
|
||||||
|
rolodex_id=rolodex_id,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
sort_by=applied_sort_by,
|
||||||
|
sort_dir=applied_sort_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
items = [ClientOut.model_validate(c) for c in clients]
|
||||||
|
return RolodexListResponse(
|
||||||
|
items=items,
|
||||||
|
pagination=Pagination(page=page, page_size=page_size, total=total, total_pages=total_pages),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/files", response_model=FilesListResponse)
|
||||||
|
async def api_list_files(
|
||||||
|
request: Request,
|
||||||
|
q: str | None = Query(None, description="Search file no/description/client name/company"),
|
||||||
|
status: str | None = Query(None, description="Case status: active or closed"),
|
||||||
|
case_type: str | None = Query(None, description="Case type contains"),
|
||||||
|
file_no: str | None = Query(None, description="File number contains"),
|
||||||
|
client_rolodex_id: str | None = Query(None, description="Legacy client Id contains"),
|
||||||
|
from_open_date: str | None = Query(None, description="Opened on/after YYYY-MM-DD"),
|
||||||
|
to_open_date: str | None = Query(None, description="Opened on/before YYYY-MM-DD"),
|
||||||
|
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="Results per page"),
|
||||||
|
sort_by: str | None = Query(None, description="Sort field: file_no, status, case_type, description, open_date, close_date, created_at, client_last_name, client_first_name, client_company"),
|
||||||
|
sort_dir: str = Query("desc", description="Sort direction: asc or desc"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> FilesListResponse:
|
||||||
|
"""Return paginated cases with simple filters as JSON."""
|
||||||
|
user = get_current_user_from_session(request.session)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
query = (
|
||||||
|
db.query(Case)
|
||||||
|
.join(Client, Case.client_id == Client.id)
|
||||||
|
.options(joinedload(Case.client))
|
||||||
|
)
|
||||||
|
|
||||||
|
filters = []
|
||||||
|
if q:
|
||||||
|
like = f"%{q}%"
|
||||||
|
filters.append(
|
||||||
|
or_(
|
||||||
|
Case.file_no.ilike(like),
|
||||||
|
Case.description.ilike(like),
|
||||||
|
Client.first_name.ilike(like),
|
||||||
|
Client.last_name.ilike(like),
|
||||||
|
Client.company.ilike(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status:
|
||||||
|
filters.append(Case.status.ilike(f"%{status}%"))
|
||||||
|
if case_type:
|
||||||
|
filters.append(Case.case_type.ilike(f"%{case_type}%"))
|
||||||
|
if file_no:
|
||||||
|
filters.append(Case.file_no.ilike(f"%{file_no}%"))
|
||||||
|
if client_rolodex_id:
|
||||||
|
filters.append(Client.rolodex_id.ilike(f"%{client_rolodex_id}%"))
|
||||||
|
if from_open_date:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(from_open_date, "%Y-%m-%d")
|
||||||
|
filters.append(Case.open_date >= dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if to_open_date:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(to_open_date, "%Y-%m-%d")
|
||||||
|
filters.append(Case.open_date <= dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
query = query.filter(and_(*filters))
|
||||||
|
|
||||||
|
# Sorting
|
||||||
|
sort_dir_norm = (sort_dir or "").lower()
|
||||||
|
if sort_dir_norm not in ("asc", "desc"):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid sort_dir. Allowed: 'asc' or 'desc'")
|
||||||
|
|
||||||
|
allowed_sort = {
|
||||||
|
"file_no": Case.file_no,
|
||||||
|
"status": Case.status,
|
||||||
|
"case_type": Case.case_type,
|
||||||
|
"description": Case.description,
|
||||||
|
"open_date": Case.open_date,
|
||||||
|
"close_date": Case.close_date,
|
||||||
|
"created_at": Case.created_at,
|
||||||
|
"client_last_name": Client.last_name,
|
||||||
|
"client_first_name": Client.first_name,
|
||||||
|
"client_company": Client.company,
|
||||||
|
"id": Case.id,
|
||||||
|
}
|
||||||
|
default_order = [Case.open_date.desc().nulls_last(), Case.created_at.desc()]
|
||||||
|
query, applied_sort_by, applied_sort_dir = _apply_sorting(query, sort_by, sort_dir_norm, allowed_sort, default_order)
|
||||||
|
|
||||||
|
total: int = query.count()
|
||||||
|
total_pages: int = (total + page_size - 1) // page_size if total > 0 else 1
|
||||||
|
if page > total_pages:
|
||||||
|
page = total_pages
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
cases = query.offset(offset).limit(page_size).all()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"api_files_list",
|
||||||
|
query=q,
|
||||||
|
status=status,
|
||||||
|
case_type=case_type,
|
||||||
|
file_no=file_no,
|
||||||
|
client_rolodex_id=client_rolodex_id,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
sort_by=applied_sort_by,
|
||||||
|
sort_dir=applied_sort_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
items = [CaseOut.model_validate(c) for c in cases]
|
||||||
|
return FilesListResponse(
|
||||||
|
items=items,
|
||||||
|
pagination=Pagination(page=page, page_size=page_size, total=total, total_pages=total_pages),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/ledger", response_model=LedgerListResponse)
|
||||||
|
async def api_list_ledger(
|
||||||
|
request: Request,
|
||||||
|
case_id: int | None = Query(None, description="Filter by case ID"),
|
||||||
|
file_no: str | None = Query(None, description="Filter by case file number contains"),
|
||||||
|
from_date: str | None = Query(None, description="On/after YYYY-MM-DD"),
|
||||||
|
to_date: str | None = Query(None, description="On/before YYYY-MM-DD"),
|
||||||
|
billed: str | None = Query(None, description="'Y' or 'N'"),
|
||||||
|
t_code: str | None = Query(None, description="Transaction code contains"),
|
||||||
|
t_type_l: str | None = Query(None, description="Legacy type flag (e.g., C/D)"),
|
||||||
|
employee_number: str | None = Query(None, description="Employee number contains"),
|
||||||
|
q: str | None = Query(None, description="Description contains"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
sort_by: str | None = Query(None, description="Sort field: transaction_date, item_no, id, amount, billed, t_code, t_type_l, employee_number, case_file_no, case_id"),
|
||||||
|
sort_dir: str = Query("desc", description="Sort direction: asc or desc"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> LedgerListResponse:
|
||||||
|
"""Return paginated ledger (transactions) with simple filters as JSON."""
|
||||||
|
user = get_current_user_from_session(request.session)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
query = (
|
||||||
|
db.query(Transaction)
|
||||||
|
.join(Case, Transaction.case_id == Case.id)
|
||||||
|
.options(joinedload(Transaction.case))
|
||||||
|
)
|
||||||
|
|
||||||
|
filters = []
|
||||||
|
if case_id is not None:
|
||||||
|
filters.append(Transaction.case_id == case_id)
|
||||||
|
if file_no:
|
||||||
|
filters.append(Case.file_no.ilike(f"%{file_no}%"))
|
||||||
|
if from_date:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(from_date, "%Y-%m-%d")
|
||||||
|
filters.append(Transaction.transaction_date >= dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if to_date:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(to_date, "%Y-%m-%d")
|
||||||
|
filters.append(Transaction.transaction_date <= dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if billed in ("Y", "N"):
|
||||||
|
filters.append(Transaction.billed == billed)
|
||||||
|
if t_code:
|
||||||
|
filters.append(Transaction.t_code.ilike(f"%{t_code}%"))
|
||||||
|
if t_type_l:
|
||||||
|
filters.append(Transaction.t_type_l.ilike(f"%{t_type_l}%"))
|
||||||
|
if employee_number:
|
||||||
|
filters.append(Transaction.employee_number.ilike(f"%{employee_number}%"))
|
||||||
|
if q:
|
||||||
|
filters.append(Transaction.description.ilike(f"%{q}%"))
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
query = query.filter(and_(*filters))
|
||||||
|
|
||||||
|
# Sorting
|
||||||
|
sort_dir_norm = (sort_dir or "").lower()
|
||||||
|
if sort_dir_norm not in ("asc", "desc"):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid sort_dir. Allowed: 'asc' or 'desc'")
|
||||||
|
allowed_sort = {
|
||||||
|
"transaction_date": Transaction.transaction_date,
|
||||||
|
"item_no": Transaction.item_no,
|
||||||
|
"id": Transaction.id,
|
||||||
|
"amount": Transaction.amount,
|
||||||
|
"billed": Transaction.billed,
|
||||||
|
"t_code": Transaction.t_code,
|
||||||
|
"t_type_l": Transaction.t_type_l,
|
||||||
|
"employee_number": Transaction.employee_number,
|
||||||
|
"case_file_no": Case.file_no,
|
||||||
|
"case_id": Transaction.case_id,
|
||||||
|
}
|
||||||
|
default_order = [
|
||||||
|
Transaction.transaction_date.desc().nulls_last(),
|
||||||
|
Transaction.item_no.asc().nulls_last(),
|
||||||
|
Transaction.id.desc(),
|
||||||
|
]
|
||||||
|
query, applied_sort_by, applied_sort_dir = _apply_sorting(query, sort_by, sort_dir_norm, allowed_sort, default_order)
|
||||||
|
|
||||||
|
total: int = query.count()
|
||||||
|
total_pages: int = (total + page_size - 1) // page_size if total > 0 else 1
|
||||||
|
if page > total_pages:
|
||||||
|
page = total_pages
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
txns = query.offset(offset).limit(page_size).all()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"api_ledger_list",
|
||||||
|
case_id=case_id,
|
||||||
|
file_no=file_no,
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date,
|
||||||
|
billed=billed,
|
||||||
|
t_code=t_code,
|
||||||
|
t_type_l=t_type_l,
|
||||||
|
employee_number=employee_number,
|
||||||
|
q=q,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
sort_by=applied_sort_by,
|
||||||
|
sort_dir=applied_sort_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
TransactionOut(
|
||||||
|
id=t.id,
|
||||||
|
case_id=t.case_id,
|
||||||
|
case_file_no=t.case.file_no if t.case else None,
|
||||||
|
transaction_date=t.transaction_date,
|
||||||
|
item_no=t.item_no,
|
||||||
|
amount=t.amount,
|
||||||
|
billed=t.billed,
|
||||||
|
t_code=t.t_code,
|
||||||
|
t_type_l=t.t_type_l,
|
||||||
|
quantity=t.quantity,
|
||||||
|
rate=t.rate,
|
||||||
|
description=t.description,
|
||||||
|
employee_number=t.employee_number,
|
||||||
|
)
|
||||||
|
for t in txns
|
||||||
|
]
|
||||||
|
|
||||||
|
return LedgerListResponse(
|
||||||
|
items=items,
|
||||||
|
pagination=Pagination(page=page, page_size=page_size, total=total, total_pages=total_pages),
|
||||||
|
)
|
||||||
|
|||||||
101
app/schemas.py
Normal file
101
app/schemas.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for API responses.
|
||||||
|
|
||||||
|
Defines output models for Clients, Phones, Cases, and Transactions, along with
|
||||||
|
shared pagination envelopes for list endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
phone_type: Optional[str] = None
|
||||||
|
phone_number: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
rolodex_id: Optional[str] = None
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
company: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
state: Optional[str] = None
|
||||||
|
zip_code: Optional[str] = None
|
||||||
|
phones: Optional[List[PhoneOut]] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseClientOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
rolodex_id: Optional[str] = None
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
company: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
file_no: str
|
||||||
|
status: Optional[str] = None
|
||||||
|
case_type: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
open_date: Optional[datetime] = None
|
||||||
|
close_date: Optional[datetime] = None
|
||||||
|
client: Optional[CaseClientOut] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
case_id: int
|
||||||
|
case_file_no: Optional[str] = None
|
||||||
|
transaction_date: Optional[datetime] = None
|
||||||
|
item_no: Optional[int] = None
|
||||||
|
amount: Optional[float] = None
|
||||||
|
billed: Optional[str] = None
|
||||||
|
t_code: Optional[str] = None
|
||||||
|
t_type_l: Optional[str] = None
|
||||||
|
quantity: Optional[float] = None
|
||||||
|
rate: Optional[float] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
employee_number: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Pagination(BaseModel):
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class RolodexListResponse(BaseModel):
|
||||||
|
items: List[ClientOut]
|
||||||
|
pagination: Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class FilesListResponse(BaseModel):
|
||||||
|
items: List[CaseOut]
|
||||||
|
pagination: Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerListResponse(BaseModel):
|
||||||
|
items: List[TransactionOut]
|
||||||
|
pagination: Pagination
|
||||||
|
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
fastapi==0.104.1
|
fastapi==0.104.1
|
||||||
|
pydantic>=2.7,<3
|
||||||
sqlalchemy==1.4.54
|
sqlalchemy==1.4.54
|
||||||
alembic==1.12.1
|
alembic==1.12.1
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
|
|||||||
27
scripts/smoke.sh
Normal file
27
scripts/smoke.sh
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${BASE_URL:-http://localhost:8000}"
|
||||||
|
|
||||||
|
echo "[1] Health check"
|
||||||
|
curl -sf "$BASE_URL/health" >/dev/null || { echo "Health check failed"; exit 1; }
|
||||||
|
|
||||||
|
echo "[2] API unauthenticated should return 401 JSON"
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/rolodex")
|
||||||
|
if [ "$code" != "401" ]; then
|
||||||
|
echo "Expected 401, got $code"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat << 'EX'
|
||||||
|
Login via UI at /login to create a session cookie in your browser.
|
||||||
|
For scripted tests, copy the cookie to cookies.txt and run examples:
|
||||||
|
|
||||||
|
curl -b cookies.txt "$BASE_URL/api/rolodex?page=1&page_size=2&sort_by=last_name&sort_dir=asc"
|
||||||
|
curl -b cookies.txt "$BASE_URL/api/files?page=1&page_size=2&sort_by=open_date&sort_dir=desc"
|
||||||
|
curl -b cookies.txt "$BASE_URL/api/ledger?page=1&page_size=2&sort_by=transaction_date&sort_dir=desc"
|
||||||
|
EX
|
||||||
|
|
||||||
|
echo "Smoke tests completed."
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user