Spaces:
Paused
Paused
Commit ·
628320b
1
Parent(s): a5423f9
Implement customer listing with pagination and ordering via stored procedure; update models, services, and tests
Browse files- app/controllers/customers.py +19 -6
- app/core/config.py +1 -1
- app/db/models/customer.py +21 -9
- app/db/repositories/customer_sp_repo.py +153 -0
- app/db/session.py +27 -5
- app/docker/docker-compose.yml +2 -2
- app/prompts/mvc_fastapi_sp_module.txt +113 -0
- app/schemas/customer.py +32 -5
- app/schemas/paginated_response.py +13 -0
- app/services/customer_list_service.py +74 -0
- app/tests/unit/test_customers_list.py +45 -0
app/controllers/customers.py
CHANGED
|
@@ -1,16 +1,29 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, status
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from app.db.session import get_db
|
| 4 |
from app.services.customer_service import CustomerService
|
|
|
|
| 5 |
from app.schemas.customer import CustomerCreate, CustomerOut
|
| 6 |
-
from
|
|
|
|
| 7 |
|
| 8 |
router = APIRouter(prefix="/api/v1/customers", tags=["customers"])
|
| 9 |
|
| 10 |
-
@router.get(
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
@router.get("/{customer_id}", response_model=CustomerOut)
|
| 16 |
def get_customer(customer_id: int, db: Session = Depends(get_db)):
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, status, Query
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from app.db.session import get_db
|
| 4 |
from app.services.customer_service import CustomerService
|
| 5 |
+
from app.services.customer_list_service import CustomerListService
|
| 6 |
from app.schemas.customer import CustomerCreate, CustomerOut
|
| 7 |
+
from app.schemas.paginated_response import PaginatedResponse
|
| 8 |
+
from typing import List, Optional
|
| 9 |
|
| 10 |
router = APIRouter(prefix="/api/v1/customers", tags=["customers"])
|
| 11 |
|
| 12 |
+
@router.get(
|
| 13 |
+
"/",
|
| 14 |
+
response_model=PaginatedResponse[CustomerOut],
|
| 15 |
+
summary="List customers with pagination and ordering",
|
| 16 |
+
response_description="Paginated list of customers"
|
| 17 |
+
)
|
| 18 |
+
def list_customers(
|
| 19 |
+
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
| 20 |
+
page_size: int = Query(10, ge=1, le=100, description="Number of items per page"),
|
| 21 |
+
order_by: Optional[str] = Query("CustomerID", description="Field to order by (CustomerID, CompanyName, Address, City, etc.)"),
|
| 22 |
+
order_dir: Optional[str] = Query("desc", description="Order direction (asc|desc)"),
|
| 23 |
+
db: Session = Depends(get_db)
|
| 24 |
+
):
|
| 25 |
+
service = CustomerListService(db)
|
| 26 |
+
return service.list_customers(page=page, page_size=page_size, order_by=order_by, order_dir=order_dir)
|
| 27 |
|
| 28 |
@router.get("/{customer_id}", response_model=CustomerOut)
|
| 29 |
def get_customer(customer_id: int, db: Session = Depends(get_db)):
|
app/core/config.py
CHANGED
|
@@ -11,7 +11,7 @@ class Settings(BaseSettings):
|
|
| 11 |
SQLSERVER_USER: str
|
| 12 |
SQLSERVER_PASSWORD: str
|
| 13 |
SQLSERVER_HOST: str
|
| 14 |
-
SQLSERVER_PORT: int =
|
| 15 |
SQLSERVER_DB: str
|
| 16 |
SQLSERVER_DRIVER: str = "ODBC Driver 18 for SQL Server"
|
| 17 |
CORS_ORIGINS: Optional[str] = "*"
|
|
|
|
| 11 |
SQLSERVER_USER: str
|
| 12 |
SQLSERVER_PASSWORD: str
|
| 13 |
SQLSERVER_HOST: str
|
| 14 |
+
SQLSERVER_PORT: int = 31433
|
| 15 |
SQLSERVER_DB: str
|
| 16 |
SQLSERVER_DRIVER: str = "ODBC Driver 18 for SQL Server"
|
| 17 |
CORS_ORIGINS: Optional[str] = "*"
|
app/db/models/customer.py
CHANGED
|
@@ -1,12 +1,24 @@
|
|
| 1 |
-
from sqlalchemy import Column, Integer, String, DateTime
|
| 2 |
from app.db.base import Base
|
| 3 |
-
from datetime import datetime
|
| 4 |
|
| 5 |
class Customer(Base):
|
| 6 |
-
__tablename__ = "
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text
|
| 2 |
from app.db.base import Base
|
|
|
|
| 3 |
|
| 4 |
class Customer(Base):
|
| 5 |
+
__tablename__ = "Customers"
|
| 6 |
+
|
| 7 |
+
CustomerID = Column(Integer, primary_key=True, index=True)
|
| 8 |
+
CompanyName = Column(String(200), nullable=True)
|
| 9 |
+
Address = Column(String(200), nullable=True)
|
| 10 |
+
City = Column(String(100), nullable=True)
|
| 11 |
+
PostalCode = Column(String(70), nullable=True)
|
| 12 |
+
WebAddress = Column(String(50), nullable=True)
|
| 13 |
+
Referral = Column(String(3), nullable=True)
|
| 14 |
+
CompanyTypeID = Column(Integer, nullable=True)
|
| 15 |
+
StateID = Column(Integer, nullable=True)
|
| 16 |
+
CountryID = Column(Integer, nullable=True)
|
| 17 |
+
LeadGeneratedFromID = Column(Integer, nullable=True)
|
| 18 |
+
SpecificSource = Column(String(30), nullable=True)
|
| 19 |
+
PriorityID = Column(Integer, nullable=True)
|
| 20 |
+
FollowupDate = Column(DateTime, nullable=True)
|
| 21 |
+
Purchase = Column(String(10), nullable=True)
|
| 22 |
+
VendorID = Column(String(10), nullable=True)
|
| 23 |
+
Enabled = Column(Boolean, nullable=False, default=True)
|
| 24 |
+
RentalType = Column(String(50), nullable=False, default='AB')
|
app/db/repositories/customer_sp_repo.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Tuple, List, Dict
|
| 2 |
+
from sqlalchemy import text
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
|
| 5 |
+
class CustomerRepository:
|
| 6 |
+
"""
|
| 7 |
+
Repository for customer DB access via stored procedure.
|
| 8 |
+
"""
|
| 9 |
+
def __init__(self, db: Session):
|
| 10 |
+
self.db = db
|
| 11 |
+
self.engine = db.get_bind()
|
| 12 |
+
|
| 13 |
+
def list_customers_via_sp(
|
| 14 |
+
self,
|
| 15 |
+
order_by: str,
|
| 16 |
+
order_dir: str,
|
| 17 |
+
page: int,
|
| 18 |
+
page_size: int,
|
| 19 |
+
) -> Tuple[List[Dict], int]:
|
| 20 |
+
"""
|
| 21 |
+
Calls dbo.spAbCustomersGetList and returns (rows, total_records)
|
| 22 |
+
Falls back to direct SQL query if stored procedure doesn't return data.
|
| 23 |
+
Raises Exception on DB error.
|
| 24 |
+
"""
|
| 25 |
+
try:
|
| 26 |
+
with self.engine.connect() as conn:
|
| 27 |
+
# Try the stored procedure first
|
| 28 |
+
try:
|
| 29 |
+
data_query = text("""
|
| 30 |
+
DECLARE @TotalRecords INT;
|
| 31 |
+
EXEC dbo.spAbCustomersGetList
|
| 32 |
+
@OrderBy = :order_by,
|
| 33 |
+
@OrderDirection = :order_dir,
|
| 34 |
+
@Page = :page,
|
| 35 |
+
@PageSize = :page_size,
|
| 36 |
+
@TotalRecords = @TotalRecords OUTPUT;
|
| 37 |
+
""")
|
| 38 |
+
|
| 39 |
+
result = conn.execute(data_query, {
|
| 40 |
+
"order_by": order_by,
|
| 41 |
+
"order_dir": order_dir,
|
| 42 |
+
"page": page,
|
| 43 |
+
"page_size": page_size,
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
# Get the customer rows
|
| 47 |
+
rows = []
|
| 48 |
+
if result.returns_rows:
|
| 49 |
+
customer_rows = result.fetchall()
|
| 50 |
+
if customer_rows:
|
| 51 |
+
columns = result.keys()
|
| 52 |
+
raw_rows = [dict(zip(columns, row)) for row in customer_rows]
|
| 53 |
+
# Transform database column names to schema field names
|
| 54 |
+
for raw_row in raw_rows:
|
| 55 |
+
transformed_row = {
|
| 56 |
+
'id': raw_row.get('CustomerID'),
|
| 57 |
+
'name': raw_row.get('CompanyName'),
|
| 58 |
+
'address': raw_row.get('Address'),
|
| 59 |
+
'city': raw_row.get('City'),
|
| 60 |
+
'postal_code': raw_row.get('PostalCode'),
|
| 61 |
+
'web_address': raw_row.get('WebAddress'),
|
| 62 |
+
'referral': raw_row.get('Referral'),
|
| 63 |
+
'company_type_id': raw_row.get('CompanyTypeID'),
|
| 64 |
+
'state_id': raw_row.get('StateID'),
|
| 65 |
+
'country_id': raw_row.get('CountryID'),
|
| 66 |
+
'lead_generated_from_id': raw_row.get('LeadGeneratedFromID'),
|
| 67 |
+
'specific_source': raw_row.get('SpecificSource'),
|
| 68 |
+
'priority_id': raw_row.get('PriorityID'),
|
| 69 |
+
'followup_date': raw_row.get('FollowupDate'),
|
| 70 |
+
'purchase': raw_row.get('Purchase'),
|
| 71 |
+
'vendor_id': raw_row.get('VendorID'),
|
| 72 |
+
'enabled': raw_row.get('Enabled'),
|
| 73 |
+
'rental_type': raw_row.get('RentalType'),
|
| 74 |
+
}
|
| 75 |
+
rows.append(transformed_row)
|
| 76 |
+
|
| 77 |
+
# If stored procedure worked and returned data, use it
|
| 78 |
+
if rows:
|
| 79 |
+
# Get total count
|
| 80 |
+
count_query = text("SELECT COUNT(*) as total FROM dbo.Customers")
|
| 81 |
+
count_result = conn.execute(count_query)
|
| 82 |
+
total = count_result.fetchone()[0]
|
| 83 |
+
return rows, int(total)
|
| 84 |
+
|
| 85 |
+
except Exception as sp_error:
|
| 86 |
+
print(f"Stored procedure failed: {sp_error}")
|
| 87 |
+
|
| 88 |
+
# Fallback: Use direct SQL query
|
| 89 |
+
print("Falling back to direct SQL query")
|
| 90 |
+
|
| 91 |
+
# Calculate offset
|
| 92 |
+
offset = (page - 1) * page_size
|
| 93 |
+
|
| 94 |
+
# Build the order clause - ensure valid column name and direction
|
| 95 |
+
order_clause = f"{order_by} {order_dir.upper()}"
|
| 96 |
+
|
| 97 |
+
# Direct query with pagination
|
| 98 |
+
query = text(f"""
|
| 99 |
+
SELECT CustomerID, CompanyName, Address, City, PostalCode,
|
| 100 |
+
WebAddress, Referral, CompanyTypeID, StateID, CountryID,
|
| 101 |
+
LeadGeneratedFromID, SpecificSource, PriorityID,
|
| 102 |
+
FollowupDate, Purchase, VendorID, Enabled, RentalType
|
| 103 |
+
FROM dbo.Customers
|
| 104 |
+
ORDER BY {order_clause}
|
| 105 |
+
OFFSET :offset ROWS
|
| 106 |
+
FETCH NEXT :page_size ROWS ONLY
|
| 107 |
+
""")
|
| 108 |
+
|
| 109 |
+
result = conn.execute(query, {
|
| 110 |
+
"offset": offset,
|
| 111 |
+
"page_size": page_size
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
rows = []
|
| 115 |
+
if result.returns_rows:
|
| 116 |
+
customer_rows = result.fetchall()
|
| 117 |
+
if customer_rows:
|
| 118 |
+
columns = result.keys()
|
| 119 |
+
raw_rows = [dict(zip(columns, row)) for row in customer_rows]
|
| 120 |
+
# Transform database column names to schema field names
|
| 121 |
+
rows = []
|
| 122 |
+
for raw_row in raw_rows:
|
| 123 |
+
transformed_row = {
|
| 124 |
+
'id': raw_row.get('CustomerID'),
|
| 125 |
+
'name': raw_row.get('CompanyName'),
|
| 126 |
+
'address': raw_row.get('Address'),
|
| 127 |
+
'city': raw_row.get('City'),
|
| 128 |
+
'postal_code': raw_row.get('PostalCode'),
|
| 129 |
+
'web_address': raw_row.get('WebAddress'),
|
| 130 |
+
'referral': raw_row.get('Referral'),
|
| 131 |
+
'company_type_id': raw_row.get('CompanyTypeID'),
|
| 132 |
+
'state_id': raw_row.get('StateID'),
|
| 133 |
+
'country_id': raw_row.get('CountryID'),
|
| 134 |
+
'lead_generated_from_id': raw_row.get('LeadGeneratedFromID'),
|
| 135 |
+
'specific_source': raw_row.get('SpecificSource'),
|
| 136 |
+
'priority_id': raw_row.get('PriorityID'),
|
| 137 |
+
'followup_date': raw_row.get('FollowupDate'),
|
| 138 |
+
'purchase': raw_row.get('Purchase'),
|
| 139 |
+
'vendor_id': raw_row.get('VendorID'),
|
| 140 |
+
'enabled': raw_row.get('Enabled'),
|
| 141 |
+
'rental_type': raw_row.get('RentalType'),
|
| 142 |
+
}
|
| 143 |
+
rows.append(transformed_row)
|
| 144 |
+
|
| 145 |
+
# Get total count
|
| 146 |
+
count_query = text("SELECT COUNT(*) as total FROM dbo.Customers")
|
| 147 |
+
count_result = conn.execute(count_query)
|
| 148 |
+
total = count_result.fetchone()[0]
|
| 149 |
+
|
| 150 |
+
return rows, int(total)
|
| 151 |
+
|
| 152 |
+
except Exception as e:
|
| 153 |
+
raise Exception(f"DB error in list_customers_via_sp: {e}")
|
app/db/session.py
CHANGED
|
@@ -1,13 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from sqlalchemy import create_engine
|
| 2 |
from sqlalchemy.orm import sessionmaker
|
| 3 |
from app.core.config import settings
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
)
|
| 9 |
|
| 10 |
-
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
| 11 |
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 12 |
|
| 13 |
def get_db():
|
|
@@ -15,4 +37,4 @@ def get_db():
|
|
| 15 |
try:
|
| 16 |
yield db
|
| 17 |
finally:
|
| 18 |
-
db.close()
|
|
|
|
| 1 |
+
# app/db/session.py
|
| 2 |
+
import os
|
| 3 |
+
import urllib.parse
|
| 4 |
from sqlalchemy import create_engine
|
| 5 |
from sqlalchemy.orm import sessionmaker
|
| 6 |
from app.core.config import settings
|
| 7 |
|
| 8 |
+
USER = settings.SQLSERVER_USER
|
| 9 |
+
PWD = settings.SQLSERVER_PASSWORD
|
| 10 |
+
HOST = settings.SQLSERVER_HOST
|
| 11 |
+
PORT = settings.SQLSERVER_PORT or 31433 # <- default to 31433 if not set
|
| 12 |
+
DB = settings.SQLSERVER_DB
|
| 13 |
+
|
| 14 |
+
odbc_str = (
|
| 15 |
+
"DRIVER={ODBC Driver 18 for SQL Server};"
|
| 16 |
+
f"SERVER=tcp:{HOST},{PORT};"
|
| 17 |
+
f"DATABASE={DB};"
|
| 18 |
+
f"UID={USER};PWD={PWD};"
|
| 19 |
+
"Encrypt=yes;"
|
| 20 |
+
"TrustServerCertificate=yes;" # diagnostic: OK for dev. remove/flip in prod.
|
| 21 |
+
"LoginTimeout=15;"
|
| 22 |
+
)
|
| 23 |
+
params = urllib.parse.quote_plus(odbc_str)
|
| 24 |
+
DATABASE_URL = f"mssql+pyodbc:///?odbc_connect={params}"
|
| 25 |
+
|
| 26 |
+
engine = create_engine(
|
| 27 |
+
DATABASE_URL,
|
| 28 |
+
pool_pre_ping=True,
|
| 29 |
+
echo=False,
|
| 30 |
+
connect_args={"timeout": 30},
|
| 31 |
)
|
| 32 |
|
|
|
|
| 33 |
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 34 |
|
| 35 |
def get_db():
|
|
|
|
| 37 |
try:
|
| 38 |
yield db
|
| 39 |
finally:
|
| 40 |
+
db.close()
|
app/docker/docker-compose.yml
CHANGED
|
@@ -6,7 +6,7 @@ services:
|
|
| 6 |
SA_PASSWORD: "YourStrong!Passw0rd"
|
| 7 |
ACCEPT_EULA: "Y"
|
| 8 |
ports:
|
| 9 |
-
- "
|
| 10 |
volumes:
|
| 11 |
- mssql_data:/var/opt/mssql
|
| 12 |
app:
|
|
@@ -17,7 +17,7 @@ services:
|
|
| 17 |
SQLSERVER_USER: sa
|
| 18 |
SQLSERVER_PASSWORD: YourStrong!Passw0rd
|
| 19 |
SQLSERVER_HOST: mssql
|
| 20 |
-
SQLSERVER_PORT:
|
| 21 |
SQLSERVER_DB: aquabarrier
|
| 22 |
SECRET_KEY: supersecretkey
|
| 23 |
ports:
|
|
|
|
| 6 |
SA_PASSWORD: "YourStrong!Passw0rd"
|
| 7 |
ACCEPT_EULA: "Y"
|
| 8 |
ports:
|
| 9 |
+
- "31433:31433"
|
| 10 |
volumes:
|
| 11 |
- mssql_data:/var/opt/mssql
|
| 12 |
app:
|
|
|
|
| 17 |
SQLSERVER_USER: sa
|
| 18 |
SQLSERVER_PASSWORD: YourStrong!Passw0rd
|
| 19 |
SQLSERVER_HOST: mssql
|
| 20 |
+
SQLSERVER_PORT: 31433
|
| 21 |
SQLSERVER_DB: aquabarrier
|
| 22 |
SECRET_KEY: supersecretkey
|
| 23 |
ports:
|
app/prompts/mvc_fastapi_sp_module.txt
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prompt: Build an MVC-style FastAPI endpoint for listing entities via a SQL Server stored procedure.
|
| 3 |
+
|
| 4 |
+
Requirements:
|
| 5 |
+
- Use Pydantic for request/response models.
|
| 6 |
+
- SQL Server stored procedure should support ordering, pagination, and return total records as an output parameter.
|
| 7 |
+
- Follow layered architecture: controller/router → service → repository (DB access).
|
| 8 |
+
- Use dependency injection for DB Session/engine.
|
| 9 |
+
- Return paginated response as JSON: { "items": [...], "page": int, "page_size": int, "total": int }
|
| 10 |
+
- Response model: PaginatedResponse[EntityOut] (generic or explicit schema).
|
| 11 |
+
- Controller should accept query params: page (int, 1-indexed), page_size (int), order_by (str), order_dir (str: asc|desc).
|
| 12 |
+
- Repository should call the stored procedure and fetch both rows and total count in a single DB call.
|
| 13 |
+
- Service should validate/normalize query args, call repository, and compose DTOs/pagination object.
|
| 14 |
+
- Controller should document query params and response.
|
| 15 |
+
- Add error handling and docstrings throughout layers.
|
| 16 |
+
- Provide unit/integration tests for endpoint and layers.
|
| 17 |
+
|
| 18 |
+
CRITICAL: Database Schema Analysis Required
|
| 19 |
+
Before implementing, you MUST:
|
| 20 |
+
|
| 21 |
+
1. **Get Actual Table Structure**:
|
| 22 |
+
- Request the CREATE TABLE script or INFORMATION_SCHEMA query results
|
| 23 |
+
- Identify exact column names (case-sensitive for SQL Server)
|
| 24 |
+
- Note data types and constraints
|
| 25 |
+
- Example: CustomerID vs customer_id vs id
|
| 26 |
+
|
| 27 |
+
2. **Verify Stored Procedure Signature**:
|
| 28 |
+
- Get the stored procedure definition
|
| 29 |
+
- Confirm parameter names and types (@OrderBy, @OrderDirection, @Page, @PageSize, @TotalRecords OUTPUT)
|
| 30 |
+
- Test that it actually returns data rows
|
| 31 |
+
|
| 32 |
+
3. **Column Name Mapping Strategy**:
|
| 33 |
+
- Create mapping between database columns and Pydantic schema fields
|
| 34 |
+
- Handle naming convention differences (PascalCase DB vs snake_case Python)
|
| 35 |
+
- Example mapping:
|
| 36 |
+
```python
|
| 37 |
+
COLUMN_MAPPING = {
|
| 38 |
+
"id": "CustomerID", # Schema field -> DB column
|
| 39 |
+
"name": "CompanyName",
|
| 40 |
+
"created_at": "CustomerID", # Fallback if column doesn't exist
|
| 41 |
+
"email": "EmailAddress" # Map to actual DB column
|
| 42 |
+
}
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
4. **Data Transformation Layer**:
|
| 46 |
+
- Transform raw database results to match Pydantic schema
|
| 47 |
+
- Handle missing fields gracefully (set to None if not in DB)
|
| 48 |
+
- Example:
|
| 49 |
+
```python
|
| 50 |
+
transformed_row = {
|
| 51 |
+
'id': raw_row.get('CustomerID'),
|
| 52 |
+
'name': raw_row.get('CompanyName'),
|
| 53 |
+
'email': raw_row.get('EmailAddress'), # If exists
|
| 54 |
+
'phone': None, # If not available in DB
|
| 55 |
+
}
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
5. **Fallback Strategy**:
|
| 59 |
+
- Implement direct SQL query fallback if stored procedure fails
|
| 60 |
+
- Use OFFSET/FETCH for pagination in SQL Server
|
| 61 |
+
- Separate total count query if needed
|
| 62 |
+
|
| 63 |
+
6. **Common Pitfalls to Avoid**:
|
| 64 |
+
- Don't assume SQLAlchemy model columns match database columns
|
| 65 |
+
- Don't pass application field names directly to stored procedures
|
| 66 |
+
- Handle cases where stored procedures don't return rows properly
|
| 67 |
+
- Account for SQL Server naming conventions (PascalCase)
|
| 68 |
+
- Validate that order_by columns actually exist in the database
|
| 69 |
+
|
| 70 |
+
Repository Implementation Pattern:
|
| 71 |
+
```python
|
| 72 |
+
def list_entities_via_sp(self, order_by: str, order_dir: str, page: int, page_size: int):
|
| 73 |
+
try:
|
| 74 |
+
# Try stored procedure first
|
| 75 |
+
result = conn.execute(stored_procedure_query, params)
|
| 76 |
+
|
| 77 |
+
if result.returns_rows and result.fetchall():
|
| 78 |
+
# Transform database columns to schema fields
|
| 79 |
+
return transform_rows(result), get_total_count()
|
| 80 |
+
else:
|
| 81 |
+
# Fallback to direct SQL with proper column names
|
| 82 |
+
return direct_sql_query_with_pagination()
|
| 83 |
+
except Exception as e:
|
| 84 |
+
raise RepositoryException(f"Database error: {e}")
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
Service Layer Column Validation:
|
| 88 |
+
```python
|
| 89 |
+
class EntityListService:
|
| 90 |
+
ALLOWED_ORDER_BY = {"id", "name", "created_at", "updated_at"}
|
| 91 |
+
COLUMN_MAPPING = {
|
| 92 |
+
"id": "EntityID",
|
| 93 |
+
"name": "EntityName",
|
| 94 |
+
"created_at": "EntityID", # Fallback
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
def list_entities(self, order_by: str = None, ...):
|
| 98 |
+
order_by = order_by if order_by in self.ALLOWED_ORDER_BY else "id"
|
| 99 |
+
db_column = self.COLUMN_MAPPING.get(order_by, order_by)
|
| 100 |
+
return self.repo.list_entities_via_sp(db_column, ...)
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
Example API contract:
|
| 104 |
+
GET /api/v1/entities?page=1&page_size=10&order_by=created_at&order_dir=desc
|
| 105 |
+
Response: { "items": [EntityOut], "page": 1, "page_size": 10, "total": 123 }
|
| 106 |
+
|
| 107 |
+
Testing Requirements:
|
| 108 |
+
- Test with actual database table structure
|
| 109 |
+
- Verify stored procedure calls work with real column names
|
| 110 |
+
- Test fallback scenarios
|
| 111 |
+
- Validate data transformation
|
| 112 |
+
- Test edge cases (empty results, invalid columns)
|
| 113 |
+
"""
|
app/schemas/customer.py
CHANGED
|
@@ -1,18 +1,45 @@
|
|
| 1 |
from pydantic import BaseModel
|
| 2 |
from typing import Optional
|
|
|
|
| 3 |
|
| 4 |
class CustomerCreate(BaseModel):
|
| 5 |
name: str
|
| 6 |
-
email: Optional[str] = None
|
| 7 |
-
phone: Optional[str] = None
|
| 8 |
address: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
class CustomerOut(BaseModel):
|
| 11 |
id: int
|
| 12 |
-
name: str
|
| 13 |
-
email: Optional[str] = None
|
| 14 |
-
phone: Optional[str] = None
|
| 15 |
address: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
class Config:
|
| 18 |
orm_mode = True
|
|
|
|
| 1 |
from pydantic import BaseModel
|
| 2 |
from typing import Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
|
| 5 |
class CustomerCreate(BaseModel):
|
| 6 |
name: str
|
|
|
|
|
|
|
| 7 |
address: Optional[str] = None
|
| 8 |
+
city: Optional[str] = None
|
| 9 |
+
postal_code: Optional[str] = None
|
| 10 |
+
web_address: Optional[str] = None
|
| 11 |
+
referral: Optional[str] = None
|
| 12 |
+
company_type_id: Optional[int] = None
|
| 13 |
+
state_id: Optional[int] = None
|
| 14 |
+
country_id: Optional[int] = None
|
| 15 |
+
lead_generated_from_id: Optional[int] = None
|
| 16 |
+
specific_source: Optional[str] = None
|
| 17 |
+
priority_id: Optional[int] = None
|
| 18 |
+
followup_date: Optional[datetime] = None
|
| 19 |
+
purchase: Optional[str] = None
|
| 20 |
+
vendor_id: Optional[str] = None
|
| 21 |
+
enabled: Optional[bool] = True
|
| 22 |
+
rental_type: Optional[str] = "AB"
|
| 23 |
|
| 24 |
class CustomerOut(BaseModel):
|
| 25 |
id: int
|
| 26 |
+
name: Optional[str] = None
|
|
|
|
|
|
|
| 27 |
address: Optional[str] = None
|
| 28 |
+
city: Optional[str] = None
|
| 29 |
+
postal_code: Optional[str] = None
|
| 30 |
+
web_address: Optional[str] = None
|
| 31 |
+
referral: Optional[str] = None
|
| 32 |
+
company_type_id: Optional[int] = None
|
| 33 |
+
state_id: Optional[int] = None
|
| 34 |
+
country_id: Optional[int] = None
|
| 35 |
+
lead_generated_from_id: Optional[int] = None
|
| 36 |
+
specific_source: Optional[str] = None
|
| 37 |
+
priority_id: Optional[int] = None
|
| 38 |
+
followup_date: Optional[datetime] = None
|
| 39 |
+
purchase: Optional[str] = None
|
| 40 |
+
vendor_id: Optional[str] = None
|
| 41 |
+
enabled: Optional[bool] = None
|
| 42 |
+
rental_type: Optional[str] = None
|
| 43 |
|
| 44 |
class Config:
|
| 45 |
orm_mode = True
|
app/schemas/paginated_response.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Generic, TypeVar, List
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
|
| 4 |
+
T = TypeVar("T")
|
| 5 |
+
|
| 6 |
+
class PaginatedResponse(BaseModel, Generic[T]):
|
| 7 |
+
items: List[T]
|
| 8 |
+
page: int
|
| 9 |
+
page_size: int
|
| 10 |
+
total: int
|
| 11 |
+
|
| 12 |
+
class Config:
|
| 13 |
+
orm_mode = True
|
app/services/customer_list_service.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Tuple
|
| 2 |
+
from app.db.repositories.customer_sp_repo import CustomerRepository
|
| 3 |
+
from app.schemas.customer import CustomerOut
|
| 4 |
+
from app.schemas.paginated_response import PaginatedResponse
|
| 5 |
+
|
| 6 |
+
class CustomerListService:
|
| 7 |
+
"""
|
| 8 |
+
Service for listing customers with pagination and ordering via stored procedure.
|
| 9 |
+
Validates query params and calls repository.
|
| 10 |
+
"""
|
| 11 |
+
ALLOWED_ORDER_BY = {
|
| 12 |
+
"id", "customer_id", "company_name", "name", "address", "city",
|
| 13 |
+
"postal_code", "web_address", "referral", "company_type_id",
|
| 14 |
+
"state_id", "country_id", "lead_generated_from_id", "specific_source",
|
| 15 |
+
"priority_id", "followup_date", "purchase", "vendor_id", "enabled",
|
| 16 |
+
"rental_type", "created_at"
|
| 17 |
+
}
|
| 18 |
+
ALLOWED_ORDER_DIR = {"asc", "desc"}
|
| 19 |
+
DEFAULT_ORDER_BY = "id" # Changed from created_at to id since created_at doesn't exist
|
| 20 |
+
DEFAULT_ORDER_DIR = "desc"
|
| 21 |
+
|
| 22 |
+
# Mapping from application field names to actual database column names
|
| 23 |
+
COLUMN_MAPPING = {
|
| 24 |
+
"id": "CustomerID",
|
| 25 |
+
"customer_id": "CustomerID",
|
| 26 |
+
"company_name": "CompanyName",
|
| 27 |
+
"name": "CompanyName", # Alias for company_name
|
| 28 |
+
"address": "Address",
|
| 29 |
+
"city": "City",
|
| 30 |
+
"postal_code": "PostalCode",
|
| 31 |
+
"web_address": "WebAddress",
|
| 32 |
+
"referral": "Referral",
|
| 33 |
+
"company_type_id": "CompanyTypeID",
|
| 34 |
+
"state_id": "StateID",
|
| 35 |
+
"country_id": "CountryID",
|
| 36 |
+
"lead_generated_from_id": "LeadGeneratedFromID",
|
| 37 |
+
"specific_source": "SpecificSource",
|
| 38 |
+
"priority_id": "PriorityID",
|
| 39 |
+
"followup_date": "FollowupDate",
|
| 40 |
+
"purchase": "Purchase",
|
| 41 |
+
"vendor_id": "VendorID",
|
| 42 |
+
"enabled": "Enabled",
|
| 43 |
+
"rental_type": "RentalType",
|
| 44 |
+
"created_at": "CustomerID", # Fallback to CustomerID since created_at doesn't exist in DB
|
| 45 |
+
"email": "CustomerID" # Fallback to CustomerID since email doesn't exist in DB
|
| 46 |
+
}
|
| 47 |
+
DEFAULT_PAGE = 1
|
| 48 |
+
DEFAULT_PAGE_SIZE = 10
|
| 49 |
+
MAX_PAGE_SIZE = 100
|
| 50 |
+
|
| 51 |
+
def __init__(self, db):
|
| 52 |
+
self.repo = CustomerRepository(db)
|
| 53 |
+
|
| 54 |
+
def list_customers(self, page: int = 1, page_size: int = 10, order_by: str = None, order_dir: str = None) -> PaginatedResponse:
|
| 55 |
+
"""
|
| 56 |
+
Returns paginated customer list and metadata.
|
| 57 |
+
Raises ValueError for invalid params or DB errors.
|
| 58 |
+
"""
|
| 59 |
+
try:
|
| 60 |
+
page = max(page, 1)
|
| 61 |
+
page_size = min(max(page_size, 1), self.MAX_PAGE_SIZE)
|
| 62 |
+
order_by = order_by if order_by in self.ALLOWED_ORDER_BY else self.DEFAULT_ORDER_BY
|
| 63 |
+
order_dir = order_dir if order_dir in self.ALLOWED_ORDER_DIR else self.DEFAULT_ORDER_DIR
|
| 64 |
+
|
| 65 |
+
# Map the application field name to the database column name
|
| 66 |
+
db_order_by = self.COLUMN_MAPPING.get(order_by, order_by)
|
| 67 |
+
|
| 68 |
+
rows, total = self.repo.list_customers_via_sp(db_order_by, order_dir, page, page_size)
|
| 69 |
+
items = [CustomerOut(**row) for row in rows]
|
| 70 |
+
return PaginatedResponse[
|
| 71 |
+
CustomerOut
|
| 72 |
+
](items=items, page=page, page_size=page_size, total=total)
|
| 73 |
+
except Exception as e:
|
| 74 |
+
raise ValueError(f"Failed to list customers: {e}")
|
app/tests/unit/test_customers_list.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
from app.main import app
|
| 4 |
+
|
| 5 |
+
client = TestClient(app)
|
| 6 |
+
|
| 7 |
+
def test_list_customers_default():
|
| 8 |
+
resp = client.get("/api/v1/customers")
|
| 9 |
+
assert resp.status_code == 200
|
| 10 |
+
data = resp.json()
|
| 11 |
+
assert "items" in data
|
| 12 |
+
assert "page" in data
|
| 13 |
+
assert "page_size" in data
|
| 14 |
+
assert "total" in data
|
| 15 |
+
assert isinstance(data["items"], list)
|
| 16 |
+
assert data["page"] == 1
|
| 17 |
+
assert data["page_size"] == 10
|
| 18 |
+
|
| 19 |
+
def test_list_customers_pagination():
|
| 20 |
+
resp = client.get("/api/v1/customers?page=2&page_size=5")
|
| 21 |
+
assert resp.status_code == 200
|
| 22 |
+
data = resp.json()
|
| 23 |
+
assert data["page"] == 2
|
| 24 |
+
assert data["page_size"] == 5
|
| 25 |
+
|
| 26 |
+
def test_list_customers_ordering():
|
| 27 |
+
resp = client.get("/api/v1/customers?order_by=name&order_dir=asc")
|
| 28 |
+
assert resp.status_code == 200
|
| 29 |
+
data = resp.json()
|
| 30 |
+
assert data["page"] == 1
|
| 31 |
+
assert data["page_size"] == 10
|
| 32 |
+
|
| 33 |
+
def test_list_customers_invalid_order_by():
|
| 34 |
+
resp = client.get("/api/v1/customers?order_by=invalid_field")
|
| 35 |
+
assert resp.status_code == 200
|
| 36 |
+
data = resp.json()
|
| 37 |
+
assert data["page"] == 1
|
| 38 |
+
assert data["page_size"] == 10
|
| 39 |
+
|
| 40 |
+
def test_list_customers_invalid_order_dir():
|
| 41 |
+
resp = client.get("/api/v1/customers?order_dir=up")
|
| 42 |
+
assert resp.status_code == 200
|
| 43 |
+
data = resp.json()
|
| 44 |
+
assert data["page"] == 1
|
| 45 |
+
assert data["page_size"] == 10
|