Spaces:
Paused
Paused
Commit ·
b79b0dc
1
Parent(s): 95c71c7
Implement project listing with pagination and sorting; add repository and service methods, update models and schemas, and include unit tests for functionality
Browse files- app/controllers/projects.py +48 -12
- app/core/exceptions.py +4 -0
- app/db/models/project.py +95 -12
- app/db/repositories/project_repo.py +108 -3
- app/schemas/paginated_response.py +1 -1
- app/schemas/project.py +99 -14
- app/services/project_service.py +153 -6
- app/tests/unit/test_projects_list.py +185 -0
- test_api.py +49 -0
app/controllers/projects.py
CHANGED
|
@@ -7,28 +7,64 @@ from typing import List
|
|
| 7 |
|
| 8 |
router = APIRouter(prefix="/api/v1/projects", tags=["projects"])
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
service = ProjectService(db)
|
| 13 |
-
return service.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
@router.get("/{
|
| 16 |
-
def get_project(
|
|
|
|
| 17 |
service = ProjectService(db)
|
| 18 |
-
return service.get(
|
| 19 |
|
| 20 |
@router.post("/", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
|
| 21 |
def create_project(project_in: ProjectCreate, db: Session = Depends(get_db)):
|
|
|
|
| 22 |
service = ProjectService(db)
|
| 23 |
return service.create(project_in.dict())
|
| 24 |
|
| 25 |
-
@router.put("/{
|
| 26 |
-
def update_project(
|
|
|
|
| 27 |
service = ProjectService(db)
|
| 28 |
-
return service.update(
|
| 29 |
|
| 30 |
-
@router.delete("/{
|
| 31 |
-
def delete_project(
|
|
|
|
| 32 |
service = ProjectService(db)
|
| 33 |
-
service.delete(
|
| 34 |
return None
|
|
|
|
| 7 |
|
| 8 |
router = APIRouter(prefix="/api/v1/projects", tags=["projects"])
|
| 9 |
|
| 10 |
+
from fastapi import APIRouter, Depends, status, Query
|
| 11 |
+
from sqlalchemy.orm import Session
|
| 12 |
+
from app.db.session import get_db
|
| 13 |
+
from app.services.project_service import ProjectService
|
| 14 |
+
from app.schemas.project import ProjectCreate, ProjectOut
|
| 15 |
+
from app.schemas.paginated_response import PaginatedResponse
|
| 16 |
+
from typing import List, Optional
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/api/v1/projects", tags=["projects"])
|
| 19 |
+
|
| 20 |
+
@router.get("/", response_model=PaginatedResponse[ProjectOut])
|
| 21 |
+
def list_projects(
|
| 22 |
+
customer_type: Optional[int] = Query(0, description="Customer type filter (0 for all types)", ge=0),
|
| 23 |
+
order_by: Optional[str] = Query("project_no", description="Field to order by"),
|
| 24 |
+
order_direction: Optional[str] = Query("asc", description="Order direction: asc or desc", regex="^(asc|desc)$"),
|
| 25 |
+
page: Optional[int] = Query(1, description="Page number (1-indexed)", ge=1),
|
| 26 |
+
page_size: Optional[int] = Query(10, description="Number of records per page", ge=1, le=100),
|
| 27 |
+
db: Session = Depends(get_db)
|
| 28 |
+
):
|
| 29 |
+
"""
|
| 30 |
+
Get paginated list of projects with filtering and sorting.
|
| 31 |
+
|
| 32 |
+
- **customer_type**: Filter by customer type (0 = all types)
|
| 33 |
+
- **order_by**: Field name to sort by (project_no, project_name, etc.)
|
| 34 |
+
- **order_direction**: Sort direction (asc or desc)
|
| 35 |
+
- **page**: Page number starting from 1
|
| 36 |
+
- **page_size**: Number of records per page (max 100)
|
| 37 |
+
"""
|
| 38 |
service = ProjectService(db)
|
| 39 |
+
return service.list_projects(
|
| 40 |
+
customer_type=customer_type,
|
| 41 |
+
order_by=order_by,
|
| 42 |
+
order_direction=order_direction,
|
| 43 |
+
page=page,
|
| 44 |
+
page_size=page_size
|
| 45 |
+
)
|
| 46 |
|
| 47 |
+
@router.get("/{project_no}", response_model=ProjectOut)
|
| 48 |
+
def get_project(project_no: int, db: Session = Depends(get_db)):
|
| 49 |
+
"""Get a specific project by ProjectNo"""
|
| 50 |
service = ProjectService(db)
|
| 51 |
+
return service.get(project_no)
|
| 52 |
|
| 53 |
@router.post("/", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
|
| 54 |
def create_project(project_in: ProjectCreate, db: Session = Depends(get_db)):
|
| 55 |
+
"""Create a new project"""
|
| 56 |
service = ProjectService(db)
|
| 57 |
return service.create(project_in.dict())
|
| 58 |
|
| 59 |
+
@router.put("/{project_no}", response_model=ProjectOut)
|
| 60 |
+
def update_project(project_no: int, project_in: ProjectCreate, db: Session = Depends(get_db)):
|
| 61 |
+
"""Update an existing project"""
|
| 62 |
service = ProjectService(db)
|
| 63 |
+
return service.update(project_no, project_in.dict())
|
| 64 |
|
| 65 |
+
@router.delete("/{project_no}", status_code=status.HTTP_204_NO_CONTENT)
|
| 66 |
+
def delete_project(project_no: int, db: Session = Depends(get_db)):
|
| 67 |
+
"""Delete a project"""
|
| 68 |
service = ProjectService(db)
|
| 69 |
+
service.delete(project_no)
|
| 70 |
return None
|
app/core/exceptions.py
CHANGED
|
@@ -11,3 +11,7 @@ class NotFoundException(HTTPException):
|
|
| 11 |
class BadRequestException(HTTPException):
|
| 12 |
def __init__(self, detail: str = "Bad request"):
|
| 13 |
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
class BadRequestException(HTTPException):
|
| 12 |
def __init__(self, detail: str = "Bad request"):
|
| 13 |
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
| 14 |
+
|
| 15 |
+
class RepositoryException(HTTPException):
|
| 16 |
+
def __init__(self, detail: str = "Database operation failed"):
|
| 17 |
+
super().__init__(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail)
|
app/db/models/project.py
CHANGED
|
@@ -1,17 +1,100 @@
|
|
| 1 |
-
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float
|
| 2 |
from sqlalchemy.orm import relationship
|
| 3 |
from app.db.base import Base
|
| 4 |
from datetime import datetime
|
| 5 |
|
| 6 |
class Project(Base):
|
| 7 |
-
__tablename__ = "
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float, Boolean, Text, DECIMAL, Numeric
|
| 2 |
from sqlalchemy.orm import relationship
|
| 3 |
from app.db.base import Base
|
| 4 |
from datetime import datetime
|
| 5 |
|
| 6 |
class Project(Base):
|
| 7 |
+
__tablename__ = "Projects" # Match actual SQL Server table name
|
| 8 |
+
|
| 9 |
+
# Primary key - maps to ProjectNo
|
| 10 |
+
project_no = Column("ProjectNo", Integer, primary_key=True, index=True)
|
| 11 |
+
|
| 12 |
+
# Basic project info
|
| 13 |
+
project_name = Column("ProjectName", String(500), nullable=True)
|
| 14 |
+
project_location = Column("ProjectLocation", String(50), nullable=True)
|
| 15 |
+
project_type = Column("ProjectType", String(50), nullable=True)
|
| 16 |
+
bid_date = Column("BidDate", DateTime, nullable=True)
|
| 17 |
+
start_date = Column("StartDate", DateTime, nullable=True)
|
| 18 |
+
is_awarded = Column("IsAwarded", Boolean, nullable=False, default=False)
|
| 19 |
+
notes = Column("Notes", Text, nullable=True)
|
| 20 |
+
|
| 21 |
+
# Barrier and lease info
|
| 22 |
+
barrier_size = Column("BarrierSize", String(50), nullable=True)
|
| 23 |
+
lease_term = Column("LeaseTerm", String(50), nullable=True)
|
| 24 |
+
purchase_option = Column("PurchaseOption", Boolean, nullable=False, default=False)
|
| 25 |
+
lead_source = Column("LeadSource", String(50), nullable=True)
|
| 26 |
+
rep = Column("rep", String(50), nullable=True)
|
| 27 |
+
|
| 28 |
+
# Engineer info
|
| 29 |
+
engineer_company_id = Column("EngineerCompanyId", Integer, nullable=True)
|
| 30 |
+
engineer_notes = Column("EngineerNotes", Text, nullable=True)
|
| 31 |
+
engineer_company = Column("EngineerCompany", String(75), nullable=True)
|
| 32 |
+
|
| 33 |
+
# Status and customer info
|
| 34 |
+
status = Column("Status", Integer, nullable=True)
|
| 35 |
+
customer_type_id = Column("CustomertTypeId", Integer, nullable=True, default=1)
|
| 36 |
+
|
| 37 |
+
# Billing address
|
| 38 |
+
bill_name = Column("Bill_Name", String(50), nullable=True)
|
| 39 |
+
bill_address1 = Column("Bill_Address1", String(50), nullable=True)
|
| 40 |
+
bill_address2 = Column("Bill_Address2", String(50), nullable=True)
|
| 41 |
+
bill_city = Column("Bill_City", String(30), nullable=True)
|
| 42 |
+
bill_state = Column("Bill_State", String(20), nullable=True)
|
| 43 |
+
bill_zip = Column("Bill_Zip", String(15), nullable=True)
|
| 44 |
+
bill_email = Column("Bill_Email", String(250), nullable=True)
|
| 45 |
+
bill_phone = Column("Bill_Phone", String(50), nullable=True)
|
| 46 |
+
|
| 47 |
+
# Shipping address
|
| 48 |
+
ship_name = Column("Ship_Name", String(50), nullable=True)
|
| 49 |
+
ship_address1 = Column("Ship_Address1", String(50), nullable=True)
|
| 50 |
+
ship_address2 = Column("Ship_Address2", String(50), nullable=True)
|
| 51 |
+
ship_city = Column("Ship_City", String(30), nullable=True)
|
| 52 |
+
ship_state = Column("Ship_State", String(20), nullable=True)
|
| 53 |
+
ship_zip = Column("Ship_Zip", String(15), nullable=True)
|
| 54 |
+
ship_email = Column("Ship_Email", String(250), nullable=True)
|
| 55 |
+
ship_phone = Column("Ship_Phone", String(50), nullable=True)
|
| 56 |
+
ship_office_phone = Column("Ship_OfficePhone", String(50), nullable=True)
|
| 57 |
+
|
| 58 |
+
# Payment and pricing info
|
| 59 |
+
acct_payable = Column("Acct_Payable", String(50), nullable=True)
|
| 60 |
+
payment_term_id = Column("PaymentTermId", Integer, nullable=True)
|
| 61 |
+
payment_note = Column("PaymentNote", String(25), nullable=True)
|
| 62 |
+
rental_price_id = Column("RentalPriceId", Integer, nullable=True)
|
| 63 |
+
purchase_price_id = Column("PurchasePriceId", Integer, nullable=True)
|
| 64 |
+
|
| 65 |
+
# Shipping and freight info
|
| 66 |
+
est_ship_date_id = Column("EstShipDateId", Integer, nullable=True)
|
| 67 |
+
fob_id = Column("FOBId", Integer, nullable=True)
|
| 68 |
+
expedite_fee = Column("ExpediteFee", DECIMAL(10, 2), nullable=True)
|
| 69 |
+
est_freight_id = Column("EstFreightId", Integer, nullable=True)
|
| 70 |
+
est_freight_fee = Column("EstFreightFee", DECIMAL(10, 2), nullable=True)
|
| 71 |
+
ship_via = Column("ShipVia", String(50), nullable=True)
|
| 72 |
+
|
| 73 |
+
# Financial info
|
| 74 |
+
tax_rate = Column("TaxRate", DECIMAL(10, 2), nullable=True)
|
| 75 |
+
weekly_charge = Column("WeeklyCharge", DECIMAL(10, 2), nullable=True)
|
| 76 |
+
commission = Column("Commission", DECIMAL(18, 2), nullable=True)
|
| 77 |
+
|
| 78 |
+
# Project details
|
| 79 |
+
crew_members = Column("CrewMembers", Integer, nullable=True)
|
| 80 |
+
tack_hoes = Column("TackHoes", Integer, nullable=True)
|
| 81 |
+
water_pump = Column("WaterPump", Integer, nullable=True)
|
| 82 |
+
water_pump2 = Column("WaterPump2", Integer, nullable=True)
|
| 83 |
+
est_installation_time = Column("EstInstalationTime", Integer, nullable=True)
|
| 84 |
+
pipes = Column("Pipes", Integer, nullable=True)
|
| 85 |
+
timpers = Column("Timpers", Integer, nullable=True)
|
| 86 |
+
repair_kits = Column("RepairKits", String(50), nullable=True)
|
| 87 |
+
installation_advisor = Column("InstallationAdvisor", String(3000), nullable=True)
|
| 88 |
+
|
| 89 |
+
# Employee and dates
|
| 90 |
+
employee_id = Column("EmployeeId", String(50), nullable=True)
|
| 91 |
+
advisor_id = Column("AdvisorId", String(50), nullable=True)
|
| 92 |
+
install_date = Column("InstallDate", DateTime, nullable=True)
|
| 93 |
+
|
| 94 |
+
# Flags and options
|
| 95 |
+
fas_dam = Column("_FasDam", Boolean, nullable=False, default=False)
|
| 96 |
+
valid_for = Column("ValidFor", String(250), nullable=True)
|
| 97 |
+
is_international = Column("IsInternational", Boolean, nullable=True)
|
| 98 |
+
order_number = Column("OrderNumber", String(50), nullable=True)
|
| 99 |
+
same_bill_address = Column("SameBillAddress", Boolean, nullable=True)
|
| 100 |
+
install = Column("Install", Boolean, nullable=True)
|
app/db/repositories/project_repo.py
CHANGED
|
@@ -1,17 +1,122 @@
|
|
| 1 |
from sqlalchemy.orm import Session
|
|
|
|
| 2 |
from app.db.models.project import Project
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
class ProjectRepository:
|
| 5 |
def __init__(self, db: Session):
|
| 6 |
self.db = db
|
| 7 |
|
| 8 |
-
def get(self,
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
def list(self, customer_id: int = None, status: str = None, skip: int = 0, limit: int = 10):
|
|
|
|
| 12 |
query = self.db.query(Project)
|
| 13 |
if customer_id:
|
| 14 |
-
query = query.filter(Project.
|
| 15 |
if status:
|
| 16 |
query = query.filter(Project.status == status)
|
| 17 |
return query.offset(skip).limit(limit).all()
|
|
|
|
| 1 |
from sqlalchemy.orm import Session
|
| 2 |
+
from sqlalchemy import text, Row
|
| 3 |
from app.db.models.project import Project
|
| 4 |
+
from typing import List, Tuple, Dict, Any
|
| 5 |
+
from app.core.exceptions import RepositoryException
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
|
| 10 |
class ProjectRepository:
|
| 11 |
def __init__(self, db: Session):
|
| 12 |
self.db = db
|
| 13 |
|
| 14 |
+
def get(self, project_no: int):
|
| 15 |
+
"""Get a single project by ProjectNo"""
|
| 16 |
+
return self.db.query(Project).filter(Project.project_no == project_no).first()
|
| 17 |
+
|
| 18 |
+
def list_via_sp(self, customer_type: int = 0, order_by: str = "ProjectNo",
|
| 19 |
+
order_direction: str = "ASC", page: int = 1, page_size: int = 10) -> Tuple[List[Dict[str, Any]], int]:
|
| 20 |
+
"""
|
| 21 |
+
Get projects list using stored procedure spProjectsGetList
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
customer_type: Customer type filter (0 for all)
|
| 25 |
+
order_by: Column name to order by
|
| 26 |
+
order_direction: ASC or DESC
|
| 27 |
+
page: Page number (1-indexed)
|
| 28 |
+
page_size: Number of records per page
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
Tuple of (project_rows, total_count)
|
| 32 |
+
"""
|
| 33 |
+
try:
|
| 34 |
+
# Call the stored procedure
|
| 35 |
+
sp_query = text("""
|
| 36 |
+
DECLARE @TotalRecords INT;
|
| 37 |
+
EXEC spProjectsGetList
|
| 38 |
+
@CustomerType = :customer_type,
|
| 39 |
+
@OrderBy = :order_by,
|
| 40 |
+
@OrderDirection = :order_direction,
|
| 41 |
+
@Page = :page,
|
| 42 |
+
@PageSize = :page_size,
|
| 43 |
+
@TotalRecords = @TotalRecords OUTPUT;
|
| 44 |
+
SELECT @TotalRecords AS TotalRecords;
|
| 45 |
+
""")
|
| 46 |
+
|
| 47 |
+
# Execute stored procedure
|
| 48 |
+
result = self.db.execute(sp_query, {
|
| 49 |
+
'customer_type': customer_type,
|
| 50 |
+
'order_by': order_by,
|
| 51 |
+
'order_direction': order_direction,
|
| 52 |
+
'page': page,
|
| 53 |
+
'page_size': page_size
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
# Get all result sets
|
| 57 |
+
projects_data = []
|
| 58 |
+
total_records = 0
|
| 59 |
+
|
| 60 |
+
# First result set contains project data
|
| 61 |
+
if result.returns_rows:
|
| 62 |
+
projects_rows = result.fetchall()
|
| 63 |
+
projects_data = [dict(row._mapping) for row in projects_rows]
|
| 64 |
+
|
| 65 |
+
# Get next result set for total count
|
| 66 |
+
if result.nextset():
|
| 67 |
+
total_result = result.fetchone()
|
| 68 |
+
if total_result:
|
| 69 |
+
total_records = total_result.TotalRecords
|
| 70 |
+
|
| 71 |
+
logger.info(f"Retrieved {len(projects_data)} projects via stored procedure")
|
| 72 |
+
return projects_data, total_records
|
| 73 |
+
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logger.error(f"Error calling stored procedure: {e}")
|
| 76 |
+
# Fallback to direct SQL query
|
| 77 |
+
return self._list_fallback(customer_type, order_by, order_direction, page, page_size)
|
| 78 |
+
|
| 79 |
+
def _list_fallback(self, customer_type: int = 0, order_by: str = "ProjectNo",
|
| 80 |
+
order_direction: str = "ASC", page: int = 1, page_size: int = 10) -> Tuple[List[Dict[str, Any]], int]:
|
| 81 |
+
"""
|
| 82 |
+
Fallback method using direct SQL query with pagination
|
| 83 |
+
"""
|
| 84 |
+
try:
|
| 85 |
+
# Build the base query
|
| 86 |
+
base_query = "SELECT * FROM Projects"
|
| 87 |
+
count_query = "SELECT COUNT(*) as total FROM Projects"
|
| 88 |
+
|
| 89 |
+
# Add WHERE clause if customer_type filter is specified
|
| 90 |
+
where_clause = ""
|
| 91 |
+
if customer_type > 0:
|
| 92 |
+
where_clause = f" WHERE CustomertTypeId = {customer_type}"
|
| 93 |
+
|
| 94 |
+
# Add ORDER BY and pagination
|
| 95 |
+
order_clause = f" ORDER BY {order_by} {order_direction}"
|
| 96 |
+
offset = (page - 1) * page_size
|
| 97 |
+
limit_clause = f" OFFSET {offset} ROWS FETCH NEXT {page_size} ROWS ONLY"
|
| 98 |
+
|
| 99 |
+
# Execute count query
|
| 100 |
+
total_result = self.db.execute(text(count_query + where_clause))
|
| 101 |
+
total_records = total_result.scalar()
|
| 102 |
+
|
| 103 |
+
# Execute data query
|
| 104 |
+
data_query = base_query + where_clause + order_clause + limit_clause
|
| 105 |
+
result = self.db.execute(text(data_query))
|
| 106 |
+
projects_data = [dict(row._mapping) for row in result.fetchall()]
|
| 107 |
+
|
| 108 |
+
logger.info(f"Retrieved {len(projects_data)} projects via fallback query")
|
| 109 |
+
return projects_data, total_records
|
| 110 |
+
|
| 111 |
+
except Exception as e:
|
| 112 |
+
logger.error(f"Error in fallback query: {e}")
|
| 113 |
+
raise RepositoryException(f"Database error: {e}")
|
| 114 |
|
| 115 |
def list(self, customer_id: int = None, status: str = None, skip: int = 0, limit: int = 10):
|
| 116 |
+
"""Legacy list method for backward compatibility"""
|
| 117 |
query = self.db.query(Project)
|
| 118 |
if customer_id:
|
| 119 |
+
query = query.filter(Project.customer_type_id == customer_id)
|
| 120 |
if status:
|
| 121 |
query = query.filter(Project.status == status)
|
| 122 |
return query.offset(skip).limit(limit).all()
|
app/schemas/paginated_response.py
CHANGED
|
@@ -10,4 +10,4 @@ class PaginatedResponse(BaseModel, Generic[T]):
|
|
| 10 |
total: int
|
| 11 |
|
| 12 |
class Config:
|
| 13 |
-
|
|
|
|
| 10 |
total: int
|
| 11 |
|
| 12 |
class Config:
|
| 13 |
+
from_attributes = True # Updated for Pydantic v2
|
app/schemas/project.py
CHANGED
|
@@ -1,25 +1,110 @@
|
|
| 1 |
from pydantic import BaseModel
|
| 2 |
from typing import Optional
|
| 3 |
from datetime import datetime
|
|
|
|
| 4 |
|
| 5 |
class ProjectCreate(BaseModel):
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
start_date: Optional[datetime] = None
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
class ProjectOut(BaseModel):
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
start_date: Optional[datetime] = None
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
class Config:
|
| 25 |
-
|
|
|
|
| 1 |
from pydantic import BaseModel
|
| 2 |
from typing import Optional
|
| 3 |
from datetime import datetime
|
| 4 |
+
from decimal import Decimal
|
| 5 |
|
| 6 |
class ProjectCreate(BaseModel):
|
| 7 |
+
project_name: Optional[str] = None
|
| 8 |
+
project_location: Optional[str] = None
|
| 9 |
+
project_type: Optional[str] = None
|
| 10 |
+
bid_date: Optional[datetime] = None
|
| 11 |
start_date: Optional[datetime] = None
|
| 12 |
+
is_awarded: bool = False
|
| 13 |
+
notes: Optional[str] = None
|
| 14 |
+
barrier_size: Optional[str] = None
|
| 15 |
+
lease_term: Optional[str] = None
|
| 16 |
+
purchase_option: bool = False
|
| 17 |
+
lead_source: Optional[str] = None
|
| 18 |
+
rep: Optional[str] = None
|
| 19 |
+
engineer_company_id: Optional[int] = None
|
| 20 |
+
engineer_notes: Optional[str] = None
|
| 21 |
+
engineer_company: Optional[str] = None
|
| 22 |
+
status: Optional[int] = None
|
| 23 |
+
customer_type_id: Optional[int] = 1
|
| 24 |
+
|
| 25 |
+
# Billing address
|
| 26 |
+
bill_name: Optional[str] = None
|
| 27 |
+
bill_address1: Optional[str] = None
|
| 28 |
+
bill_address2: Optional[str] = None
|
| 29 |
+
bill_city: Optional[str] = None
|
| 30 |
+
bill_state: Optional[str] = None
|
| 31 |
+
bill_zip: Optional[str] = None
|
| 32 |
+
bill_email: Optional[str] = None
|
| 33 |
+
bill_phone: Optional[str] = None
|
| 34 |
+
|
| 35 |
+
# Shipping address
|
| 36 |
+
ship_name: Optional[str] = None
|
| 37 |
+
ship_address1: Optional[str] = None
|
| 38 |
+
ship_address2: Optional[str] = None
|
| 39 |
+
ship_city: Optional[str] = None
|
| 40 |
+
ship_state: Optional[str] = None
|
| 41 |
+
ship_zip: Optional[str] = None
|
| 42 |
+
ship_email: Optional[str] = None
|
| 43 |
+
ship_phone: Optional[str] = None
|
| 44 |
+
ship_office_phone: Optional[str] = None
|
| 45 |
|
| 46 |
class ProjectOut(BaseModel):
|
| 47 |
+
project_no: int
|
| 48 |
+
project_name: Optional[str] = None
|
| 49 |
+
project_location: Optional[str] = None
|
| 50 |
+
project_type: Optional[str] = None
|
| 51 |
+
bid_date: Optional[datetime] = None
|
| 52 |
start_date: Optional[datetime] = None
|
| 53 |
+
is_awarded: bool = False
|
| 54 |
+
notes: Optional[str] = None
|
| 55 |
+
barrier_size: Optional[str] = None
|
| 56 |
+
lease_term: Optional[str] = None
|
| 57 |
+
purchase_option: bool = False
|
| 58 |
+
lead_source: Optional[str] = None
|
| 59 |
+
rep: Optional[str] = None
|
| 60 |
+
engineer_company_id: Optional[int] = None
|
| 61 |
+
engineer_notes: Optional[str] = None
|
| 62 |
+
engineer_company: Optional[str] = None
|
| 63 |
+
status: Optional[int] = None
|
| 64 |
+
customer_type_id: Optional[int] = None
|
| 65 |
+
|
| 66 |
+
# Billing address
|
| 67 |
+
bill_name: Optional[str] = None
|
| 68 |
+
bill_address1: Optional[str] = None
|
| 69 |
+
bill_address2: Optional[str] = None
|
| 70 |
+
bill_city: Optional[str] = None
|
| 71 |
+
bill_state: Optional[str] = None
|
| 72 |
+
bill_zip: Optional[str] = None
|
| 73 |
+
bill_email: Optional[str] = None
|
| 74 |
+
bill_phone: Optional[str] = None
|
| 75 |
+
|
| 76 |
+
# Shipping address
|
| 77 |
+
ship_name: Optional[str] = None
|
| 78 |
+
ship_address1: Optional[str] = None
|
| 79 |
+
ship_address2: Optional[str] = None
|
| 80 |
+
ship_city: Optional[str] = None
|
| 81 |
+
ship_state: Optional[str] = None
|
| 82 |
+
ship_zip: Optional[str] = None
|
| 83 |
+
ship_email: Optional[str] = None
|
| 84 |
+
ship_phone: Optional[str] = None
|
| 85 |
+
ship_office_phone: Optional[str] = None
|
| 86 |
+
|
| 87 |
+
# Payment and pricing info
|
| 88 |
+
acct_payable: Optional[str] = None
|
| 89 |
+
payment_term_id: Optional[int] = None
|
| 90 |
+
payment_note: Optional[str] = None
|
| 91 |
+
rental_price_id: Optional[int] = None
|
| 92 |
+
purchase_price_id: Optional[int] = None
|
| 93 |
+
|
| 94 |
+
# Financial info
|
| 95 |
+
tax_rate: Optional[Decimal] = None
|
| 96 |
+
weekly_charge: Optional[Decimal] = None
|
| 97 |
+
commission: Optional[Decimal] = None
|
| 98 |
+
|
| 99 |
+
# Project details
|
| 100 |
+
crew_members: Optional[int] = None
|
| 101 |
+
install_date: Optional[datetime] = None
|
| 102 |
+
employee_id: Optional[str] = None
|
| 103 |
+
advisor_id: Optional[str] = None
|
| 104 |
+
|
| 105 |
+
# Flags
|
| 106 |
+
is_international: Optional[bool] = None
|
| 107 |
+
order_number: Optional[str] = None
|
| 108 |
|
| 109 |
class Config:
|
| 110 |
+
from_attributes = True # Updated for Pydantic v2
|
app/services/project_service.py
CHANGED
|
@@ -2,34 +2,181 @@ from sqlalchemy.orm import Session
|
|
| 2 |
from app.db.models.project import Project
|
| 3 |
from app.db.repositories.project_repo import ProjectRepository
|
| 4 |
from app.core.exceptions import NotFoundException
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
class ProjectService:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
def __init__(self, db: Session):
|
| 8 |
self.repo = ProjectRepository(db)
|
| 9 |
|
| 10 |
-
def get(self,
|
| 11 |
-
project
|
|
|
|
| 12 |
if not project:
|
| 13 |
raise NotFoundException("Project not found")
|
| 14 |
return project
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
def list(self, customer_id: int = None, status: str = None, skip: int = 0, limit: int = 10):
|
|
|
|
| 17 |
return self.repo.list(customer_id, status, skip, limit)
|
| 18 |
|
| 19 |
def create(self, data):
|
| 20 |
project = Project(**data)
|
| 21 |
return self.repo.create(project)
|
| 22 |
|
| 23 |
-
def update(self,
|
| 24 |
-
project = self.repo.get(
|
| 25 |
if not project:
|
| 26 |
raise NotFoundException("Project not found")
|
| 27 |
for k, v in data.items():
|
| 28 |
setattr(project, k, v)
|
| 29 |
return self.repo.update(project)
|
| 30 |
|
| 31 |
-
def delete(self,
|
| 32 |
-
project = self.repo.get(
|
| 33 |
if not project:
|
| 34 |
raise NotFoundException("Project not found")
|
| 35 |
self.repo.delete(project)
|
|
|
|
| 2 |
from app.db.models.project import Project
|
| 3 |
from app.db.repositories.project_repo import ProjectRepository
|
| 4 |
from app.core.exceptions import NotFoundException
|
| 5 |
+
from app.schemas.project import ProjectOut
|
| 6 |
+
from app.schemas.paginated_response import PaginatedResponse
|
| 7 |
+
from typing import List, Dict, Any
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
|
| 12 |
class ProjectService:
|
| 13 |
+
# Allowed columns for ordering with their database column mappings
|
| 14 |
+
ALLOWED_ORDER_BY = {
|
| 15 |
+
"project_no": "ProjectNo",
|
| 16 |
+
"project_name": "ProjectName",
|
| 17 |
+
"project_location": "ProjectLocation",
|
| 18 |
+
"project_type": "ProjectType",
|
| 19 |
+
"bid_date": "BidDate",
|
| 20 |
+
"start_date": "StartDate",
|
| 21 |
+
"status": "Status",
|
| 22 |
+
"is_awarded": "IsAwarded",
|
| 23 |
+
"customer_type_id": "CustomertTypeId"
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
def __init__(self, db: Session):
|
| 27 |
self.repo = ProjectRepository(db)
|
| 28 |
|
| 29 |
+
def get(self, project_no: int):
|
| 30 |
+
"""Get a single project by ProjectNo"""
|
| 31 |
+
project = self.repo.get(project_no)
|
| 32 |
if not project:
|
| 33 |
raise NotFoundException("Project not found")
|
| 34 |
return project
|
| 35 |
|
| 36 |
+
def list_projects(self, customer_type: int = 0, order_by: str = "project_no",
|
| 37 |
+
order_direction: str = "asc", page: int = 1, page_size: int = 10) -> PaginatedResponse[ProjectOut]:
|
| 38 |
+
"""
|
| 39 |
+
Get paginated list of projects using stored procedure
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
customer_type: Customer type filter (0 for all types)
|
| 43 |
+
order_by: Field name to order by (snake_case)
|
| 44 |
+
order_direction: "asc" or "desc"
|
| 45 |
+
page: Page number (1-indexed)
|
| 46 |
+
page_size: Number of records per page
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
PaginatedResponse containing ProjectOut items
|
| 50 |
+
"""
|
| 51 |
+
# Validate and normalize parameters
|
| 52 |
+
order_by = order_by.lower() if order_by else "project_no"
|
| 53 |
+
order_direction = order_direction.upper() if order_direction and order_direction.lower() in ["asc", "desc"] else "ASC"
|
| 54 |
+
|
| 55 |
+
# Map Python field name to database column name
|
| 56 |
+
db_order_by = self.ALLOWED_ORDER_BY.get(order_by, "ProjectNo")
|
| 57 |
+
|
| 58 |
+
# Validate pagination parameters
|
| 59 |
+
page = max(1, page)
|
| 60 |
+
page_size = min(max(1, page_size), 100) # Limit max page size to 100
|
| 61 |
+
|
| 62 |
+
# Validate customer_type
|
| 63 |
+
customer_type = max(0, customer_type) if customer_type else 0
|
| 64 |
+
|
| 65 |
+
logger.info(f"Listing projects: customer_type={customer_type}, order_by={db_order_by}, "
|
| 66 |
+
f"direction={order_direction}, page={page}, page_size={page_size}")
|
| 67 |
+
|
| 68 |
+
# Call repository method
|
| 69 |
+
projects_data, total_count = self.repo.list_via_sp(
|
| 70 |
+
customer_type=customer_type,
|
| 71 |
+
order_by=db_order_by,
|
| 72 |
+
order_direction=order_direction,
|
| 73 |
+
page=page,
|
| 74 |
+
page_size=page_size
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Transform database rows to Pydantic models
|
| 78 |
+
project_items = []
|
| 79 |
+
for row_data in projects_data:
|
| 80 |
+
try:
|
| 81 |
+
# Transform database column names to schema field names
|
| 82 |
+
transformed_data = self._transform_db_row_to_schema(row_data)
|
| 83 |
+
project_out = ProjectOut(**transformed_data)
|
| 84 |
+
project_items.append(project_out)
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.warning(f"Error transforming project row: {e}")
|
| 87 |
+
continue
|
| 88 |
+
|
| 89 |
+
return PaginatedResponse[ProjectOut](
|
| 90 |
+
items=project_items,
|
| 91 |
+
page=page,
|
| 92 |
+
page_size=page_size,
|
| 93 |
+
total=total_count
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
def _transform_db_row_to_schema(self, row_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 97 |
+
"""
|
| 98 |
+
Transform database row data to match ProjectOut schema field names
|
| 99 |
+
Maps database column names (PascalCase) to Python field names (snake_case)
|
| 100 |
+
"""
|
| 101 |
+
# Database column to schema field mapping
|
| 102 |
+
field_mapping = {
|
| 103 |
+
'ProjectNo': 'project_no',
|
| 104 |
+
'ProjectName': 'project_name',
|
| 105 |
+
'ProjectLocation': 'project_location',
|
| 106 |
+
'ProjectType': 'project_type',
|
| 107 |
+
'BidDate': 'bid_date',
|
| 108 |
+
'StartDate': 'start_date',
|
| 109 |
+
'IsAwarded': 'is_awarded',
|
| 110 |
+
'Notes': 'notes',
|
| 111 |
+
'BarrierSize': 'barrier_size',
|
| 112 |
+
'LeaseTerm': 'lease_term',
|
| 113 |
+
'PurchaseOption': 'purchase_option',
|
| 114 |
+
'LeadSource': 'lead_source',
|
| 115 |
+
'rep': 'rep',
|
| 116 |
+
'EngineerCompanyId': 'engineer_company_id',
|
| 117 |
+
'EngineerNotes': 'engineer_notes',
|
| 118 |
+
'EngineerCompany': 'engineer_company',
|
| 119 |
+
'Status': 'status',
|
| 120 |
+
'CustomertTypeId': 'customer_type_id',
|
| 121 |
+
'Bill_Name': 'bill_name',
|
| 122 |
+
'Bill_Address1': 'bill_address1',
|
| 123 |
+
'Bill_Address2': 'bill_address2',
|
| 124 |
+
'Bill_City': 'bill_city',
|
| 125 |
+
'Bill_State': 'bill_state',
|
| 126 |
+
'Bill_Zip': 'bill_zip',
|
| 127 |
+
'Bill_Email': 'bill_email',
|
| 128 |
+
'Bill_Phone': 'bill_phone',
|
| 129 |
+
'Ship_Name': 'ship_name',
|
| 130 |
+
'Ship_Address1': 'ship_address1',
|
| 131 |
+
'Ship_Address2': 'ship_address2',
|
| 132 |
+
'Ship_City': 'ship_city',
|
| 133 |
+
'Ship_State': 'ship_state',
|
| 134 |
+
'Ship_Zip': 'ship_zip',
|
| 135 |
+
'Ship_Email': 'ship_email',
|
| 136 |
+
'Ship_Phone': 'ship_phone',
|
| 137 |
+
'Ship_OfficePhone': 'ship_office_phone',
|
| 138 |
+
'Acct_Payable': 'acct_payable',
|
| 139 |
+
'PaymentTermId': 'payment_term_id',
|
| 140 |
+
'PaymentNote': 'payment_note',
|
| 141 |
+
'RentalPriceId': 'rental_price_id',
|
| 142 |
+
'PurchasePriceId': 'purchase_price_id',
|
| 143 |
+
'TaxRate': 'tax_rate',
|
| 144 |
+
'WeeklyCharge': 'weekly_charge',
|
| 145 |
+
'Commission': 'commission',
|
| 146 |
+
'CrewMembers': 'crew_members',
|
| 147 |
+
'InstallDate': 'install_date',
|
| 148 |
+
'EmployeeId': 'employee_id',
|
| 149 |
+
'AdvisorId': 'advisor_id',
|
| 150 |
+
'IsInternational': 'is_international',
|
| 151 |
+
'OrderNumber': 'order_number'
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
transformed = {}
|
| 155 |
+
for db_column, value in row_data.items():
|
| 156 |
+
schema_field = field_mapping.get(db_column)
|
| 157 |
+
if schema_field:
|
| 158 |
+
transformed[schema_field] = value
|
| 159 |
+
|
| 160 |
+
return transformed
|
| 161 |
+
|
| 162 |
def list(self, customer_id: int = None, status: str = None, skip: int = 0, limit: int = 10):
|
| 163 |
+
"""Legacy list method for backward compatibility"""
|
| 164 |
return self.repo.list(customer_id, status, skip, limit)
|
| 165 |
|
| 166 |
def create(self, data):
|
| 167 |
project = Project(**data)
|
| 168 |
return self.repo.create(project)
|
| 169 |
|
| 170 |
+
def update(self, project_no: int, data):
|
| 171 |
+
project = self.repo.get(project_no)
|
| 172 |
if not project:
|
| 173 |
raise NotFoundException("Project not found")
|
| 174 |
for k, v in data.items():
|
| 175 |
setattr(project, k, v)
|
| 176 |
return self.repo.update(project)
|
| 177 |
|
| 178 |
+
def delete(self, project_no: int):
|
| 179 |
+
project = self.repo.get(project_no)
|
| 180 |
if not project:
|
| 181 |
raise NotFoundException("Project not found")
|
| 182 |
self.repo.delete(project)
|
app/tests/unit/test_projects_list.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from unittest.mock import Mock, patch
|
| 3 |
+
from app.services.project_service import ProjectService
|
| 4 |
+
from app.schemas.project import ProjectOut
|
| 5 |
+
from app.schemas.paginated_response import PaginatedResponse
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class TestProjectService:
|
| 9 |
+
"""Test ProjectService list_projects functionality"""
|
| 10 |
+
|
| 11 |
+
def test_list_projects_default_parameters(self):
|
| 12 |
+
"""Test list_projects with default parameters"""
|
| 13 |
+
# Mock database session
|
| 14 |
+
mock_db = Mock()
|
| 15 |
+
|
| 16 |
+
# Mock repository response
|
| 17 |
+
mock_projects_data = [
|
| 18 |
+
{
|
| 19 |
+
'ProjectNo': 1,
|
| 20 |
+
'ProjectName': 'Test Project 1',
|
| 21 |
+
'ProjectLocation': 'Location 1',
|
| 22 |
+
'ProjectType': 'Type A',
|
| 23 |
+
'Status': 1,
|
| 24 |
+
'IsAwarded': True,
|
| 25 |
+
'CustomertTypeId': 1
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
'ProjectNo': 2,
|
| 29 |
+
'ProjectName': 'Test Project 2',
|
| 30 |
+
'ProjectLocation': 'Location 2',
|
| 31 |
+
'ProjectType': 'Type B',
|
| 32 |
+
'Status': 2,
|
| 33 |
+
'IsAwarded': False,
|
| 34 |
+
'CustomertTypeId': 2
|
| 35 |
+
}
|
| 36 |
+
]
|
| 37 |
+
total_count = 2
|
| 38 |
+
|
| 39 |
+
# Create service and mock repository
|
| 40 |
+
service = ProjectService(mock_db)
|
| 41 |
+
service.repo.list_via_sp = Mock(return_value=(mock_projects_data, total_count))
|
| 42 |
+
|
| 43 |
+
# Call the method
|
| 44 |
+
result = service.list_projects()
|
| 45 |
+
|
| 46 |
+
# Verify result
|
| 47 |
+
assert isinstance(result, PaginatedResponse)
|
| 48 |
+
assert len(result.items) == 2
|
| 49 |
+
assert result.page == 1
|
| 50 |
+
assert result.page_size == 10
|
| 51 |
+
assert result.total == 2
|
| 52 |
+
|
| 53 |
+
# Verify first project
|
| 54 |
+
first_project = result.items[0]
|
| 55 |
+
assert isinstance(first_project, ProjectOut)
|
| 56 |
+
assert first_project.project_no == 1
|
| 57 |
+
assert first_project.project_name == 'Test Project 1'
|
| 58 |
+
assert first_project.project_location == 'Location 1'
|
| 59 |
+
assert first_project.is_awarded is True
|
| 60 |
+
|
| 61 |
+
def test_list_projects_with_custom_parameters(self):
|
| 62 |
+
"""Test list_projects with custom parameters"""
|
| 63 |
+
mock_db = Mock()
|
| 64 |
+
|
| 65 |
+
mock_projects_data = []
|
| 66 |
+
total_count = 0
|
| 67 |
+
|
| 68 |
+
service = ProjectService(mock_db)
|
| 69 |
+
service.repo.list_via_sp = Mock(return_value=(mock_projects_data, total_count))
|
| 70 |
+
|
| 71 |
+
# Call with custom parameters
|
| 72 |
+
result = service.list_projects(
|
| 73 |
+
customer_type=1,
|
| 74 |
+
order_by="project_name",
|
| 75 |
+
order_direction="desc",
|
| 76 |
+
page=2,
|
| 77 |
+
page_size=5
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Verify repository was called with correct mapped parameters
|
| 81 |
+
service.repo.list_via_sp.assert_called_once_with(
|
| 82 |
+
customer_type=1,
|
| 83 |
+
order_by="ProjectName", # Should be mapped to database column
|
| 84 |
+
order_direction="DESC",
|
| 85 |
+
page=2,
|
| 86 |
+
page_size=5
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# Verify result structure
|
| 90 |
+
assert result.page == 2
|
| 91 |
+
assert result.page_size == 5
|
| 92 |
+
assert result.total == 0
|
| 93 |
+
assert len(result.items) == 0
|
| 94 |
+
|
| 95 |
+
def test_list_projects_parameter_validation(self):
|
| 96 |
+
"""Test parameter validation and normalization"""
|
| 97 |
+
mock_db = Mock()
|
| 98 |
+
service = ProjectService(mock_db)
|
| 99 |
+
service.repo.list_via_sp = Mock(return_value=([], 0))
|
| 100 |
+
|
| 101 |
+
# Test invalid order_by defaults to project_no
|
| 102 |
+
service.list_projects(order_by="invalid_field")
|
| 103 |
+
service.repo.list_via_sp.assert_called_with(
|
| 104 |
+
customer_type=0,
|
| 105 |
+
order_by="ProjectNo", # Should default to ProjectNo
|
| 106 |
+
order_direction="ASC",
|
| 107 |
+
page=1,
|
| 108 |
+
page_size=10
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# Test invalid order_direction defaults to ASC
|
| 112 |
+
service.list_projects(order_direction="invalid")
|
| 113 |
+
service.repo.list_via_sp.assert_called_with(
|
| 114 |
+
customer_type=0,
|
| 115 |
+
order_by="ProjectNo",
|
| 116 |
+
order_direction="ASC", # Should default to ASC
|
| 117 |
+
page=1,
|
| 118 |
+
page_size=10
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# Test negative page number becomes 1
|
| 122 |
+
service.list_projects(page=-1)
|
| 123 |
+
service.repo.list_via_sp.assert_called_with(
|
| 124 |
+
customer_type=0,
|
| 125 |
+
order_by="ProjectNo",
|
| 126 |
+
order_direction="ASC",
|
| 127 |
+
page=1, # Should be normalized to 1
|
| 128 |
+
page_size=10
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
# Test page_size over limit becomes 100
|
| 132 |
+
service.list_projects(page_size=500)
|
| 133 |
+
service.repo.list_via_sp.assert_called_with(
|
| 134 |
+
customer_type=0,
|
| 135 |
+
order_by="ProjectNo",
|
| 136 |
+
order_direction="ASC",
|
| 137 |
+
page=1,
|
| 138 |
+
page_size=100 # Should be capped at 100
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
def test_transform_db_row_to_schema(self):
|
| 142 |
+
"""Test database row transformation to schema"""
|
| 143 |
+
mock_db = Mock()
|
| 144 |
+
service = ProjectService(mock_db)
|
| 145 |
+
|
| 146 |
+
# Test database row data
|
| 147 |
+
db_row = {
|
| 148 |
+
'ProjectNo': 123,
|
| 149 |
+
'ProjectName': 'Test Project',
|
| 150 |
+
'ProjectLocation': 'Test Location',
|
| 151 |
+
'ProjectType': 'Test Type',
|
| 152 |
+
'BidDate': '2024-01-01',
|
| 153 |
+
'StartDate': '2024-01-15',
|
| 154 |
+
'IsAwarded': True,
|
| 155 |
+
'Status': 1,
|
| 156 |
+
'CustomertTypeId': 2,
|
| 157 |
+
'Bill_Name': 'Bill Name',
|
| 158 |
+
'Ship_Name': 'Ship Name',
|
| 159 |
+
'UnknownColumn': 'Should be ignored' # This should be ignored
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
# Transform the data
|
| 163 |
+
result = service._transform_db_row_to_schema(db_row)
|
| 164 |
+
|
| 165 |
+
# Verify transformation
|
| 166 |
+
expected_fields = {
|
| 167 |
+
'project_no': 123,
|
| 168 |
+
'project_name': 'Test Project',
|
| 169 |
+
'project_location': 'Test Location',
|
| 170 |
+
'project_type': 'Test Type',
|
| 171 |
+
'bid_date': '2024-01-01',
|
| 172 |
+
'start_date': '2024-01-15',
|
| 173 |
+
'is_awarded': True,
|
| 174 |
+
'status': 1,
|
| 175 |
+
'customer_type_id': 2,
|
| 176 |
+
'bill_name': 'Bill Name',
|
| 177 |
+
'ship_name': 'Ship Name'
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
for field, expected_value in expected_fields.items():
|
| 181 |
+
assert result[field] == expected_value
|
| 182 |
+
|
| 183 |
+
# Verify unknown column is not included
|
| 184 |
+
assert 'UnknownColumn' not in result
|
| 185 |
+
assert 'unknown_column' not in result
|
test_api.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
import json
|
| 3 |
+
|
| 4 |
+
def test_projects_list_endpoint():
|
| 5 |
+
"""Test the projects list endpoint"""
|
| 6 |
+
base_url = "http://localhost:8001"
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
# Test basic endpoint
|
| 10 |
+
response = requests.get(f"{base_url}/api/v1/projects/")
|
| 11 |
+
print(f"Status Code: {response.status_code}")
|
| 12 |
+
print(f"Response Headers: {dict(response.headers)}")
|
| 13 |
+
|
| 14 |
+
if response.status_code == 200:
|
| 15 |
+
data = response.json()
|
| 16 |
+
print(f"Response structure: {list(data.keys())}")
|
| 17 |
+
print(f"Number of items: {len(data.get('items', []))}")
|
| 18 |
+
print(f"Page: {data.get('page')}")
|
| 19 |
+
print(f"Page size: {data.get('page_size')}")
|
| 20 |
+
print(f"Total: {data.get('total')}")
|
| 21 |
+
|
| 22 |
+
if data.get('items'):
|
| 23 |
+
print(f"First item keys: {list(data['items'][0].keys())}")
|
| 24 |
+
else:
|
| 25 |
+
print(f"Error response: {response.text}")
|
| 26 |
+
|
| 27 |
+
# Test with parameters
|
| 28 |
+
params = {
|
| 29 |
+
"customer_type": 1,
|
| 30 |
+
"order_by": "project_name",
|
| 31 |
+
"order_direction": "desc",
|
| 32 |
+
"page": 1,
|
| 33 |
+
"page_size": 5
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
response2 = requests.get(f"{base_url}/api/v1/projects/", params=params)
|
| 37 |
+
print(f"\nWith parameters - Status Code: {response2.status_code}")
|
| 38 |
+
|
| 39 |
+
if response2.status_code == 200:
|
| 40 |
+
data2 = response2.json()
|
| 41 |
+
print(f"Filtered results - Total: {data2.get('total')}, Items: {len(data2.get('items', []))}")
|
| 42 |
+
|
| 43 |
+
except requests.exceptions.ConnectionError:
|
| 44 |
+
print("Server is not running on port 8001")
|
| 45 |
+
except Exception as e:
|
| 46 |
+
print(f"Error testing endpoint: {e}")
|
| 47 |
+
|
| 48 |
+
if __name__ == "__main__":
|
| 49 |
+
test_projects_list_endpoint()
|