MukeshKapoor25 commited on
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 CHANGED
@@ -7,28 +7,64 @@ from typing import List
7
 
8
  router = APIRouter(prefix="/api/v1/projects", tags=["projects"])
9
 
10
- @router.get("/", response_model=List[ProjectOut])
11
- def list_projects(customer_id: int = None, status: str = None, skip: int = 0, limit: int = 10, db: Session = Depends(get_db)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  service = ProjectService(db)
13
- return service.list(customer_id, status, skip, limit)
 
 
 
 
 
 
14
 
15
- @router.get("/{project_id}", response_model=ProjectOut)
16
- def get_project(project_id: int, db: Session = Depends(get_db)):
 
17
  service = ProjectService(db)
18
- return service.get(project_id)
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("/{project_id}", response_model=ProjectOut)
26
- def update_project(project_id: int, project_in: ProjectCreate, db: Session = Depends(get_db)):
 
27
  service = ProjectService(db)
28
- return service.update(project_id, project_in.dict())
29
 
30
- @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
31
- def delete_project(project_id: int, db: Session = Depends(get_db)):
 
32
  service = ProjectService(db)
33
- service.delete(project_id)
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__ = "projects"
8
- id = Column(Integer, primary_key=True, index=True)
9
- name = Column(String(255), nullable=False)
10
- customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
11
- start_date = Column(DateTime, nullable=True)
12
- end_date = Column(DateTime, nullable=True)
13
- status = Column(String(50), nullable=False)
14
- budget = Column(Float, nullable=True)
15
- description = Column(String(255), nullable=True)
16
- created_at = Column(DateTime, default=datetime.utcnow)
17
- customer = relationship("Customer")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, project_id: int):
9
- return self.db.query(Project).filter(Project.id == project_id).first()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.customer_id == customer_id)
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
- orm_mode = True
 
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
- name: str
7
- customer_id: int
 
 
8
  start_date: Optional[datetime] = None
9
- end_date: Optional[datetime] = None
10
- status: str
11
- budget: Optional[float] = None
12
- description: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  class ProjectOut(BaseModel):
15
- id: int
16
- name: str
17
- customer_id: int
 
 
18
  start_date: Optional[datetime] = None
19
- end_date: Optional[datetime] = None
20
- status: str
21
- budget: Optional[float] = None
22
- description: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  class Config:
25
- orm_mode = True
 
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, project_id: int):
11
- project = self.repo.get(project_id)
 
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, project_id: int, data):
24
- project = self.repo.get(project_id)
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, project_id: int):
32
- project = self.repo.get(project_id)
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()