MukeshKapoor25 commited on
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 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 typing import List
 
7
 
8
  router = APIRouter(prefix="/api/v1/customers", tags=["customers"])
9
 
10
- @router.get("/", response_model=List[CustomerOut])
11
- def list_customers(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)):
12
- service = CustomerService(db)
13
- return service.list(skip, limit)
 
 
 
 
 
 
 
 
 
 
 
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 = 1433
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__ = "customers"
7
- id = Column(Integer, primary_key=True, index=True)
8
- name = Column(String(255), nullable=False)
9
- email = Column(String(255), unique=True, index=True, nullable=True)
10
- phone = Column(String(50), nullable=True)
11
- address = Column(String(255), nullable=True)
12
- created_at = Column(DateTime, default=datetime.utcnow)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- DATABASE_URL = (
6
- f"mssql+pyodbc://{settings.SQLSERVER_USER}:{settings.SQLSERVER_PASSWORD}"
7
- f"@{settings.SQLSERVER_HOST}:{settings.SQLSERVER_PORT}/{settings.SQLSERVER_DB}?driver={settings.SQLSERVER_DRIVER.replace(' ', '+')}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- - "1433:1433"
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: 1433
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