Spaces:
Paused
Add new reference models and update repositories for enhanced data handling
Browse files- Introduced new models: StatusInfo, EstShipDate, FOB, and EstFreight to expand reference data capabilities.
- Updated existing models (Country, CompanyType, PaymentTerm, etc.) to align with new schema requirements and removed CustomerStatus.
- Modified BidderRepository to streamline project number handling.
- Enhanced ReferenceDataRepository to include new reference models and improved stored procedure handling.
- Updated schemas to reflect changes in models, including new output classes for FOB, EstShipDate, EstFreight, and StatusInfo.
- Refactored ReferenceDataService to incorporate new methods for fetching FOBs, estimated ship dates, estimated freights, and status info.
- Added tools for scanning dynamic SQL in stored procedures, including a SQL script and a Python CLI wrapper.
- app/controllers/reference.py +45 -15
- app/db/models/reference.py +75 -47
- app/db/repositories/bidder_repo.py +4 -8
- app/db/repositories/reference_repo.py +223 -95
- app/schemas/reference.py +64 -38
- app/services/reference_service.py +66 -25
- tools/README_FIND_DYNAMIC_SQL.md +34 -0
- tools/find_dynamic_sql_procs.py +51 -0
- tools/find_dynamic_sql_procs.sql +45 -0
|
@@ -5,7 +5,7 @@ from app.services.reference_service import ReferenceDataService
|
|
| 5 |
from app.schemas.reference import (
|
| 6 |
StateOut, CountryOut, CompanyTypeOut, LeadSourceOut, PaymentTermOut,
|
| 7 |
PurchasePriceOut, RentalPriceOut, BarrierSizeOut, ProductApplicationOut,
|
| 8 |
-
|
| 9 |
)
|
| 10 |
from app.core.dependencies import get_current_user_optional
|
| 11 |
from app.schemas.auth import CurrentUser
|
|
@@ -36,7 +36,6 @@ def get_all_reference_data(
|
|
| 36 |
- Rental Prices
|
| 37 |
- Barrier Sizes
|
| 38 |
- Product Applications
|
| 39 |
-
- Customer Statuses
|
| 40 |
- Project Statuses
|
| 41 |
|
| 42 |
**Note**: This endpoint is public but may have enhanced data for authenticated users.
|
|
@@ -161,31 +160,62 @@ def get_product_applications(
|
|
| 161 |
service = ReferenceDataService(db)
|
| 162 |
return service.get_product_applications(active_only=active_only)
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
| 167 |
db: Session = Depends(get_db)
|
| 168 |
):
|
| 169 |
"""
|
| 170 |
-
Get all
|
| 171 |
|
| 172 |
-
Returns a list of all available
|
| 173 |
"""
|
| 174 |
service = ReferenceDataService(db)
|
| 175 |
-
return service.
|
|
|
|
| 176 |
|
| 177 |
-
@router.get("/
|
| 178 |
-
def
|
| 179 |
-
active_only: bool = Query(True, description="Return only active
|
| 180 |
db: Session = Depends(get_db)
|
| 181 |
):
|
| 182 |
"""
|
| 183 |
-
Get all
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
"""
|
| 187 |
service = ReferenceDataService(db)
|
| 188 |
-
return service.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
@router.post("/clear-cache", status_code=status.HTTP_200_OK)
|
| 191 |
def clear_reference_cache(
|
|
|
|
| 5 |
from app.schemas.reference import (
|
| 6 |
StateOut, CountryOut, CompanyTypeOut, LeadSourceOut, PaymentTermOut,
|
| 7 |
PurchasePriceOut, RentalPriceOut, BarrierSizeOut, ProductApplicationOut,
|
| 8 |
+
ReferenceDataResponse, FOBOut, EstShipDateOut, EstFreightOut, StatusInfoOut
|
| 9 |
)
|
| 10 |
from app.core.dependencies import get_current_user_optional
|
| 11 |
from app.schemas.auth import CurrentUser
|
|
|
|
| 36 |
- Rental Prices
|
| 37 |
- Barrier Sizes
|
| 38 |
- Product Applications
|
|
|
|
| 39 |
- Project Statuses
|
| 40 |
|
| 41 |
**Note**: This endpoint is public but may have enhanced data for authenticated users.
|
|
|
|
| 160 |
service = ReferenceDataService(db)
|
| 161 |
return service.get_product_applications(active_only=active_only)
|
| 162 |
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@router.get("/fobs", response_model=List[FOBOut])
|
| 166 |
+
def get_FOB(
|
| 167 |
+
active_only: bool = Query(True, description="Return only active FOBS"),
|
| 168 |
db: Session = Depends(get_db)
|
| 169 |
):
|
| 170 |
"""
|
| 171 |
+
Get all FOBs (Free on Board terms).
|
| 172 |
|
| 173 |
+
Returns a list of all available FOB terms used in shipping.
|
| 174 |
"""
|
| 175 |
service = ReferenceDataService(db)
|
| 176 |
+
return service.get_fobs(active_only=active_only)
|
| 177 |
+
|
| 178 |
|
| 179 |
+
@router.get("/est-ship-dates", response_model=List[EstShipDateOut])
|
| 180 |
+
def get_est_ship_dates(
|
| 181 |
+
active_only: bool = Query(True, description="Return only active estimated ship dates"),
|
| 182 |
db: Session = Depends(get_db)
|
| 183 |
):
|
| 184 |
"""
|
| 185 |
+
Get all Estimated Ship Date options.
|
| 186 |
+
Returns a list of estimated shipping date options from the DB.
|
| 187 |
+
"""
|
| 188 |
+
service = ReferenceDataService(db)
|
| 189 |
+
return service.get_est_ship_dates(active_only=active_only)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@router.get("/est-freights", response_model=List[EstFreightOut])
|
| 193 |
+
def get_est_freights(
|
| 194 |
+
active_only: bool = Query(True, description="Return only active estimated freights"),
|
| 195 |
+
db: Session = Depends(get_db)
|
| 196 |
+
):
|
| 197 |
+
"""
|
| 198 |
+
Get all Estimated Freight options.
|
| 199 |
+
Returns a list of estimated freight options from the DB.
|
| 200 |
"""
|
| 201 |
service = ReferenceDataService(db)
|
| 202 |
+
return service.get_est_freights(active_only=active_only)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
@router.get("/status-info", response_model=List[StatusInfoOut])
|
| 206 |
+
def get_status_info(
|
| 207 |
+
active_only: bool = Query(True, description="Return only active status info records"),
|
| 208 |
+
db: Session = Depends(get_db)
|
| 209 |
+
):
|
| 210 |
+
"""
|
| 211 |
+
Get all StatusInfo records.
|
| 212 |
+
Returns a list of project status metadata (ID, description, abbreviation).
|
| 213 |
+
"""
|
| 214 |
+
service = ReferenceDataService(db)
|
| 215 |
+
return service.get_status_info(active_only=active_only)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
|
| 219 |
|
| 220 |
@router.post("/clear-cache", status_code=status.HTTP_200_OK)
|
| 221 |
def clear_reference_cache(
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime
|
| 2 |
from app.db.base import Base
|
| 3 |
|
| 4 |
class State(Base):
|
|
@@ -9,84 +9,93 @@ class State(Base):
|
|
| 9 |
description = Column("Description", String(255), nullable=True)
|
| 10 |
enabled = Column("Enabled", Boolean, nullable=True)
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
class Country(Base):
|
| 13 |
__tablename__ = "Countries"
|
| 14 |
|
| 15 |
country_id = Column("CountryID", Integer, primary_key=True, index=True)
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
|
| 22 |
class CompanyType(Base):
|
| 23 |
__tablename__ = "CompanyTypes"
|
| 24 |
|
| 25 |
company_type_id = Column("CompanyTypeID", Integer, primary_key=True, index=True)
|
| 26 |
-
|
| 27 |
description = Column("Description", String(255), nullable=True)
|
| 28 |
-
|
| 29 |
|
| 30 |
class LeadGeneratedFrom(Base):
|
| 31 |
__tablename__ = "LeadGeneratedFroms"
|
| 32 |
|
| 33 |
lead_generated_from_id = Column("LeadGeneratedFromID", Integer, primary_key=True, index=True)
|
| 34 |
-
source_name = Column("
|
| 35 |
description = Column("Description", String(255), nullable=True)
|
| 36 |
-
|
|
|
|
|
|
|
| 37 |
|
| 38 |
class PaymentTerm(Base):
|
| 39 |
-
__tablename__ = "
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
days = Column("Days", Integer, nullable=False)
|
| 44 |
-
description = Column("Description", String(255), nullable=True)
|
| 45 |
-
is_active = Column("IsActive", Boolean, nullable=False, default=True)
|
| 46 |
|
|
|
|
| 47 |
class PurchasePrice(Base):
|
| 48 |
-
__tablename__ = "
|
| 49 |
|
| 50 |
-
purchase_price_id = Column("
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
effective_date = Column("EffectiveDate", DateTime, nullable=True)
|
| 54 |
-
is_active = Column("IsActive", Boolean, nullable=False, default=True)
|
| 55 |
|
| 56 |
class RentalPrice(Base):
|
| 57 |
-
__tablename__ = "
|
| 58 |
|
| 59 |
-
rental_price_id = Column("
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
is_active = Column("IsActive", Boolean, nullable=False, default=True)
|
| 64 |
-
|
| 65 |
class BarrierSize(Base):
|
| 66 |
__tablename__ = "BarrierSizes"
|
| 67 |
|
| 68 |
-
barrier_size_id = Column("
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
| 74 |
|
| 75 |
class ProductApplication(Base):
|
| 76 |
__tablename__ = "ProductApplications"
|
| 77 |
|
| 78 |
-
application_id = Column("
|
| 79 |
-
|
| 80 |
description = Column("Description", Text, nullable=True)
|
| 81 |
-
|
| 82 |
|
| 83 |
-
|
| 84 |
-
__tablename__ = "CustomerStatuses"
|
| 85 |
-
|
| 86 |
-
status_id = Column("StatusID", Integer, primary_key=True, index=True)
|
| 87 |
-
status_name = Column("StatusName", String(50), nullable=False)
|
| 88 |
-
description = Column("Description", String(255), nullable=True)
|
| 89 |
-
is_active = Column("IsActive", Boolean, nullable=False, default=True)
|
| 90 |
|
| 91 |
class ProjectStatus(Base):
|
| 92 |
__tablename__ = "ProjectStatuses"
|
|
@@ -95,4 +104,23 @@ class ProjectStatus(Base):
|
|
| 95 |
status_name = Column("StatusName", String(50), nullable=False)
|
| 96 |
description = Column("Description", String(255), nullable=True)
|
| 97 |
color_code = Column("ColorCode", String(7), nullable=True) # For UI display
|
| 98 |
-
is_active = Column("IsActive", Boolean, nullable=False, default=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, Numeric
|
| 2 |
from app.db.base import Base
|
| 3 |
|
| 4 |
class State(Base):
|
|
|
|
| 9 |
description = Column("Description", String(255), nullable=True)
|
| 10 |
enabled = Column("Enabled", Boolean, nullable=True)
|
| 11 |
|
| 12 |
+
|
| 13 |
+
class StatusInfo(Base):
|
| 14 |
+
__tablename__ = "StatusInfo"
|
| 15 |
+
|
| 16 |
+
# DB table uses ID, Desc, Abrv
|
| 17 |
+
status_info_id = Column("ID", Integer, primary_key=True, index=True)
|
| 18 |
+
description = Column("Desc", String(255), nullable=True)
|
| 19 |
+
abrv = Column("Abrv", String(50), nullable=True)
|
| 20 |
+
# optional enabled mapping if present
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class EstShipDate(Base):
|
| 24 |
+
__tablename__ = "EstShipDate"
|
| 25 |
+
|
| 26 |
+
# DB table uses Id and EstShipDateDescription
|
| 27 |
+
est_ship_date_id = Column("Id", Integer, primary_key=True, index=True)
|
| 28 |
+
est_ship_date_description = Column("EstShipDateDescription", String(255), nullable=True)
|
| 29 |
+
|
| 30 |
class Country(Base):
|
| 31 |
__tablename__ = "Countries"
|
| 32 |
|
| 33 |
country_id = Column("CountryID", Integer, primary_key=True, index=True)
|
| 34 |
+
customer_type_id = Column("CustomerTypeID", Integer, nullable=True)
|
| 35 |
+
description = Column("Description", String(255), nullable=True)
|
| 36 |
+
enabled = Column("Enabled", Boolean, nullable=True)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
|
| 40 |
class CompanyType(Base):
|
| 41 |
__tablename__ = "CompanyTypes"
|
| 42 |
|
| 43 |
company_type_id = Column("CompanyTypeID", Integer, primary_key=True, index=True)
|
| 44 |
+
customer_type_id = Column("CustomerTypeID", Integer, nullable=True)
|
| 45 |
description = Column("Description", String(255), nullable=True)
|
| 46 |
+
enabled = Column("Inactive", Boolean, nullable=True)
|
| 47 |
|
| 48 |
class LeadGeneratedFrom(Base):
|
| 49 |
__tablename__ = "LeadGeneratedFroms"
|
| 50 |
|
| 51 |
lead_generated_from_id = Column("LeadGeneratedFromID", Integer, primary_key=True, index=True)
|
| 52 |
+
source_name = Column("CustomerTypeID", String(100), nullable=False)
|
| 53 |
description = Column("Description", String(255), nullable=True)
|
| 54 |
+
enabled = Column("Enabled", Boolean, nullable=False, default=True)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
|
| 58 |
class PaymentTerm(Base):
|
| 59 |
+
__tablename__ = "PaymentTerm"
|
| 60 |
+
payment_term_id = Column("ID", Integer, primary_key=True, index=True)
|
| 61 |
+
description = Column("PaymentDescription", String(50), nullable=False)
|
| 62 |
+
enabled = Column("Enabled", Boolean, nullable=False, default=True)
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
+
|
| 65 |
class PurchasePrice(Base):
|
| 66 |
+
__tablename__ = "PurchasePrice"
|
| 67 |
|
| 68 |
+
purchase_price_id = Column("Id", Integer, primary_key=True, index=True)
|
| 69 |
+
description = Column("Price", String(100), nullable=False)
|
| 70 |
+
enabled = Column("enabled", Boolean, nullable=False, default=True)
|
|
|
|
|
|
|
| 71 |
|
| 72 |
class RentalPrice(Base):
|
| 73 |
+
__tablename__ = "RentalPrice"
|
| 74 |
|
| 75 |
+
rental_price_id = Column("Id", Integer, primary_key=True, index=True)
|
| 76 |
+
description = Column("Price", String(100), nullable=False)
|
| 77 |
+
enabled = Column("Enabled", Boolean, nullable=False, default=True)
|
| 78 |
+
|
|
|
|
|
|
|
| 79 |
class BarrierSize(Base):
|
| 80 |
__tablename__ = "BarrierSizes"
|
| 81 |
|
| 82 |
+
barrier_size_id = Column("Id", Integer, primary_key=True, index=True)
|
| 83 |
+
length = Column("Lenght", Numeric(precision=18, scale=2), nullable=True)
|
| 84 |
+
height = Column("Height", Numeric(precision=18, scale=2), nullable=True)
|
| 85 |
+
width = Column("Width", Numeric(precision=18, scale=2), nullable=True)
|
| 86 |
+
cableunits = Column("CableUnits", Numeric(precision=18, scale=2), nullable=True)
|
| 87 |
+
price = Column("Price", Numeric(precision=19, scale=4), nullable=False)
|
| 88 |
+
is_standard = Column("IsStandard", Boolean, nullable=False, default=True)
|
| 89 |
|
| 90 |
class ProductApplication(Base):
|
| 91 |
__tablename__ = "ProductApplications"
|
| 92 |
|
| 93 |
+
application_id = Column("ProductApplicationID", Integer, primary_key=True, index=True)
|
| 94 |
+
customer_type_id = Column("CustomerTypeID", String(100), nullable=False)
|
| 95 |
description = Column("Description", Text, nullable=True)
|
| 96 |
+
enabled = Column("Enabled", Boolean, nullable=False, default=True)
|
| 97 |
|
| 98 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
class ProjectStatus(Base):
|
| 101 |
__tablename__ = "ProjectStatuses"
|
|
|
|
| 104 |
status_name = Column("StatusName", String(50), nullable=False)
|
| 105 |
description = Column("Description", String(255), nullable=True)
|
| 106 |
color_code = Column("ColorCode", String(7), nullable=True) # For UI display
|
| 107 |
+
is_active = Column("IsActive", Boolean, nullable=False, default=True)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class FOB(Base):
|
| 111 |
+
__tablename__ = "FOB"
|
| 112 |
+
|
| 113 |
+
# DB table uses Id and FobDescription (per DB schema)
|
| 114 |
+
fob_id = Column("Id", Integer, primary_key=True, index=True)
|
| 115 |
+
fob_description = Column("FobDescription", String(255), nullable=True)
|
| 116 |
+
# Some reference tables include Enabled/IsActive; include optional mapping
|
| 117 |
+
enabled = Column("Enabled", Boolean, nullable=True)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class EstFreight(Base):
|
| 121 |
+
__tablename__ = "EstFreight"
|
| 122 |
+
|
| 123 |
+
# DB table uses Id and EstFreightDescription
|
| 124 |
+
est_freight_id = Column("Id", Integer, primary_key=True, index=True)
|
| 125 |
+
est_freight_description = Column("EstFreightDescription", String(255), nullable=True)
|
| 126 |
+
|
|
@@ -64,19 +64,15 @@ class BidderRepository:
|
|
| 64 |
try:
|
| 65 |
# Handle string or integer proj_no values
|
| 66 |
# Try both the original value and converted value to handle both cases
|
| 67 |
-
|
| 68 |
-
|
| 69 |
# Try to handle numeric project numbers stored in database as int or string
|
| 70 |
try:
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
(Bidder.ProjNo == original_proj_no) |
|
| 74 |
-
(Bidder.ProjNo == str(numeric_proj_no)) |
|
| 75 |
-
(Bidder.ProjNo == numeric_proj_no)
|
| 76 |
)
|
| 77 |
except (ValueError, TypeError):
|
| 78 |
# If conversion to int fails, just use the original string
|
| 79 |
-
query = self.db.query(Bidder).filter(Bidder.ProjNo ==
|
| 80 |
# Validate order_by field
|
| 81 |
if not hasattr(Bidder, order_by):
|
| 82 |
order_by = "Id"
|
|
|
|
| 64 |
try:
|
| 65 |
# Handle string or integer proj_no values
|
| 66 |
# Try both the original value and converted value to handle both cases
|
| 67 |
+
|
|
|
|
| 68 |
# Try to handle numeric project numbers stored in database as int or string
|
| 69 |
try:
|
| 70 |
+
query = self.db.query(Bidder).filter(
|
| 71 |
+
(Bidder.ProjNo == proj_no)
|
|
|
|
|
|
|
|
|
|
| 72 |
)
|
| 73 |
except (ValueError, TypeError):
|
| 74 |
# If conversion to int fails, just use the original string
|
| 75 |
+
query = self.db.query(Bidder).filter(Bidder.ProjNo == str(proj_no))
|
| 76 |
# Validate order_by field
|
| 77 |
if not hasattr(Bidder, order_by):
|
| 78 |
order_by = "Id"
|
|
@@ -2,8 +2,7 @@ from sqlalchemy.orm import Session
|
|
| 2 |
from sqlalchemy import text
|
| 3 |
from app.db.models.reference import (
|
| 4 |
State, Country, CompanyType, LeadGeneratedFrom, PaymentTerm,
|
| 5 |
-
PurchasePrice, RentalPrice, BarrierSize, ProductApplication,
|
| 6 |
-
CustomerStatus, ProjectStatus
|
| 7 |
)
|
| 8 |
from typing import List, Dict, Any, Optional, Type, Union
|
| 9 |
from app.core.exceptions import RepositoryException
|
|
@@ -32,7 +31,6 @@ class ReferenceDataRepository:
|
|
| 32 |
'rental_prices': RentalPrice,
|
| 33 |
'barrier_sizes': BarrierSize,
|
| 34 |
'product_applications': ProductApplication,
|
| 35 |
-
'customer_statuses': CustomerStatus,
|
| 36 |
'project_statuses': ProjectStatus
|
| 37 |
}
|
| 38 |
|
|
@@ -43,9 +41,24 @@ class ReferenceDataRepository:
|
|
| 43 |
return self._cache[cache_key]
|
| 44 |
|
| 45 |
try:
|
| 46 |
-
# Try stored procedure first with required OrderBy parameter
|
| 47 |
-
sp_query = text("
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
if result.returns_rows:
|
| 51 |
# Map the stored procedure results to our expected schema
|
|
@@ -55,9 +68,8 @@ class ReferenceDataRepository:
|
|
| 55 |
# Map SP output to our API format
|
| 56 |
state_data = {
|
| 57 |
'state_id': row_dict.get('StateID', None),
|
| 58 |
-
'
|
| 59 |
-
'
|
| 60 |
-
'Enabled': None # No country in DB
|
| 61 |
}
|
| 62 |
states_data.append(state_data)
|
| 63 |
|
|
@@ -68,7 +80,7 @@ class ReferenceDataRepository:
|
|
| 68 |
except Exception as e:
|
| 69 |
logger.warning(f"Stored procedure failed, using fallback: {e}", exc_info=True)
|
| 70 |
|
| 71 |
-
|
| 72 |
|
| 73 |
def get_countries(self, active_only: bool = True) -> List[Dict[str, Any]]:
|
| 74 |
"""Get all countries"""
|
|
@@ -77,43 +89,45 @@ class ReferenceDataRepository:
|
|
| 77 |
return self._cache[cache_key]
|
| 78 |
|
| 79 |
try:
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
if result.returns_rows:
|
| 85 |
-
countries_data = [
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
self._cache[cache_key] = countries_data
|
| 90 |
logger.info(f"Retrieved {len(countries_data)} countries via stored procedure")
|
| 91 |
return countries_data
|
| 92 |
|
| 93 |
except Exception as e:
|
| 94 |
-
logger.warning(f"Stored procedure failed, using fallback: {e}")
|
| 95 |
-
|
| 96 |
-
# Fallback to direct query
|
| 97 |
-
query = self.db.query(Country)
|
| 98 |
-
# Only apply active filter if we know the column exists
|
| 99 |
-
# Skip active_only filtering since it seems the column might not exist in the DB
|
| 100 |
-
|
| 101 |
-
countries = query.all()
|
| 102 |
-
countries_data = [
|
| 103 |
-
{
|
| 104 |
-
'country_id': country.country_id,
|
| 105 |
-
'country_name': country.country_name,
|
| 106 |
-
'country_code': country.country_code,
|
| 107 |
-
# Skip is_active in the response if we can't reliably get it
|
| 108 |
-
# 'is_active': getattr(country, 'is_active', True) # Default to True if attribute doesn't exist
|
| 109 |
-
}
|
| 110 |
-
for country in countries
|
| 111 |
-
]
|
| 112 |
-
|
| 113 |
-
self._cache[cache_key] = countries_data
|
| 114 |
-
logger.info(f"Retrieved {len(countries_data)} countries via direct query")
|
| 115 |
-
return countries_data
|
| 116 |
|
|
|
|
|
|
|
| 117 |
def get_company_types(self, active_only: bool = True) -> List[Dict[str, Any]]:
|
| 118 |
"""Get all company types"""
|
| 119 |
cache_key = f"company_types_{active_only}"
|
|
@@ -122,15 +136,18 @@ class ReferenceDataRepository:
|
|
| 122 |
|
| 123 |
query = self.db.query(CompanyType)
|
| 124 |
if active_only:
|
| 125 |
-
|
|
|
|
| 126 |
|
| 127 |
company_types = query.all()
|
| 128 |
company_types_data = [
|
| 129 |
{
|
| 130 |
'company_type_id': ct.company_type_id,
|
| 131 |
-
|
|
|
|
| 132 |
'description': ct.description,
|
| 133 |
-
|
|
|
|
| 134 |
}
|
| 135 |
for ct in company_types
|
| 136 |
]
|
|
@@ -147,15 +164,17 @@ class ReferenceDataRepository:
|
|
| 147 |
|
| 148 |
query = self.db.query(LeadGeneratedFrom)
|
| 149 |
if active_only:
|
| 150 |
-
query = query.filter(LeadGeneratedFrom.
|
| 151 |
|
| 152 |
lead_sources = query.all()
|
| 153 |
lead_sources_data = [
|
| 154 |
{
|
| 155 |
'lead_generated_from_id': ls.lead_generated_from_id,
|
| 156 |
-
|
|
|
|
|
|
|
| 157 |
'description': ls.description,
|
| 158 |
-
'is_active': ls.
|
| 159 |
}
|
| 160 |
for ls in lead_sources
|
| 161 |
]
|
|
@@ -172,16 +191,14 @@ class ReferenceDataRepository:
|
|
| 172 |
|
| 173 |
query = self.db.query(PaymentTerm)
|
| 174 |
if active_only:
|
| 175 |
-
query = query.filter(PaymentTerm.
|
| 176 |
|
| 177 |
payment_terms = query.all()
|
| 178 |
payment_terms_data = [
|
| 179 |
{
|
| 180 |
'payment_term_id': pt.payment_term_id,
|
| 181 |
-
'term_name': pt.term_name,
|
| 182 |
-
'days': pt.days,
|
| 183 |
'description': pt.description,
|
| 184 |
-
'is_active': pt.
|
| 185 |
}
|
| 186 |
for pt in payment_terms
|
| 187 |
]
|
|
@@ -198,16 +215,15 @@ class ReferenceDataRepository:
|
|
| 198 |
|
| 199 |
query = self.db.query(PurchasePrice)
|
| 200 |
if active_only:
|
| 201 |
-
query = query.filter(PurchasePrice.
|
| 202 |
|
| 203 |
purchase_prices = query.all()
|
| 204 |
purchase_prices_data = [
|
| 205 |
{
|
| 206 |
'purchase_price_id': pp.purchase_price_id,
|
| 207 |
-
'
|
| 208 |
-
'
|
| 209 |
-
'
|
| 210 |
-
'is_active': pp.is_active
|
| 211 |
}
|
| 212 |
for pp in purchase_prices
|
| 213 |
]
|
|
@@ -224,16 +240,15 @@ class ReferenceDataRepository:
|
|
| 224 |
|
| 225 |
query = self.db.query(RentalPrice)
|
| 226 |
if active_only:
|
| 227 |
-
query = query.filter(RentalPrice.
|
| 228 |
|
| 229 |
rental_prices = query.all()
|
| 230 |
rental_prices_data = [
|
| 231 |
{
|
| 232 |
'rental_price_id': rp.rental_price_id,
|
| 233 |
-
'
|
| 234 |
-
'
|
| 235 |
-
'
|
| 236 |
-
'is_active': rp.is_active
|
| 237 |
}
|
| 238 |
for rp in rental_prices
|
| 239 |
]
|
|
@@ -242,25 +257,27 @@ class ReferenceDataRepository:
|
|
| 242 |
logger.info(f"Retrieved {len(rental_prices_data)} rental prices")
|
| 243 |
return rental_prices_data
|
| 244 |
|
| 245 |
-
def get_barrier_sizes(self,
|
| 246 |
"""Get all barrier sizes"""
|
| 247 |
-
cache_key = f"barrier_sizes_{
|
| 248 |
if cache_key in self._cache:
|
| 249 |
return self._cache[cache_key]
|
| 250 |
|
| 251 |
query = self.db.query(BarrierSize)
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
| 255 |
barrier_sizes = query.all()
|
| 256 |
barrier_sizes_data = [
|
| 257 |
{
|
| 258 |
'barrier_size_id': bs.barrier_size_id,
|
| 259 |
-
'size_name': bs.size_name,
|
| 260 |
'length': bs.length,
|
| 261 |
'height': bs.height,
|
| 262 |
-
'
|
| 263 |
-
'
|
|
|
|
|
|
|
| 264 |
}
|
| 265 |
for bs in barrier_sizes
|
| 266 |
]
|
|
@@ -277,47 +294,124 @@ class ReferenceDataRepository:
|
|
| 277 |
|
| 278 |
query = self.db.query(ProductApplication)
|
| 279 |
if active_only:
|
| 280 |
-
query = query.filter(ProductApplication.
|
| 281 |
|
| 282 |
product_applications = query.all()
|
| 283 |
product_applications_data = [
|
| 284 |
{
|
| 285 |
'application_id': pa.application_id,
|
| 286 |
-
|
|
|
|
| 287 |
'description': pa.description,
|
| 288 |
-
'
|
| 289 |
}
|
| 290 |
for pa in product_applications
|
| 291 |
]
|
| 292 |
-
|
| 293 |
self._cache[cache_key] = product_applications_data
|
| 294 |
logger.info(f"Retrieved {len(product_applications_data)} product applications")
|
| 295 |
return product_applications_data
|
| 296 |
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
|
|
|
| 300 |
if cache_key in self._cache:
|
| 301 |
return self._cache[cache_key]
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
def get_project_statuses(self, active_only: bool = True) -> List[Dict[str, Any]]:
|
| 323 |
"""Get all project statuses"""
|
|
@@ -357,10 +451,44 @@ class ReferenceDataRepository:
|
|
| 357 |
'rental_prices': self.get_rental_prices(active_only),
|
| 358 |
'barrier_sizes': self.get_barrier_sizes(active_only),
|
| 359 |
'product_applications': self.get_product_applications(active_only),
|
| 360 |
-
'
|
| 361 |
-
'
|
|
|
|
| 362 |
}
|
| 363 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
def clear_cache(self):
|
| 365 |
"""Clear the internal cache to force fresh data on next request"""
|
| 366 |
self._cache.clear()
|
|
|
|
| 2 |
from sqlalchemy import text
|
| 3 |
from app.db.models.reference import (
|
| 4 |
State, Country, CompanyType, LeadGeneratedFrom, PaymentTerm,
|
| 5 |
+
PurchasePrice, RentalPrice, BarrierSize, ProductApplication, ProjectStatus
|
|
|
|
| 6 |
)
|
| 7 |
from typing import List, Dict, Any, Optional, Type, Union
|
| 8 |
from app.core.exceptions import RepositoryException
|
|
|
|
| 31 |
'rental_prices': RentalPrice,
|
| 32 |
'barrier_sizes': BarrierSize,
|
| 33 |
'product_applications': ProductApplication,
|
|
|
|
| 34 |
'project_statuses': ProjectStatus
|
| 35 |
}
|
| 36 |
|
|
|
|
| 41 |
return self._cache[cache_key]
|
| 42 |
|
| 43 |
try:
|
| 44 |
+
# Try stored procedure first with required OrderBy parameter using SQLAlchemy parameterized text
|
| 45 |
+
sp_query = text("""
|
| 46 |
+
DECLARE @TotalRecords INT;
|
| 47 |
+
EXEC dbo.spStatesGetList
|
| 48 |
+
@OrderBy = :order_by,
|
| 49 |
+
@OrderDirection = :order_dir,
|
| 50 |
+
@Page = :page,
|
| 51 |
+
@PageSize = :page_size,
|
| 52 |
+
@TotalRecords = @TotalRecords OUTPUT;
|
| 53 |
+
SELECT @TotalRecords AS TotalRecords;
|
| 54 |
+
""")
|
| 55 |
+
params = {
|
| 56 |
+
"order_by": "Description",
|
| 57 |
+
"order_dir": "ASC",
|
| 58 |
+
"page": 1,
|
| 59 |
+
"page_size": 1000,
|
| 60 |
+
}
|
| 61 |
+
result = self.db.execute(sp_query, params)
|
| 62 |
|
| 63 |
if result.returns_rows:
|
| 64 |
# Map the stored procedure results to our expected schema
|
|
|
|
| 68 |
# Map SP output to our API format
|
| 69 |
state_data = {
|
| 70 |
'state_id': row_dict.get('StateID', None),
|
| 71 |
+
'country_name': row_dict.get('Description', None),
|
| 72 |
+
'is_active': row_dict.get('IsActive', True)
|
|
|
|
| 73 |
}
|
| 74 |
states_data.append(state_data)
|
| 75 |
|
|
|
|
| 80 |
except Exception as e:
|
| 81 |
logger.warning(f"Stored procedure failed, using fallback: {e}", exc_info=True)
|
| 82 |
|
| 83 |
+
return []
|
| 84 |
|
| 85 |
def get_countries(self, active_only: bool = True) -> List[Dict[str, Any]]:
|
| 86 |
"""Get all countries"""
|
|
|
|
| 89 |
return self._cache[cache_key]
|
| 90 |
|
| 91 |
try:
|
| 92 |
+
sp_query = text("""
|
| 93 |
+
DECLARE @TotalRecords INT;
|
| 94 |
+
EXEC dbo.spCountriesGetList
|
| 95 |
+
@OrderBy = :order_by,
|
| 96 |
+
@OrderDirection = :order_dir,
|
| 97 |
+
@Page = :page,
|
| 98 |
+
@PageSize = :page_size,
|
| 99 |
+
@TotalRecords = @TotalRecords OUTPUT;
|
| 100 |
+
SELECT @TotalRecords AS TotalRecords;
|
| 101 |
+
""")
|
| 102 |
+
params = {
|
| 103 |
+
"order_by": "Description",
|
| 104 |
+
"order_dir": "ASC",
|
| 105 |
+
"page": 1,
|
| 106 |
+
"page_size": 1000,
|
| 107 |
+
}
|
| 108 |
+
result = self.db.execute(sp_query, params)
|
| 109 |
|
| 110 |
if result.returns_rows:
|
| 111 |
+
countries_data = []
|
| 112 |
+
for row in result.fetchall():
|
| 113 |
+
row_dict = dict(row._mapping)
|
| 114 |
+
country_data = {
|
| 115 |
+
'country_id': row_dict.get('CountryID', None),
|
| 116 |
+
'country_name': row_dict.get('Description', None),
|
| 117 |
+
'is_active': row_dict.get('IsActive', True)
|
| 118 |
+
}
|
| 119 |
+
countries_data.append(country_data)
|
| 120 |
+
if active_only:
|
| 121 |
+
countries_data = [c for c in countries_data if c.get('is_active', True)]
|
| 122 |
self._cache[cache_key] = countries_data
|
| 123 |
logger.info(f"Retrieved {len(countries_data)} countries via stored procedure")
|
| 124 |
return countries_data
|
| 125 |
|
| 126 |
except Exception as e:
|
| 127 |
+
logger.warning(f"Stored procedure failed, using fallback: {e}", exc_info=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
+
return []
|
| 130 |
+
|
| 131 |
def get_company_types(self, active_only: bool = True) -> List[Dict[str, Any]]:
|
| 132 |
"""Get all company types"""
|
| 133 |
cache_key = f"company_types_{active_only}"
|
|
|
|
| 136 |
|
| 137 |
query = self.db.query(CompanyType)
|
| 138 |
if active_only:
|
| 139 |
+
# The CompanyType model maps the DB "Enabled" column to attribute `enabled`
|
| 140 |
+
query = query.filter(CompanyType.enabled == True)
|
| 141 |
|
| 142 |
company_types = query.all()
|
| 143 |
company_types_data = [
|
| 144 |
{
|
| 145 |
'company_type_id': ct.company_type_id,
|
| 146 |
+
# The DB uses Description for the readable name; expose it as company_type_name
|
| 147 |
+
'company_type_name': ct.description,
|
| 148 |
'description': ct.description,
|
| 149 |
+
# Map enabled -> is_active for API consumers
|
| 150 |
+
'is_active': ct.enabled
|
| 151 |
}
|
| 152 |
for ct in company_types
|
| 153 |
]
|
|
|
|
| 164 |
|
| 165 |
query = self.db.query(LeadGeneratedFrom)
|
| 166 |
if active_only:
|
| 167 |
+
query = query.filter(LeadGeneratedFrom.enabled == True)
|
| 168 |
|
| 169 |
lead_sources = query.all()
|
| 170 |
lead_sources_data = [
|
| 171 |
{
|
| 172 |
'lead_generated_from_id': ls.lead_generated_from_id,
|
| 173 |
+
# source_name may be stored in a DB column that can contain numeric codes;
|
| 174 |
+
# coerce to string so API consistently returns a string for frontend dropdowns
|
| 175 |
+
'source_name': str(ls.source_name) if ls.source_name is not None else None,
|
| 176 |
'description': ls.description,
|
| 177 |
+
'is_active': ls.enabled
|
| 178 |
}
|
| 179 |
for ls in lead_sources
|
| 180 |
]
|
|
|
|
| 191 |
|
| 192 |
query = self.db.query(PaymentTerm)
|
| 193 |
if active_only:
|
| 194 |
+
query = query.filter(PaymentTerm.enabled == True)
|
| 195 |
|
| 196 |
payment_terms = query.all()
|
| 197 |
payment_terms_data = [
|
| 198 |
{
|
| 199 |
'payment_term_id': pt.payment_term_id,
|
|
|
|
|
|
|
| 200 |
'description': pt.description,
|
| 201 |
+
'is_active': pt.enabled
|
| 202 |
}
|
| 203 |
for pt in payment_terms
|
| 204 |
]
|
|
|
|
| 215 |
|
| 216 |
query = self.db.query(PurchasePrice)
|
| 217 |
if active_only:
|
| 218 |
+
query = query.filter(PurchasePrice.enabled == True)
|
| 219 |
|
| 220 |
purchase_prices = query.all()
|
| 221 |
purchase_prices_data = [
|
| 222 |
{
|
| 223 |
'purchase_price_id': pp.purchase_price_id,
|
| 224 |
+
# expose price under 'price' to match PurchasePriceOut schema
|
| 225 |
+
'price': pp.description,
|
| 226 |
+
'enabled': pp.enabled
|
|
|
|
| 227 |
}
|
| 228 |
for pp in purchase_prices
|
| 229 |
]
|
|
|
|
| 240 |
|
| 241 |
query = self.db.query(RentalPrice)
|
| 242 |
if active_only:
|
| 243 |
+
query = query.filter(RentalPrice.enabled == True)
|
| 244 |
|
| 245 |
rental_prices = query.all()
|
| 246 |
rental_prices_data = [
|
| 247 |
{
|
| 248 |
'rental_price_id': rp.rental_price_id,
|
| 249 |
+
# expose price under 'price' to match RentalPriceOut schema
|
| 250 |
+
'price': rp.description,
|
| 251 |
+
'enabled': rp.enabled
|
|
|
|
| 252 |
}
|
| 253 |
for rp in rental_prices
|
| 254 |
]
|
|
|
|
| 257 |
logger.info(f"Retrieved {len(rental_prices_data)} rental prices")
|
| 258 |
return rental_prices_data
|
| 259 |
|
| 260 |
+
def get_barrier_sizes(self, standard_only: bool = True) -> List[Dict[str, Any]]:
|
| 261 |
"""Get all barrier sizes"""
|
| 262 |
+
cache_key = f"barrier_sizes_{standard_only}"
|
| 263 |
if cache_key in self._cache:
|
| 264 |
return self._cache[cache_key]
|
| 265 |
|
| 266 |
query = self.db.query(BarrierSize)
|
| 267 |
+
|
| 268 |
+
if standard_only:
|
| 269 |
+
query = query.filter(BarrierSize.is_standard == True)
|
| 270 |
+
|
| 271 |
barrier_sizes = query.all()
|
| 272 |
barrier_sizes_data = [
|
| 273 |
{
|
| 274 |
'barrier_size_id': bs.barrier_size_id,
|
|
|
|
| 275 |
'length': bs.length,
|
| 276 |
'height': bs.height,
|
| 277 |
+
'width': bs.width,
|
| 278 |
+
'cableunits': bs.cableunits,
|
| 279 |
+
'price': bs.price,
|
| 280 |
+
'is_standard': bs.is_standard
|
| 281 |
}
|
| 282 |
for bs in barrier_sizes
|
| 283 |
]
|
|
|
|
| 294 |
|
| 295 |
query = self.db.query(ProductApplication)
|
| 296 |
if active_only:
|
| 297 |
+
query = query.filter(ProductApplication.enabled == True)
|
| 298 |
|
| 299 |
product_applications = query.all()
|
| 300 |
product_applications_data = [
|
| 301 |
{
|
| 302 |
'application_id': pa.application_id,
|
| 303 |
+
# customer_type_id may be stored as codes or numbers; expose as string for API
|
| 304 |
+
'customer_type_id': str(pa.customer_type_id) if pa.customer_type_id is not None else None,
|
| 305 |
'description': pa.description,
|
| 306 |
+
'enabled': pa.enabled
|
| 307 |
}
|
| 308 |
for pa in product_applications
|
| 309 |
]
|
| 310 |
+
|
| 311 |
self._cache[cache_key] = product_applications_data
|
| 312 |
logger.info(f"Retrieved {len(product_applications_data)} product applications")
|
| 313 |
return product_applications_data
|
| 314 |
|
| 315 |
+
|
| 316 |
+
def get_fobs(self, active_only: bool = True) -> List[Dict[str, Any]]:
|
| 317 |
+
"""Get all FOB (Free On Board) terms"""
|
| 318 |
+
cache_key = f"fobs_{active_only}"
|
| 319 |
if cache_key in self._cache:
|
| 320 |
return self._cache[cache_key]
|
| 321 |
+
|
| 322 |
+
# FOB table is small; query directly
|
| 323 |
+
try:
|
| 324 |
+
from app.db.models.reference import FOB
|
| 325 |
+
query = self.db.query(FOB)
|
| 326 |
+
if active_only and hasattr(FOB, 'enabled'):
|
| 327 |
+
query = query.filter(FOB.enabled == True)
|
| 328 |
+
|
| 329 |
+
fobs = query.all()
|
| 330 |
+
fobs_data = [
|
| 331 |
+
{
|
| 332 |
+
'fob_id': f.fob_id,
|
| 333 |
+
'fob_description': f.fob_description,
|
| 334 |
+
'enabled': getattr(f, 'enabled', None)
|
| 335 |
+
}
|
| 336 |
+
for f in fobs
|
| 337 |
+
]
|
| 338 |
+
|
| 339 |
+
if active_only:
|
| 340 |
+
fobs_data = [f for f in fobs_data if f.get('enabled', True) is not False]
|
| 341 |
+
|
| 342 |
+
self._cache[cache_key] = fobs_data
|
| 343 |
+
logger.info(f"Retrieved {len(fobs_data)} fobs")
|
| 344 |
+
return fobs_data
|
| 345 |
+
except Exception as e:
|
| 346 |
+
logger.warning(f"Failed to query FOB table: {e}", exc_info=True)
|
| 347 |
+
return []
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def get_est_ship_dates(self, active_only: bool = True) -> List[Dict[str, Any]]:
|
| 351 |
+
"""Get all estimated ship-date options"""
|
| 352 |
+
cache_key = f"est_ship_dates_{active_only}"
|
| 353 |
+
if cache_key in self._cache:
|
| 354 |
+
return self._cache[cache_key]
|
| 355 |
+
|
| 356 |
+
try:
|
| 357 |
+
from app.db.models.reference import EstShipDate
|
| 358 |
+
query = self.db.query(EstShipDate)
|
| 359 |
+
if active_only and hasattr(EstShipDate, 'enabled'):
|
| 360 |
+
query = query.filter(EstShipDate.enabled == True)
|
| 361 |
+
|
| 362 |
+
rows = query.all()
|
| 363 |
+
data = [
|
| 364 |
+
{
|
| 365 |
+
'est_ship_date_id': r.est_ship_date_id,
|
| 366 |
+
'est_ship_date_description': r.est_ship_date_description,
|
| 367 |
+
'enabled': getattr(r, 'enabled', None)
|
| 368 |
+
}
|
| 369 |
+
for r in rows
|
| 370 |
+
]
|
| 371 |
+
|
| 372 |
+
if active_only:
|
| 373 |
+
data = [d for d in data if d.get('enabled', True) is not False]
|
| 374 |
+
|
| 375 |
+
self._cache[cache_key] = data
|
| 376 |
+
logger.info(f"Retrieved {len(data)} est ship dates")
|
| 377 |
+
return data
|
| 378 |
+
except Exception as e:
|
| 379 |
+
logger.warning(f"Failed to query EstShipDate table: {e}", exc_info=True)
|
| 380 |
+
return []
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def get_est_freights(self, active_only: bool = True) -> List[Dict[str, Any]]:
|
| 384 |
+
"""Get all estimated freight options"""
|
| 385 |
+
cache_key = f"est_freights_{active_only}"
|
| 386 |
+
if cache_key in self._cache:
|
| 387 |
+
return self._cache[cache_key]
|
| 388 |
+
|
| 389 |
+
try:
|
| 390 |
+
from app.db.models.reference import EstFreight
|
| 391 |
+
query = self.db.query(EstFreight)
|
| 392 |
+
if active_only and hasattr(EstFreight, 'enabled'):
|
| 393 |
+
query = query.filter(EstFreight.enabled == True)
|
| 394 |
+
|
| 395 |
+
rows = query.all()
|
| 396 |
+
data = [
|
| 397 |
+
{
|
| 398 |
+
'est_freight_id': r.est_freight_id,
|
| 399 |
+
'est_freight_description': r.est_freight_description,
|
| 400 |
+
'enabled': getattr(r, 'enabled', None)
|
| 401 |
+
}
|
| 402 |
+
for r in rows
|
| 403 |
+
]
|
| 404 |
+
|
| 405 |
+
if active_only:
|
| 406 |
+
data = [d for d in data if d.get('enabled', True) is not False]
|
| 407 |
+
|
| 408 |
+
self._cache[cache_key] = data
|
| 409 |
+
logger.info(f"Retrieved {len(data)} est freights")
|
| 410 |
+
return data
|
| 411 |
+
except Exception as e:
|
| 412 |
+
logger.warning(f"Failed to query EstFreight table: {e}", exc_info=True)
|
| 413 |
+
return []
|
| 414 |
+
|
| 415 |
|
| 416 |
def get_project_statuses(self, active_only: bool = True) -> List[Dict[str, Any]]:
|
| 417 |
"""Get all project statuses"""
|
|
|
|
| 451 |
'rental_prices': self.get_rental_prices(active_only),
|
| 452 |
'barrier_sizes': self.get_barrier_sizes(active_only),
|
| 453 |
'product_applications': self.get_product_applications(active_only),
|
| 454 |
+
'fobs': self.get_fobs(active_only),
|
| 455 |
+
'est_ship_dates': self.get_est_ship_dates(active_only),
|
| 456 |
+
'est_freights': self.get_est_freights(active_only),
|
| 457 |
}
|
| 458 |
|
| 459 |
+
def get_status_info(self, active_only: bool = True) -> List[Dict[str, Any]]:
|
| 460 |
+
"""Get all StatusInfo records"""
|
| 461 |
+
cache_key = f"status_info_{active_only}"
|
| 462 |
+
if cache_key in self._cache:
|
| 463 |
+
return self._cache[cache_key]
|
| 464 |
+
|
| 465 |
+
try:
|
| 466 |
+
from app.db.models.reference import StatusInfo
|
| 467 |
+
query = self.db.query(StatusInfo)
|
| 468 |
+
if active_only and hasattr(StatusInfo, 'enabled'):
|
| 469 |
+
query = query.filter(StatusInfo.enabled == True)
|
| 470 |
+
|
| 471 |
+
rows = query.all()
|
| 472 |
+
data = [
|
| 473 |
+
{
|
| 474 |
+
'status_info_id': r.status_info_id,
|
| 475 |
+
'description': r.description,
|
| 476 |
+
'abrv': r.abrv,
|
| 477 |
+
'enabled': getattr(r, 'enabled', None)
|
| 478 |
+
}
|
| 479 |
+
for r in rows
|
| 480 |
+
]
|
| 481 |
+
|
| 482 |
+
if active_only:
|
| 483 |
+
data = [d for d in data if d.get('enabled', True) is not False]
|
| 484 |
+
|
| 485 |
+
self._cache[cache_key] = data
|
| 486 |
+
logger.info(f"Retrieved {len(data)} status info records")
|
| 487 |
+
return data
|
| 488 |
+
except Exception as e:
|
| 489 |
+
logger.warning(f"Failed to query StatusInfo table: {e}", exc_info=True)
|
| 490 |
+
return []
|
| 491 |
+
|
| 492 |
def clear_cache(self):
|
| 493 |
"""Clear the internal cache to force fresh data on next request"""
|
| 494 |
self._cache.clear()
|
|
@@ -1,78 +1,102 @@
|
|
| 1 |
from pydantic import BaseModel, Field
|
| 2 |
-
from typing import Optional,
|
| 3 |
from datetime import datetime
|
|
|
|
|
|
|
| 4 |
|
| 5 |
class StateOut(BaseModel):
|
| 6 |
state_id: int = Field(..., description="State ID")
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
| 10 |
|
| 11 |
class CountryOut(BaseModel):
|
| 12 |
country_id: int = Field(..., description="Country ID")
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
| 16 |
|
| 17 |
class CompanyTypeOut(BaseModel):
|
| 18 |
company_type_id: int = Field(..., description="Company type ID")
|
| 19 |
-
|
| 20 |
description: Optional[str] = Field(None, description="Description")
|
| 21 |
-
|
|
|
|
| 22 |
|
| 23 |
class LeadSourceOut(BaseModel):
|
| 24 |
lead_generated_from_id: int = Field(..., description="Lead source ID")
|
| 25 |
source_name: str = Field(..., description="Source name")
|
| 26 |
description: Optional[str] = Field(None, description="Description")
|
| 27 |
-
|
|
|
|
| 28 |
|
| 29 |
class PaymentTermOut(BaseModel):
|
| 30 |
payment_term_id: int = Field(..., description="Payment term ID")
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
is_active: bool = Field(..., description="Active status")
|
| 35 |
|
| 36 |
class PurchasePriceOut(BaseModel):
|
| 37 |
purchase_price_id: int = Field(..., description="Purchase price ID")
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
is_active: bool = Field(..., description="Active status")
|
| 42 |
|
| 43 |
class RentalPriceOut(BaseModel):
|
| 44 |
rental_price_id: int = Field(..., description="Rental price ID")
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
is_active: bool = Field(..., description="Active status")
|
| 49 |
|
| 50 |
class BarrierSizeOut(BaseModel):
|
| 51 |
barrier_size_id: int = Field(..., description="Barrier size ID")
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
| 57 |
|
| 58 |
class ProductApplicationOut(BaseModel):
|
| 59 |
application_id: int = Field(..., description="Application ID")
|
| 60 |
-
|
| 61 |
description: Optional[str] = Field(None, description="Description")
|
| 62 |
-
|
| 63 |
|
| 64 |
-
class CustomerStatusOut(BaseModel):
|
| 65 |
-
status_id: int = Field(..., description="Status ID")
|
| 66 |
-
status_name: str = Field(..., description="Status name")
|
| 67 |
-
description: Optional[str] = Field(None, description="Description")
|
| 68 |
-
is_active: bool = Field(..., description="Active status")
|
| 69 |
|
| 70 |
class ProjectStatusOut(BaseModel):
|
| 71 |
status_id: int = Field(..., description="Status ID")
|
| 72 |
status_name: str = Field(..., description="Status name")
|
| 73 |
description: Optional[str] = Field(None, description="Description")
|
| 74 |
-
color_code: Optional[str] = Field(None, description="
|
| 75 |
-
is_active: bool = Field(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
class ReferenceDataResponse(BaseModel):
|
| 78 |
"""Complete reference data response containing all lookup tables"""
|
|
@@ -85,5 +109,7 @@ class ReferenceDataResponse(BaseModel):
|
|
| 85 |
rental_prices: List[RentalPriceOut] = Field(..., description="List of rental prices")
|
| 86 |
barrier_sizes: List[BarrierSizeOut] = Field(..., description="List of barrier sizes")
|
| 87 |
product_applications: List[ProductApplicationOut] = Field(..., description="List of product applications")
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
| 1 |
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Optional, List
|
| 3 |
from datetime import datetime
|
| 4 |
+
from decimal import Decimal
|
| 5 |
+
|
| 6 |
|
| 7 |
class StateOut(BaseModel):
|
| 8 |
state_id: int = Field(..., description="State ID")
|
| 9 |
+
customer_type_id: Optional[int] = Field(None, description="Customer type ID")
|
| 10 |
+
description: Optional[str] = Field(None, description="Description / state name")
|
| 11 |
+
enabled: Optional[bool] = Field(None, description="Enabled flag")
|
| 12 |
+
|
| 13 |
|
| 14 |
class CountryOut(BaseModel):
|
| 15 |
country_id: int = Field(..., description="Country ID")
|
| 16 |
+
customer_type_id: Optional[int] = Field(None, description="Customer type ID")
|
| 17 |
+
description: Optional[str] = Field(None, description="Description / country name")
|
| 18 |
+
enabled: Optional[bool] = Field(None, description="Enabled flag")
|
| 19 |
+
|
| 20 |
|
| 21 |
class CompanyTypeOut(BaseModel):
|
| 22 |
company_type_id: int = Field(..., description="Company type ID")
|
| 23 |
+
customer_type_id: Optional[int] = Field(None, description="Customer type ID")
|
| 24 |
description: Optional[str] = Field(None, description="Description")
|
| 25 |
+
inactive: Optional[bool] = Field(None, description="Inactive flag (model uses column 'Inactive')")
|
| 26 |
+
|
| 27 |
|
| 28 |
class LeadSourceOut(BaseModel):
|
| 29 |
lead_generated_from_id: int = Field(..., description="Lead source ID")
|
| 30 |
source_name: str = Field(..., description="Source name")
|
| 31 |
description: Optional[str] = Field(None, description="Description")
|
| 32 |
+
enabled: Optional[bool] = Field(True, description="Enabled flag")
|
| 33 |
+
|
| 34 |
|
| 35 |
class PaymentTermOut(BaseModel):
|
| 36 |
payment_term_id: int = Field(..., description="Payment term ID")
|
| 37 |
+
description: str = Field(..., description="Payment description / term name")
|
| 38 |
+
enabled: Optional[bool] = Field(True, description="Enabled flag")
|
| 39 |
+
|
|
|
|
| 40 |
|
| 41 |
class PurchasePriceOut(BaseModel):
|
| 42 |
purchase_price_id: int = Field(..., description="Purchase price ID")
|
| 43 |
+
price: str = Field(..., description="Price value or description")
|
| 44 |
+
enabled: Optional[bool] = Field(True, description="Enabled flag")
|
| 45 |
+
|
|
|
|
| 46 |
|
| 47 |
class RentalPriceOut(BaseModel):
|
| 48 |
rental_price_id: int = Field(..., description="Rental price ID")
|
| 49 |
+
price: str = Field(..., description="Price value or description")
|
| 50 |
+
enabled: Optional[bool] = Field(True, description="Enabled flag")
|
| 51 |
+
|
|
|
|
| 52 |
|
| 53 |
class BarrierSizeOut(BaseModel):
|
| 54 |
barrier_size_id: int = Field(..., description="Barrier size ID")
|
| 55 |
+
length: Optional[Decimal] = Field(None, description="Length (numeric)")
|
| 56 |
+
height: Optional[Decimal] = Field(None, description="Height (numeric)")
|
| 57 |
+
width: Optional[Decimal] = Field(None, description="Width (numeric)")
|
| 58 |
+
cableunits: Optional[Decimal] = Field(None, description="Cable units (numeric)")
|
| 59 |
+
price: Optional[Decimal] = Field(None, description="Price (numeric)")
|
| 60 |
+
is_standard: Optional[bool] = Field(True, description="Is standard flag")
|
| 61 |
+
|
| 62 |
|
| 63 |
class ProductApplicationOut(BaseModel):
|
| 64 |
application_id: int = Field(..., description="Application ID")
|
| 65 |
+
customer_type_id: str = Field(..., description="Customer type ID / code")
|
| 66 |
description: Optional[str] = Field(None, description="Description")
|
| 67 |
+
enabled: Optional[bool] = Field(True, description="Enabled flag")
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
class ProjectStatusOut(BaseModel):
|
| 71 |
status_id: int = Field(..., description="Status ID")
|
| 72 |
status_name: str = Field(..., description="Status name")
|
| 73 |
description: Optional[str] = Field(None, description="Description")
|
| 74 |
+
color_code: Optional[str] = Field(None, description="Hex color code, e.g. #RRGGBB")
|
| 75 |
+
is_active: Optional[bool] = Field(True, description="Is active flag")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class FOBOut(BaseModel):
|
| 79 |
+
fob_id: int = Field(..., description="FOB ID")
|
| 80 |
+
fob_description: Optional[str] = Field(None, description="FOB description / name")
|
| 81 |
+
enabled: Optional[bool] = Field(True, description="Enabled flag")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class EstShipDateOut(BaseModel):
|
| 85 |
+
est_ship_date_id: int = Field(..., description="Estimated ship date ID")
|
| 86 |
+
est_ship_date_description: Optional[str] = Field(None, description="Estimated ship date description")
|
| 87 |
+
enabled: Optional[bool] = Field(True, description="Enabled flag")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class EstFreightOut(BaseModel):
|
| 91 |
+
est_freight_id: int = Field(..., description="Estimated freight ID")
|
| 92 |
+
est_freight_description: Optional[str] = Field(None, description="Estimated freight description")
|
| 93 |
+
enabled: Optional[bool] = Field(True, description="Enabled flag")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class StatusInfoOut(BaseModel):
|
| 97 |
+
status_info_id: int = Field(..., description="StatusInfo ID")
|
| 98 |
+
description: Optional[str] = Field(None, description="Status description")
|
| 99 |
+
abrv: Optional[str] = Field(None, description="Abbreviation")
|
| 100 |
|
| 101 |
class ReferenceDataResponse(BaseModel):
|
| 102 |
"""Complete reference data response containing all lookup tables"""
|
|
|
|
| 109 |
rental_prices: List[RentalPriceOut] = Field(..., description="List of rental prices")
|
| 110 |
barrier_sizes: List[BarrierSizeOut] = Field(..., description="List of barrier sizes")
|
| 111 |
product_applications: List[ProductApplicationOut] = Field(..., description="List of product applications")
|
| 112 |
+
project_statuses: Optional[List[ProjectStatusOut]] = Field([], description="List of project statuses")
|
| 113 |
+
fobs: Optional[List[FOBOut]] = Field([], description="List of FOB (Free On Board) terms")
|
| 114 |
+
est_ship_dates: Optional[List[EstShipDateOut]] = Field([], description="List of Estimated Ship Date options")
|
| 115 |
+
est_freights: Optional[List[EstFreightOut]] = Field([], description="List of Estimated Freight options")
|
|
@@ -3,7 +3,7 @@ from app.db.repositories.reference_repo import ReferenceDataRepository
|
|
| 3 |
from app.schemas.reference import (
|
| 4 |
StateOut, CountryOut, CompanyTypeOut, LeadSourceOut, PaymentTermOut,
|
| 5 |
PurchasePriceOut, RentalPriceOut, BarrierSizeOut, ProductApplicationOut,
|
| 6 |
-
|
| 7 |
)
|
| 8 |
from typing import List, Dict, Any, Optional
|
| 9 |
import logging
|
|
@@ -64,16 +64,32 @@ class ReferenceDataService:
|
|
| 64 |
product_applications_data = self.repo.get_product_applications(active_only)
|
| 65 |
return [ProductApplicationOut(**pa) for pa in product_applications_data]
|
| 66 |
|
| 67 |
-
def
|
| 68 |
-
"""Get all
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
def get_all_reference_data(self, active_only: bool = True) -> ReferenceDataResponse:
|
| 78 |
"""
|
| 79 |
Get all reference data in a single response for efficiency.
|
|
@@ -84,20 +100,45 @@ class ReferenceDataService:
|
|
| 84 |
# Get all data from repository
|
| 85 |
all_data = self.repo.get_all_reference_data(active_only)
|
| 86 |
|
| 87 |
-
# Transform to Pydantic models
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
except Exception as e:
|
| 102 |
logger.error(f"Error retrieving all reference data: {e}")
|
| 103 |
raise
|
|
|
|
| 3 |
from app.schemas.reference import (
|
| 4 |
StateOut, CountryOut, CompanyTypeOut, LeadSourceOut, PaymentTermOut,
|
| 5 |
PurchasePriceOut, RentalPriceOut, BarrierSizeOut, ProductApplicationOut,
|
| 6 |
+
ReferenceDataResponse
|
| 7 |
)
|
| 8 |
from typing import List, Dict, Any, Optional
|
| 9 |
import logging
|
|
|
|
| 64 |
product_applications_data = self.repo.get_product_applications(active_only)
|
| 65 |
return [ProductApplicationOut(**pa) for pa in product_applications_data]
|
| 66 |
|
| 67 |
+
def get_fobs(self, active_only: bool = True):
|
| 68 |
+
"""Get all FOBs as Pydantic models"""
|
| 69 |
+
fobs_data = self.repo.get_fobs(active_only)
|
| 70 |
+
# Import here to avoid circular import at module load time
|
| 71 |
+
from app.schemas.reference import FOBOut
|
| 72 |
+
return [FOBOut(**f) for f in fobs_data]
|
| 73 |
+
|
| 74 |
+
def get_est_ship_dates(self, active_only: bool = True):
|
| 75 |
+
"""Get all estimated ship-date options as Pydantic models"""
|
| 76 |
+
est_data = self.repo.get_est_ship_dates(active_only)
|
| 77 |
+
from app.schemas.reference import EstShipDateOut
|
| 78 |
+
return [EstShipDateOut(**e) for e in est_data]
|
| 79 |
+
|
| 80 |
+
def get_est_freights(self, active_only: bool = True):
|
| 81 |
+
"""Get all estimated freight options as Pydantic models"""
|
| 82 |
+
est_data = self.repo.get_est_freights(active_only)
|
| 83 |
+
from app.schemas.reference import EstFreightOut
|
| 84 |
+
return [EstFreightOut(**e) for e in est_data]
|
| 85 |
+
|
| 86 |
+
def get_status_info(self, active_only: bool = True):
|
| 87 |
+
"""Get all StatusInfo records as Pydantic models"""
|
| 88 |
+
data = self.repo.get_status_info(active_only)
|
| 89 |
+
from app.schemas.reference import StatusInfoOut
|
| 90 |
+
return [StatusInfoOut(**d) for d in data]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
def get_all_reference_data(self, active_only: bool = True) -> ReferenceDataResponse:
|
| 94 |
"""
|
| 95 |
Get all reference data in a single response for efficiency.
|
|
|
|
| 100 |
# Get all data from repository
|
| 101 |
all_data = self.repo.get_all_reference_data(active_only)
|
| 102 |
|
| 103 |
+
# Transform to Pydantic models using keys provided by the repository.
|
| 104 |
+
# The repository returns a dict with a subset of keys; map only those.
|
| 105 |
+
response_kwargs = {}
|
| 106 |
+
if 'states' in all_data:
|
| 107 |
+
response_kwargs['states'] = [StateOut(**state) for state in all_data['states']]
|
| 108 |
+
if 'countries' in all_data:
|
| 109 |
+
response_kwargs['countries'] = [CountryOut(**country) for country in all_data['countries']]
|
| 110 |
+
if 'company_types' in all_data:
|
| 111 |
+
response_kwargs['company_types'] = [CompanyTypeOut(**ct) for ct in all_data['company_types']]
|
| 112 |
+
if 'lead_sources' in all_data:
|
| 113 |
+
response_kwargs['lead_sources'] = [LeadSourceOut(**ls) for ls in all_data['lead_sources']]
|
| 114 |
+
if 'payment_terms' in all_data:
|
| 115 |
+
response_kwargs['payment_terms'] = [PaymentTermOut(**pt) for pt in all_data['payment_terms']]
|
| 116 |
+
if 'purchase_prices' in all_data:
|
| 117 |
+
response_kwargs['purchase_prices'] = [PurchasePriceOut(**pp) for pp in all_data['purchase_prices']]
|
| 118 |
+
if 'rental_prices' in all_data:
|
| 119 |
+
response_kwargs['rental_prices'] = [RentalPriceOut(**rp) for rp in all_data['rental_prices']]
|
| 120 |
+
if 'barrier_sizes' in all_data:
|
| 121 |
+
response_kwargs['barrier_sizes'] = [BarrierSizeOut(**bs) for bs in all_data['barrier_sizes']]
|
| 122 |
+
if 'product_applications' in all_data:
|
| 123 |
+
response_kwargs['product_applications'] = [ProductApplicationOut(**pa) for pa in all_data['product_applications']]
|
| 124 |
+
if 'fobs' in all_data:
|
| 125 |
+
from app.schemas.reference import FOBOut
|
| 126 |
+
response_kwargs['fobs'] = [FOBOut(**fb) for fb in all_data['fobs']]
|
| 127 |
+
if 'est_ship_dates' in all_data:
|
| 128 |
+
from app.schemas.reference import EstShipDateOut
|
| 129 |
+
response_kwargs['est_ship_dates'] = [EstShipDateOut(**ed) for ed in all_data['est_ship_dates']]
|
| 130 |
+
if 'est_freights' in all_data:
|
| 131 |
+
from app.schemas.reference import EstFreightOut
|
| 132 |
+
response_kwargs['est_freights'] = [EstFreightOut(**ef) for ef in all_data['est_freights']]
|
| 133 |
+
if 'status_info' in all_data:
|
| 134 |
+
from app.schemas.reference import StatusInfoOut
|
| 135 |
+
response_kwargs['status_info'] = [StatusInfoOut(**si) for si in all_data['status_info']]
|
| 136 |
+
# project_statuses and other optional tables may be added by the repo later
|
| 137 |
+
if 'project_statuses' in all_data:
|
| 138 |
+
from app.schemas.reference import ProjectStatusOut
|
| 139 |
+
response_kwargs['project_statuses'] = [ProjectStatusOut(**ps) for ps in all_data['project_statuses']]
|
| 140 |
+
|
| 141 |
+
return ReferenceDataResponse(**response_kwargs)
|
| 142 |
except Exception as e:
|
| 143 |
logger.error(f"Error retrieving all reference data: {e}")
|
| 144 |
raise
|
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Find dynamic SQL in stored procedures
|
| 2 |
+
|
| 3 |
+
Files
|
| 4 |
+
- find_dynamic_sql_procs.sql: parameterized T-SQL scanner. Use in SSMS, Azure Data Studio, or sqlcmd.
|
| 5 |
+
- find_dynamic_sql_procs.py: small CLI wrapper that runs the SQL and emits CSV.
|
| 6 |
+
|
| 7 |
+
Quick examples
|
| 8 |
+
|
| 9 |
+
1) Run the SQL directly (Azure Data Studio/SSMS):
|
| 10 |
+
|
| 11 |
+
-- edit variables at top of file or use client substitution
|
| 12 |
+
:setvar TopN 200
|
| 13 |
+
:setvar ContextChars 120
|
| 14 |
+
|
| 15 |
+
-- open and run the file
|
| 16 |
+
|
| 17 |
+
2) Run via python wrapper (recommended for automation):
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
python tools/find_dynamic_sql_procs.py --server demo.azonix.in --database hs-prod3 --uid myuser --pwd "mypassword" --top 200 > dynamic_procs.csv
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
Notes & limitations
|
| 24 |
+
- The scanner uses simple LIKE patterns to detect likely dynamic SQL usage (sp_executesql, EXEC(@var), string concatenation).
|
| 25 |
+
- It prioritizes precision via a small confidence score but will miss obfuscated dynamic SQL and may return false positives from comments or similar tokens.
|
| 26 |
+
- Combine the output with manual inspection of `definition` for highest accuracy.
|
| 27 |
+
- The Python wrapper requires `pyodbc` and an appropriate ODBC driver (e.g. ODBC Driver 17/18 for SQL Server).
|
| 28 |
+
|
| 29 |
+
Security
|
| 30 |
+
- Avoid putting credentials in shell history. Prefer using a secure credential store or trusted connections.
|
| 31 |
+
|
| 32 |
+
Next steps (optional)
|
| 33 |
+
- Add a mode to export full proc definitions to files.
|
| 34 |
+
- Add a deeper parser using tSQLt or ANTLR for T-SQL to more accurately detect dynamic SQL boundaries.
|
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""tools/find_dynamic_sql_procs.py
|
| 2 |
+
Simple CLI wrapper that runs the T-SQL scanner against a SQL Server database using pyodbc.
|
| 3 |
+
Usage:
|
| 4 |
+
python tools/find_dynamic_sql_procs.py --server demo.azonix.in --database hs-prod3 --uid user --pwd pass
|
| 5 |
+
|
| 6 |
+
It prints a CSV to stdout with basic columns and confidence score.
|
| 7 |
+
|
| 8 |
+
Notes:
|
| 9 |
+
- Requires pyodbc installed in your environment.
|
| 10 |
+
- Use Windows authentication by omitting uid/pwd and passing --trusted.
|
| 11 |
+
"""
|
| 12 |
+
import argparse
|
| 13 |
+
import csv
|
| 14 |
+
import sys
|
| 15 |
+
import pyodbc
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
|
| 19 |
+
SQL_PATH = os.path.join(os.path.dirname(__file__), 'find_dynamic_sql_procs.sql')
|
| 20 |
+
SQL_TEMPLATE = open(SQL_PATH, 'r', encoding='utf-8').read()
|
| 21 |
+
|
| 22 |
+
def run_scan(conn_str, top=1000):
|
| 23 |
+
sql = SQL_TEMPLATE.replace('TOP(@Top)', f'TOP({top})') if 'TOP(@Top)' in SQL_TEMPLATE else SQL_TEMPLATE
|
| 24 |
+
with pyodbc.connect(conn_str, autocommit=True) as cn:
|
| 25 |
+
cur = cn.cursor()
|
| 26 |
+
cur.execute(sql)
|
| 27 |
+
cols = [c[0] for c in cur.description]
|
| 28 |
+
writer = csv.writer(sys.stdout)
|
| 29 |
+
writer.writerow(cols)
|
| 30 |
+
for row in cur:
|
| 31 |
+
writer.writerow(row)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
if __name__ == '__main__':
|
| 35 |
+
ap = argparse.ArgumentParser(description='Find procs using dynamic SQL patterns')
|
| 36 |
+
ap.add_argument('--server', required=True)
|
| 37 |
+
ap.add_argument('--database', required=True)
|
| 38 |
+
ap.add_argument('--uid')
|
| 39 |
+
ap.add_argument('--pwd')
|
| 40 |
+
ap.add_argument('--trusted', action='store_true')
|
| 41 |
+
ap.add_argument('--top', type=int, default=1000)
|
| 42 |
+
args = ap.parse_args()
|
| 43 |
+
|
| 44 |
+
if args.trusted:
|
| 45 |
+
conn = f'DRIVER={{ODBC Driver 17 for SQL Server}};SERVER={args.server};DATABASE={args.database};Trusted_Connection=yes;'
|
| 46 |
+
else:
|
| 47 |
+
if not args.uid or not args.pwd:
|
| 48 |
+
ap.error('Either --trusted or both --uid and --pwd are required')
|
| 49 |
+
conn = f'DRIVER={{ODBC Driver 17 for SQL Server}};SERVER={args.server};DATABASE={args.database};UID={args.uid};PWD={args.pwd};'
|
| 50 |
+
|
| 51 |
+
run_scan(conn, top=args.top)
|
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- tools/find_dynamic_sql_procs.sql
|
| 2 |
+
-- Parameterized T-SQL to scan all stored procedure sources for dynamic-SQL patterns.
|
| 3 |
+
-- Usage:
|
| 4 |
+
-- :setvar SearchPattern "sp_executesql|EXEC\s*\(|EXEC\s+@|\+\s*'"
|
| 5 |
+
-- :setvar TopN 1000
|
| 6 |
+
-- :setvar ContextChars 120
|
| 7 |
+
-- Run in SSMS, Azure Data Studio or sqlcmd (sqlcmd: use -v to pass variables).
|
| 8 |
+
|
| 9 |
+
SET NOCOUNT ON;
|
| 10 |
+
|
| 11 |
+
-- variables via sqlcmd or client substitution
|
| 12 |
+
DECLARE @SearchPattern NVARCHAR(400) = 'sp_executesql|EXEC\s*\(|EXEC\s+@|\+\s*\''; -- regex-like tokens (plain LIKE uses %)
|
| 13 |
+
DECLARE @Top INT = 1000;
|
| 14 |
+
DECLARE @Context INT = 120; -- characters of surrounding context
|
| 15 |
+
|
| 16 |
+
-- We use simple LIKE checks plus more specific patterns to score confidence.
|
| 17 |
+
|
| 18 |
+
SELECT TOP(@Top)
|
| 19 |
+
s.name AS schema_name,
|
| 20 |
+
p.name AS proc_name,
|
| 21 |
+
p.create_date,
|
| 22 |
+
p.modify_date,
|
| 23 |
+
m.definition,
|
| 24 |
+
-- basic token flags
|
| 25 |
+
CASE WHEN m.definition LIKE '%sp_executesql%' THEN 1 ELSE 0 END AS has_sp_executesql,
|
| 26 |
+
CASE WHEN m.definition LIKE '%EXEC %(%' ESCAPE '\' THEN 1 ELSE 0 END AS has_exec_parenthesis,
|
| 27 |
+
CASE WHEN m.definition LIKE '%EXEC @%' THEN 1 ELSE 0 END AS has_exec_variable,
|
| 28 |
+
CASE WHEN m.definition LIKE '%''+%''%' ESCAPE '\' OR m.definition LIKE '%'' + %' ESCAPE '\' THEN 1 ELSE 0 END AS has_string_concat,
|
| 29 |
+
-- rough confidence score
|
| 30 |
+
(CASE WHEN m.definition LIKE '%sp_executesql%' THEN 3 ELSE 0 END
|
| 31 |
+
+ CASE WHEN m.definition LIKE '%EXEC %(%' ESCAPE '\' THEN 2 ELSE 0 END
|
| 32 |
+
+ CASE WHEN m.definition LIKE '%EXEC @%' THEN 2 ELSE 0 END
|
| 33 |
+
+ CASE WHEN m.definition LIKE '%''+%''%' ESCAPE '\' OR m.definition LIKE '%'' + %' ESCAPE '\' THEN 1 ELSE 0 END) AS confidence_score
|
| 34 |
+
FROM sys.procedures p
|
| 35 |
+
JOIN sys.schemas s ON p.schema_id = s.schema_id
|
| 36 |
+
LEFT JOIN sys.sql_modules m ON p.object_id = m.object_id
|
| 37 |
+
WHERE m.definition IS NOT NULL
|
| 38 |
+
AND (
|
| 39 |
+
m.definition LIKE '%sp_executesql%'
|
| 40 |
+
OR m.definition LIKE '%EXEC %(%' ESCAPE '\'
|
| 41 |
+
OR m.definition LIKE '%EXEC @%'
|
| 42 |
+
OR m.definition LIKE '%''+%''%' ESCAPE '\'
|
| 43 |
+
OR m.definition LIKE '%'' + %' ESCAPE '\'
|
| 44 |
+
)
|
| 45 |
+
ORDER BY confidence_score DESC, s.name, p.name;
|