From 1eb8ba8eddcc7356a53e41f0f58df995071b8fdd Mon Sep 17 00:00:00 2001 From: HotSwapp <47397945+HotSwapp@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:05:09 -0500 Subject: [PATCH] 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. --- README.md | 33 +++ app/__pycache__/main.cpython-313.pyc | Bin 91455 -> 108690 bytes app/__pycache__/models.cpython-313.pyc | Bin 10613 -> 30299 bytes app/__pycache__/schemas.cpython-313.pyc | Bin 0 -> 4348 bytes app/main.py | 376 +++++++++++++++++++++++- app/schemas.py | 101 +++++++ delphi.db | Bin 81920 -> 409600 bytes requirements.txt | 1 + scripts/smoke.sh | 27 ++ 9 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 app/__pycache__/schemas.cpython-313.pyc create mode 100644 app/schemas.py create mode 100644 scripts/smoke.sh diff --git a/README.md b/README.md index 5323865..90544ea 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,39 @@ curl http://localhost:8000/health 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 ``` diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 72a3c55a4b0a5fd3dc1d3eefd24822762a05f0da..748beca1865449b2a1c6afac8a010dfec8c84484 100644 GIT binary patch delta 28387 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 delta 16079 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 diff --git a/app/__pycache__/models.cpython-313.pyc b/app/__pycache__/models.cpython-313.pyc index 1cecd62c60acbe0cd4a05ddea193df428bc089e1..1809b45a777e4a45e4e688b7c1912b27578de781 100644 GIT binary patch literal 30299 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 HJof(&MhZ(l delta 2558 zcmbW3O>7%Q7=}Hz(KcE(yPJC3^{zX+ zPSPLJMR1@hRRtVizFCtj#6c-JyqSGx zc4qe3cfR#^KcfdPM(%T5&_(~=Y`>eo)3Fw5cE`}#z~E}ql|wntOfVb5A=dWJgtHvx zSi5E>l8xeM(siEec8$R8^bglHySiODU#lxud%kYOQ*rB=>!)tO;l`L7q;AOJHZV6# z-Br$EH!?dy?Wn_TVs0IE>m6>KxiRWCINStt8>!pmaGNj0Q;C(uVD8j&Q>vJk3s;f_ zU6NHJxj;Vf0p%NA(4q16G>3DQMwhHgI5OPeVVh8uL%X& z3aCP*iTi1vfv6MWgkAn<@90oZ80&g07LAF3xL9DA3 zH*jOA$mX$+Wt4~nWHYo;-%NEzG;_f3FIf^36f6Fx)k?VBT?*EJtxgxofv zFxwNal?wARt^&qgKRN0Elh`{LvQ*#q{L6GBRzO}hWzA4@4f|kY9LOlh7znVz$3b=_ zl7`+i*{ol)iKNSK#6CqRLwXu7oj6T9Z5-_3GdK>TUjhLlmQN5(Wl_d$;6RKVWV+z8 zg6EsYYx|vOY8Mn$<~6+vmG!3atbYGU5Lo>f z?Px$qB-wpZIUVfDS?&0hKDr|aTaW+hfHMODI|H# zqQ+RviWrww!Tu#NsN(_-k;_JMHB1Nnql=DW8MVA=`t7s7{;ce>%FdF+PBcp;dm(Jy zL$Eko7Jf8X{-^1Wm69q?;0<~SwafT~$}b*-&^@;oc^`&d=p6dC#&z~<*I9Did_DBQ Kl|VdVbNmI575Fp& diff --git a/app/__pycache__/schemas.cpython-313.pyc b/app/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a6a7f5987909344203f84f711e7aa43ccae77dc3 GIT binary patch literal 4348 zcmb_f-EZ606(=QM!hIQrqA2pV!Rkw&cFv_N(Vk;0MGW}IbI!fIyyu?Z zx##FOn^igZBftM{x2ka5x2T-_iIKn`OFYMY&FLKBbY4%a^OXciXecMwla(Y%GFey` zDk2dp5|NlLuBR$8kt+&OG;W4l;B;x0(^FOXon*8wQJGZ+s}i%OnN@ zm^I6+Ik4tq)*Q1Iz*>x1^UOK{*2$Q)z^qeXosLlIVA8xN{#0q@R4Xogvs;*i)wrd5N*AD%5sI|O?%ojW+KLIU+|f+8p*ruEO6ru-z{0M}vxhh9^;6*~`NphLf6IDS^z6m3`Ktx>xO&V$v z(^9BaQ@{mTSKj4`tgA$63Z*oa*`#cCqjjBE#ni1~# z7R^Mm+ipFx$kXRM&F>JeWtd?|>}T!J3Z8Q%mCAkZ`Ca?Xi@sG*34>rivllRUKhe4K^rVKTOY3fi@nU)@EX{Kd{T83%ap_bKiK%6{k)f`ZzpcjEEd3}Nu zfGU$zi4q98GJ~#V5V8Q9BnL=U;yJ+K-x_v;1Wf&ZBOtCoJ^eyxnR&oWO|g78OX_OQ-YjKVJt8se+FM2XCzI+rA<>iHr%u+Q0m{fg?YEw6K6DU zu~Mvyzz7+%h!G+%LIo|lrA1&w2DFF+A}|8q%MDjinZ|BuSS8K0!f-6Z-YX8ZEYl{2 zT8_<}9BO%{O`%pT0I#O?OOWG5{W6(oilrHvi#0?@uMc2ij9w{5A!rk#n?J+UA=}7( zm`e-cxTA_VGj1rL$EeYi9a=3Q=YD>~r5(pJVMH=pUK`pF>6z^~R)b0*s|i#N8EFlN z%FseUBM5~`W3>52p=J=Agm%V zntqJZ#pJ|Gh#=sa!5;xmk#p27^lz7sgsG!TH+w=kRvw@fJm_v=DHzjuER72Vl$%Fg z5Bm=oUG0{U?qiYzv$qbR@RZK788>!{%}Fr8AAgM7{lD0ZypC=i8>zaQJxHcgS_|@ zR@u+9wq;Z#Yg9zK2W*?rOOFmH@|Zk^ch2@3TWCYT1q1A`z^(LbPnhi&X1@_;BX4lR zYx1V}0Ln#h%9kpvzMqYJi3&}P{2FY`5Ap;h?2JxuBj73{2WEQ0OxyvS@R}T$ z*svN+i=6Vngyr|Am=CCeG(l!qXs)(wlxgFxKX9@ zd(p404@eCLc=!+g0Pyc5&-4FDh`jJaj^h`<HIPL9es^!c!DCR!@b?du z0SAx4Tngm10SAx4lnQg~0SAx4Y#!{N4LEoVmIQwK@XCOL#~_#Bul}Vp;NUS(QHFI! L@|7P@X1n|^OtR~8 literal 0 HcmV?d00001 diff --git a/app/main.py b/app/main.py index e2bfbfa..f6e1e82 100644 --- a/app/main.py +++ b/app/main.py @@ -16,7 +16,7 @@ from typing import Optional, List, Dict, Any from io import StringIO 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 fastapi.middleware.cors import CORSMiddleware 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 .auth import authenticate_user, get_current_user_from_session from .logging_config import setup_logging +from .schemas import ( + ClientOut, + PhoneOut, + CaseOut, + TransactionOut, + Pagination, + RolodexListResponse, + FilesListResponse, + LedgerListResponse, +) # Load environment variables load_dotenv() @@ -73,6 +83,9 @@ class AuthMiddleware(BaseHTTPMiddleware): # Enforce authentication for other paths 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 await call_next(request) @@ -2185,3 +2198,364 @@ async def phone_book_report( "report_phone_book.html", {"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), + ) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..499e3d6 --- /dev/null +++ b/app/schemas.py @@ -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 + + diff --git a/delphi.db b/delphi.db index 93b6de77d6510d2f45f6b5b697a12c55d6d85880..c2d81f351ca2aa255539c7ca3bcbca9e04b8de17 100644 GIT binary patch literal 409600 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.7,<3 sqlalchemy==1.4.54 alembic==1.12.1 python-multipart==0.0.6 diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100644 index 0000000..7268da6 --- /dev/null +++ b/scripts/smoke.sh @@ -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." + +