Spaces:
Running
Running
Add validation report for AquaBarrier project implementation
Browse files- Comprehensive assessment of stored procedure integration and architecture
- Highlights strengths in API design, data models, and pagination
- Identifies areas for improvement including customer entity implementation and authentication
- Provides immediate action items and compliance scorecard for future development
This view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +34 -0
- DATABASE_CHANGES_CONSOLIDATED.sql +322 -0
- Dockerfile +25 -0
- QUERY_TIMING.md +215 -0
- QUICKSTART_TIMING.md +140 -0
- README 2.md +511 -0
- app/Dockerfile +25 -0
- app/Makefile +14 -0
- app/README.md +55 -0
- app/__pycache__/app.cpython-313.pyc +0 -0
- app/app.py +98 -0
- app/controllers/__pycache__/auth.cpython-313.pyc +0 -0
- app/controllers/__pycache__/bidders.cpython-313.pyc +0 -0
- app/controllers/__pycache__/customers.cpython-313.pyc +0 -0
- app/controllers/__pycache__/dashboard.cpython-313.pyc +0 -0
- app/controllers/__pycache__/distributors.cpython-313.pyc +0 -0
- app/controllers/__pycache__/employees.cpython-313.pyc +0 -0
- app/controllers/__pycache__/notes.cpython-313.pyc +0 -0
- app/controllers/__pycache__/projects.cpython-313.pyc +0 -0
- app/controllers/__pycache__/reference.cpython-313.pyc +0 -0
- app/controllers/__pycache__/reports.cpython-313.pyc +0 -0
- app/controllers/auth.py +86 -0
- app/controllers/bidders.py +695 -0
- app/controllers/customers.py +417 -0
- app/controllers/dashboard.py +104 -0
- app/controllers/distributors.py +300 -0
- app/controllers/employees.py +102 -0
- app/controllers/notes.py +136 -0
- app/controllers/projects.py +131 -0
- app/controllers/reference.py +934 -0
- app/controllers/reports.py +26 -0
- app/core/__pycache__/config.cpython-313.pyc +0 -0
- app/core/__pycache__/dependencies.cpython-313.pyc +0 -0
- app/core/__pycache__/exceptions.cpython-313.pyc +0 -0
- app/core/__pycache__/logging.cpython-313.pyc +0 -0
- app/core/__pycache__/security.cpython-313.pyc +0 -0
- app/core/__pycache__/timing.cpython-313.pyc +0 -0
- app/core/config.py +22 -0
- app/core/dependencies.py +101 -0
- app/core/exceptions.py +17 -0
- app/core/logging.py +30 -0
- app/core/security.py +31 -0
- app/core/timing.py +102 -0
- app/db/__pycache__/base.cpython-313.pyc +0 -0
- app/db/__pycache__/session.cpython-313.pyc +0 -0
- app/db/base.py +14 -0
- app/db/migrations/env.py +35 -0
- app/db/models/__init__.py +16 -0
- app/db/models/__pycache__/__init__.cpython-313.pyc +0 -0
- app/db/models/__pycache__/address.cpython-313.pyc +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
.env
|
| 7 |
+
|
| 8 |
+
# Alembic
|
| 9 |
+
app/db/migrations/versions/
|
| 10 |
+
|
| 11 |
+
# VSCode
|
| 12 |
+
.vscode/
|
| 13 |
+
|
| 14 |
+
# Docker
|
| 15 |
+
app/docker/*.log
|
| 16 |
+
app/docker/mssql_data/
|
| 17 |
+
|
| 18 |
+
# Test & Coverage
|
| 19 |
+
.coverage
|
| 20 |
+
htmlcov/
|
| 21 |
+
*.sqlite3
|
| 22 |
+
|
| 23 |
+
# OS
|
| 24 |
+
.DS_Store
|
| 25 |
+
|
| 26 |
+
# Logs
|
| 27 |
+
*.log
|
| 28 |
+
|
| 29 |
+
# Node
|
| 30 |
+
node_modules/
|
| 31 |
+
|
| 32 |
+
# Others
|
| 33 |
+
*.swp
|
| 34 |
+
*.bak
|
DATABASE_CHANGES_CONSOLIDATED.sql
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- ============================================================================
|
| 2 |
+
-- DATABASE CHANGES - Consolidated Script
|
| 3 |
+
-- Date: 30 November 2025
|
| 4 |
+
-- Purpose: Performance optimization for AquaBarrier project management API
|
| 5 |
+
-- ============================================================================
|
| 6 |
+
|
| 7 |
+
-- ============================================================================
|
| 8 |
+
-- PART 1: ADD TIMESTAMP COLUMNS TO PROJECTS TABLE
|
| 9 |
+
-- ============================================================================
|
| 10 |
+
-- Purpose: Support dashboard metrics and track record lifecycle
|
| 11 |
+
-- Impact: Enables "New Projects" filtering in dashboard
|
| 12 |
+
-- ============================================================================
|
| 13 |
+
|
| 14 |
+
-- Add CreatedDate column (if not exists)
|
| 15 |
+
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
| 16 |
+
WHERE TABLE_NAME = 'Projects' AND COLUMN_NAME = 'CreatedDate')
|
| 17 |
+
BEGIN
|
| 18 |
+
ALTER TABLE [dbo].[Projects] ADD [CreatedDate] [datetime2](7) NULL DEFAULT (getutcdate());
|
| 19 |
+
PRINT 'Added CreatedDate column to Projects table';
|
| 20 |
+
END
|
| 21 |
+
ELSE
|
| 22 |
+
PRINT 'CreatedDate already exists in Projects table';
|
| 23 |
+
GO
|
| 24 |
+
|
| 25 |
+
-- Add ModifiedDate column (if not exists)
|
| 26 |
+
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
| 27 |
+
WHERE TABLE_NAME = 'Projects' AND COLUMN_NAME = 'ModifiedDate')
|
| 28 |
+
BEGIN
|
| 29 |
+
ALTER TABLE [dbo].[Projects] ADD [ModifiedDate] [datetime2](7) NULL DEFAULT (getutcdate());
|
| 30 |
+
PRINT 'Added ModifiedDate column to Projects table';
|
| 31 |
+
END
|
| 32 |
+
ELSE
|
| 33 |
+
PRINT 'ModifiedDate already exists in Projects table';
|
| 34 |
+
GO
|
| 35 |
+
|
| 36 |
+
-- Backfill CreatedDate with current date for existing records (if NULL)
|
| 37 |
+
UPDATE [dbo].[Projects]
|
| 38 |
+
SET [CreatedDate] = GETUTCDATE()
|
| 39 |
+
WHERE [CreatedDate] IS NULL;
|
| 40 |
+
PRINT 'Backfilled CreatedDate for ' + CAST(@@ROWCOUNT AS varchar) + ' existing Projects';
|
| 41 |
+
GO
|
| 42 |
+
|
| 43 |
+
-- ============================================================================
|
| 44 |
+
-- PART 2: CREATE PERFORMANCE INDEXES
|
| 45 |
+
-- ============================================================================
|
| 46 |
+
-- Purpose: Optimize query performance for project and customer endpoints
|
| 47 |
+
-- Impact: Reduces query execution time from 5+ seconds to sub-second
|
| 48 |
+
-- ============================================================================
|
| 49 |
+
|
| 50 |
+
-- Index for dashboard metrics filtering by CreatedDate
|
| 51 |
+
IF NOT EXISTS (SELECT 1 FROM sys.indexes
|
| 52 |
+
WHERE name = 'IX_Projects_CreatedDate' AND object_id = OBJECT_ID('dbo.Projects'))
|
| 53 |
+
BEGIN
|
| 54 |
+
CREATE NONCLUSTERED INDEX [IX_Projects_CreatedDate] ON [dbo].[Projects]
|
| 55 |
+
(
|
| 56 |
+
[CreatedDate] ASC
|
| 57 |
+
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF,
|
| 58 |
+
DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON,
|
| 59 |
+
ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY];
|
| 60 |
+
PRINT 'Created index IX_Projects_CreatedDate';
|
| 61 |
+
END
|
| 62 |
+
ELSE
|
| 63 |
+
PRINT 'Index IX_Projects_CreatedDate already exists';
|
| 64 |
+
GO
|
| 65 |
+
|
| 66 |
+
-- Index for Bidders ProjectNo lookups (if not exists)
|
| 67 |
+
IF NOT EXISTS (SELECT 1 FROM sys.indexes
|
| 68 |
+
WHERE name = 'IX_Bidders_ProjectNo' AND object_id = OBJECT_ID('dbo.Bidders'))
|
| 69 |
+
BEGIN
|
| 70 |
+
CREATE NONCLUSTERED INDEX [IX_Bidders_ProjectNo] ON [dbo].[Bidders]
|
| 71 |
+
(
|
| 72 |
+
[ProjNo] ASC
|
| 73 |
+
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF,
|
| 74 |
+
DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON,
|
| 75 |
+
ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY];
|
| 76 |
+
PRINT 'Created index IX_Bidders_ProjectNo';
|
| 77 |
+
END
|
| 78 |
+
ELSE
|
| 79 |
+
PRINT 'Index IX_Bidders_ProjectNo already exists';
|
| 80 |
+
GO
|
| 81 |
+
|
| 82 |
+
-- Index for Bidders Id (keyset pagination)
|
| 83 |
+
IF NOT EXISTS (SELECT 1 FROM sys.indexes
|
| 84 |
+
WHERE name = 'IX_Bidders_Id' AND object_id = OBJECT_ID('dbo.Bidders'))
|
| 85 |
+
BEGIN
|
| 86 |
+
CREATE NONCLUSTERED INDEX [IX_Bidders_Id] ON [dbo].[Bidders]
|
| 87 |
+
(
|
| 88 |
+
[Id] ASC
|
| 89 |
+
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF,
|
| 90 |
+
DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON,
|
| 91 |
+
ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY];
|
| 92 |
+
PRINT 'Created index IX_Bidders_Id';
|
| 93 |
+
END
|
| 94 |
+
ELSE
|
| 95 |
+
PRINT 'Index IX_Bidders_Id already exists';
|
| 96 |
+
GO
|
| 97 |
+
|
| 98 |
+
-- Index for Bidders CustId lookups
|
| 99 |
+
IF NOT EXISTS (SELECT 1 FROM sys.indexes
|
| 100 |
+
WHERE name = 'IX_Bidders_CustId' AND object_id = OBJECT_ID('dbo.Bidders'))
|
| 101 |
+
BEGIN
|
| 102 |
+
CREATE NONCLUSTERED INDEX [IX_Bidders_CustId] ON [dbo].[Bidders]
|
| 103 |
+
(
|
| 104 |
+
[CustId] ASC
|
| 105 |
+
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF,
|
| 106 |
+
DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON,
|
| 107 |
+
ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY];
|
| 108 |
+
PRINT 'Created index IX_Bidders_CustId';
|
| 109 |
+
END
|
| 110 |
+
ELSE
|
| 111 |
+
PRINT 'Index IX_Bidders_CustId already exists';
|
| 112 |
+
GO
|
| 113 |
+
|
| 114 |
+
-- Index for Customers CustomerID lookups
|
| 115 |
+
IF EXISTS (SELECT 1 FROM sys.indexes
|
| 116 |
+
WHERE name = 'IX_Customers_CustomerID' AND object_id = OBJECT_ID('dbo.Customers'))
|
| 117 |
+
BEGIN
|
| 118 |
+
DROP INDEX [IX_Customers_CustomerID] ON [dbo].[Customers];
|
| 119 |
+
PRINT 'Dropped existing IX_Customers_CustomerID to recreate with INCLUDE(CompanyName)';
|
| 120 |
+
END
|
| 121 |
+
GO
|
| 122 |
+
|
| 123 |
+
CREATE NONCLUSTERED INDEX [IX_Customers_CustomerID] ON [dbo].[Customers]
|
| 124 |
+
(
|
| 125 |
+
[CustomerID] ASC
|
| 126 |
+
)
|
| 127 |
+
INCLUDE ([CompanyName])
|
| 128 |
+
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF,
|
| 129 |
+
DROP_EXISTING = OFF, ONLINE = ON, ALLOW_ROW_LOCKS = ON,
|
| 130 |
+
ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY];
|
| 131 |
+
PRINT 'Created index IX_Customers_CustomerID with INCLUDE(CompanyName)';
|
| 132 |
+
GO
|
| 133 |
+
|
| 134 |
+
-- Composite index to optimize keyset pagination and project bidders fetch
|
| 135 |
+
IF NOT EXISTS (SELECT 1 FROM sys.indexes
|
| 136 |
+
WHERE name = 'IX_Bidders_ProjNo_Id' AND object_id = OBJECT_ID('dbo.Bidders'))
|
| 137 |
+
BEGIN
|
| 138 |
+
CREATE NONCLUSTERED INDEX [IX_Bidders_ProjNo_Id] ON [dbo].[Bidders]
|
| 139 |
+
(
|
| 140 |
+
[ProjNo] ASC,
|
| 141 |
+
[Id] ASC
|
| 142 |
+
)
|
| 143 |
+
INCLUDE ([CustId])
|
| 144 |
+
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF,
|
| 145 |
+
DROP_EXISTING = OFF, ONLINE = ON, ALLOW_ROW_LOCKS = ON,
|
| 146 |
+
ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY];
|
| 147 |
+
PRINT 'Created index IX_Bidders_ProjNo_Id';
|
| 148 |
+
END
|
| 149 |
+
ELSE
|
| 150 |
+
PRINT 'Index IX_Bidders_ProjNo_Id already exists';
|
| 151 |
+
GO
|
| 152 |
+
|
| 153 |
+
-- Composite index to support ORDER BY [Primary] DESC, Id for offset pagination
|
| 154 |
+
IF NOT EXISTS (SELECT 1 FROM sys.indexes
|
| 155 |
+
WHERE name = 'IX_Bidders_ProjNo_Primary_Id' AND object_id = OBJECT_ID('dbo.Bidders'))
|
| 156 |
+
BEGIN
|
| 157 |
+
CREATE NONCLUSTERED INDEX [IX_Bidders_ProjNo_Primary_Id] ON [dbo].[Bidders]
|
| 158 |
+
(
|
| 159 |
+
[ProjNo] ASC,
|
| 160 |
+
[Primary] DESC,
|
| 161 |
+
[Id] ASC
|
| 162 |
+
)
|
| 163 |
+
INCLUDE (
|
| 164 |
+
[CustId], [Quote], [DateLastContact], [DateFollowup],
|
| 165 |
+
[CustType], [EmailAddress], [ReplacementCost], [Enabled]
|
| 166 |
+
)
|
| 167 |
+
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF,
|
| 168 |
+
DROP_EXISTING = OFF, ONLINE = ON, ALLOW_ROW_LOCKS = ON,
|
| 169 |
+
ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY];
|
| 170 |
+
PRINT 'Created index IX_Bidders_ProjNo_Primary_Id';
|
| 171 |
+
END
|
| 172 |
+
ELSE
|
| 173 |
+
PRINT 'Index IX_Bidders_ProjNo_Primary_Id already exists';
|
| 174 |
+
GO
|
| 175 |
+
|
| 176 |
+
-- ============================================================================
|
| 177 |
+
-- PART 3: OPTIONAL - MIGRATE Bidders.CustId FROM nvarchar(15) TO int
|
| 178 |
+
-- ============================================================================
|
| 179 |
+
-- Purpose: Eliminate CAST() in JOIN clause for better index usage
|
| 180 |
+
-- Impact: Removes type conversion overhead, allows index seek on Customers
|
| 181 |
+
-- WARNING: This is a breaking schema change - test thoroughly before applying
|
| 182 |
+
-- Status: OPTIONAL - Only apply if type mismatch is causing performance issues
|
| 183 |
+
-- ============================================================================
|
| 184 |
+
|
| 185 |
+
/*
|
| 186 |
+
-- Uncomment this section to perform the migration
|
| 187 |
+
|
| 188 |
+
-- Step 1: Add new int column
|
| 189 |
+
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
| 190 |
+
WHERE TABLE_NAME = 'Bidders' AND COLUMN_NAME = 'CustId_New')
|
| 191 |
+
BEGIN
|
| 192 |
+
ALTER TABLE [dbo].[Bidders] ADD [CustId_New] [int] NULL;
|
| 193 |
+
PRINT 'Added CustId_New column';
|
| 194 |
+
END
|
| 195 |
+
GO
|
| 196 |
+
|
| 197 |
+
-- Step 2: Migrate data
|
| 198 |
+
UPDATE [dbo].[Bidders]
|
| 199 |
+
SET [CustId_New] = CAST([CustId] AS int)
|
| 200 |
+
WHERE ISNUMERIC([CustId]) = 1
|
| 201 |
+
AND [CustId_New] IS NULL;
|
| 202 |
+
PRINT 'Migrated ' + CAST(@@ROWCOUNT AS varchar) + ' rows to CustId_New';
|
| 203 |
+
GO
|
| 204 |
+
|
| 205 |
+
-- Step 3: Drop index on old column
|
| 206 |
+
IF EXISTS (SELECT 1 FROM sys.indexes
|
| 207 |
+
WHERE name = 'IX_Bidders_CustId' AND object_id = OBJECT_ID('dbo.Bidders'))
|
| 208 |
+
BEGIN
|
| 209 |
+
DROP INDEX [IX_Bidders_CustId] ON [dbo].[Bidders];
|
| 210 |
+
PRINT 'Dropped index IX_Bidders_CustId';
|
| 211 |
+
END
|
| 212 |
+
GO
|
| 213 |
+
|
| 214 |
+
-- Step 4: Drop old nvarchar column
|
| 215 |
+
IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
| 216 |
+
WHERE TABLE_NAME = 'Bidders' AND COLUMN_NAME = 'CustId' AND DATA_TYPE = 'nvarchar')
|
| 217 |
+
BEGIN
|
| 218 |
+
ALTER TABLE [dbo].[Bidders] DROP COLUMN [CustId];
|
| 219 |
+
PRINT 'Dropped old nvarchar CustId column';
|
| 220 |
+
END
|
| 221 |
+
GO
|
| 222 |
+
|
| 223 |
+
-- Step 5: Rename new column
|
| 224 |
+
IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
| 225 |
+
WHERE TABLE_NAME = 'Bidders' AND COLUMN_NAME = 'CustId_New')
|
| 226 |
+
AND NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
| 227 |
+
WHERE TABLE_NAME = 'Bidders' AND COLUMN_NAME = 'CustId')
|
| 228 |
+
BEGIN
|
| 229 |
+
EXEC sp_rename 'dbo.Bidders.CustId_New', 'CustId', 'COLUMN';
|
| 230 |
+
PRINT 'Renamed CustId_New to CustId';
|
| 231 |
+
END
|
| 232 |
+
GO
|
| 233 |
+
|
| 234 |
+
-- Step 6: Set NOT NULL constraint
|
| 235 |
+
IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
| 236 |
+
WHERE TABLE_NAME = 'Bidders' AND COLUMN_NAME = 'CustId'
|
| 237 |
+
AND DATA_TYPE = 'int' AND IS_NULLABLE = 'YES')
|
| 238 |
+
BEGIN
|
| 239 |
+
IF NOT EXISTS (SELECT 1 FROM [dbo].[Bidders] WHERE [CustId] IS NULL)
|
| 240 |
+
BEGIN
|
| 241 |
+
ALTER TABLE [dbo].[Bidders] ALTER COLUMN [CustId] [int] NOT NULL;
|
| 242 |
+
PRINT 'Set CustId to NOT NULL';
|
| 243 |
+
END
|
| 244 |
+
ELSE
|
| 245 |
+
BEGIN
|
| 246 |
+
PRINT 'WARNING: NULL values exist in CustId, cannot set NOT NULL';
|
| 247 |
+
END
|
| 248 |
+
END
|
| 249 |
+
GO
|
| 250 |
+
|
| 251 |
+
-- Step 7: Recreate index on new int column
|
| 252 |
+
IF NOT EXISTS (SELECT 1 FROM sys.indexes
|
| 253 |
+
WHERE name = 'IX_Bidders_CustId' AND object_id = OBJECT_ID('dbo.Bidders'))
|
| 254 |
+
BEGIN
|
| 255 |
+
CREATE NONCLUSTERED INDEX [IX_Bidders_CustId] ON [dbo].[Bidders]
|
| 256 |
+
(
|
| 257 |
+
[CustId] ASC
|
| 258 |
+
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF,
|
| 259 |
+
DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON,
|
| 260 |
+
ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY];
|
| 261 |
+
PRINT 'Recreated index IX_Bidders_CustId on int column';
|
| 262 |
+
END
|
| 263 |
+
GO
|
| 264 |
+
*/
|
| 265 |
+
|
| 266 |
+
-- ============================================================================
|
| 267 |
+
-- VERIFICATION QUERIES
|
| 268 |
+
-- ============================================================================
|
| 269 |
+
|
| 270 |
+
PRINT '';
|
| 271 |
+
PRINT '============================================================================';
|
| 272 |
+
PRINT 'VERIFICATION RESULTS';
|
| 273 |
+
PRINT '============================================================================';
|
| 274 |
+
|
| 275 |
+
-- Verify Projects timestamp columns
|
| 276 |
+
SELECT 'Projects Timestamp Columns' AS CheckType;
|
| 277 |
+
SELECT
|
| 278 |
+
COLUMN_NAME,
|
| 279 |
+
DATA_TYPE,
|
| 280 |
+
IS_NULLABLE,
|
| 281 |
+
COLUMN_DEFAULT
|
| 282 |
+
FROM INFORMATION_SCHEMA.COLUMNS
|
| 283 |
+
WHERE TABLE_NAME = 'Projects'
|
| 284 |
+
AND COLUMN_NAME IN ('CreatedDate', 'ModifiedDate')
|
| 285 |
+
ORDER BY COLUMN_NAME;
|
| 286 |
+
|
| 287 |
+
-- Verify all indexes exist
|
| 288 |
+
SELECT 'Performance Indexes' AS CheckType;
|
| 289 |
+
SELECT
|
| 290 |
+
t.name AS TableName,
|
| 291 |
+
i.name AS IndexName,
|
| 292 |
+
c.name AS ColumnName,
|
| 293 |
+
ty.name AS DataType
|
| 294 |
+
FROM sys.indexes i
|
| 295 |
+
JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
|
| 296 |
+
JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
|
| 297 |
+
JOIN sys.types ty ON c.user_type_id = ty.user_type_id
|
| 298 |
+
JOIN sys.tables t ON i.object_id = t.object_id
|
| 299 |
+
WHERE i.name IN (
|
| 300 |
+
'IX_Projects_CreatedDate',
|
| 301 |
+
'IX_Bidders_ProjectNo',
|
| 302 |
+
'IX_Bidders_Id',
|
| 303 |
+
'IX_Bidders_CustId',
|
| 304 |
+
'IX_Customers_CustomerID'
|
| 305 |
+
)
|
| 306 |
+
ORDER BY t.name, i.name;
|
| 307 |
+
|
| 308 |
+
-- Verify Bidders.CustId data type
|
| 309 |
+
SELECT 'Bidders.CustId Data Type' AS CheckType;
|
| 310 |
+
SELECT
|
| 311 |
+
COLUMN_NAME,
|
| 312 |
+
DATA_TYPE,
|
| 313 |
+
CHARACTER_MAXIMUM_LENGTH,
|
| 314 |
+
IS_NULLABLE
|
| 315 |
+
FROM INFORMATION_SCHEMA.COLUMNS
|
| 316 |
+
WHERE TABLE_NAME = 'Bidders' AND COLUMN_NAME = 'CustId';
|
| 317 |
+
|
| 318 |
+
PRINT '';
|
| 319 |
+
PRINT '============================================================================';
|
| 320 |
+
PRINT 'DATABASE CHANGES COMPLETE';
|
| 321 |
+
PRINT '============================================================================';
|
| 322 |
+
GO
|
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use a lightweight and stable Python 3.11 base image (Bullseye)
|
| 2 |
+
FROM python:3.11-slim-bullseye AS base
|
| 3 |
+
|
| 4 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 5 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
+
PATH="/home/user/.local/bin:$PATH"
|
| 7 |
+
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
openssl \
|
| 10 |
+
ca-certificates \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
RUN useradd -m -u 1000 user
|
| 14 |
+
USER user
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
|
| 17 |
+
COPY --chown=user ./requirements.txt requirements.txt
|
| 18 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 19 |
+
pip install --no-cache-dir --upgrade -r requirements.txt
|
| 20 |
+
|
| 21 |
+
COPY --chown=user . /app
|
| 22 |
+
|
| 23 |
+
EXPOSE 7860
|
| 24 |
+
|
| 25 |
+
CMD ["uvicorn", "app.app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "4", "--log-level", "info"]
|
QUERY_TIMING.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Query Timing and Performance Monitoring
|
| 2 |
+
|
| 3 |
+
This application includes comprehensive query timing and performance monitoring capabilities to help identify slow database queries and API endpoints.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
### 1. Automatic Query Timing
|
| 8 |
+
|
| 9 |
+
All database queries are automatically timed using SQLAlchemy event listeners. Query execution times are logged with different severity levels:
|
| 10 |
+
|
| 11 |
+
- **DEBUG** (< 500ms): Normal queries
|
| 12 |
+
- **INFO** (500ms - 1s): Medium-duration queries
|
| 13 |
+
- **WARNING** (> 1s): Slow queries that may need optimization
|
| 14 |
+
|
| 15 |
+
**Configuration:**
|
| 16 |
+
|
| 17 |
+
The query timing logger is configured in `app/core/logging.py`. To change the verbosity:
|
| 18 |
+
|
| 19 |
+
```python
|
| 20 |
+
# In app/core/logging.py, modify:
|
| 21 |
+
query_logger.setLevel(logging.INFO) # Shows medium/slow queries (default)
|
| 22 |
+
query_logger.setLevel(logging.DEBUG) # Shows ALL queries
|
| 23 |
+
query_logger.setLevel(logging.WARNING) # Shows only slow queries
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
**Log Output Example:**
|
| 27 |
+
|
| 28 |
+
```
|
| 29 |
+
2025-11-17 10:30:45 WARNING sqlalchemy.query.timing SLOW QUERY (1.234s): SELECT * FROM Projects WHERE Status = 5 ORDER BY ProjectNo...
|
| 30 |
+
2025-11-17 10:30:46 INFO sqlalchemy.query.timing Query took 0.678s: SELECT COUNT(*) FROM Customers WHERE CustomerID IS NOT NULL
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
### 2. Request-Level Timing
|
| 34 |
+
|
| 35 |
+
All HTTP requests are timed via middleware. Processing times are logged and included in response headers:
|
| 36 |
+
|
| 37 |
+
- **Response Header:** `X-Process-Time` contains the total processing time in seconds
|
| 38 |
+
- **Logging Levels:**
|
| 39 |
+
- DEBUG (< 1s): Fast requests
|
| 40 |
+
- INFO (1s - 2s): Medium requests
|
| 41 |
+
- WARNING (> 2s): Slow requests
|
| 42 |
+
|
| 43 |
+
**Log Output Example:**
|
| 44 |
+
|
| 45 |
+
```
|
| 46 |
+
2025-11-17 10:30:45 WARNING app.request.timing SLOW REQUEST (2.456s): GET /api/v1/projects/ - Status 200
|
| 47 |
+
2025-11-17 10:30:46 INFO app.request.timing Request took 1.123s: GET /api/v1/dashboard/widgets/info - Status 200
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
**Client Usage:**
|
| 51 |
+
|
| 52 |
+
Check the `X-Process-Time` header in API responses:
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
curl -v http://localhost:8124/api/v1/projects/
|
| 56 |
+
# Response includes: X-Process-Time: 0.234
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### 3. Manual Timing Utilities
|
| 60 |
+
|
| 61 |
+
For timing specific operations in your code, use the utilities in `app/core/timing.py`:
|
| 62 |
+
|
| 63 |
+
#### Context Manager
|
| 64 |
+
|
| 65 |
+
```python
|
| 66 |
+
from app.core.timing import timer
|
| 67 |
+
|
| 68 |
+
# Time a specific code block
|
| 69 |
+
with timer("Complex calculation"):
|
| 70 |
+
result = perform_complex_operation()
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
#### Function Decorator
|
| 74 |
+
|
| 75 |
+
```python
|
| 76 |
+
from app.core.timing import timed
|
| 77 |
+
|
| 78 |
+
@timed("Processing customer data")
|
| 79 |
+
def process_customers(data):
|
| 80 |
+
# ... your code ...
|
| 81 |
+
return result
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
#### Manual Timer
|
| 85 |
+
|
| 86 |
+
```python
|
| 87 |
+
from app.core.timing import QueryTimer
|
| 88 |
+
|
| 89 |
+
qt = QueryTimer()
|
| 90 |
+
qt.start("Custom query operation")
|
| 91 |
+
result = db.execute(some_complex_query)
|
| 92 |
+
elapsed = qt.stop() # Returns elapsed time in seconds
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
## Viewing Query Timings
|
| 96 |
+
|
| 97 |
+
### Development Mode
|
| 98 |
+
|
| 99 |
+
When running the application with `uvicorn`, query timings are logged to stdout:
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
uvicorn app.app:app --reload --port 8124
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
### Production Recommendations
|
| 106 |
+
|
| 107 |
+
For production deployments:
|
| 108 |
+
|
| 109 |
+
1. **Set appropriate log levels** to avoid excessive logging:
|
| 110 |
+
```python
|
| 111 |
+
# Only log slow queries
|
| 112 |
+
query_logger.setLevel(logging.WARNING)
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
2. **Use structured logging** with JSON format for easier parsing:
|
| 116 |
+
```python
|
| 117 |
+
import json
|
| 118 |
+
import logging
|
| 119 |
+
|
| 120 |
+
class JSONFormatter(logging.Formatter):
|
| 121 |
+
def format(self, record):
|
| 122 |
+
return json.dumps({
|
| 123 |
+
'timestamp': self.formatTime(record),
|
| 124 |
+
'level': record.levelname,
|
| 125 |
+
'logger': record.name,
|
| 126 |
+
'message': record.getMessage()
|
| 127 |
+
})
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
3. **Aggregate metrics** using tools like:
|
| 131 |
+
- Prometheus + Grafana
|
| 132 |
+
- Datadog
|
| 133 |
+
- New Relic
|
| 134 |
+
- Application Insights
|
| 135 |
+
|
| 136 |
+
## Performance Thresholds
|
| 137 |
+
|
| 138 |
+
Current thresholds are configured as:
|
| 139 |
+
|
| 140 |
+
| Threshold | Query Time | Request Time | Level |
|
| 141 |
+
|-----------|-----------|--------------|-------|
|
| 142 |
+
| Fast | < 500ms | < 1s | DEBUG |
|
| 143 |
+
| Medium | 500ms-1s | 1s-2s | INFO |
|
| 144 |
+
| Slow | > 1s | > 2s | WARNING |
|
| 145 |
+
|
| 146 |
+
To adjust thresholds, modify the event listeners in `app/db/session.py` and middleware in `app/app.py`.
|
| 147 |
+
|
| 148 |
+
## Troubleshooting Slow Queries
|
| 149 |
+
|
| 150 |
+
When slow queries are detected:
|
| 151 |
+
|
| 152 |
+
1. **Check the query structure** - Look for missing indexes or inefficient joins
|
| 153 |
+
2. **Review query parameters** - Ensure proper filtering to reduce result sets
|
| 154 |
+
3. **Analyze execution plans** - Use SQL Server's execution plan analyzer
|
| 155 |
+
4. **Consider caching** - For frequently-accessed data that changes infrequently
|
| 156 |
+
5. **Optimize database** - Update statistics, rebuild indexes, etc.
|
| 157 |
+
|
| 158 |
+
### Example: Identifying Slow Queries
|
| 159 |
+
|
| 160 |
+
```bash
|
| 161 |
+
# Run your API and monitor logs
|
| 162 |
+
uvicorn app.app:app --reload --port 8124
|
| 163 |
+
|
| 164 |
+
# In another terminal, make requests
|
| 165 |
+
curl http://localhost:8124/api/v1/projects/?page=1&page_size=100
|
| 166 |
+
|
| 167 |
+
# Check logs for warnings
|
| 168 |
+
# Output might show:
|
| 169 |
+
# WARNING sqlalchemy.query.timing SLOW QUERY (1.456s): SELECT ... FROM Projects ...
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
## Integration with Monitoring Tools
|
| 173 |
+
|
| 174 |
+
### Adding Metrics Export
|
| 175 |
+
|
| 176 |
+
You can extend the timing functionality to export metrics:
|
| 177 |
+
|
| 178 |
+
```python
|
| 179 |
+
# Example: Export to Prometheus
|
| 180 |
+
from prometheus_client import Histogram
|
| 181 |
+
|
| 182 |
+
query_duration = Histogram('db_query_duration_seconds', 'Database query duration')
|
| 183 |
+
|
| 184 |
+
@event.listens_for(Engine, "after_cursor_execute")
|
| 185 |
+
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
| 186 |
+
total_time = time.time() - conn.info['query_start_time'].pop(-1)
|
| 187 |
+
query_duration.observe(total_time)
|
| 188 |
+
# ... existing logging code ...
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
## Best Practices
|
| 192 |
+
|
| 193 |
+
1. **Monitor regularly** - Review slow query logs weekly
|
| 194 |
+
2. **Set alerts** - Configure alerts for queries exceeding thresholds
|
| 195 |
+
3. **Baseline performance** - Establish normal query times for comparison
|
| 196 |
+
4. **Test at scale** - Performance test with production-like data volumes
|
| 197 |
+
5. **Index optimization** - Ensure proper indexes exist for frequent queries
|
| 198 |
+
6. **Connection pooling** - Verify proper pool configuration in `session.py`
|
| 199 |
+
|
| 200 |
+
## Environment Variables
|
| 201 |
+
|
| 202 |
+
Control logging behavior via environment variables:
|
| 203 |
+
|
| 204 |
+
```bash
|
| 205 |
+
# Set in .env or environment
|
| 206 |
+
LOG_LEVEL=DEBUG # Shows all query timings
|
| 207 |
+
LOG_LEVEL=INFO # Shows medium and slow queries (default)
|
| 208 |
+
LOG_LEVEL=WARNING # Shows only slow queries
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
## Additional Resources
|
| 212 |
+
|
| 213 |
+
- [SQLAlchemy Performance Tips](https://docs.sqlalchemy.org/en/14/faq/performance.html)
|
| 214 |
+
- [FastAPI Performance](https://fastapi.tiangolo.com/deployment/concepts/)
|
| 215 |
+
- [SQL Server Query Performance](https://docs.microsoft.com/en-us/sql/relational-databases/performance/performance-monitoring-and-tuning-tools)
|
QUICKSTART_TIMING.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Quick Start: Query Timing
|
| 2 |
+
|
| 3 |
+
## ✅ What Was Added
|
| 4 |
+
|
| 5 |
+
Query timing and performance monitoring is now active in your application. All database queries and API requests are automatically timed and logged.
|
| 6 |
+
|
| 7 |
+
## 🚀 How to Use
|
| 8 |
+
|
| 9 |
+
### 1. Start Your Application
|
| 10 |
+
```bash
|
| 11 |
+
uvicorn app.app:app --reload --port 8124
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
### 2. Watch the Logs
|
| 15 |
+
You'll automatically see timing information:
|
| 16 |
+
|
| 17 |
+
**Query Timing:**
|
| 18 |
+
```
|
| 19 |
+
2025-11-17 10:30:45 INFO sqlalchemy.query.timing Query took 0.678s: SELECT * FROM Projects WHERE Status = 5...
|
| 20 |
+
2025-11-17 10:30:46 WARNING sqlalchemy.query.timing SLOW QUERY (1.234s): SELECT COUNT(*) FROM Customers...
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
**Request Timing:**
|
| 24 |
+
```
|
| 25 |
+
2025-11-17 10:30:47 INFO app.request.timing Request took 1.123s: GET /api/v1/dashboard/widgets/info - Status 200
|
| 26 |
+
2025-11-17 10:30:48 WARNING app.request.timing SLOW REQUEST (2.456s): GET /api/v1/projects/ - Status 200
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
### 3. Check Response Headers
|
| 30 |
+
Every API response includes timing:
|
| 31 |
+
```bash
|
| 32 |
+
curl -v http://localhost:8124/api/v1/projects/
|
| 33 |
+
# Look for: X-Process-Time: 0.234
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
## 📊 Timing Thresholds
|
| 37 |
+
|
| 38 |
+
### Database Queries
|
| 39 |
+
- 🟢 **DEBUG** (< 500ms): Fast queries - normal
|
| 40 |
+
- 🟡 **INFO** (500ms-1s): Medium queries - monitor
|
| 41 |
+
- 🔴 **WARNING** (>1s): Slow queries - needs attention
|
| 42 |
+
|
| 43 |
+
### API Requests
|
| 44 |
+
- 🟢 **DEBUG** (< 1s): Fast requests - normal
|
| 45 |
+
- 🟡 **INFO** (1s-2s): Medium requests - monitor
|
| 46 |
+
- 🔴 **WARNING** (>2s): Slow requests - needs optimization
|
| 47 |
+
|
| 48 |
+
## ⚙️ Configuration
|
| 49 |
+
|
| 50 |
+
### See All Queries
|
| 51 |
+
Edit `app/core/logging.py`:
|
| 52 |
+
```python
|
| 53 |
+
query_logger.setLevel(logging.DEBUG) # Show all queries
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### See Only Slow Queries
|
| 57 |
+
```python
|
| 58 |
+
query_logger.setLevel(logging.WARNING) # Show only slow queries
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### Default (Recommended)
|
| 62 |
+
```python
|
| 63 |
+
query_logger.setLevel(logging.INFO) # Show medium and slow queries
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
## 🔧 Manual Timing in Code
|
| 67 |
+
|
| 68 |
+
Add timing to specific operations:
|
| 69 |
+
|
| 70 |
+
```python
|
| 71 |
+
from app.core.timing import timer
|
| 72 |
+
|
| 73 |
+
# Time a code block
|
| 74 |
+
with timer("Complex calculation"):
|
| 75 |
+
result = perform_calculation()
|
| 76 |
+
|
| 77 |
+
# Or use a decorator
|
| 78 |
+
from app.core.timing import timed
|
| 79 |
+
|
| 80 |
+
@timed("Data processing")
|
| 81 |
+
def process_data(data):
|
| 82 |
+
# ... your code ...
|
| 83 |
+
return result
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
## 🧪 Test the Feature
|
| 87 |
+
|
| 88 |
+
### Run the Demo
|
| 89 |
+
```bash
|
| 90 |
+
.venv/bin/python examples/query_timing_demo.py
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
### Test with Live API
|
| 94 |
+
```bash
|
| 95 |
+
.venv/bin/python test_query_timing.py
|
| 96 |
+
```
|
| 97 |
+
(Make sure your application is running first!)
|
| 98 |
+
|
| 99 |
+
## 📖 Full Documentation
|
| 100 |
+
|
| 101 |
+
- **Comprehensive Guide**: `QUERY_TIMING.md`
|
| 102 |
+
- **Implementation Details**: `IMPLEMENTATION_SUMMARY.md`
|
| 103 |
+
- **Code Examples**: `examples/query_timing_demo.py`
|
| 104 |
+
|
| 105 |
+
## 🎯 What to Look For
|
| 106 |
+
|
| 107 |
+
1. **Slow Queries**: Look for WARNING logs with "SLOW QUERY"
|
| 108 |
+
2. **Slow Endpoints**: Look for WARNING logs with "SLOW REQUEST"
|
| 109 |
+
3. **Response Headers**: Check X-Process-Time in API responses
|
| 110 |
+
4. **Pattern Analysis**: Track which endpoints are consistently slow
|
| 111 |
+
|
| 112 |
+
## 💡 Next Steps
|
| 113 |
+
|
| 114 |
+
1. ✅ Run your application normally
|
| 115 |
+
2. ✅ Monitor logs for slow operations
|
| 116 |
+
3. ✅ Identify bottlenecks from WARNING messages
|
| 117 |
+
4. ✅ Optimize slow queries and endpoints
|
| 118 |
+
5. ✅ Set up alerts for production monitoring
|
| 119 |
+
|
| 120 |
+
## 🐛 Troubleshooting
|
| 121 |
+
|
| 122 |
+
**Not seeing timing logs?**
|
| 123 |
+
- Check that your app is running with `uvicorn`
|
| 124 |
+
- Verify LOG_LEVEL is set to INFO or DEBUG
|
| 125 |
+
- Look for logs with "sqlalchemy.query.timing" or "app.request.timing"
|
| 126 |
+
|
| 127 |
+
**Want to disable timing?**
|
| 128 |
+
- Set query_logger level to ERROR in `app/core/logging.py`
|
| 129 |
+
- Comment out the event listeners in `app/db/session.py`
|
| 130 |
+
|
| 131 |
+
## ✨ Benefits
|
| 132 |
+
|
| 133 |
+
- 🎯 **Zero Code Changes**: Works automatically with all existing queries
|
| 134 |
+
- 📊 **Actionable Data**: Clear indicators of what needs optimization
|
| 135 |
+
- 🔍 **Production Ready**: Minimal overhead, can be tuned per environment
|
| 136 |
+
- 📈 **Scalable**: Ready to integrate with monitoring tools (Prometheus, Datadog, etc.)
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
**That's it!** Your application now automatically tracks and logs query performance. Just run it and watch the logs! 🚀
|
README 2.md
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Aqua Barrier
|
| 3 |
+
emoji: 👀
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
short_description: AB MPI services
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# AquaBarrier Core API
|
| 12 |
+
|
| 13 |
+
A comprehensive FastAPI-based web service for managing customers, projects, employees, bidders, distributors, and related business entities for the AquaBarrier MPI (Modular Project Interface) platform.
|
| 14 |
+
|
| 15 |
+
## Overview
|
| 16 |
+
|
| 17 |
+
**AquaBarrier Core API** is a production-ready REST API built with FastAPI and SQLAlchemy, designed to handle complex business logic for managing barriers, projects, customer relationships, and organizational data. The application uses SQL Server as its primary database and follows modern Python best practices with type hints, async operations, and comprehensive testing.
|
| 18 |
+
|
| 19 |
+
### Key Features
|
| 20 |
+
- **Multi-module Architecture**: Organized controllers, services, and repositories for clean separation of concerns
|
| 21 |
+
- **JWT Authentication**: Secure token-based authentication with role-based access control
|
| 22 |
+
- **Comprehensive APIs**: 10+ resource modules including customers, projects, employees, bidders, distributors, and more
|
| 23 |
+
- **Database ORM**: SQLAlchemy 2.0+ with async support and database migrations via Alembic
|
| 24 |
+
- **Request Timing**: Built-in middleware for performance monitoring and slow request detection
|
| 25 |
+
- **CORS Support**: Configured for cross-origin requests
|
| 26 |
+
- **API Documentation**: Auto-generated OpenAPI (Swagger) and ReDoc documentation
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## Project Structure
|
| 31 |
+
|
| 32 |
+
```
|
| 33 |
+
ab-ms-core/
|
| 34 |
+
├── app/ # Main application package
|
| 35 |
+
│ ├── app.py # FastAPI application entry point
|
| 36 |
+
│ ├── Dockerfile # Container configuration
|
| 37 |
+
│ ├── Makefile # Development commands
|
| 38 |
+
│ ├── controllers/ # API route handlers
|
| 39 |
+
│ │ ├── auth.py # Authentication endpoints
|
| 40 |
+
│ │ ├── customers.py # Customer management
|
| 41 |
+
│ │ ├── projects.py # Project management
|
| 42 |
+
│ │ ├── employees.py # Employee management
|
| 43 |
+
│ │ ├── bidders.py # Bidder management
|
| 44 |
+
│ │ ├── distributors.py # Distributor management
|
| 45 |
+
│ │ ├── dashboard.py # Dashboard metrics
|
| 46 |
+
│ │ ├── reports.py # Reporting endpoints
|
| 47 |
+
│ │ ├── reference.py # Reference data
|
| 48 |
+
│ │ └── notes.py # Notes management
|
| 49 |
+
│ ├── services/ # Business logic layer
|
| 50 |
+
│ │ ├── customer_service.py # Customer operations
|
| 51 |
+
│ │ ├── project_service.py # Project operations
|
| 52 |
+
│ │ ├── auth_service.py # Authentication logic
|
| 53 |
+
│ │ ├── bidder_service.py # Bidder operations
|
| 54 |
+
│ │ ├── employee_service.py # Employee operations
|
| 55 |
+
│ │ ├── reference_service.py # Reference operations
|
| 56 |
+
│ │ ├── barrier_size_service.py # Barrier size operations
|
| 57 |
+
│ │ └── [other services...] # Additional business logic
|
| 58 |
+
│ ├── db/ # Database layer
|
| 59 |
+
│ │ ├── models/ # SQLAlchemy ORM models
|
| 60 |
+
│ │ │ ├── customer.py # Customer model
|
| 61 |
+
│ │ │ ├── project.py # Project model
|
| 62 |
+
│ │ │ ├── employee.py # Employee model
|
| 63 |
+
│ │ │ ├── bidder.py # Bidder model
|
| 64 |
+
│ │ │ ├── barrier_size.py # Barrier size model
|
| 65 |
+
│ │ │ ├── distributor.py # Distributor model
|
| 66 |
+
│ │ │ ├── contact.py # Contact model
|
| 67 |
+
│ │ │ ├── address.py # Address model
|
| 68 |
+
│ │ │ └── [other models...] # Additional models
|
| 69 |
+
│ │ ├── repositories/ # Data access layer
|
| 70 |
+
│ │ │ ├── customer_repo.py # Customer queries
|
| 71 |
+
│ │ │ ├── customer_sp_repo.py # Customer stored procedures
|
| 72 |
+
│ │ │ ├── project_repo.py # Project queries
|
| 73 |
+
│ │ │ ├── bidder_repo.py # Bidder queries
|
| 74 |
+
│ │ │ └── [other repos...] # Additional repositories
|
| 75 |
+
│ │ ├── migrations/ # Alembic database migrations
|
| 76 |
+
│ │ ├── base.py # SQLAlchemy base configuration
|
| 77 |
+
│ │ └── session.py # Database session management
|
| 78 |
+
│ ├── schemas/ # Pydantic request/response models
|
| 79 |
+
│ │ ├── customer.py # Customer schemas
|
| 80 |
+
│ │ ├── project.py # Project schemas
|
| 81 |
+
│ │ ├── employee.py # Employee schemas
|
| 82 |
+
│ │ ├── auth.py # Auth schemas
|
| 83 |
+
│ │ ├── bidder.py # Bidder schemas
|
| 84 |
+
│ │ └── [other schemas...] # Additional schemas
|
| 85 |
+
│ ├── core/ # Core utilities
|
| 86 |
+
│ │ ├── config.py # Settings and configuration
|
| 87 |
+
│ │ ├── dependencies.py # Dependency injection
|
| 88 |
+
│ │ ├── security.py # Security utilities
|
| 89 |
+
│ │ ├── exceptions.py # Custom exceptions
|
| 90 |
+
│ │ ├── logging.py # Logging configuration
|
| 91 |
+
│ │ └── timing.py # Performance timing utilities
|
| 92 |
+
│ └── tests/ # Unit tests
|
| 93 |
+
│ └── unit/ # Unit test suite
|
| 94 |
+
├── tests/ # Integration and functional tests
|
| 95 |
+
├── docker/ # Docker configuration
|
| 96 |
+
│ └── docker-compose.yml # Container orchestration
|
| 97 |
+
├── pyproject.toml # Project metadata and dependencies
|
| 98 |
+
├── requirements.txt # Python dependencies
|
| 99 |
+
└── [Test files and examples] # Demo and test scripts
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
---
|
| 103 |
+
|
| 104 |
+
## Technology Stack
|
| 105 |
+
|
| 106 |
+
- **Framework**: FastAPI 0.100.0+ (modern async Python web framework)
|
| 107 |
+
- **Database**: SQL Server with ODBC Driver 18
|
| 108 |
+
- **ORM**: SQLAlchemy 2.0+ (async-first ORM)
|
| 109 |
+
- **Authentication**: JWT tokens with PyJWT
|
| 110 |
+
- **Password Hashing**: Passlib with bcrypt
|
| 111 |
+
- **Validation**: Pydantic 2.0+ (with email support)
|
| 112 |
+
- **Migrations**: Alembic (database version control)
|
| 113 |
+
- **Testing**: Pytest with async support
|
| 114 |
+
- **Code Quality**: Ruff (linting), MyPy (type checking)
|
| 115 |
+
- **Server**: Uvicorn (ASGI server)
|
| 116 |
+
- **Runtime**: Python 3.13+
|
| 117 |
+
|
| 118 |
+
---
|
| 119 |
+
|
| 120 |
+
## Getting Started
|
| 121 |
+
|
| 122 |
+
### Prerequisites
|
| 123 |
+
- **Python 3.13+**
|
| 124 |
+
- **Docker & Docker Compose** (for containerized setup)
|
| 125 |
+
- **SQL Server** (local or remote)
|
| 126 |
+
- **Git**
|
| 127 |
+
|
| 128 |
+
### Setup & Installation
|
| 129 |
+
|
| 130 |
+
#### Option 1: Using Docker Compose (Recommended)
|
| 131 |
+
|
| 132 |
+
```bash
|
| 133 |
+
# Clone the repository
|
| 134 |
+
git clone <repository-url>
|
| 135 |
+
cd ab-ms-core
|
| 136 |
+
|
| 137 |
+
# Navigate to the app directory
|
| 138 |
+
cd app
|
| 139 |
+
|
| 140 |
+
# Start the application with database
|
| 141 |
+
make start
|
| 142 |
+
# or manually:
|
| 143 |
+
docker-compose -f docker/docker-compose.yml up --build
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
The application will be available at:
|
| 147 |
+
- **API**: `http://localhost:8000`
|
| 148 |
+
- **Swagger UI**: `http://localhost:8000/docs`
|
| 149 |
+
- **ReDoc**: `http://localhost:8000/redoc`
|
| 150 |
+
|
| 151 |
+
#### Option 2: Local Development Setup
|
| 152 |
+
|
| 153 |
+
1. **Install Python Dependencies**
|
| 154 |
+
```bash
|
| 155 |
+
pip install -r requirements.txt
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
2. **Set up Environment Variables**
|
| 159 |
+
Create a `.env` file in the root directory:
|
| 160 |
+
```env
|
| 161 |
+
SQLSERVER_HOST=localhost
|
| 162 |
+
SQLSERVER_PORT=1433
|
| 163 |
+
SQLSERVER_USER=sa
|
| 164 |
+
SQLSERVER_PASSWORD=YourStrong!Passw0rd
|
| 165 |
+
SQLSERVER_DB=aquabarrier
|
| 166 |
+
SECRET_KEY=your-secret-key-here
|
| 167 |
+
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
| 168 |
+
LOG_LEVEL=INFO
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
3. **Start the Application**
|
| 172 |
+
```bash
|
| 173 |
+
# From the root directory
|
| 174 |
+
uvicorn app.app:app --reload --host 0.0.0.0 --port 8000
|
| 175 |
+
|
| 176 |
+
# With debug logging
|
| 177 |
+
uvicorn app.app:app --reload --port 8000 --log-level debug
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
The API will be available at `http://localhost:8000`
|
| 181 |
+
|
| 182 |
+
---
|
| 183 |
+
|
| 184 |
+
## Common Commands
|
| 185 |
+
|
| 186 |
+
### Development Commands
|
| 187 |
+
```bash
|
| 188 |
+
# Run database migrations
|
| 189 |
+
make migrate
|
| 190 |
+
# or
|
| 191 |
+
alembic upgrade head
|
| 192 |
+
|
| 193 |
+
# Create a new migration
|
| 194 |
+
alembic revision --autogenerate -m "Description of changes"
|
| 195 |
+
|
| 196 |
+
# Run linting
|
| 197 |
+
make lint
|
| 198 |
+
# or
|
| 199 |
+
ruff check app/
|
| 200 |
+
|
| 201 |
+
# Run type checking
|
| 202 |
+
make typecheck
|
| 203 |
+
# or
|
| 204 |
+
mypy app/
|
| 205 |
+
|
| 206 |
+
# Run tests
|
| 207 |
+
make test
|
| 208 |
+
# or
|
| 209 |
+
pytest app/tests/unit -v
|
| 210 |
+
|
| 211 |
+
# Stop running containers
|
| 212 |
+
make stop
|
| 213 |
+
# or
|
| 214 |
+
docker-compose -f docker/docker-compose.yml down
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
### Code Quality
|
| 218 |
+
```bash
|
| 219 |
+
# Format code (using Ruff)
|
| 220 |
+
ruff format app/
|
| 221 |
+
|
| 222 |
+
# Check for unused imports
|
| 223 |
+
ruff check --select F401 app/
|
| 224 |
+
|
| 225 |
+
# Full type checking
|
| 226 |
+
mypy app/ --strict
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
---
|
| 230 |
+
|
| 231 |
+
## API Endpoints Overview
|
| 232 |
+
|
| 233 |
+
### Authentication (`/api/v1/auth`)
|
| 234 |
+
- `POST /login` - User login with credentials
|
| 235 |
+
- `POST /refresh` - Refresh access token
|
| 236 |
+
- `POST /logout` - User logout
|
| 237 |
+
|
| 238 |
+
### Customers (`/api/v1/customers`)
|
| 239 |
+
- `GET /` - List all customers (with pagination)
|
| 240 |
+
- `GET /{id}` - Get customer details
|
| 241 |
+
- `POST /` - Create new customer
|
| 242 |
+
- `PUT /{id}` - Update customer
|
| 243 |
+
- `DELETE /{id}` - Delete customer
|
| 244 |
+
- `GET /{id}/projects` - Get customer's projects
|
| 245 |
+
- `GET /{id}/contacts` - Get customer's contacts
|
| 246 |
+
|
| 247 |
+
### Projects (`/api/v1/projects`)
|
| 248 |
+
- `GET /` - List all projects
|
| 249 |
+
- `GET /{id}` - Get project details
|
| 250 |
+
- `POST /` - Create new project
|
| 251 |
+
- `PUT /{id}` - Update project
|
| 252 |
+
- `DELETE /{id}` - Delete project
|
| 253 |
+
|
| 254 |
+
### Employees (`/api/v1/employees`)
|
| 255 |
+
- `GET /` - List all employees
|
| 256 |
+
- `GET /{id}` - Get employee details
|
| 257 |
+
- `POST /` - Create new employee
|
| 258 |
+
- `PUT /{id}` - Update employee
|
| 259 |
+
|
| 260 |
+
### Bidders (`/api/v1/bidders`)
|
| 261 |
+
- `GET /` - List all bidders
|
| 262 |
+
- `GET /{id}` - Get bidder details
|
| 263 |
+
- `POST /` - Create new bidder
|
| 264 |
+
- `PUT /{id}` - Update bidder
|
| 265 |
+
|
| 266 |
+
### Distributors (`/api/v1/distributors`)
|
| 267 |
+
- `GET /` - List all distributors
|
| 268 |
+
- `GET /{id}` - Get distributor details
|
| 269 |
+
- `POST /` - Create new distributor
|
| 270 |
+
|
| 271 |
+
### Reports (`/api/v1/reports`)
|
| 272 |
+
- `GET /` - Generate reports
|
| 273 |
+
- `GET /{report-type}` - Get specific report type
|
| 274 |
+
|
| 275 |
+
### Reference Data (`/api/v1/reference`)
|
| 276 |
+
- `GET /states` - List all states
|
| 277 |
+
- `GET /countries` - List all countries
|
| 278 |
+
- `GET /barrier-sizes` - List all barrier sizes
|
| 279 |
+
|
| 280 |
+
### Dashboard (`/api/v1/dashboard`)
|
| 281 |
+
- `GET /stats` - Get dashboard statistics
|
| 282 |
+
- `GET /metrics` - Get performance metrics
|
| 283 |
+
|
| 284 |
+
---
|
| 285 |
+
|
| 286 |
+
## Database Models
|
| 287 |
+
|
| 288 |
+
### Core Entities
|
| 289 |
+
|
| 290 |
+
**Customer**
|
| 291 |
+
- CustomerID (PK)
|
| 292 |
+
- CompanyName, Address, City, PostalCode
|
| 293 |
+
- StateID, CountryID
|
| 294 |
+
- CompanyTypeID, LeadGeneratedFromID
|
| 295 |
+
- PriorityID, FollowupDate
|
| 296 |
+
- RentalType (default: 'AB')
|
| 297 |
+
- Enabled status
|
| 298 |
+
|
| 299 |
+
**Project**
|
| 300 |
+
- ProjectID (PK)
|
| 301 |
+
- CustomerID (FK)
|
| 302 |
+
- ProjectName, Description
|
| 303 |
+
- Status, Priority
|
| 304 |
+
- StartDate, EndDate
|
| 305 |
+
- Budget information
|
| 306 |
+
|
| 307 |
+
**Employee**
|
| 308 |
+
- EmployeeID (PK)
|
| 309 |
+
- FirstName, LastName, Email
|
| 310 |
+
- Phone, Department
|
| 311 |
+
- Status information
|
| 312 |
+
|
| 313 |
+
**Bidder**
|
| 314 |
+
- BidderID (PK)
|
| 315 |
+
- BidderName, ContactInfo
|
| 316 |
+
- Associated barrier sizes
|
| 317 |
+
|
| 318 |
+
**Barrier Size**
|
| 319 |
+
- BarrierSizeID (PK)
|
| 320 |
+
- Description, Dimensions
|
| 321 |
+
- Capacity information
|
| 322 |
+
|
| 323 |
+
**Contact**
|
| 324 |
+
- ContactID (PK)
|
| 325 |
+
- CustomerID (FK)
|
| 326 |
+
- ContactName, Email, Phone
|
| 327 |
+
|
| 328 |
+
**Address**
|
| 329 |
+
- AddressID (PK)
|
| 330 |
+
- CustomerID/ContactID (FK)
|
| 331 |
+
- Street, City, State, PostalCode
|
| 332 |
+
|
| 333 |
+
---
|
| 334 |
+
|
| 335 |
+
## Development Workflow
|
| 336 |
+
|
| 337 |
+
### 1. **Access API Documentation**
|
| 338 |
+
- **Swagger UI**: `http://localhost:8000/docs` - Interactive API testing
|
| 339 |
+
- **ReDoc**: `http://localhost:8000/redoc` - Beautiful API documentation
|
| 340 |
+
|
| 341 |
+
### 2. **Database Connection**
|
| 342 |
+
- **Host**: `localhost` (or your SQL Server host)
|
| 343 |
+
- **Port**: `31433` (Docker) or `1433` (Local MSSQL)
|
| 344 |
+
- **Database**: `aquabarrier`
|
| 345 |
+
- **Driver**: ODBC Driver 18 for SQL Server
|
| 346 |
+
|
| 347 |
+
### 3. **Authentication Flow**
|
| 348 |
+
- Login via `/api/v1/auth/login` with username/password
|
| 349 |
+
- Receive JWT access token (15-minute expiration)
|
| 350 |
+
- Use token in Authorization header: `Bearer <token>`
|
| 351 |
+
- Refresh token endpoint for extended sessions (7-day expiration)
|
| 352 |
+
|
| 353 |
+
### 4. **Adding New Features**
|
| 354 |
+
1. Create/update model in `app/db/models/`
|
| 355 |
+
2. Create schema in `app/schemas/`
|
| 356 |
+
3. Create repository in `app/db/repositories/`
|
| 357 |
+
4. Create service in `app/services/`
|
| 358 |
+
5. Create controller in `app/controllers/`
|
| 359 |
+
6. Add tests in `app/tests/unit/`
|
| 360 |
+
7. Create migration: `alembic revision --autogenerate -m "description"`
|
| 361 |
+
|
| 362 |
+
### 5. **Testing**
|
| 363 |
+
- Unit tests in `app/tests/unit/`
|
| 364 |
+
- Use pytest fixtures in `conftest.py`
|
| 365 |
+
- Run with: `pytest app/tests/unit -v`
|
| 366 |
+
- Test coverage analysis available
|
| 367 |
+
|
| 368 |
+
---
|
| 369 |
+
|
| 370 |
+
## Performance Features
|
| 371 |
+
|
| 372 |
+
### Request Timing Middleware
|
| 373 |
+
- Automatically logs request processing time
|
| 374 |
+
- Slow request threshold: 2.0 seconds (warning level)
|
| 375 |
+
- Provides visibility into API performance bottlenecks
|
| 376 |
+
|
| 377 |
+
### Database Optimization
|
| 378 |
+
- Connection pooling via SQLAlchemy
|
| 379 |
+
- Indexed queries on primary keys
|
| 380 |
+
- Stored procedure support for complex operations
|
| 381 |
+
- Async database operations for better throughput
|
| 382 |
+
|
| 383 |
+
---
|
| 384 |
+
|
| 385 |
+
## Environment Configuration
|
| 386 |
+
|
| 387 |
+
The application uses environment variables (`.env` file) for configuration:
|
| 388 |
+
|
| 389 |
+
```env
|
| 390 |
+
# Application
|
| 391 |
+
APP_NAME=AquaBarrier Core API
|
| 392 |
+
API_V1_STR=/api/v1
|
| 393 |
+
SECRET_KEY=your-secret-key-minimum-32-characters
|
| 394 |
+
|
| 395 |
+
# JWT Settings
|
| 396 |
+
JWT_ALGORITHM=HS256
|
| 397 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
| 398 |
+
REFRESH_TOKEN_EXPIRE_DAYS=7
|
| 399 |
+
|
| 400 |
+
# SQL Server Connection
|
| 401 |
+
SQLSERVER_HOST=localhost
|
| 402 |
+
SQLSERVER_PORT=1433
|
| 403 |
+
SQLSERVER_USER=sa
|
| 404 |
+
SQLSERVER_PASSWORD=YourStrong!Passw0rd
|
| 405 |
+
SQLSERVER_DB=aquabarrier
|
| 406 |
+
SQLSERVER_DRIVER=ODBC Driver 18 for SQL Server
|
| 407 |
+
|
| 408 |
+
# API Settings
|
| 409 |
+
CORS_ORIGINS=*
|
| 410 |
+
|
| 411 |
+
# Logging
|
| 412 |
+
LOG_LEVEL=INFO
|
| 413 |
+
```
|
| 414 |
+
|
| 415 |
+
---
|
| 416 |
+
|
| 417 |
+
## Troubleshooting
|
| 418 |
+
|
| 419 |
+
### Connection Issues
|
| 420 |
+
- Verify SQL Server is running and accessible
|
| 421 |
+
- Check `.env` file has correct credentials
|
| 422 |
+
- Ensure ODBC Driver 18 is installed: `pip install pyodbc`
|
| 423 |
+
|
| 424 |
+
### Migration Issues
|
| 425 |
+
- Run: `alembic upgrade head`
|
| 426 |
+
- Check `app/db/migrations/` for migration files
|
| 427 |
+
- Review `alembic.ini` configuration
|
| 428 |
+
|
| 429 |
+
### Port Already in Use
|
| 430 |
+
```bash
|
| 431 |
+
# Find process using port 8000
|
| 432 |
+
lsof -i :8000
|
| 433 |
+
# Kill process
|
| 434 |
+
kill -9 <PID>
|
| 435 |
+
```
|
| 436 |
+
|
| 437 |
+
### Docker Issues
|
| 438 |
+
```bash
|
| 439 |
+
# View logs
|
| 440 |
+
docker-compose logs -f
|
| 441 |
+
|
| 442 |
+
# Rebuild containers
|
| 443 |
+
docker-compose down
|
| 444 |
+
docker-compose up --build
|
| 445 |
+
```
|
| 446 |
+
|
| 447 |
+
---
|
| 448 |
+
|
| 449 |
+
## Testing
|
| 450 |
+
|
| 451 |
+
### Run All Tests
|
| 452 |
+
```bash
|
| 453 |
+
pytest app/tests/unit -v
|
| 454 |
+
```
|
| 455 |
+
|
| 456 |
+
### Run Specific Test File
|
| 457 |
+
```bash
|
| 458 |
+
pytest app/tests/unit/test_customers.py -v
|
| 459 |
+
```
|
| 460 |
+
|
| 461 |
+
### Run with Coverage
|
| 462 |
+
```bash
|
| 463 |
+
pytest app/tests/unit --cov=app --cov-report=html
|
| 464 |
+
```
|
| 465 |
+
|
| 466 |
+
### Available Test Files
|
| 467 |
+
- `test_address_repository.py` - Address data access tests
|
| 468 |
+
- `test_customer_service.py` - Customer business logic tests
|
| 469 |
+
- `test_api.py` - API endpoint tests
|
| 470 |
+
- `test_barrier_*.py` - Barrier size operation tests
|
| 471 |
+
- `test_reference_*.py` - Reference data tests
|
| 472 |
+
|
| 473 |
+
---
|
| 474 |
+
|
| 475 |
+
## Project Status
|
| 476 |
+
|
| 477 |
+
- ✅ Core API structure established
|
| 478 |
+
- ✅ All main entity modules implemented
|
| 479 |
+
- ✅ Database migrations configured
|
| 480 |
+
- ✅ Authentication system in place
|
| 481 |
+
- ✅ Comprehensive test suite
|
| 482 |
+
- ✅ Docker containerization
|
| 483 |
+
- ✅ API documentation
|
| 484 |
+
|
| 485 |
+
---
|
| 486 |
+
|
| 487 |
+
## Contributing
|
| 488 |
+
|
| 489 |
+
1. Create a feature branch
|
| 490 |
+
2. Make changes following the existing code structure
|
| 491 |
+
3. Add tests for new functionality
|
| 492 |
+
4. Run linting and type checking
|
| 493 |
+
5. Submit a pull request
|
| 494 |
+
|
| 495 |
+
---
|
| 496 |
+
|
| 497 |
+
## License
|
| 498 |
+
|
| 499 |
+
[Add your license here]
|
| 500 |
+
|
| 501 |
+
---
|
| 502 |
+
|
| 503 |
+
## Support
|
| 504 |
+
|
| 505 |
+
For issues, questions, or contributions, please contact the development team or open an issue in the repository.
|
| 506 |
+
- Username: `sa`
|
| 507 |
+
- Password: `YourStrong!Passw0rd`
|
| 508 |
+
|
| 509 |
+
3. **Logs**
|
| 510 |
+
- Docker logs: `docker-compose -f docker/docker-compose.yml logs -f app`
|
| 511 |
+
|
app/Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use a lightweight and stable Python 3.11 base image (Bullseye)
|
| 2 |
+
FROM python:3.11-slim-bullseye AS base
|
| 3 |
+
|
| 4 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 5 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
+
PATH="/home/user/.local/bin:$PATH"
|
| 7 |
+
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
openssl \
|
| 10 |
+
ca-certificates \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
RUN useradd -m -u 1000 user
|
| 14 |
+
USER user
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
|
| 17 |
+
COPY --chown=user ./requirements.txt requirements.txt
|
| 18 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 19 |
+
pip install --no-cache-dir --upgrade -r requirements.txt
|
| 20 |
+
|
| 21 |
+
COPY --chown=user . /app
|
| 22 |
+
|
| 23 |
+
EXPOSE 7860
|
| 24 |
+
|
| 25 |
+
CMD ["uvicorn", "app.app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "4", "--log-level", "info"]
|
app/Makefile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
start:
|
| 2 |
+
docker-compose up --build
|
| 3 |
+
stop:
|
| 4 |
+
docker-compose down
|
| 5 |
+
migrate:
|
| 6 |
+
alembic upgrade head
|
| 7 |
+
lint:
|
| 8 |
+
ruff app/
|
| 9 |
+
typecheck:
|
| 10 |
+
mypy app/
|
| 11 |
+
test:
|
| 12 |
+
pytest app/tests/unit
|
| 13 |
+
test-integration:
|
| 14 |
+
pytest app/tests/integration
|
app/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AquaBarrier Core API
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
short_description: Core Management Services
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# AquaBarrier Core API
|
| 16 |
+
|
| 17 |
+
## Overview
|
| 18 |
+
Production-ready FastAPI backend for customer/project management, dashboard, and reporting. Uses SQL Server, JWT auth, Docker, Alembic, and more.
|
| 19 |
+
|
| 20 |
+
## Quickstart
|
| 21 |
+
1. Copy `.env` and adjust secrets.
|
| 22 |
+
2. `docker-compose up --build`
|
| 23 |
+
3. Visit [http://localhost:8000/docs](http://localhost:8000/docs)
|
| 24 |
+
|
| 25 |
+
## Main Endpoints
|
| 26 |
+
- Auth: `/api/v1/auth/register`, `/api/v1/auth/login`, `/api/v1/auth/refresh`, `/api/v1/auth/logout`
|
| 27 |
+
- Customers: `/api/v1/customers` CRUD
|
| 28 |
+
- Projects: `/api/v1/projects` CRUD
|
| 29 |
+
- Dashboard: `/api/v1/dashboard/summary`, `/api/v1/dashboard/customer/{id}/overview`
|
| 30 |
+
- Reports: `/api/v1/reports/projects`
|
| 31 |
+
|
| 32 |
+
## Migrations
|
| 33 |
+
- Alembic scripts in `app/db/migrations`
|
| 34 |
+
- Run migrations: `make migrate`
|
| 35 |
+
|
| 36 |
+
## Testing
|
| 37 |
+
- Unit: `pytest app/tests/unit`
|
| 38 |
+
- Integration: `pytest app/tests/integration`
|
| 39 |
+
|
| 40 |
+
## Lint & Type Check
|
| 41 |
+
- `ruff app/`
|
| 42 |
+
- `mypy app/`
|
| 43 |
+
|
| 44 |
+
## Production Run
|
| 45 |
+
- Uvicorn: `uvicorn app.app:app --host 0.0.0.0 --port 8000`
|
| 46 |
+
- Gunicorn: `gunicorn -k uvicorn.workers.UvicornWorker app.app:app`
|
| 47 |
+
|
| 48 |
+
## Environment Variables
|
| 49 |
+
See `.env` for all required variables.
|
| 50 |
+
|
| 51 |
+
## Seed Data
|
| 52 |
+
- Add admin/sample data: `python app/seed.py`
|
| 53 |
+
|
| 54 |
+
## CI/CD
|
| 55 |
+
- Example GitHub Actions pipeline included.
|
app/__pycache__/app.cpython-313.pyc
ADDED
|
Binary file (3.99 kB). View file
|
|
|
app/app.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, Request
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
from app.core.logging import setup_logging
|
| 5 |
+
from app.controllers.auth import router as auth_router
|
| 6 |
+
from app.controllers.customers import router as customers_router
|
| 7 |
+
from app.controllers.projects import router as projects_router
|
| 8 |
+
from app.controllers.dashboard import router as dashboard_router
|
| 9 |
+
from app.controllers.reports import router as reports_router
|
| 10 |
+
from app.controllers.employees import router as employees_router
|
| 11 |
+
from app.controllers.reference import router as reference_router
|
| 12 |
+
from app.controllers.bidders import router as bidders_router
|
| 13 |
+
from app.controllers.distributors import router as distributors_router
|
| 14 |
+
from app.controllers.notes import router as notes_router
|
| 15 |
+
import logging
|
| 16 |
+
import time
|
| 17 |
+
# addresses router removed; address endpoints now live under customers.py
|
| 18 |
+
|
| 19 |
+
setup_logging(settings.LOG_LEVEL)
|
| 20 |
+
|
| 21 |
+
app = FastAPI(title=settings.APP_NAME)
|
| 22 |
+
|
| 23 |
+
# Request timing logger
|
| 24 |
+
request_logger = logging.getLogger("app.request.timing")
|
| 25 |
+
|
| 26 |
+
app.add_middleware(
|
| 27 |
+
CORSMiddleware,
|
| 28 |
+
allow_origins=[settings.CORS_ORIGINS],
|
| 29 |
+
allow_credentials=True,
|
| 30 |
+
allow_methods=["*"],
|
| 31 |
+
allow_headers=["*"],
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@app.middleware("http")
|
| 36 |
+
async def request_timing_middleware(request: Request, call_next):
|
| 37 |
+
"""Track and log request processing time."""
|
| 38 |
+
start_time = time.time()
|
| 39 |
+
|
| 40 |
+
response = await call_next(request)
|
| 41 |
+
|
| 42 |
+
process_time = time.time() - start_time
|
| 43 |
+
|
| 44 |
+
# Log with different levels based on processing time
|
| 45 |
+
path = request.url.path
|
| 46 |
+
method = request.method
|
| 47 |
+
status = response.status_code
|
| 48 |
+
|
| 49 |
+
if process_time > 2.0: # Slow request threshold: 2 seconds
|
| 50 |
+
request_logger.warning(
|
| 51 |
+
f"SLOW REQUEST ({process_time:.3f}s): {method} {path} - Status {status}"
|
| 52 |
+
)
|
| 53 |
+
elif process_time > 1.0: # Medium request threshold: 1 second
|
| 54 |
+
request_logger.info(
|
| 55 |
+
f"Request took {process_time:.3f}s: {method} {path} - Status {status}"
|
| 56 |
+
)
|
| 57 |
+
else:
|
| 58 |
+
request_logger.debug(
|
| 59 |
+
f"Request took {process_time:.3f}s: {method} {path} - Status {status}"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Add timing header to response
|
| 63 |
+
response.headers["X-Process-Time"] = f"{process_time:.3f}"
|
| 64 |
+
|
| 65 |
+
return response
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@app.middleware("http")
|
| 69 |
+
async def remove_content_type_for_no_content(request, call_next):
|
| 70 |
+
"""Remove Content-Type header on 204 No Content responses.
|
| 71 |
+
|
| 72 |
+
Some clients/tools prefer a bare 204 with no Content-Type header. FastAPI
|
| 73 |
+
will normally include the header; this middleware strips it when the
|
| 74 |
+
response has no content.
|
| 75 |
+
"""
|
| 76 |
+
response = await call_next(request)
|
| 77 |
+
try:
|
| 78 |
+
if response.status_code == 204:
|
| 79 |
+
# Some Response classes store headers in response.headers (MutableHeaders)
|
| 80 |
+
# Remove Content-Type if present so responses are truly empty.
|
| 81 |
+
if "content-type" in response.headers:
|
| 82 |
+
del response.headers["content-type"]
|
| 83 |
+
except Exception:
|
| 84 |
+
# Be conservative: don't let middleware failures break the request.
|
| 85 |
+
pass
|
| 86 |
+
|
| 87 |
+
return response
|
| 88 |
+
|
| 89 |
+
app.include_router(auth_router)
|
| 90 |
+
app.include_router(customers_router)
|
| 91 |
+
app.include_router(projects_router)
|
| 92 |
+
app.include_router(dashboard_router)
|
| 93 |
+
app.include_router(reports_router)
|
| 94 |
+
app.include_router(employees_router)
|
| 95 |
+
app.include_router(reference_router)
|
| 96 |
+
app.include_router(bidders_router)
|
| 97 |
+
app.include_router(notes_router)
|
| 98 |
+
# addresses router removed; address endpoints now included in customers_router
|
app/controllers/__pycache__/auth.cpython-313.pyc
ADDED
|
Binary file (4.1 kB). View file
|
|
|
app/controllers/__pycache__/bidders.cpython-313.pyc
ADDED
|
Binary file (28.1 kB). View file
|
|
|
app/controllers/__pycache__/customers.cpython-313.pyc
ADDED
|
Binary file (20.8 kB). View file
|
|
|
app/controllers/__pycache__/dashboard.cpython-313.pyc
ADDED
|
Binary file (5.02 kB). View file
|
|
|
app/controllers/__pycache__/distributors.cpython-313.pyc
ADDED
|
Binary file (13.5 kB). View file
|
|
|
app/controllers/__pycache__/employees.cpython-313.pyc
ADDED
|
Binary file (4.94 kB). View file
|
|
|
app/controllers/__pycache__/notes.cpython-313.pyc
ADDED
|
Binary file (8.04 kB). View file
|
|
|
app/controllers/__pycache__/projects.cpython-313.pyc
ADDED
|
Binary file (6.49 kB). View file
|
|
|
app/controllers/__pycache__/reference.cpython-313.pyc
ADDED
|
Binary file (35.7 kB). View file
|
|
|
app/controllers/__pycache__/reports.cpython-313.pyc
ADDED
|
Binary file (2.04 kB). View file
|
|
|
app/controllers/auth.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, status, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.db.session import get_db
|
| 4 |
+
from app.services.auth_service import AuthService
|
| 5 |
+
from app.schemas.user import UserCreate
|
| 6 |
+
from app.schemas.auth import (
|
| 7 |
+
LoginRequest, UserLoginRequest, EmployeeLoginRequest,
|
| 8 |
+
TokenResponse, UserTokenResponse, EmployeeTokenResponse,
|
| 9 |
+
RefreshTokenRequest, RefreshTokenResponse, CurrentUser
|
| 10 |
+
)
|
| 11 |
+
from app.core.dependencies import get_current_user, get_current_user_optional
|
| 12 |
+
from app.core.exceptions import AuthException
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/api/v1/auth", tags=["authentication"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.post("/login")
|
| 21 |
+
def login(login_request: LoginRequest, db: Session = Depends(get_db)):
|
| 22 |
+
"""
|
| 23 |
+
Universal login endpoint
|
| 24 |
+
|
| 25 |
+
Supports both user (email) and employee (EmployeeID) authentication.
|
| 26 |
+
- Set auth_type to "user" for email-based login
|
| 27 |
+
- Set auth_type to "employee" for EmployeeID-based login
|
| 28 |
+
- Set auth_type to "auto" (default) to auto-detect based on username format
|
| 29 |
+
"""
|
| 30 |
+
service = AuthService(db)
|
| 31 |
+
try:
|
| 32 |
+
result = service.authenticate_user(
|
| 33 |
+
login_request.username,
|
| 34 |
+
login_request.password
|
| 35 |
+
)
|
| 36 |
+
return result
|
| 37 |
+
except AuthException as e:
|
| 38 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.post("/refresh", response_model=RefreshTokenResponse)
|
| 42 |
+
def refresh_access_token(refresh_request: RefreshTokenRequest, db: Session = Depends(get_db)):
|
| 43 |
+
"""
|
| 44 |
+
Refresh access token
|
| 45 |
+
|
| 46 |
+
Generate a new access token using a valid refresh token.
|
| 47 |
+
"""
|
| 48 |
+
service = AuthService(db)
|
| 49 |
+
try:
|
| 50 |
+
result = service.refresh_token(refresh_request.refresh_token)
|
| 51 |
+
return RefreshTokenResponse(**result)
|
| 52 |
+
except AuthException as e:
|
| 53 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
|
| 54 |
+
|
| 55 |
+
@router.get("/me", response_model=CurrentUser)
|
| 56 |
+
def get_me(current_user: CurrentUser = Depends(get_current_user)):
|
| 57 |
+
"""
|
| 58 |
+
Get current user information
|
| 59 |
+
|
| 60 |
+
Returns details about the currently authenticated user or employee.
|
| 61 |
+
"""
|
| 62 |
+
return current_user
|
| 63 |
+
|
| 64 |
+
@router.post("/logout")
|
| 65 |
+
def logout():
|
| 66 |
+
"""
|
| 67 |
+
Logout endpoint
|
| 68 |
+
|
| 69 |
+
Since JWTs are stateless, logout is handled on the client side by discarding tokens.
|
| 70 |
+
This endpoint exists for API completeness and could be extended to maintain
|
| 71 |
+
a blacklist of revoked tokens if needed.
|
| 72 |
+
"""
|
| 73 |
+
return {"message": "Successfully logged out"}
|
| 74 |
+
|
| 75 |
+
@router.get("/validate")
|
| 76 |
+
def validate_token(current_user: CurrentUser = Depends(get_current_user_optional)):
|
| 77 |
+
"""
|
| 78 |
+
Token validation endpoint
|
| 79 |
+
|
| 80 |
+
Validates if the provided token is valid and returns user information.
|
| 81 |
+
Returns null if no token or invalid token.
|
| 82 |
+
"""
|
| 83 |
+
if current_user:
|
| 84 |
+
return {"valid": True, "user": current_user}
|
| 85 |
+
else:
|
| 86 |
+
return {"valid": False, "user": None}
|
app/controllers/bidders.py
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, status, Query, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.db.session import get_db
|
| 4 |
+
from app.services.bidder_service import BidderService
|
| 5 |
+
from app.core.exceptions import NotFoundException
|
| 6 |
+
from app.schemas.bidder import BidderCreate, BidderOut
|
| 7 |
+
from app.schemas.paginated_response import PaginatedResponse
|
| 8 |
+
from app.schemas.barrier_size import (
|
| 9 |
+
BarrierSizesCreate,
|
| 10 |
+
BarrierSizesUpdate,
|
| 11 |
+
BarrierSizesUpdateWithAssociation,
|
| 12 |
+
BarrierSizesOut,
|
| 13 |
+
)
|
| 14 |
+
from app.schemas.bidders_barrier_sizes import BidderBarrierSizeDetail
|
| 15 |
+
from app.schemas.bidder_contact import BidderContactDetail
|
| 16 |
+
from app.services import barrier_size_service
|
| 17 |
+
from typing import Optional, List
|
| 18 |
+
import logging
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
# Common query parameter descriptions
|
| 23 |
+
PAGE_DESC = "Page number (1-indexed)"
|
| 24 |
+
PAGE_SIZE_DESC = "Number of items per page"
|
| 25 |
+
ORDER_BY_DESC = "Field to order by"
|
| 26 |
+
ORDER_DIR_DESC = "Order direction (asc|desc)"
|
| 27 |
+
PROJ_NO_DESC = "Project number (required)"
|
| 28 |
+
|
| 29 |
+
router = APIRouter(prefix="/api/v1/bidders", tags=["bidders"])
|
| 30 |
+
|
| 31 |
+
@router.get(
|
| 32 |
+
"/",
|
| 33 |
+
response_model=PaginatedResponse[BidderOut],
|
| 34 |
+
summary="List bidders by project",
|
| 35 |
+
response_description="Paginated list of bidders for a specific project"
|
| 36 |
+
)
|
| 37 |
+
def list_bidders(
|
| 38 |
+
proj_no: str = Query(..., description=PROJ_NO_DESC),
|
| 39 |
+
page: int = Query(1, ge=1, description=PAGE_DESC),
|
| 40 |
+
page_size: int = Query(10, ge=1, le=100, description=PAGE_SIZE_DESC),
|
| 41 |
+
order_by: Optional[str] = Query("Id", description=ORDER_BY_DESC),
|
| 42 |
+
order_dir: Optional[str] = Query("asc", description=ORDER_DIR_DESC),
|
| 43 |
+
db: Session = Depends(get_db)
|
| 44 |
+
):
|
| 45 |
+
"""Get all bidders for a specific project with pagination and ordering"""
|
| 46 |
+
try:
|
| 47 |
+
logger.info(f"Listing bidders for project {proj_no}: page={page}, page_size={page_size}")
|
| 48 |
+
bidder_service = BidderService(db)
|
| 49 |
+
result = bidder_service.get_by_project_no(
|
| 50 |
+
proj_no=proj_no,
|
| 51 |
+
page=page,
|
| 52 |
+
page_size=page_size,
|
| 53 |
+
order_by=order_by,
|
| 54 |
+
order_dir=order_dir.upper()
|
| 55 |
+
)
|
| 56 |
+
logger.info(f"Successfully retrieved {len(result.items)} bidders for project {proj_no}")
|
| 57 |
+
return result
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.error(f"Error listing bidders for project {proj_no}: {e}")
|
| 60 |
+
return PaginatedResponse[BidderOut](
|
| 61 |
+
items=[],
|
| 62 |
+
page=page,
|
| 63 |
+
page_size=page_size,
|
| 64 |
+
total=0
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
@router.get(
|
| 68 |
+
"/project/{proj_no}",
|
| 69 |
+
response_model=PaginatedResponse[BidderOut],
|
| 70 |
+
summary="List bidders by project path parameter",
|
| 71 |
+
response_description="Paginated list of bidders for a specific project"
|
| 72 |
+
)
|
| 73 |
+
def list_bidders_by_project(
|
| 74 |
+
proj_no: str,
|
| 75 |
+
page: int = Query(1, ge=1, description=PAGE_DESC),
|
| 76 |
+
page_size: int = Query(10, ge=1, le=100, description=PAGE_SIZE_DESC),
|
| 77 |
+
order_by: Optional[str] = Query("Id", description=ORDER_BY_DESC),
|
| 78 |
+
order_dir: Optional[str] = Query("asc", description=ORDER_DIR_DESC),
|
| 79 |
+
db: Session = Depends(get_db)
|
| 80 |
+
):
|
| 81 |
+
"""Get all bidders for a specific project with pagination and ordering (using path parameter)"""
|
| 82 |
+
try:
|
| 83 |
+
logger.info(f"Listing bidders for project {proj_no}: page={page}, page_size={page_size}")
|
| 84 |
+
bidder_service = BidderService(db)
|
| 85 |
+
result = bidder_service.get_by_project_no(
|
| 86 |
+
proj_no=proj_no,
|
| 87 |
+
page=page,
|
| 88 |
+
page_size=page_size,
|
| 89 |
+
order_by=order_by,
|
| 90 |
+
order_dir=order_dir.upper()
|
| 91 |
+
)
|
| 92 |
+
logger.info(f"Successfully retrieved {len(result.items)} bidders for project {proj_no}")
|
| 93 |
+
return result
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.error(f"Error listing bidders for project {proj_no}: {e}")
|
| 96 |
+
return PaginatedResponse[BidderOut](
|
| 97 |
+
items=[],
|
| 98 |
+
page=page,
|
| 99 |
+
page_size=page_size,
|
| 100 |
+
total=0
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
@router.get(
|
| 104 |
+
"/customer/{cust_id}",
|
| 105 |
+
response_model=PaginatedResponse[BidderOut],
|
| 106 |
+
summary="List bidders by customer",
|
| 107 |
+
response_description="Paginated list of bidders for a specific customer"
|
| 108 |
+
)
|
| 109 |
+
def list_bidders_by_customer(
|
| 110 |
+
cust_id: int,
|
| 111 |
+
page: int = Query(1, ge=1, description=PAGE_DESC),
|
| 112 |
+
page_size: int = Query(10, ge=1, le=100, description=PAGE_SIZE_DESC),
|
| 113 |
+
order_by: Optional[str] = Query("Id", description=ORDER_BY_DESC),
|
| 114 |
+
order_dir: Optional[str] = Query("asc", description=ORDER_DIR_DESC),
|
| 115 |
+
db: Session = Depends(get_db)
|
| 116 |
+
):
|
| 117 |
+
"""Get all bidders for a specific customer with pagination and ordering"""
|
| 118 |
+
try:
|
| 119 |
+
logger.info(f"Listing bidders for customer {cust_id}: page={page}, page_size={page_size}")
|
| 120 |
+
bidder_service = BidderService(db)
|
| 121 |
+
result = bidder_service.get_by_customer_id(
|
| 122 |
+
cust_id=cust_id,
|
| 123 |
+
page=page,
|
| 124 |
+
page_size=page_size,
|
| 125 |
+
order_by=order_by,
|
| 126 |
+
order_dir=order_dir.upper()
|
| 127 |
+
)
|
| 128 |
+
logger.info(f"Successfully retrieved {len(result.items)} bidders for customer {cust_id}")
|
| 129 |
+
return result
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.error(f"Error listing bidders for customer {cust_id}: {e}")
|
| 132 |
+
return PaginatedResponse[BidderOut](
|
| 133 |
+
items=[],
|
| 134 |
+
page=page,
|
| 135 |
+
page_size=page_size,
|
| 136 |
+
total=0
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
@router.get(
|
| 140 |
+
"/all",
|
| 141 |
+
response_model=PaginatedResponse[BidderOut],
|
| 142 |
+
summary="List all bidders",
|
| 143 |
+
response_description="Paginated list of all bidders without any filters"
|
| 144 |
+
)
|
| 145 |
+
def list_all_bidders(
|
| 146 |
+
page: int = Query(1, ge=1, description=PAGE_DESC),
|
| 147 |
+
page_size: int = Query(10, ge=1, le=100, description=PAGE_SIZE_DESC),
|
| 148 |
+
order_by: Optional[str] = Query("Id", description=ORDER_BY_DESC),
|
| 149 |
+
order_dir: Optional[str] = Query("asc", description=ORDER_DIR_DESC),
|
| 150 |
+
db: Session = Depends(get_db)
|
| 151 |
+
):
|
| 152 |
+
"""Get all bidders with pagination and ordering"""
|
| 153 |
+
try:
|
| 154 |
+
logger.info(f"Listing all bidders: page={page}, page_size={page_size}")
|
| 155 |
+
bidder_service = BidderService(db)
|
| 156 |
+
result = bidder_service.list(
|
| 157 |
+
page=page,
|
| 158 |
+
page_size=page_size,
|
| 159 |
+
order_by=order_by,
|
| 160 |
+
order_direction=order_dir.upper()
|
| 161 |
+
)
|
| 162 |
+
logger.info(f"Successfully retrieved {len(result.items)} bidders")
|
| 163 |
+
return result
|
| 164 |
+
except Exception as e:
|
| 165 |
+
logger.error(f"Error listing all bidders: {e}")
|
| 166 |
+
return PaginatedResponse[BidderOut](
|
| 167 |
+
items=[],
|
| 168 |
+
page=page,
|
| 169 |
+
page_size=page_size,
|
| 170 |
+
total=0
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
# BarrierSizes endpoints - Must be defined BEFORE the generic /{bidder_id} route
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@router.post(
|
| 178 |
+
"/barrier-sizes",
|
| 179 |
+
response_model=BarrierSizesOut,
|
| 180 |
+
status_code=status.HTTP_201_CREATED,
|
| 181 |
+
summary="Create a new barrier size and associate with bidder",
|
| 182 |
+
response_description="Created barrier size with bidder association"
|
| 183 |
+
)
|
| 184 |
+
def create_barrier_size(
|
| 185 |
+
obj_in: BarrierSizesCreate,
|
| 186 |
+
db: Session = Depends(get_db)
|
| 187 |
+
):
|
| 188 |
+
"""Create a new barrier size entry and associate it with a bidder"""
|
| 189 |
+
try:
|
| 190 |
+
logger.info(f"Creating new barrier size for bidder {obj_in.bidder_id}")
|
| 191 |
+
|
| 192 |
+
# TODO: Add bidder validation once we confirm bidder exists in database
|
| 193 |
+
# For now, skip validation to test the basic functionality
|
| 194 |
+
|
| 195 |
+
result = barrier_size_service.create(db, obj_in)
|
| 196 |
+
logger.info(f"Successfully created barrier size {result.Id} for bidder {obj_in.bidder_id}")
|
| 197 |
+
return result
|
| 198 |
+
except Exception as e:
|
| 199 |
+
logger.error(f"Error creating barrier size: {e}")
|
| 200 |
+
import traceback
|
| 201 |
+
traceback.print_exc() # Print full traceback for debugging
|
| 202 |
+
raise HTTPException(
|
| 203 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 204 |
+
detail=f"Failed to create barrier size: {str(e)}"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@router.get(
|
| 210 |
+
"/barrier-sizes/all",
|
| 211 |
+
response_model=List[BarrierSizesOut],
|
| 212 |
+
summary="Get all barrier sizes (for debugging)",
|
| 213 |
+
response_description="List of all barrier sizes in the system"
|
| 214 |
+
)
|
| 215 |
+
def get_all_barrier_sizes(db: Session = Depends(get_db)):
|
| 216 |
+
"""Get all barrier sizes in the system (for debugging purposes)"""
|
| 217 |
+
try:
|
| 218 |
+
barrier_sizes = barrier_size_service.get_all(db, skip=0, limit=50)
|
| 219 |
+
logger.info(f"Found {len(barrier_sizes)} barrier sizes in database")
|
| 220 |
+
return barrier_sizes
|
| 221 |
+
except Exception as e:
|
| 222 |
+
logger.error(f"Error getting all barrier sizes: {e}")
|
| 223 |
+
raise HTTPException(
|
| 224 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 225 |
+
detail="Failed to get barrier sizes"
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
@router.get(
|
| 229 |
+
"/barrier-sizes/{barrier_size_id}",
|
| 230 |
+
response_model=BarrierSizesOut,
|
| 231 |
+
summary="Get a specific barrier size by ID (for debugging)",
|
| 232 |
+
response_description="Specific barrier size details"
|
| 233 |
+
)
|
| 234 |
+
def get_barrier_size_by_id(barrier_size_id: int, db: Session = Depends(get_db)):
|
| 235 |
+
"""Get a specific barrier size by ID (for debugging purposes)"""
|
| 236 |
+
try:
|
| 237 |
+
barrier_size = barrier_size_service.get(db, barrier_size_id)
|
| 238 |
+
if not barrier_size:
|
| 239 |
+
raise HTTPException(status_code=404, detail=f"Barrier size {barrier_size_id} not found")
|
| 240 |
+
return barrier_size
|
| 241 |
+
except HTTPException:
|
| 242 |
+
raise
|
| 243 |
+
except Exception as e:
|
| 244 |
+
logger.error(f"Error getting barrier size {barrier_size_id}: {e}")
|
| 245 |
+
raise HTTPException(
|
| 246 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 247 |
+
detail="Failed to get barrier size"
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
@router.get(
|
| 251 |
+
"/{bidder_id}/barrier-sizes",
|
| 252 |
+
response_model=List[BidderBarrierSizeDetail],
|
| 253 |
+
summary="Get barrier sizes associated with a bidder",
|
| 254 |
+
response_description="List of barrier sizes with full details including InventoryId and InstallAdvisorFees"
|
| 255 |
+
)
|
| 256 |
+
def get_bidder_barrier_sizes(bidder_id: int, db: Session = Depends(get_db)):
|
| 257 |
+
"""Get all barrier sizes associated with a specific bidder"""
|
| 258 |
+
try:
|
| 259 |
+
barrier_sizes = barrier_size_service.get_by_bidder(db, bidder_id)
|
| 260 |
+
return barrier_sizes # Return empty list if no barrier sizes found
|
| 261 |
+
except Exception as e:
|
| 262 |
+
logger.error(f"Error getting barrier sizes for bidder {bidder_id}: {e}")
|
| 263 |
+
raise HTTPException(
|
| 264 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 265 |
+
detail="Failed to get barrier sizes for bidder"
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
@router.get(
|
| 269 |
+
"/{bidder_id}/contacts",
|
| 270 |
+
response_model=List[BidderContactDetail],
|
| 271 |
+
summary="Get contacts associated with a bidder",
|
| 272 |
+
response_description="List of contacts associated with the bidder with full contact details"
|
| 273 |
+
)
|
| 274 |
+
def get_bidder_contacts(bidder_id: int, db: Session = Depends(get_db)):
|
| 275 |
+
"""Get all contacts associated with a specific bidder"""
|
| 276 |
+
try:
|
| 277 |
+
bidder_service = BidderService(db)
|
| 278 |
+
# First verify bidder exists
|
| 279 |
+
bidder = bidder_service.get(bidder_id)
|
| 280 |
+
|
| 281 |
+
# Get contacts using the repository method that already exists
|
| 282 |
+
contacts = bidder_service.repo.get_bidder_contacts_raw(bidder_id)
|
| 283 |
+
|
| 284 |
+
# Map to schema
|
| 285 |
+
contact_details = []
|
| 286 |
+
for contact in contacts:
|
| 287 |
+
contact_details.append(BidderContactDetail(
|
| 288 |
+
id=contact.get('Id'),
|
| 289 |
+
contact_id=contact.get('ContactId'),
|
| 290 |
+
bidder_id=contact.get('BidderId'),
|
| 291 |
+
enabled=contact.get('Enabled', True),
|
| 292 |
+
first_name=contact.get('FirstName'),
|
| 293 |
+
last_name=contact.get('LastName'),
|
| 294 |
+
title=contact.get('Title'),
|
| 295 |
+
email_address=contact.get('EmailAddress'),
|
| 296 |
+
work_phone=contact.get('WorkPhone'),
|
| 297 |
+
mobile_phone=contact.get('MobilePhone')
|
| 298 |
+
))
|
| 299 |
+
|
| 300 |
+
return contact_details
|
| 301 |
+
except NotFoundException:
|
| 302 |
+
raise HTTPException(
|
| 303 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 304 |
+
detail=f"Bidder {bidder_id} not found"
|
| 305 |
+
)
|
| 306 |
+
except Exception as e:
|
| 307 |
+
logger.error(f"Error getting contacts for bidder {bidder_id}: {e}")
|
| 308 |
+
raise HTTPException(
|
| 309 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 310 |
+
detail="Failed to get contacts for bidder"
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
@router.post(
|
| 314 |
+
"/{bidder_id}/contacts",
|
| 315 |
+
response_model=List[dict],
|
| 316 |
+
status_code=status.HTTP_201_CREATED,
|
| 317 |
+
summary="Associate contacts with a bidder",
|
| 318 |
+
response_description="List of created BidderContact associations"
|
| 319 |
+
)
|
| 320 |
+
def add_bidder_contacts(
|
| 321 |
+
bidder_id: int,
|
| 322 |
+
contact_ids: List[int],
|
| 323 |
+
db: Session = Depends(get_db)
|
| 324 |
+
):
|
| 325 |
+
"""Associate multiple contacts with a bidder"""
|
| 326 |
+
try:
|
| 327 |
+
bidder_service = BidderService(db)
|
| 328 |
+
# First verify bidder exists
|
| 329 |
+
bidder = bidder_service.get(bidder_id)
|
| 330 |
+
|
| 331 |
+
# Create the associations
|
| 332 |
+
created_associations = bidder_service.repo.create_bidder_contacts(bidder_id, contact_ids)
|
| 333 |
+
|
| 334 |
+
logger.info(f"Successfully associated {len(contact_ids)} contacts with bidder {bidder_id}")
|
| 335 |
+
return created_associations
|
| 336 |
+
|
| 337 |
+
except NotFoundException:
|
| 338 |
+
raise HTTPException(
|
| 339 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 340 |
+
detail=f"Bidder {bidder_id} not found"
|
| 341 |
+
)
|
| 342 |
+
except Exception as e:
|
| 343 |
+
logger.error(f"Error associating contacts with bidder {bidder_id}: {e}")
|
| 344 |
+
raise HTTPException(
|
| 345 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 346 |
+
detail="Failed to associate contacts with bidder"
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
@router.put(
|
| 352 |
+
"/barrier-sizes/{id}",
|
| 353 |
+
response_model=BarrierSizesOut,
|
| 354 |
+
summary="Update a barrier size (BarrierSizes table only)",
|
| 355 |
+
response_description="Updated barrier size"
|
| 356 |
+
)
|
| 357 |
+
def update_barrier_size(
|
| 358 |
+
id: int,
|
| 359 |
+
barrier_update: BarrierSizesUpdate,
|
| 360 |
+
db: Session = Depends(get_db)
|
| 361 |
+
):
|
| 362 |
+
"""
|
| 363 |
+
Update an existing barrier size in the BarrierSizes table only.
|
| 364 |
+
|
| 365 |
+
This endpoint directly updates the BarrierSizes table without checking or modifying
|
| 366 |
+
the BiddersBarrierSizes association table.
|
| 367 |
+
|
| 368 |
+
Only updates the fields that are provided (non-None values) in the JSON body.
|
| 369 |
+
|
| 370 |
+
Request body example:
|
| 371 |
+
{
|
| 372 |
+
"Height": 10.5,
|
| 373 |
+
"Width": 8.0,
|
| 374 |
+
"Lenght": 12.0,
|
| 375 |
+
"CableUnits": 5,
|
| 376 |
+
"Price": 1500.00,
|
| 377 |
+
"IsStandard": true
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
All fields in the request body are optional.
|
| 381 |
+
"""
|
| 382 |
+
try:
|
| 383 |
+
logger.info(f"Updating barrier size {id} (BarrierSizes table only)")
|
| 384 |
+
|
| 385 |
+
# Direct update using the barrier size service
|
| 386 |
+
updated_barrier = barrier_size_service.update(db, id, barrier_update)
|
| 387 |
+
|
| 388 |
+
if not updated_barrier:
|
| 389 |
+
raise HTTPException(
|
| 390 |
+
status_code=404,
|
| 391 |
+
detail=f"Barrier size {id} not found in BarrierSizes table"
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
logger.info(f"Successfully updated barrier size {id}")
|
| 395 |
+
return updated_barrier
|
| 396 |
+
except HTTPException:
|
| 397 |
+
raise
|
| 398 |
+
except Exception as e:
|
| 399 |
+
logger.error(f"Error updating barrier size {id}: {e}")
|
| 400 |
+
raise HTTPException(
|
| 401 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 402 |
+
detail="Failed to update barrier size"
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
@router.put(
|
| 406 |
+
"/barrier-sizes/{id}/with-association",
|
| 407 |
+
response_model=dict,
|
| 408 |
+
summary="Update a barrier size with bidder association",
|
| 409 |
+
response_description="Updated barrier size and bidder association"
|
| 410 |
+
)
|
| 411 |
+
def update_barrier_size_with_association(
|
| 412 |
+
id: int,
|
| 413 |
+
barrier_update: BarrierSizesUpdateWithAssociation,
|
| 414 |
+
db: Session = Depends(get_db)
|
| 415 |
+
):
|
| 416 |
+
"""
|
| 417 |
+
Update an existing barrier size and/or its bidder association.
|
| 418 |
+
|
| 419 |
+
Updates both BarrierSizes table and BiddersBarrierSizes table if bidder_id is provided.
|
| 420 |
+
Only updates the fields that are provided (non-None values) in the JSON body.
|
| 421 |
+
|
| 422 |
+
Request body example:
|
| 423 |
+
{
|
| 424 |
+
"Height": 10.5,
|
| 425 |
+
"Width": 8.0,
|
| 426 |
+
"Lenght": 12.0,
|
| 427 |
+
"CableUnits": 5,
|
| 428 |
+
"Price": 1500.00,
|
| 429 |
+
"IsStandard": true,
|
| 430 |
+
"inventory_id": 123,
|
| 431 |
+
"install_advisor_fees": 250.00,
|
| 432 |
+
"bidder_id": 456
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
All fields in the request body are optional.
|
| 436 |
+
"""
|
| 437 |
+
try:
|
| 438 |
+
logger.info(f"Updating barrier size {id} with associations")
|
| 439 |
+
|
| 440 |
+
result = barrier_size_service.update_barrier_with_association(
|
| 441 |
+
db=db,
|
| 442 |
+
barrier_size_id=id,
|
| 443 |
+
height=barrier_update.Height,
|
| 444 |
+
width=barrier_update.Width,
|
| 445 |
+
length=barrier_update.Lenght,
|
| 446 |
+
cable_units=barrier_update.CableUnits,
|
| 447 |
+
price=barrier_update.Price,
|
| 448 |
+
is_standard=barrier_update.IsStandard,
|
| 449 |
+
inventory_id=barrier_update.inventory_id,
|
| 450 |
+
install_advisor_fees=barrier_update.install_advisor_fees,
|
| 451 |
+
bidder_id=barrier_update.bidder_id
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
if not result:
|
| 455 |
+
raise HTTPException(
|
| 456 |
+
status_code=404,
|
| 457 |
+
detail=f"Barrier size {id} not found. Please create the barrier size first using POST /api/v1/bidders/barrier-sizes"
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
logger.info(f"Successfully updated barrier size {id} with associations")
|
| 461 |
+
return result
|
| 462 |
+
except HTTPException:
|
| 463 |
+
raise
|
| 464 |
+
except Exception as e:
|
| 465 |
+
logger.error(f"Error updating barrier size {id} with associations: {e}")
|
| 466 |
+
raise HTTPException(
|
| 467 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 468 |
+
detail="Failed to update barrier size with associations"
|
| 469 |
+
)
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
@router.delete(
|
| 474 |
+
"/barrier-sizes/by-bidder/{bidder_id}",
|
| 475 |
+
status_code=status.HTTP_204_NO_CONTENT,
|
| 476 |
+
summary="Delete all barrier sizes associated with a bidder",
|
| 477 |
+
response_description="All barrier sizes and associations for the bidder deleted successfully"
|
| 478 |
+
)
|
| 479 |
+
def delete_barrier_sizes_by_bidder(bidder_id: int, db: Session = Depends(get_db)):
|
| 480 |
+
"""
|
| 481 |
+
Delete all barrier sizes associated with a specific bidder.
|
| 482 |
+
|
| 483 |
+
This implements the two-step SQL logic:
|
| 484 |
+
1. DELETE FROM BarrierSizes WHERE Id IN (SELECT BarrierSizeId FROM BiddersBarrierSizes WHERE BidderId = {bidder_id})
|
| 485 |
+
2. DELETE FROM BiddersBarrierSizes WHERE BidderId = {bidder_id}
|
| 486 |
+
|
| 487 |
+
Parameters:
|
| 488 |
+
- bidder_id: The bidder ID to delete all barrier sizes for
|
| 489 |
+
"""
|
| 490 |
+
try:
|
| 491 |
+
logger.info(f"Deleting all barrier sizes for bidder {bidder_id}")
|
| 492 |
+
result = barrier_size_service.delete_all_by_bidder(db, bidder_id)
|
| 493 |
+
|
| 494 |
+
logger.info(f"Successfully deleted barrier sizes for bidder {bidder_id}: {result}")
|
| 495 |
+
return None
|
| 496 |
+
except Exception as e:
|
| 497 |
+
logger.error(f"Error deleting barrier sizes for bidder {bidder_id}: {e}")
|
| 498 |
+
raise HTTPException(
|
| 499 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 500 |
+
detail="Failed to delete barrier sizes for bidder"
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
@router.delete(
|
| 504 |
+
"/barrier-sizes/bidder/{bidder_id}/barrier/{barrier_size_id}",
|
| 505 |
+
status_code=status.HTTP_204_NO_CONTENT,
|
| 506 |
+
summary="Delete barrier size by bidder and barrier size IDs",
|
| 507 |
+
response_description="Barrier size and association deleted successfully"
|
| 508 |
+
)
|
| 509 |
+
def delete_barrier_size_by_bidder_and_barrier(
|
| 510 |
+
bidder_id: int,
|
| 511 |
+
barrier_size_id: int,
|
| 512 |
+
cascade: bool = Query(True, description="If true, deletes the barrier size itself if no other bidders use it"),
|
| 513 |
+
db: Session = Depends(get_db)
|
| 514 |
+
):
|
| 515 |
+
"""
|
| 516 |
+
Delete barrier size association and optionally the barrier size itself.
|
| 517 |
+
|
| 518 |
+
This endpoint implements the SQL equivalent of:
|
| 519 |
+
DELETE FROM BiddersBarrierSizes WHERE BidderId={bidder_id} AND BarrierSizeId={barrier_size_id}
|
| 520 |
+
DELETE FROM BarrierSizes WHERE Id={barrier_size_id} (if cascade=true and no other associations exist)
|
| 521 |
+
|
| 522 |
+
Parameters:
|
| 523 |
+
- bidder_id: The bidder ID to remove association for
|
| 524 |
+
- barrier_size_id: The barrier size ID to delete association for
|
| 525 |
+
- cascade: If True, also deletes the BarrierSize record if no other bidders are using it
|
| 526 |
+
"""
|
| 527 |
+
try:
|
| 528 |
+
logger.info(f"Deleting barrier size association: bidder_id={bidder_id}, barrier_size_id={barrier_size_id}, cascade={cascade}")
|
| 529 |
+
result = barrier_size_service.delete_by_bidder_and_barrier_id(db, bidder_id, barrier_size_id, cascade)
|
| 530 |
+
|
| 531 |
+
if not result["association_deleted"]:
|
| 532 |
+
raise HTTPException(
|
| 533 |
+
status_code=404,
|
| 534 |
+
detail=f"Association not found between bidder {bidder_id} and barrier size {barrier_size_id}"
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
logger.info(f"Successfully deleted barrier size association and/or barrier size: {result}")
|
| 538 |
+
return None
|
| 539 |
+
except HTTPException:
|
| 540 |
+
raise
|
| 541 |
+
except Exception as e:
|
| 542 |
+
logger.error(f"Error deleting barrier size association: {e}")
|
| 543 |
+
raise HTTPException(
|
| 544 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 545 |
+
detail="Failed to delete barrier size association"
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
# Bidder endpoints - These must come AFTER the barrier-sizes routes
|
| 550 |
+
|
| 551 |
+
@router.get(
|
| 552 |
+
"/{bidder_id}",
|
| 553 |
+
response_model=BidderOut,
|
| 554 |
+
summary="Get a bidder by ID",
|
| 555 |
+
response_description="Bidder details"
|
| 556 |
+
)
|
| 557 |
+
def get_bidder(bidder_id: int, db: Session = Depends(get_db)):
|
| 558 |
+
"""Get a specific bidder by ID"""
|
| 559 |
+
try:
|
| 560 |
+
bidder_service = BidderService(db)
|
| 561 |
+
return bidder_service.get(bidder_id)
|
| 562 |
+
except Exception as e:
|
| 563 |
+
logger.error(f"Error getting bidder {bidder_id}: {e}")
|
| 564 |
+
raise HTTPException(
|
| 565 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 566 |
+
detail=f"Bidder {bidder_id} not found"
|
| 567 |
+
)
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
@router.post(
|
| 571 |
+
"/",
|
| 572 |
+
response_model=BidderOut,
|
| 573 |
+
status_code=status.HTTP_201_CREATED,
|
| 574 |
+
summary="Create a new bidder",
|
| 575 |
+
response_description="Created bidder details"
|
| 576 |
+
)
|
| 577 |
+
def create_bidder(
|
| 578 |
+
bidder_in: BidderCreate,
|
| 579 |
+
proj_no: str = Query(..., description=PROJ_NO_DESC),
|
| 580 |
+
db: Session = Depends(get_db)
|
| 581 |
+
):
|
| 582 |
+
"""
|
| 583 |
+
Create a new bidder with optional contact associations.
|
| 584 |
+
|
| 585 |
+
To associate multiple contacts with the bidder during creation,
|
| 586 |
+
include a 'contact_ids' field in the request body with a list of contact IDs.
|
| 587 |
+
The contact_ids field is OPTIONAL - if not provided, the bidder will be
|
| 588 |
+
created without any contact associations.
|
| 589 |
+
|
| 590 |
+
Example request body:
|
| 591 |
+
{
|
| 592 |
+
"cust_id": 1,
|
| 593 |
+
"quote": 5000.00,
|
| 594 |
+
"notes": "Project notes",
|
| 595 |
+
"email_address": "test@example.com",
|
| 596 |
+
"contact_ids": [1, 2, 3] // OPTIONAL: List of contact IDs to associate
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
This will create the bidder and automatically create entries in the
|
| 600 |
+
BidderContact table linking the bidder to the specified contacts.
|
| 601 |
+
If contact_ids is omitted, only the bidder record is created.
|
| 602 |
+
"""
|
| 603 |
+
try:
|
| 604 |
+
bidder_service = BidderService(db)
|
| 605 |
+
logger.info(f"Creating new bidder for project {proj_no}")
|
| 606 |
+
|
| 607 |
+
# Update the bidder data with the project number
|
| 608 |
+
bidder_data = bidder_in.dict()
|
| 609 |
+
bidder_data["proj_no"] = proj_no
|
| 610 |
+
|
| 611 |
+
result = bidder_service.create(bidder_data)
|
| 612 |
+
logger.info(f"Successfully created bidder {result.id} for project {proj_no}")
|
| 613 |
+
return result
|
| 614 |
+
except ValueError as e:
|
| 615 |
+
logger.error(f"Validation error creating bidder: {e}")
|
| 616 |
+
raise HTTPException(
|
| 617 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 618 |
+
detail=str(e)
|
| 619 |
+
)
|
| 620 |
+
except Exception as e:
|
| 621 |
+
logger.error(f"Error creating bidder: {e}")
|
| 622 |
+
raise HTTPException(
|
| 623 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 624 |
+
detail="Failed to create bidder"
|
| 625 |
+
)
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
@router.put(
|
| 629 |
+
"/{bidder_id}",
|
| 630 |
+
response_model=BidderOut,
|
| 631 |
+
summary="Update a bidder",
|
| 632 |
+
response_description="Updated bidder details"
|
| 633 |
+
)
|
| 634 |
+
def update_bidder(
|
| 635 |
+
bidder_id: int,
|
| 636 |
+
bidder_in: BidderCreate,
|
| 637 |
+
proj_no: str = Query(..., description=PROJ_NO_DESC),
|
| 638 |
+
db: Session = Depends(get_db)
|
| 639 |
+
):
|
| 640 |
+
"""Update an existing bidder"""
|
| 641 |
+
try:
|
| 642 |
+
bidder_service = BidderService(db)
|
| 643 |
+
logger.info(f"Updating bidder {bidder_id} for project {proj_no}")
|
| 644 |
+
|
| 645 |
+
# Update the bidder data with the project number
|
| 646 |
+
bidder_data = bidder_in.dict()
|
| 647 |
+
bidder_data["proj_no"] = proj_no
|
| 648 |
+
|
| 649 |
+
result = bidder_service.update(bidder_id, bidder_data)
|
| 650 |
+
logger.info(f"Successfully updated bidder {bidder_id} for project {proj_no}")
|
| 651 |
+
return result
|
| 652 |
+
except ValueError as e:
|
| 653 |
+
logger.error(f"Validation error updating bidder {bidder_id}: {e}")
|
| 654 |
+
raise HTTPException(
|
| 655 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 656 |
+
detail=str(e)
|
| 657 |
+
)
|
| 658 |
+
except Exception as e:
|
| 659 |
+
logger.error(f"Error updating bidder {bidder_id}: {e}")
|
| 660 |
+
raise HTTPException(
|
| 661 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 662 |
+
detail="Failed to update bidder"
|
| 663 |
+
)
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
@router.delete(
|
| 667 |
+
"/{bidder_id}",
|
| 668 |
+
status_code=status.HTTP_204_NO_CONTENT,
|
| 669 |
+
summary="Delete a bidder",
|
| 670 |
+
response_description="Bidder deleted successfully"
|
| 671 |
+
)
|
| 672 |
+
def delete_bidder(
|
| 673 |
+
bidder_id: int,
|
| 674 |
+
proj_no: str = Query(..., description=PROJ_NO_DESC),
|
| 675 |
+
db: Session = Depends(get_db)
|
| 676 |
+
):
|
| 677 |
+
"""Delete a bidder"""
|
| 678 |
+
try:
|
| 679 |
+
bidder_service = BidderService(db)
|
| 680 |
+
logger.info(f"Deleting bidder {bidder_id} for project {proj_no}")
|
| 681 |
+
|
| 682 |
+
# First verify the bidder belongs to the specified project
|
| 683 |
+
bidder = bidder_service.get(bidder_id)
|
| 684 |
+
if bidder.proj_no != proj_no:
|
| 685 |
+
raise ValueError(f"Bidder {bidder_id} does not belong to project {proj_no}")
|
| 686 |
+
|
| 687 |
+
bidder_service.delete(bidder_id)
|
| 688 |
+
logger.info(f"Successfully deleted bidder {bidder_id} for project {proj_no}")
|
| 689 |
+
return None
|
| 690 |
+
except Exception as e:
|
| 691 |
+
logger.error(f"Error deleting bidder {bidder_id}: {e}")
|
| 692 |
+
raise HTTPException(
|
| 693 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 694 |
+
detail="Failed to delete bidder"
|
| 695 |
+
)
|
app/controllers/customers.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, status, Query, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.db.session import get_db
|
| 4 |
+
from app.services.customer_service import CustomerService
|
| 5 |
+
from app.services.customer_list_service import CustomerListService
|
| 6 |
+
from app.services.contact_service import ContactService
|
| 7 |
+
from app.services.address_service import AddressService
|
| 8 |
+
from app.services.project_service import ProjectService
|
| 9 |
+
from app.schemas.customer import CustomerCreate, CustomerOut, CustomerUpdate
|
| 10 |
+
from app.schemas.contact import ContactCreate, ContactOut
|
| 11 |
+
from app.schemas.address import AddressCreate, AddressOut
|
| 12 |
+
from app.schemas.project_detail import ProjectCustomerOut
|
| 13 |
+
from app.schemas.paginated_response import PaginatedResponse
|
| 14 |
+
from typing import List, Optional
|
| 15 |
+
import logging
|
| 16 |
+
import os
|
| 17 |
+
from app.db.models.address import Address
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
router = APIRouter(prefix="/api/v1/customers", tags=["customers"])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _customer_model_to_response(customer):
|
| 25 |
+
"""Map a SQLAlchemy Customer model instance to the API response dict."""
|
| 26 |
+
if not customer:
|
| 27 |
+
return None
|
| 28 |
+
return {
|
| 29 |
+
'id': getattr(customer, 'CustomerID', None),
|
| 30 |
+
'name': getattr(customer, 'CompanyName', None),
|
| 31 |
+
'address': getattr(customer, 'Address', None),
|
| 32 |
+
'city': getattr(customer, 'City', None),
|
| 33 |
+
'postal_code': getattr(customer, 'PostalCode', None),
|
| 34 |
+
'web_address': getattr(customer, 'WebAddress', None),
|
| 35 |
+
'referral': getattr(customer, 'Referral', None),
|
| 36 |
+
'company_type_id': getattr(customer, 'CompanyTypeID', None),
|
| 37 |
+
'state_id': getattr(customer, 'StateID', None),
|
| 38 |
+
'country_id': getattr(customer, 'CountryID', None),
|
| 39 |
+
'lead_generated_from_id': getattr(customer, 'LeadGeneratedFromID', None),
|
| 40 |
+
'specific_source': getattr(customer, 'SpecificSource', None),
|
| 41 |
+
'priority_id': getattr(customer, 'PriorityID', None),
|
| 42 |
+
'followup_date': getattr(customer, 'FollowupDate', None),
|
| 43 |
+
'purchase': getattr(customer, 'Purchase', None),
|
| 44 |
+
'vendor_id': getattr(customer, 'VendorID', None),
|
| 45 |
+
'enabled': getattr(customer, 'Enabled', None),
|
| 46 |
+
'rental_type': getattr(customer, 'RentalType', None),
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
@router.get(
|
| 50 |
+
"/",
|
| 51 |
+
response_model=PaginatedResponse[CustomerOut],
|
| 52 |
+
summary="List customers with pagination and ordering",
|
| 53 |
+
response_description="Paginated list of customers"
|
| 54 |
+
)
|
| 55 |
+
def list_customers(
|
| 56 |
+
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
| 57 |
+
page_size: int = Query(10, ge=1, le=100, description="Number of items per page"),
|
| 58 |
+
order_by: Optional[str] = Query("CustomerID", description="Field to order by (CustomerID, CompanyName, Address, City, etc.)"),
|
| 59 |
+
order_dir: Optional[str] = Query("desc", description="Order direction (asc|desc)"),
|
| 60 |
+
name: Optional[str] = Query(None, description="Filter by customer name (wildcard search, case-insensitive)"),
|
| 61 |
+
db: Session = Depends(get_db)
|
| 62 |
+
):
|
| 63 |
+
try:
|
| 64 |
+
logger.info(f"Listing customers: page={page}, page_size={page_size}, order_by={order_by}, order_dir={order_dir}, name={name}")
|
| 65 |
+
service = CustomerListService(db)
|
| 66 |
+
result = service.list_customers(page=page, page_size=page_size, order_by=order_by, order_dir=order_dir, name=name)
|
| 67 |
+
logger.info(f"Successfully retrieved customers: {len(result.items)} items")
|
| 68 |
+
return result
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"Error listing customers: {e}")
|
| 71 |
+
# Return empty result instead of 500 error
|
| 72 |
+
return PaginatedResponse[CustomerOut](
|
| 73 |
+
items=[],
|
| 74 |
+
page=page,
|
| 75 |
+
page_size=page_size,
|
| 76 |
+
total=0
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
@router.get("/{customer_id}", response_model=CustomerOut)
|
| 80 |
+
def get_customer(customer_id: int, db: Session = Depends(get_db)):
|
| 81 |
+
service = CustomerService(db)
|
| 82 |
+
customer = service.get(customer_id)
|
| 83 |
+
return _customer_model_to_response(customer)
|
| 84 |
+
|
| 85 |
+
@router.post("/", response_model=CustomerOut, status_code=status.HTTP_201_CREATED)
|
| 86 |
+
def create_customer(customer_in: CustomerCreate, db: Session = Depends(get_db)):
|
| 87 |
+
service = CustomerService(db)
|
| 88 |
+
try:
|
| 89 |
+
customer = service.create(customer_in.dict())
|
| 90 |
+
response = _customer_model_to_response(customer)
|
| 91 |
+
return response
|
| 92 |
+
except ValueError as e:
|
| 93 |
+
logger.error(f"Validation error creating customer: {e}")
|
| 94 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 95 |
+
|
| 96 |
+
@router.put("/{customer_id}", response_model=CustomerOut)
|
| 97 |
+
def update_customer(customer_id: int, customer_in: CustomerUpdate, db: Session = Depends(get_db)):
|
| 98 |
+
"""Update a customer. Accepts partial updates; only provided fields will be changed."""
|
| 99 |
+
service = CustomerService(db)
|
| 100 |
+
try:
|
| 101 |
+
# Only include fields explicitly set by the client to avoid Pydantic-required fields causing validation errors
|
| 102 |
+
update_data = customer_in.dict(exclude_unset=True)
|
| 103 |
+
customer = service.update(customer_id, update_data)
|
| 104 |
+
response = _customer_model_to_response(customer)
|
| 105 |
+
return response
|
| 106 |
+
except ValueError as e:
|
| 107 |
+
logger.error(f"Validation error updating customer {customer_id}: {e}")
|
| 108 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 109 |
+
|
| 110 |
+
@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 111 |
+
def delete_customer(customer_id: int, db: Session = Depends(get_db)):
|
| 112 |
+
service = CustomerService(db)
|
| 113 |
+
service.delete(customer_id)
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
# Address-related endpoints (moved from app/controllers/addresses.py)
|
| 117 |
+
@router.get(
|
| 118 |
+
"/{customer_id}/addresses",
|
| 119 |
+
response_model=PaginatedResponse[AddressOut],
|
| 120 |
+
summary="List addresses for a customer",
|
| 121 |
+
response_description="Paginated list of addresses for a customer"
|
| 122 |
+
)
|
| 123 |
+
def list_addresses(
|
| 124 |
+
customer_id: int,
|
| 125 |
+
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
| 126 |
+
page_size: int = Query(10, ge=1, le=100, description="Number of items per page"),
|
| 127 |
+
order_by: Optional[str] = Query("Id", description="Field to order by"),
|
| 128 |
+
order_dir: Optional[str] = Query("asc", description="Order direction (asc|desc)"),
|
| 129 |
+
db: Session = Depends(get_db)
|
| 130 |
+
):
|
| 131 |
+
try:
|
| 132 |
+
service = AddressService(db)
|
| 133 |
+
result = service.list(page=page, page_size=page_size, order_by=order_by, order_dir=order_dir, customer_id=customer_id)
|
| 134 |
+
return result
|
| 135 |
+
except Exception as e:
|
| 136 |
+
logger.error(f"Error listing addresses for customer {customer_id}: {e}")
|
| 137 |
+
return PaginatedResponse[AddressOut](items=[], page=page, page_size=page_size, total=0)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@router.get("/{customer_id}/addresses/{address_id}", response_model=AddressOut, summary="Get address by id")
|
| 143 |
+
def get_address(customer_id: int, address_id: int, db: Session = Depends(get_db)):
|
| 144 |
+
try:
|
| 145 |
+
service = AddressService(db)
|
| 146 |
+
addr = service.get(address_id)
|
| 147 |
+
if addr.customer_id != customer_id:
|
| 148 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Address {address_id} not found for customer {customer_id}")
|
| 149 |
+
return addr
|
| 150 |
+
except HTTPException:
|
| 151 |
+
raise
|
| 152 |
+
except Exception as e:
|
| 153 |
+
logger.error(f"Error getting address {address_id}: {e}")
|
| 154 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Address {address_id} not found")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@router.post("/{customer_id}/addresses", response_model=AddressOut, status_code=status.HTTP_201_CREATED)
|
| 158 |
+
def create_address(customer_id: int, address_in: AddressCreate, db: Session = Depends(get_db)):
|
| 159 |
+
try:
|
| 160 |
+
service = AddressService(db)
|
| 161 |
+
data = address_in.dict()
|
| 162 |
+
data['customer_id'] = customer_id
|
| 163 |
+
result = service.create(data)
|
| 164 |
+
return result
|
| 165 |
+
except ValueError as e:
|
| 166 |
+
logger.error(f"Validation error creating address for customer {customer_id}: {e}")
|
| 167 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.error(f"Error creating address for customer {customer_id}: {e}")
|
| 170 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create address")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@router.put("/{customer_id}/addresses/{address_id}", response_model=AddressOut)
|
| 174 |
+
def update_address(customer_id: int, address_id: int, address_in: AddressCreate, db: Session = Depends(get_db)):
|
| 175 |
+
try:
|
| 176 |
+
service = AddressService(db)
|
| 177 |
+
existing = service.get(address_id)
|
| 178 |
+
if existing.customer_id != customer_id:
|
| 179 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Address {address_id} not found for customer {customer_id}")
|
| 180 |
+
|
| 181 |
+
update_data = address_in.dict(exclude_unset=True)
|
| 182 |
+
update_data['customer_id'] = customer_id
|
| 183 |
+
result = service.update(address_id, update_data)
|
| 184 |
+
return result
|
| 185 |
+
except HTTPException:
|
| 186 |
+
raise
|
| 187 |
+
except ValueError as e:
|
| 188 |
+
logger.error(f"Validation error updating address {address_id}: {e}")
|
| 189 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logger.error(f"Error updating address {address_id}: {e}")
|
| 192 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Address {address_id} not found")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@router.delete("/{customer_id}/addresses/{address_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 196 |
+
def delete_address(customer_id: int, address_id: int, db: Session = Depends(get_db)):
|
| 197 |
+
try:
|
| 198 |
+
service = AddressService(db)
|
| 199 |
+
existing = service.get(address_id)
|
| 200 |
+
if existing.customer_id != customer_id:
|
| 201 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Address {address_id} not found for customer {customer_id}")
|
| 202 |
+
|
| 203 |
+
service.delete(address_id)
|
| 204 |
+
return None
|
| 205 |
+
except HTTPException:
|
| 206 |
+
raise
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.error(f"Error deleting address {address_id}: {e}")
|
| 209 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete address")
|
| 210 |
+
|
| 211 |
+
# Contact-related endpoints
|
| 212 |
+
@router.get(
|
| 213 |
+
"/{customer_id}/contacts",
|
| 214 |
+
response_model=PaginatedResponse[ContactOut],
|
| 215 |
+
summary="List contacts for a specific customer",
|
| 216 |
+
response_description="Paginated list of customer contacts"
|
| 217 |
+
)
|
| 218 |
+
def list_customer_contacts(
|
| 219 |
+
customer_id: int,
|
| 220 |
+
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
| 221 |
+
page_size: int = Query(10, ge=1, le=100, description="Number of items per page"),
|
| 222 |
+
order_by: Optional[str] = Query("ContactID", description="Field to order by (ContactID, FirstName, LastName, etc.)"),
|
| 223 |
+
order_dir: Optional[str] = Query("asc", description="Order direction (asc|desc)"),
|
| 224 |
+
db: Session = Depends(get_db)
|
| 225 |
+
):
|
| 226 |
+
"""Get all contacts for a specific customer with pagination and ordering"""
|
| 227 |
+
try:
|
| 228 |
+
logger.info(f"Listing contacts for customer {customer_id}: page={page}, page_size={page_size}")
|
| 229 |
+
contact_service = ContactService(db)
|
| 230 |
+
result = contact_service.get_by_customer_id(
|
| 231 |
+
customer_id=customer_id,
|
| 232 |
+
page=page,
|
| 233 |
+
page_size=page_size,
|
| 234 |
+
order_by=order_by,
|
| 235 |
+
order_dir=order_dir
|
| 236 |
+
)
|
| 237 |
+
logger.info(f"Successfully retrieved {len(result.items)} contacts for customer {customer_id}")
|
| 238 |
+
return result
|
| 239 |
+
except Exception as e:
|
| 240 |
+
logger.error(f"Error listing contacts for customer {customer_id}: {e}")
|
| 241 |
+
return PaginatedResponse[ContactOut](
|
| 242 |
+
items=[],
|
| 243 |
+
page=page,
|
| 244 |
+
page_size=page_size,
|
| 245 |
+
total=0
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
@router.get(
|
| 249 |
+
"/{customer_id}/contacts/{contact_id}",
|
| 250 |
+
response_model=ContactOut,
|
| 251 |
+
summary="Get a specific contact for a customer",
|
| 252 |
+
response_description="Contact details"
|
| 253 |
+
)
|
| 254 |
+
def get_customer_contact(
|
| 255 |
+
customer_id: int,
|
| 256 |
+
contact_id: int,
|
| 257 |
+
db: Session = Depends(get_db)
|
| 258 |
+
):
|
| 259 |
+
"""Get a specific contact by ID (validates it belongs to the customer)"""
|
| 260 |
+
try:
|
| 261 |
+
contact_service = ContactService(db)
|
| 262 |
+
contact = contact_service.get(contact_id)
|
| 263 |
+
|
| 264 |
+
# Verify the contact belongs to the specified customer
|
| 265 |
+
if contact.customer_id != customer_id:
|
| 266 |
+
raise HTTPException(
|
| 267 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 268 |
+
detail=f"Contact {contact_id} not found for customer {customer_id}"
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
return contact
|
| 272 |
+
except HTTPException:
|
| 273 |
+
raise
|
| 274 |
+
except Exception as e:
|
| 275 |
+
logger.error(f"Error getting contact {contact_id} for customer {customer_id}: {e}")
|
| 276 |
+
raise HTTPException(
|
| 277 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 278 |
+
detail=f"Contact {contact_id} not found for customer {customer_id}"
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
@router.post(
|
| 282 |
+
"/{customer_id}/contacts",
|
| 283 |
+
response_model=ContactOut,
|
| 284 |
+
status_code=status.HTTP_201_CREATED,
|
| 285 |
+
summary="Create a new contact for a customer",
|
| 286 |
+
response_description="Created contact details"
|
| 287 |
+
)
|
| 288 |
+
def create_customer_contact(
|
| 289 |
+
customer_id: int,
|
| 290 |
+
contact_in: ContactCreate,
|
| 291 |
+
db: Session = Depends(get_db)
|
| 292 |
+
):
|
| 293 |
+
"""Create a new contact for the specified customer"""
|
| 294 |
+
try:
|
| 295 |
+
# Ensure the contact is associated with the correct customer
|
| 296 |
+
contact_data = contact_in.dict()
|
| 297 |
+
contact_data['customer_id'] = customer_id
|
| 298 |
+
|
| 299 |
+
contact_service = ContactService(db)
|
| 300 |
+
|
| 301 |
+
result = contact_service.create(contact_data)
|
| 302 |
+
return result
|
| 303 |
+
except ValueError as e:
|
| 304 |
+
raise HTTPException(
|
| 305 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 306 |
+
detail=str(e)
|
| 307 |
+
)
|
| 308 |
+
except Exception as e:
|
| 309 |
+
raise HTTPException(
|
| 310 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 311 |
+
detail="Failed to create contact"
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
@router.put(
|
| 315 |
+
"/{customer_id}/contacts/{contact_id}",
|
| 316 |
+
response_model=ContactOut,
|
| 317 |
+
summary="Update a customer contact",
|
| 318 |
+
response_description="Updated contact details"
|
| 319 |
+
)
|
| 320 |
+
def update_customer_contact(
|
| 321 |
+
customer_id: int,
|
| 322 |
+
contact_id: int,
|
| 323 |
+
contact_in: ContactCreate,
|
| 324 |
+
db: Session = Depends(get_db)
|
| 325 |
+
):
|
| 326 |
+
"""Update an existing contact for the specified customer"""
|
| 327 |
+
try:
|
| 328 |
+
contact_service = ContactService(db)
|
| 329 |
+
|
| 330 |
+
# First verify the contact exists and belongs to the customer
|
| 331 |
+
existing_contact = contact_service.get(contact_id)
|
| 332 |
+
if existing_contact.customer_id != customer_id:
|
| 333 |
+
raise HTTPException(
|
| 334 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 335 |
+
detail=f"Contact {contact_id} not found for customer {customer_id}"
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
# Set the customer_id to ensure it doesn't change
|
| 339 |
+
contact_data = contact_in.dict()
|
| 340 |
+
contact_data['customer_id'] = customer_id
|
| 341 |
+
|
| 342 |
+
logger.info(f"Updating contact {contact_id} for customer {customer_id}")
|
| 343 |
+
result = contact_service.update(contact_id, contact_data)
|
| 344 |
+
logger.info(f"Successfully updated contact {contact_id} for customer {customer_id}")
|
| 345 |
+
return result
|
| 346 |
+
except HTTPException:
|
| 347 |
+
raise
|
| 348 |
+
except ValueError as e:
|
| 349 |
+
logger.error(f"Validation error updating contact {contact_id} for customer {customer_id}: {e}")
|
| 350 |
+
raise HTTPException(
|
| 351 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 352 |
+
detail=str(e)
|
| 353 |
+
)
|
| 354 |
+
except Exception as e:
|
| 355 |
+
logger.error(f"Error updating contact {contact_id} for customer {customer_id}: {e}")
|
| 356 |
+
raise HTTPException(
|
| 357 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 358 |
+
detail="Failed to update contact"
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
@router.delete(
|
| 362 |
+
"/{customer_id}/contacts/{contact_id}",
|
| 363 |
+
status_code=status.HTTP_204_NO_CONTENT,
|
| 364 |
+
summary="Delete a customer contact",
|
| 365 |
+
response_description="Contact deleted successfully"
|
| 366 |
+
)
|
| 367 |
+
def delete_customer_contact(
|
| 368 |
+
customer_id: int,
|
| 369 |
+
contact_id: int,
|
| 370 |
+
db: Session = Depends(get_db)
|
| 371 |
+
):
|
| 372 |
+
"""Delete a contact for the specified customer"""
|
| 373 |
+
try:
|
| 374 |
+
contact_service = ContactService(db)
|
| 375 |
+
|
| 376 |
+
# First verify the contact exists and belongs to the customer
|
| 377 |
+
existing_contact = contact_service.get(contact_id)
|
| 378 |
+
if existing_contact.customer_id != customer_id:
|
| 379 |
+
raise HTTPException(
|
| 380 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 381 |
+
detail=f"Contact {contact_id} not found for customer {customer_id}"
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
logger.info(f"Deleting contact {contact_id} for customer {customer_id}")
|
| 385 |
+
contact_service.delete(contact_id)
|
| 386 |
+
logger.info(f"Successfully deleted contact {contact_id} for customer {customer_id}")
|
| 387 |
+
return None
|
| 388 |
+
except HTTPException:
|
| 389 |
+
raise
|
| 390 |
+
except Exception as e:
|
| 391 |
+
logger.error(f"Error deleting contact {contact_id} for customer {customer_id}: {e}")
|
| 392 |
+
raise HTTPException(
|
| 393 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 394 |
+
detail="Failed to delete contact"
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
# Project-related endpoints
|
| 398 |
+
@router.get("/{customer_id}/projects", response_model=List[ProjectCustomerOut])
|
| 399 |
+
def get_customer_projects(
|
| 400 |
+
customer_id: str,
|
| 401 |
+
page: Optional[int] = Query(1, description="Page number (1-indexed)", ge=1),
|
| 402 |
+
page_size: Optional[int] = Query(100, description="Number of records per page", ge=1, le=1000),
|
| 403 |
+
db: Session = Depends(get_db)
|
| 404 |
+
):
|
| 405 |
+
"""Get all projects associated with a specific customer
|
| 406 |
+
|
| 407 |
+
Returns a paginated list of all project-bidder relationships for the specified
|
| 408 |
+
customer ID, including complete details (barrier sizes, contacts, notes) for each.
|
| 409 |
+
|
| 410 |
+
- **customer_id**: The customer ID (CustId from Bidders table)
|
| 411 |
+
- **page**: Page number starting from 1
|
| 412 |
+
- **page_size**: Number of records per page (max 1000)
|
| 413 |
+
"""
|
| 414 |
+
service = ProjectService(db)
|
| 415 |
+
return service.get_customer_projects(customer_id, page=page, page_size=page_size)
|
| 416 |
+
|
| 417 |
+
|
app/controllers/dashboard.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from typing import List
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from sqlalchemy import func, or_, and_
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from app.schemas.dashboard import InfoWidget
|
| 7 |
+
from app.db.session import get_db
|
| 8 |
+
from app.db.models.project import Project
|
| 9 |
+
from app.core.timing import timer
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"])
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("/widgets/info", response_model=List[InfoWidget], summary="Dashboard info widgets")
|
| 17 |
+
def get_info_widgets(db: Session = Depends(get_db)) -> List[InfoWidget]:
|
| 18 |
+
"""Return live counts for dashboard info widgets.
|
| 19 |
+
|
| 20 |
+
Definitions:
|
| 21 |
+
- Projects Executed: projects with InstallDate within the last 7 days.
|
| 22 |
+
- New Projects: projects with CreatedDate within the last 7 days.
|
| 23 |
+
- Active Projects: projects where Status = 5 (per StatusInfo mapping).
|
| 24 |
+
|
| 25 |
+
Deltas compare the last 7 days vs the prior 7-day window for the first two metrics.
|
| 26 |
+
"""
|
| 27 |
+
logger.debug("Fetching dashboard info widgets")
|
| 28 |
+
|
| 29 |
+
now = datetime.utcnow()
|
| 30 |
+
current_start = now - timedelta(days=7)
|
| 31 |
+
prev_start = now - timedelta(days=14)
|
| 32 |
+
prev_end = current_start
|
| 33 |
+
|
| 34 |
+
def pct_change(curr: int, prev: int) -> float:
|
| 35 |
+
if prev is None or prev == 0:
|
| 36 |
+
return 0.0
|
| 37 |
+
return round(((curr - prev) / prev) * 100.0, 2)
|
| 38 |
+
|
| 39 |
+
# Projects Executed: InstallDate in last 7 days
|
| 40 |
+
with timer("Dashboard: Projects Executed query"):
|
| 41 |
+
executed_current = (
|
| 42 |
+
db.query(func.count(Project.project_no))
|
| 43 |
+
.filter(Project.install_date >= current_start, Project.install_date < now)
|
| 44 |
+
.scalar()
|
| 45 |
+
or 0
|
| 46 |
+
)
|
| 47 |
+
executed_prev = (
|
| 48 |
+
db.query(func.count(Project.project_no))
|
| 49 |
+
.filter(Project.install_date >= prev_start, Project.install_date < prev_end)
|
| 50 |
+
.scalar()
|
| 51 |
+
or 0
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# New Projects: CreatedDate in last 7 days
|
| 55 |
+
with timer("Dashboard: New Projects query"):
|
| 56 |
+
new_current = (
|
| 57 |
+
db.query(func.count(Project.project_no))
|
| 58 |
+
.filter(Project.created_date >= current_start, Project.created_date < now)
|
| 59 |
+
.scalar()
|
| 60 |
+
or 0
|
| 61 |
+
)
|
| 62 |
+
new_prev = (
|
| 63 |
+
db.query(func.count(Project.project_no))
|
| 64 |
+
.filter(Project.created_date >= prev_start, Project.created_date < prev_end)
|
| 65 |
+
.scalar()
|
| 66 |
+
or 0
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Active Projects: Status = 5 (from StatusInfo)
|
| 70 |
+
ACTIVE_STATUS_ID = 5
|
| 71 |
+
with timer("Dashboard: Active Projects query"):
|
| 72 |
+
active_current = (
|
| 73 |
+
db.query(func.count(Project.project_no))
|
| 74 |
+
.filter(Project.status == ACTIVE_STATUS_ID)
|
| 75 |
+
.scalar()
|
| 76 |
+
or 0
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
widgets: List[InfoWidget] = [
|
| 80 |
+
InfoWidget(
|
| 81 |
+
label="Projects Executed",
|
| 82 |
+
value=int(executed_current),
|
| 83 |
+
subtitle="Last 7 days",
|
| 84 |
+
delta_percent=pct_change(int(executed_current), int(executed_prev)),
|
| 85 |
+
delta_from="from last week",
|
| 86 |
+
),
|
| 87 |
+
InfoWidget(
|
| 88 |
+
label="New Projects",
|
| 89 |
+
value=int(new_current),
|
| 90 |
+
subtitle="Last 7 days",
|
| 91 |
+
delta_percent=pct_change(int(new_current), int(new_prev)),
|
| 92 |
+
delta_from="from last week",
|
| 93 |
+
),
|
| 94 |
+
InfoWidget(
|
| 95 |
+
label="Active Projects",
|
| 96 |
+
value=int(active_current),
|
| 97 |
+
subtitle="Currently in progress",
|
| 98 |
+
# Without historical snapshots, prior active count is unknown; report 0% change.
|
| 99 |
+
delta_percent=0.0,
|
| 100 |
+
delta_from="from last week",
|
| 101 |
+
),
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
return widgets
|
app/controllers/distributors.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, status, Query, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.db.session import get_db
|
| 4 |
+
from app.services.distributor_service import DistributorService
|
| 5 |
+
from app.schemas.distributor import DistributorCreate, DistributorOut
|
| 6 |
+
from app.schemas.paginated_response import PaginatedResponse
|
| 7 |
+
from typing import Optional, List
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# Common query parameter descriptions
|
| 13 |
+
PAGE_DESC = "Page number (1-indexed)"
|
| 14 |
+
PAGE_SIZE_DESC = "Number of items per page"
|
| 15 |
+
ORDER_BY_DESC = "Field to order by"
|
| 16 |
+
ORDER_DIR_DESC = "Order direction (asc|desc)"
|
| 17 |
+
TERRITORY_DESC = "Territory/Region name"
|
| 18 |
+
STATUS_DESC = "Distributor status (active|inactive|pending)"
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api/v1/distributors", tags=["distributors"])
|
| 21 |
+
|
| 22 |
+
@router.get(
|
| 23 |
+
"/",
|
| 24 |
+
response_model=PaginatedResponse[DistributorOut],
|
| 25 |
+
summary="List all distributors",
|
| 26 |
+
response_description="Paginated list of distributors"
|
| 27 |
+
)
|
| 28 |
+
def list_distributors(
|
| 29 |
+
page: int = Query(1, ge=1, description=PAGE_DESC),
|
| 30 |
+
page_size: int = Query(10, ge=1, le=100, description=PAGE_SIZE_DESC),
|
| 31 |
+
order_by: Optional[str] = Query("Id", description=ORDER_BY_DESC),
|
| 32 |
+
order_dir: Optional[str] = Query("asc", description=ORDER_DIR_DESC),
|
| 33 |
+
db: Session = Depends(get_db)
|
| 34 |
+
):
|
| 35 |
+
"""Get all distributors with pagination and ordering"""
|
| 36 |
+
try:
|
| 37 |
+
logger.info(f"Listing distributors: page={page}, page_size={page_size}")
|
| 38 |
+
distributor_service = DistributorService(db)
|
| 39 |
+
result = distributor_service.list(
|
| 40 |
+
page=page,
|
| 41 |
+
page_size=page_size,
|
| 42 |
+
order_by=order_by,
|
| 43 |
+
order_direction=order_dir.upper()
|
| 44 |
+
)
|
| 45 |
+
logger.info(f"Successfully retrieved {len(result.items)} distributors")
|
| 46 |
+
return result
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error(f"Error listing distributors: {e}")
|
| 49 |
+
return PaginatedResponse[DistributorOut](
|
| 50 |
+
items=[],
|
| 51 |
+
page=page,
|
| 52 |
+
page_size=page_size,
|
| 53 |
+
total=0
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
@router.get(
|
| 57 |
+
"/territory/{territory}",
|
| 58 |
+
response_model=PaginatedResponse[DistributorOut],
|
| 59 |
+
summary="List distributors by territory",
|
| 60 |
+
response_description="Paginated list of distributors for a specific territory"
|
| 61 |
+
)
|
| 62 |
+
def list_distributors_by_territory(
|
| 63 |
+
territory: str,
|
| 64 |
+
page: int = Query(1, ge=1, description=PAGE_DESC),
|
| 65 |
+
page_size: int = Query(10, ge=1, le=100, description=PAGE_SIZE_DESC),
|
| 66 |
+
order_by: Optional[str] = Query("Id", description=ORDER_BY_DESC),
|
| 67 |
+
order_dir: Optional[str] = Query("asc", description=ORDER_DIR_DESC),
|
| 68 |
+
db: Session = Depends(get_db)
|
| 69 |
+
):
|
| 70 |
+
"""Get all distributors for a specific territory with pagination and ordering"""
|
| 71 |
+
try:
|
| 72 |
+
logger.info(f"Listing distributors for territory {territory}: page={page}, page_size={page_size}")
|
| 73 |
+
distributor_service = DistributorService(db)
|
| 74 |
+
result = distributor_service.get_by_territory(
|
| 75 |
+
territory=territory,
|
| 76 |
+
page=page,
|
| 77 |
+
page_size=page_size,
|
| 78 |
+
order_by=order_by,
|
| 79 |
+
order_dir=order_dir.upper()
|
| 80 |
+
)
|
| 81 |
+
logger.info(f"Successfully retrieved {len(result.items)} distributors for territory {territory}")
|
| 82 |
+
return result
|
| 83 |
+
except Exception as e:
|
| 84 |
+
logger.error(f"Error listing distributors for territory {territory}: {e}")
|
| 85 |
+
return PaginatedResponse[DistributorOut](
|
| 86 |
+
items=[],
|
| 87 |
+
page=page,
|
| 88 |
+
page_size=page_size,
|
| 89 |
+
total=0
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
@router.get(
|
| 93 |
+
"/status/{status}",
|
| 94 |
+
response_model=PaginatedResponse[DistributorOut],
|
| 95 |
+
summary="List distributors by status",
|
| 96 |
+
response_description="Paginated list of distributors with a specific status"
|
| 97 |
+
)
|
| 98 |
+
def list_distributors_by_status(
|
| 99 |
+
status: str,
|
| 100 |
+
page: int = Query(1, ge=1, description=PAGE_DESC),
|
| 101 |
+
page_size: int = Query(10, ge=1, le=100, description=PAGE_SIZE_DESC),
|
| 102 |
+
order_by: Optional[str] = Query("Id", description=ORDER_BY_DESC),
|
| 103 |
+
order_dir: Optional[str] = Query("asc", description=ORDER_DIR_DESC),
|
| 104 |
+
db: Session = Depends(get_db)
|
| 105 |
+
):
|
| 106 |
+
"""Get all distributors with a specific status with pagination and ordering"""
|
| 107 |
+
try:
|
| 108 |
+
logger.info(f"Listing distributors with status {status}: page={page}, page_size={page_size}")
|
| 109 |
+
distributor_service = DistributorService(db)
|
| 110 |
+
result = distributor_service.get_by_status(
|
| 111 |
+
status=status,
|
| 112 |
+
page=page,
|
| 113 |
+
page_size=page_size,
|
| 114 |
+
order_by=order_by,
|
| 115 |
+
order_dir=order_dir.upper()
|
| 116 |
+
)
|
| 117 |
+
logger.info(f"Successfully retrieved {len(result.items)} distributors with status {status}")
|
| 118 |
+
return result
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(f"Error listing distributors with status {status}: {e}")
|
| 121 |
+
return PaginatedResponse[DistributorOut](
|
| 122 |
+
items=[],
|
| 123 |
+
page=page,
|
| 124 |
+
page_size=page_size,
|
| 125 |
+
total=0
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
@router.get(
|
| 129 |
+
"/territories",
|
| 130 |
+
response_model=List[str],
|
| 131 |
+
summary="Get all territories",
|
| 132 |
+
response_description="List of unique territories"
|
| 133 |
+
)
|
| 134 |
+
def get_territories(db: Session = Depends(get_db)):
|
| 135 |
+
"""Get all unique territories"""
|
| 136 |
+
try:
|
| 137 |
+
distributor_service = DistributorService(db)
|
| 138 |
+
territories = distributor_service.get_territories()
|
| 139 |
+
logger.info(f"Successfully retrieved {len(territories)} territories")
|
| 140 |
+
return territories
|
| 141 |
+
except Exception as e:
|
| 142 |
+
logger.error(f"Error getting territories: {e}")
|
| 143 |
+
return []
|
| 144 |
+
|
| 145 |
+
@router.get(
|
| 146 |
+
"/{distributor_id}",
|
| 147 |
+
response_model=DistributorOut,
|
| 148 |
+
summary="Get a distributor by ID",
|
| 149 |
+
response_description="Distributor details"
|
| 150 |
+
)
|
| 151 |
+
def get_distributor(distributor_id: int, db: Session = Depends(get_db)):
|
| 152 |
+
"""Get a specific distributor by ID"""
|
| 153 |
+
try:
|
| 154 |
+
distributor_service = DistributorService(db)
|
| 155 |
+
return distributor_service.get(distributor_id)
|
| 156 |
+
except Exception as e:
|
| 157 |
+
logger.error(f"Error getting distributor {distributor_id}: {e}")
|
| 158 |
+
raise HTTPException(
|
| 159 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 160 |
+
detail=f"Distributor {distributor_id} not found"
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
@router.post(
|
| 164 |
+
"/",
|
| 165 |
+
response_model=DistributorOut,
|
| 166 |
+
status_code=status.HTTP_201_CREATED,
|
| 167 |
+
summary="Create a new distributor",
|
| 168 |
+
response_description="Created distributor details"
|
| 169 |
+
)
|
| 170 |
+
def create_distributor(
|
| 171 |
+
distributor_in: DistributorCreate,
|
| 172 |
+
db: Session = Depends(get_db)
|
| 173 |
+
):
|
| 174 |
+
"""Create a new distributor"""
|
| 175 |
+
try:
|
| 176 |
+
distributor_service = DistributorService(db)
|
| 177 |
+
logger.info(f"Creating new distributor: {distributor_in.company_name}")
|
| 178 |
+
|
| 179 |
+
result = distributor_service.create(distributor_in.dict())
|
| 180 |
+
logger.info(f"Successfully created distributor {result.id}: {result.company_name}")
|
| 181 |
+
return result
|
| 182 |
+
except ValueError as e:
|
| 183 |
+
logger.error(f"Validation error creating distributor: {e}")
|
| 184 |
+
raise HTTPException(
|
| 185 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 186 |
+
detail=str(e)
|
| 187 |
+
)
|
| 188 |
+
except Exception as e:
|
| 189 |
+
logger.error(f"Error creating distributor: {e}")
|
| 190 |
+
raise HTTPException(
|
| 191 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 192 |
+
detail="Failed to create distributor"
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
@router.put(
|
| 196 |
+
"/{distributor_id}",
|
| 197 |
+
response_model=DistributorOut,
|
| 198 |
+
summary="Update a distributor",
|
| 199 |
+
response_description="Updated distributor details"
|
| 200 |
+
)
|
| 201 |
+
def update_distributor(
|
| 202 |
+
distributor_id: int,
|
| 203 |
+
distributor_in: DistributorCreate,
|
| 204 |
+
db: Session = Depends(get_db)
|
| 205 |
+
):
|
| 206 |
+
"""Update an existing distributor"""
|
| 207 |
+
try:
|
| 208 |
+
distributor_service = DistributorService(db)
|
| 209 |
+
logger.info(f"Updating distributor {distributor_id}: {distributor_in.company_name}")
|
| 210 |
+
|
| 211 |
+
result = distributor_service.update(distributor_id, distributor_in.dict())
|
| 212 |
+
logger.info(f"Successfully updated distributor {distributor_id}: {result.company_name}")
|
| 213 |
+
return result
|
| 214 |
+
except ValueError as e:
|
| 215 |
+
logger.error(f"Validation error updating distributor {distributor_id}: {e}")
|
| 216 |
+
raise HTTPException(
|
| 217 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 218 |
+
detail=str(e)
|
| 219 |
+
)
|
| 220 |
+
except Exception as e:
|
| 221 |
+
logger.error(f"Error updating distributor {distributor_id}: {e}")
|
| 222 |
+
raise HTTPException(
|
| 223 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 224 |
+
detail="Failed to update distributor"
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
@router.delete(
|
| 228 |
+
"/{distributor_id}",
|
| 229 |
+
status_code=status.HTTP_204_NO_CONTENT,
|
| 230 |
+
summary="Delete a distributor",
|
| 231 |
+
response_description="Distributor deleted successfully"
|
| 232 |
+
)
|
| 233 |
+
def delete_distributor(
|
| 234 |
+
distributor_id: int,
|
| 235 |
+
db: Session = Depends(get_db)
|
| 236 |
+
):
|
| 237 |
+
"""Delete a distributor"""
|
| 238 |
+
try:
|
| 239 |
+
distributor_service = DistributorService(db)
|
| 240 |
+
logger.info(f"Deleting distributor {distributor_id}")
|
| 241 |
+
|
| 242 |
+
distributor_service.delete(distributor_id)
|
| 243 |
+
logger.info(f"Successfully deleted distributor {distributor_id}")
|
| 244 |
+
return None
|
| 245 |
+
except Exception as e:
|
| 246 |
+
logger.error(f"Error deleting distributor {distributor_id}: {e}")
|
| 247 |
+
raise HTTPException(
|
| 248 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 249 |
+
detail="Failed to delete distributor"
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
@router.post(
|
| 253 |
+
"/{distributor_id}/activate",
|
| 254 |
+
response_model=DistributorOut,
|
| 255 |
+
summary="Activate a distributor",
|
| 256 |
+
response_description="Activated distributor details"
|
| 257 |
+
)
|
| 258 |
+
def activate_distributor(
|
| 259 |
+
distributor_id: int,
|
| 260 |
+
db: Session = Depends(get_db)
|
| 261 |
+
):
|
| 262 |
+
"""Activate a distributor (set status to active)"""
|
| 263 |
+
try:
|
| 264 |
+
distributor_service = DistributorService(db)
|
| 265 |
+
logger.info(f"Activating distributor {distributor_id}")
|
| 266 |
+
|
| 267 |
+
result = distributor_service.activate(distributor_id)
|
| 268 |
+
logger.info(f"Successfully activated distributor {distributor_id}")
|
| 269 |
+
return result
|
| 270 |
+
except Exception as e:
|
| 271 |
+
logger.error(f"Error activating distributor {distributor_id}: {e}")
|
| 272 |
+
raise HTTPException(
|
| 273 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 274 |
+
detail="Failed to activate distributor"
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
@router.post(
|
| 278 |
+
"/{distributor_id}/deactivate",
|
| 279 |
+
response_model=DistributorOut,
|
| 280 |
+
summary="Deactivate a distributor",
|
| 281 |
+
response_description="Deactivated distributor details"
|
| 282 |
+
)
|
| 283 |
+
def deactivate_distributor(
|
| 284 |
+
distributor_id: int,
|
| 285 |
+
db: Session = Depends(get_db)
|
| 286 |
+
):
|
| 287 |
+
"""Deactivate a distributor (set status to inactive)"""
|
| 288 |
+
try:
|
| 289 |
+
distributor_service = DistributorService(db)
|
| 290 |
+
logger.info(f"Deactivating distributor {distributor_id}")
|
| 291 |
+
|
| 292 |
+
result = distributor_service.deactivate(distributor_id)
|
| 293 |
+
logger.info(f"Successfully deactivated distributor {distributor_id}")
|
| 294 |
+
return result
|
| 295 |
+
except Exception as e:
|
| 296 |
+
logger.error(f"Error deactivating distributor {distributor_id}: {e}")
|
| 297 |
+
raise HTTPException(
|
| 298 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 299 |
+
detail="Failed to deactivate distributor"
|
| 300 |
+
)
|
app/controllers/employees.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, status, Query, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.db.session import get_db
|
| 4 |
+
from app.services.employee_service import EmployeeService
|
| 5 |
+
from app.schemas.employee import EmployeeCreate, EmployeeOut, EmployeeUpdate
|
| 6 |
+
from app.schemas.paginated_response import PaginatedResponse
|
| 7 |
+
from typing import List, Optional
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/api/v1/employees", tags=["employees"])
|
| 13 |
+
|
| 14 |
+
@router.get("/", response_model=PaginatedResponse[EmployeeOut])
|
| 15 |
+
def list_employees(
|
| 16 |
+
order_by: Optional[str] = Query("last_name", description="Field to order by"),
|
| 17 |
+
order_direction: Optional[str] = Query("asc", description="Order direction: asc or desc", regex="^(asc|desc)$"),
|
| 18 |
+
page: Optional[int] = Query(1, description="Page number (1-indexed)", ge=1),
|
| 19 |
+
page_size: Optional[int] = Query(10, description="Number of records per page", ge=1, le=100),
|
| 20 |
+
db: Session = Depends(get_db)
|
| 21 |
+
):
|
| 22 |
+
"""
|
| 23 |
+
Get paginated list of employees with sorting.
|
| 24 |
+
|
| 25 |
+
- **order_by**: Field name to sort by (employee_id, last_name, first_name, etc.)
|
| 26 |
+
- **order_direction**: Sort direction (asc or desc)
|
| 27 |
+
- **page**: Page number starting from 1
|
| 28 |
+
- **page_size**: Number of records per page (max 100)
|
| 29 |
+
"""
|
| 30 |
+
service = EmployeeService(db)
|
| 31 |
+
return service.list_employees(
|
| 32 |
+
order_by=order_by,
|
| 33 |
+
order_direction=order_direction,
|
| 34 |
+
page=page,
|
| 35 |
+
page_size=page_size
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
@router.get("/{employee_id}", response_model=EmployeeOut)
|
| 39 |
+
def get_employee(employee_id: str, db: Session = Depends(get_db)):
|
| 40 |
+
"""
|
| 41 |
+
Get a specific employee by EmployeeID
|
| 42 |
+
|
| 43 |
+
- **employee_id**: The unique identifier for the employee
|
| 44 |
+
"""
|
| 45 |
+
service = EmployeeService(db)
|
| 46 |
+
return service.get(employee_id)
|
| 47 |
+
|
| 48 |
+
@router.post("/", response_model=EmployeeOut, status_code=status.HTTP_201_CREATED)
|
| 49 |
+
def create_employee(employee_in: EmployeeCreate, db: Session = Depends(get_db)):
|
| 50 |
+
"""
|
| 51 |
+
Create a new employee using stored procedure
|
| 52 |
+
|
| 53 |
+
Creates a new employee record using the spEmployeesInsert stored procedure.
|
| 54 |
+
All employee fields including personal information, contact details, and status
|
| 55 |
+
can be provided in the request body.
|
| 56 |
+
|
| 57 |
+
Returns the created employee with the provided or generated EmployeeID.
|
| 58 |
+
"""
|
| 59 |
+
service = EmployeeService(db)
|
| 60 |
+
|
| 61 |
+
# Check if employee_id is provided and if it's unique
|
| 62 |
+
if employee_in.employee_id:
|
| 63 |
+
try:
|
| 64 |
+
# Try to get an existing employee with the same ID
|
| 65 |
+
existing_employee = service.get(employee_in.employee_id)
|
| 66 |
+
if existing_employee:
|
| 67 |
+
raise HTTPException(
|
| 68 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 69 |
+
detail=f"Employee with ID {employee_in.employee_id} already exists"
|
| 70 |
+
)
|
| 71 |
+
except HTTPException as e:
|
| 72 |
+
# If HTTPException was raised with status 400, re-raise it
|
| 73 |
+
if e.status_code == status.HTTP_400_BAD_REQUEST:
|
| 74 |
+
raise
|
| 75 |
+
# Otherwise, it's a 404 (not found) which is what we want
|
| 76 |
+
pass
|
| 77 |
+
|
| 78 |
+
return service.create(employee_in.model_dump())
|
| 79 |
+
|
| 80 |
+
@router.delete("/{employee_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 81 |
+
def delete_employee(employee_id: str, db: Session = Depends(get_db)):
|
| 82 |
+
"""
|
| 83 |
+
Delete an employee
|
| 84 |
+
|
| 85 |
+
- **employee_id**: The unique identifier for the employee to delete
|
| 86 |
+
"""
|
| 87 |
+
service = EmployeeService(db)
|
| 88 |
+
service.delete(employee_id)
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@router.put("/{employee_id}", response_model=EmployeeOut)
|
| 93 |
+
def update_employee(employee_id: str, employee_in: EmployeeUpdate, db: Session = Depends(get_db)):
|
| 94 |
+
"""
|
| 95 |
+
Update an existing employee with partial data.
|
| 96 |
+
|
| 97 |
+
- **employee_id**: The EmployeeID of the employee to update
|
| 98 |
+
- **employee_in**: Partial employee data to update (only provided fields are changed)
|
| 99 |
+
"""
|
| 100 |
+
service = EmployeeService(db)
|
| 101 |
+
# Convert to dict and let service handle validation of provided fields
|
| 102 |
+
return service.update(employee_id, employee_in.model_dump(exclude_unset=True))
|
app/controllers/notes.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, status
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.db.session import get_db
|
| 4 |
+
from app.services.notes_service import NotesService
|
| 5 |
+
from app.schemas.notes import CustomerNoteCreate, CustomerNoteOut, BidderNoteCreate, CustomerNoteUpdate, BidderNoteUpdate
|
| 6 |
+
from app.schemas.project import ProjectNoteCreate, ProjectNoteOut, ProjectNoteUpdate
|
| 7 |
+
from typing import List
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/api/v1/notes", tags=["notes"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.get("/projects/{project_no}", response_model=List[ProjectNoteOut])
|
| 13 |
+
def list_project_notes(project_no: int, db: Session = Depends(get_db)):
|
| 14 |
+
service = NotesService(db)
|
| 15 |
+
notes = service.list_project_notes(project_no)
|
| 16 |
+
# Map DB rows to ProjectNoteOut expected fields
|
| 17 |
+
mapped = []
|
| 18 |
+
for r in notes:
|
| 19 |
+
mapped.append({
|
| 20 |
+
'note_id': r.get('ID') or r.get('NoteID'),
|
| 21 |
+
'project_no': r.get('ProjectNo'),
|
| 22 |
+
'date': r.get('Time') or r.get('Date'),
|
| 23 |
+
'employee_id': r.get('EmployeeID'),
|
| 24 |
+
'employee_name': None,
|
| 25 |
+
'customer_id': r.get('CustomerID'),
|
| 26 |
+
'customer_name': None,
|
| 27 |
+
'notes': r.get('Notes')
|
| 28 |
+
})
|
| 29 |
+
return mapped
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post("/projects/{project_no}", status_code=status.HTTP_201_CREATED)
|
| 33 |
+
def create_project_note(project_no: int, note_in: ProjectNoteCreate, db: Session = Depends(get_db)):
|
| 34 |
+
service = NotesService(db)
|
| 35 |
+
note_id = service.create_project_note(project_no, note_in.model_dump())
|
| 36 |
+
return {"id": note_id}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.put("/projects/{project_no}/{note_id}", response_model=ProjectNoteOut)
|
| 40 |
+
def update_project_note(project_no: int, note_id: int, note_in: ProjectNoteUpdate, db: Session = Depends(get_db)):
|
| 41 |
+
service = NotesService(db)
|
| 42 |
+
updated = service.update_project_note(project_no, note_id, note_in.model_dump(exclude_unset=True))
|
| 43 |
+
# Map to response model fields
|
| 44 |
+
return {
|
| 45 |
+
'note_id': updated.get('ID') or updated.get('NoteID'),
|
| 46 |
+
'project_no': updated.get('ProjectNo'),
|
| 47 |
+
'date': updated.get('Time') or updated.get('Date'),
|
| 48 |
+
'employee_id': updated.get('EmployeeID'),
|
| 49 |
+
'employee_name': None,
|
| 50 |
+
'customer_id': updated.get('CustomerID'),
|
| 51 |
+
'customer_name': None,
|
| 52 |
+
'notes': updated.get('Notes')
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.delete("/projects/{project_no}/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 57 |
+
def delete_project_note(project_no: int, note_id: int, db: Session = Depends(get_db)):
|
| 58 |
+
service = NotesService(db)
|
| 59 |
+
service.delete_project_note(project_no, note_id)
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@router.get("/customers/{customer_id}", response_model=List[CustomerNoteOut])
|
| 64 |
+
def list_customer_notes(customer_id: int, db: Session = Depends(get_db)):
|
| 65 |
+
service = NotesService(db)
|
| 66 |
+
notes = service.list_customer_notes(customer_id)
|
| 67 |
+
# Map DB rows to CustomerNoteOut expected fields
|
| 68 |
+
mapped = []
|
| 69 |
+
for r in notes:
|
| 70 |
+
mapped.append({
|
| 71 |
+
'note_id2': r.get('NoteID2'),
|
| 72 |
+
'note_id': r.get('NoteID'),
|
| 73 |
+
'customer_id': r.get('CustomerID'),
|
| 74 |
+
'date': r.get('Date'),
|
| 75 |
+
'employee_id': r.get('EmployeeID'),
|
| 76 |
+
'customer_type_id': r.get('CustomerTypeID'),
|
| 77 |
+
'notes': r.get('Notes')
|
| 78 |
+
})
|
| 79 |
+
return mapped
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@router.post("/customers/{customer_id}", status_code=status.HTTP_201_CREATED)
|
| 83 |
+
def create_customer_note(customer_id: int, note_in: CustomerNoteCreate, db: Session = Depends(get_db)):
|
| 84 |
+
service = NotesService(db)
|
| 85 |
+
note_id = service.create_customer_note(customer_id, note_in.model_dump())
|
| 86 |
+
return {"id": note_id}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@router.put("/customers/{customer_id}/{note_id2}", response_model=CustomerNoteOut)
|
| 90 |
+
def update_customer_note(customer_id: int, note_id2: int, note_in: CustomerNoteUpdate, db: Session = Depends(get_db)):
|
| 91 |
+
service = NotesService(db)
|
| 92 |
+
updated = service.update_customer_note(customer_id, note_id2, note_in.model_dump(exclude_unset=True))
|
| 93 |
+
return {
|
| 94 |
+
'note_id2': updated.get('NoteID2'),
|
| 95 |
+
'note_id': updated.get('NoteID'),
|
| 96 |
+
'customer_id': updated.get('CustomerID'),
|
| 97 |
+
'date': updated.get('Date'),
|
| 98 |
+
'employee_id': updated.get('EmployeeID'),
|
| 99 |
+
'customer_type_id': updated.get('CustomerTypeID'),
|
| 100 |
+
'notes': updated.get('Notes')
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.delete("/customers/{customer_id}/{note_id2}", status_code=status.HTTP_204_NO_CONTENT)
|
| 105 |
+
def delete_customer_note(customer_id: int, note_id2: int, db: Session = Depends(get_db)):
|
| 106 |
+
service = NotesService(db)
|
| 107 |
+
service.delete_customer_note(customer_id, note_id2)
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@router.get("/bidders/{bidder_id}", response_model=List[dict])
|
| 112 |
+
def list_bidder_notes(bidder_id: int, db: Session = Depends(get_db)):
|
| 113 |
+
service = NotesService(db)
|
| 114 |
+
notes = service.list_bidder_notes(bidder_id)
|
| 115 |
+
return notes
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@router.post("/bidders/{bidder_id}", status_code=status.HTTP_201_CREATED)
|
| 119 |
+
def create_bidder_note(bidder_id: int, note_in: BidderNoteCreate, db: Session = Depends(get_db)):
|
| 120 |
+
service = NotesService(db)
|
| 121 |
+
note_id = service.create_bidder_note(bidder_id, note_in.model_dump())
|
| 122 |
+
return {"id": note_id}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@router.put("/bidders/{bidder_id}/{note_id}", response_model=dict)
|
| 126 |
+
def update_bidder_note(bidder_id: int, note_id: int, note_in: BidderNoteUpdate, db: Session = Depends(get_db)):
|
| 127 |
+
service = NotesService(db)
|
| 128 |
+
updated = service.update_bidder_note(bidder_id, note_id, note_in.model_dump(exclude_unset=True))
|
| 129 |
+
return updated
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@router.delete("/bidders/{bidder_id}/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 133 |
+
def delete_bidder_note(bidder_id: int, note_id: int, db: Session = Depends(get_db)):
|
| 134 |
+
service = NotesService(db)
|
| 135 |
+
service.delete_bidder_note(bidder_id, note_id)
|
| 136 |
+
return None
|
app/controllers/projects.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, status, Query
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.db.session import get_db
|
| 4 |
+
from app.services.project_service import ProjectService
|
| 5 |
+
from app.schemas.project import ProjectCreate, ProjectOut
|
| 6 |
+
from app.schemas.project_detail import ProjectDetailOut, ProjectCustomerOut, ProjectDetailNoCustomersOut
|
| 7 |
+
from app.schemas.paginated_response import PaginatedResponse
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/api/v1/projects", tags=["projects"])
|
| 11 |
+
|
| 12 |
+
@router.get("/", response_model=PaginatedResponse[ProjectOut])
|
| 13 |
+
def list_projects(
|
| 14 |
+
customer_type: Optional[int] = Query(0, description="Customer type filter (0 for all types)", ge=0),
|
| 15 |
+
status: Optional[int] = Query(None, description="Status filter (None excludes status=3, pass specific value to include all)"),
|
| 16 |
+
order_by: Optional[str] = Query("project_no", description="Field to order by"),
|
| 17 |
+
order_direction: Optional[str] = Query("asc", description="Order direction: asc or desc", regex="^(asc|desc)$"),
|
| 18 |
+
page: Optional[int] = Query(1, description="Page number (1-indexed)", ge=1),
|
| 19 |
+
page_size: Optional[int] = Query(10, description="Number of records per page", ge=1, le=100),
|
| 20 |
+
db: Session = Depends(get_db)
|
| 21 |
+
):
|
| 22 |
+
"""
|
| 23 |
+
Get paginated list of projects with filtering and sorting.
|
| 24 |
+
|
| 25 |
+
- **customer_type**: Filter by customer type (0 = all types)
|
| 26 |
+
- **status**: Filter by status (None = exclude status 3, pass specific value to include all)
|
| 27 |
+
- **order_by**: Field name to sort by (project_no, project_name, etc.)
|
| 28 |
+
- **order_direction**: Sort direction (asc or desc)
|
| 29 |
+
- **page**: Page number starting from 1
|
| 30 |
+
- **page_size**: Number of records per page (max 100)
|
| 31 |
+
"""
|
| 32 |
+
service = ProjectService(db)
|
| 33 |
+
return service.list_projects(
|
| 34 |
+
customer_type=customer_type,
|
| 35 |
+
status=status,
|
| 36 |
+
order_by=order_by,
|
| 37 |
+
order_direction=order_direction,
|
| 38 |
+
page=page,
|
| 39 |
+
page_size=page_size
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
@router.get("/{project_no}", response_model=ProjectDetailNoCustomersOut)
|
| 43 |
+
def get_project(project_no: int, db: Session = Depends(get_db)):
|
| 44 |
+
"""Get a specific project by ProjectNo without customers collection
|
| 45 |
+
|
| 46 |
+
For customer data, use `/api/v1/projects/{project_no}/customers` instead.
|
| 47 |
+
"""
|
| 48 |
+
service = ProjectService(db)
|
| 49 |
+
# Use lean method that does not fetch customers or notes for performance
|
| 50 |
+
return service.get_detail_no_customers(project_no)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@router.get(
|
| 54 |
+
"/{project_no}/customers",
|
| 55 |
+
response_model=List[ProjectCustomerOut],
|
| 56 |
+
response_model_exclude={"barrier_sizes", "contacts", "bidder_notes"}
|
| 57 |
+
)
|
| 58 |
+
def get_project_customers(
|
| 59 |
+
project_no: int,
|
| 60 |
+
page: Optional[int] = Query(1, description="Page number (1-indexed)", ge=1),
|
| 61 |
+
page_size: Optional[int] = Query(25, description="Number of records per page", ge=1, le=1000),
|
| 62 |
+
last_id: Optional[int] = Query(None, description="Keyset pagination anchor: last seen bidder Id"),
|
| 63 |
+
db: Session = Depends(get_db)
|
| 64 |
+
):
|
| 65 |
+
"""Get customers associated with a specific project (by ProjectNo)
|
| 66 |
+
|
| 67 |
+
Returns a paginated list of customers for the project. Pagination defaults
|
| 68 |
+
to page=1 and page_size=100 to avoid very large responses.
|
| 69 |
+
"""
|
| 70 |
+
service = ProjectService(db)
|
| 71 |
+
# Use the dedicated service method to fetch only customers for the project
|
| 72 |
+
return service.get_customers(project_no, page=page, page_size=page_size, last_id=last_id)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@router.get(
|
| 76 |
+
"/{project_no}/customers/{customer_id}",
|
| 77 |
+
response_model=ProjectCustomerOut,
|
| 78 |
+
response_model_exclude={"barrier_sizes", "contacts", "bidder_notes"}
|
| 79 |
+
)
|
| 80 |
+
def get_project_customer_detail(
|
| 81 |
+
project_no: int,
|
| 82 |
+
customer_id: str,
|
| 83 |
+
db: Session = Depends(get_db)
|
| 84 |
+
):
|
| 85 |
+
"""Get detailed bidder information for a specific customer on a project
|
| 86 |
+
|
| 87 |
+
Returns the complete bidder details including barrier sizes, contacts, and notes
|
| 88 |
+
for the specified project number and customer ID combination.
|
| 89 |
+
|
| 90 |
+
- **project_no**: The project number
|
| 91 |
+
- **customer_id**: The customer ID (CustId from Bidders table)
|
| 92 |
+
"""
|
| 93 |
+
service = ProjectService(db)
|
| 94 |
+
return service.get_project_customer_detail(project_no, customer_id)
|
| 95 |
+
|
| 96 |
+
@router.post("/", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
|
| 97 |
+
def create_project(project_in: ProjectCreate, db: Session = Depends(get_db)):
|
| 98 |
+
"""
|
| 99 |
+
Create a new project using stored procedure
|
| 100 |
+
|
| 101 |
+
Creates a new project record using the spProjectsInsert stored procedure.
|
| 102 |
+
All project fields including billing, shipping, payment, and project details
|
| 103 |
+
can be provided in the request body.
|
| 104 |
+
|
| 105 |
+
Returns the created project with the generated ProjectNo.
|
| 106 |
+
"""
|
| 107 |
+
service = ProjectService(db)
|
| 108 |
+
return service.create(project_in.model_dump())
|
| 109 |
+
|
| 110 |
+
@router.put("/{project_no}", response_model=ProjectOut)
|
| 111 |
+
def update_project(project_no: int, project_in: ProjectCreate, db: Session = Depends(get_db)):
|
| 112 |
+
"""
|
| 113 |
+
Update an existing project using stored procedure
|
| 114 |
+
|
| 115 |
+
Updates an existing project record using the spProjectsUpdate stored procedure.
|
| 116 |
+
All project fields including billing, shipping, payment, and project details
|
| 117 |
+
can be updated in the request body.
|
| 118 |
+
|
| 119 |
+
Returns the updated project data.
|
| 120 |
+
"""
|
| 121 |
+
service = ProjectService(db)
|
| 122 |
+
return service.update(project_no, project_in.model_dump())
|
| 123 |
+
|
| 124 |
+
@router.delete("/{project_no}", status_code=status.HTTP_204_NO_CONTENT)
|
| 125 |
+
def delete_project(project_no: int, db: Session = Depends(get_db)):
|
| 126 |
+
"""Delete a project"""
|
| 127 |
+
service = ProjectService(db)
|
| 128 |
+
service.delete(project_no)
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
|
app/controllers/reference.py
ADDED
|
@@ -0,0 +1,934 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Query, status, HTTPException, Response
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.db.session import get_db
|
| 4 |
+
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 |
+
ReferenceDataResponse, FOBOut, EstShipDateOut, EstFreightOut, StatusInfoOut, ProjectReferenceResponse, PriorityOut,
|
| 9 |
+
StateCreateIn, StateUpdateIn, CountryCreateIn, CountryUpdateIn, CompanyTypeCreateIn, CompanyTypeUpdateIn,
|
| 10 |
+
LeadSourceCreateIn, LeadSourceUpdateIn, PaymentTermCreateIn, PaymentTermUpdateIn,
|
| 11 |
+
PurchasePriceCreateIn, PurchasePriceUpdateIn, RentalPriceCreateIn, RentalPriceUpdateIn,
|
| 12 |
+
BarrierSizeCreateIn, BarrierSizeUpdateIn, ProductApplicationCreateIn, ProductApplicationUpdateIn,
|
| 13 |
+
FOBCreateIn, FOBUpdateIn, EstShipDateCreateIn, EstShipDateUpdateIn,
|
| 14 |
+
EstFreightCreateIn, EstFreightUpdateIn, StatusInfoCreateIn, StatusInfoUpdateIn,
|
| 15 |
+
PriorityCreateIn, PriorityUpdateIn, PhoneTypeOut, PhoneTypeCreateIn, PhoneTypeUpdateIn
|
| 16 |
+
)
|
| 17 |
+
from app.core.dependencies import get_current_user_optional
|
| 18 |
+
from app.schemas.auth import CurrentUser
|
| 19 |
+
from typing import List, Optional
|
| 20 |
+
import logging
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
router = APIRouter(prefix="/api/v1/reference", tags=["reference-data"])
|
| 25 |
+
|
| 26 |
+
@router.get("/all", response_model=ReferenceDataResponse)
|
| 27 |
+
def get_all_reference_data(
|
| 28 |
+
active_only: bool = Query(True, description="Return only active records"),
|
| 29 |
+
customer_type_id: Optional[int] = Query(None, description="Filter by customer type ID"),
|
| 30 |
+
db: Session = Depends(get_db),
|
| 31 |
+
current_user: Optional[CurrentUser] = Depends(get_current_user_optional)
|
| 32 |
+
):
|
| 33 |
+
"""
|
| 34 |
+
Get all reference/lookup data in a single response.
|
| 35 |
+
|
| 36 |
+
This endpoint is designed for frontend applications that need to populate
|
| 37 |
+
all dropdowns and lookup data efficiently. It includes:
|
| 38 |
+
- States/Provinces
|
| 39 |
+
- Countries
|
| 40 |
+
- Company Types
|
| 41 |
+
- Lead Sources
|
| 42 |
+
- Payment Terms
|
| 43 |
+
- Purchase Prices
|
| 44 |
+
- Rental Prices
|
| 45 |
+
- Barrier Sizes
|
| 46 |
+
- Product Applications
|
| 47 |
+
- Project Statuses
|
| 48 |
+
|
| 49 |
+
**Note**: This endpoint is public but may have enhanced data for authenticated users.
|
| 50 |
+
"""
|
| 51 |
+
service = ReferenceDataService(db)
|
| 52 |
+
return service.get_all_reference_data(active_only=active_only, customer_type_id=customer_type_id)
|
| 53 |
+
|
| 54 |
+
@router.get("/states", response_model=List[StateOut])
|
| 55 |
+
def get_states(
|
| 56 |
+
active_only: bool = Query(True, description="Return only active states"),
|
| 57 |
+
customer_type_id: Optional[int] = Query(None, description="Filter by customer type ID"),
|
| 58 |
+
db: Session = Depends(get_db)
|
| 59 |
+
):
|
| 60 |
+
"""
|
| 61 |
+
Get all states/provinces.
|
| 62 |
+
|
| 63 |
+
Returns a list of all states and provinces available in the system.
|
| 64 |
+
"""
|
| 65 |
+
service = ReferenceDataService(db)
|
| 66 |
+
return service.get_states(active_only=active_only, customer_type_id=customer_type_id)
|
| 67 |
+
|
| 68 |
+
@router.get("/countries", response_model=List[CountryOut])
|
| 69 |
+
def get_countries(
|
| 70 |
+
active_only: bool = Query(True, description="Return only active countries"),
|
| 71 |
+
db: Session = Depends(get_db)
|
| 72 |
+
):
|
| 73 |
+
"""
|
| 74 |
+
Get all countries.
|
| 75 |
+
|
| 76 |
+
Returns a list of all countries available in the system.
|
| 77 |
+
"""
|
| 78 |
+
service = ReferenceDataService(db)
|
| 79 |
+
return service.get_countries(active_only=active_only)
|
| 80 |
+
|
| 81 |
+
@router.get("/company-types", response_model=List[CompanyTypeOut])
|
| 82 |
+
def get_company_types(
|
| 83 |
+
active_only: bool = Query(True, description="Return only active company types"),
|
| 84 |
+
customer_type_id: Optional[int] = Query(None, description="Filter by customer type ID"),
|
| 85 |
+
db: Session = Depends(get_db)
|
| 86 |
+
):
|
| 87 |
+
"""
|
| 88 |
+
Get all company types.
|
| 89 |
+
|
| 90 |
+
Returns a list of all company types used for customer classification.
|
| 91 |
+
Optionally filtered by customer_type_id.
|
| 92 |
+
"""
|
| 93 |
+
service = ReferenceDataService(db)
|
| 94 |
+
return service.get_company_types(active_only=active_only, customer_type_id=customer_type_id)
|
| 95 |
+
|
| 96 |
+
@router.get("/lead-sources", response_model=List[LeadSourceOut])
|
| 97 |
+
def get_lead_sources(
|
| 98 |
+
active_only: bool = Query(True, description="Return only active lead sources"),
|
| 99 |
+
customer_type_id: Optional[int] = Query(None, description="Filter by customer type ID"),
|
| 100 |
+
db: Session = Depends(get_db)
|
| 101 |
+
):
|
| 102 |
+
"""
|
| 103 |
+
Get all lead generation sources.
|
| 104 |
+
|
| 105 |
+
Returns a list of all sources where leads can be generated from.
|
| 106 |
+
Optionally filtered by customer_type_id.
|
| 107 |
+
"""
|
| 108 |
+
service = ReferenceDataService(db)
|
| 109 |
+
return service.get_lead_sources(active_only=active_only, customer_type_id=customer_type_id)
|
| 110 |
+
|
| 111 |
+
@router.get("/payment-terms", response_model=List[PaymentTermOut])
|
| 112 |
+
def get_payment_terms(
|
| 113 |
+
active_only: bool = Query(True, description="Return only active payment terms"),
|
| 114 |
+
db: Session = Depends(get_db)
|
| 115 |
+
):
|
| 116 |
+
"""
|
| 117 |
+
Get all payment terms.
|
| 118 |
+
|
| 119 |
+
Returns a list of all available payment terms for transactions.
|
| 120 |
+
"""
|
| 121 |
+
service = ReferenceDataService(db)
|
| 122 |
+
return service.get_payment_terms(active_only=active_only)
|
| 123 |
+
|
| 124 |
+
@router.get("/purchase-prices", response_model=List[PurchasePriceOut])
|
| 125 |
+
def get_purchase_prices(
|
| 126 |
+
active_only: bool = Query(True, description="Return only active purchase prices"),
|
| 127 |
+
db: Session = Depends(get_db)
|
| 128 |
+
):
|
| 129 |
+
"""
|
| 130 |
+
Get all purchase prices.
|
| 131 |
+
|
| 132 |
+
Returns a list of all available purchase price options.
|
| 133 |
+
"""
|
| 134 |
+
service = ReferenceDataService(db)
|
| 135 |
+
return service.get_purchase_prices(active_only=active_only)
|
| 136 |
+
|
| 137 |
+
@router.get("/rental-prices", response_model=List[RentalPriceOut])
|
| 138 |
+
def get_rental_prices(
|
| 139 |
+
active_only: bool = Query(True, description="Return only active rental prices"),
|
| 140 |
+
db: Session = Depends(get_db)
|
| 141 |
+
):
|
| 142 |
+
"""
|
| 143 |
+
Get all rental prices.
|
| 144 |
+
|
| 145 |
+
Returns a list of all available rental price options.
|
| 146 |
+
"""
|
| 147 |
+
service = ReferenceDataService(db)
|
| 148 |
+
return service.get_rental_prices(active_only=active_only)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@router.get("/product-applications", response_model=List[ProductApplicationOut])
|
| 153 |
+
def get_product_applications(
|
| 154 |
+
active_only: bool = Query(True, description="Return only active product applications"),
|
| 155 |
+
db: Session = Depends(get_db)
|
| 156 |
+
):
|
| 157 |
+
"""
|
| 158 |
+
Get all product applications.
|
| 159 |
+
|
| 160 |
+
Returns a list of all available product application types.
|
| 161 |
+
"""
|
| 162 |
+
service = ReferenceDataService(db)
|
| 163 |
+
return service.get_product_applications(active_only=active_only)
|
| 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 |
+
@router.get("/project-reference", response_model=ProjectReferenceResponse)
|
| 219 |
+
def get_project_reference(
|
| 220 |
+
active_only: bool = Query(True, description="Return only active project reference records"),
|
| 221 |
+
db: Session = Depends(get_db)
|
| 222 |
+
):
|
| 223 |
+
"""
|
| 224 |
+
Return grouped reference data used for project creation forms.
|
| 225 |
+
Only returns the subset of reference tables needed by project forms to reduce payload.
|
| 226 |
+
"""
|
| 227 |
+
service = ReferenceDataService(db)
|
| 228 |
+
return service.get_project_reference(active_only=active_only)
|
| 229 |
+
|
| 230 |
+
@router.post("/clear-cache", status_code=status.HTTP_200_OK)
|
| 231 |
+
def clear_reference_cache(
|
| 232 |
+
current_user: CurrentUser = Depends(get_current_user_optional),
|
| 233 |
+
db: Session = Depends(get_db)
|
| 234 |
+
):
|
| 235 |
+
"""
|
| 236 |
+
Clear the reference data cache.
|
| 237 |
+
|
| 238 |
+
Forces fresh data to be loaded on the next request.
|
| 239 |
+
This endpoint may require authentication in production.
|
| 240 |
+
"""
|
| 241 |
+
service = ReferenceDataService(db)
|
| 242 |
+
service.clear_cache()
|
| 243 |
+
return {"message": "Reference data cache cleared successfully"}
|
| 244 |
+
|
| 245 |
+
@router.get("/priorities", response_model=List[PriorityOut])
|
| 246 |
+
def get_priorities(
|
| 247 |
+
active_only: bool = Query(True, description="Return only active priorities"),
|
| 248 |
+
customer_type_id: Optional[int] = Query(None, description="Filter by customer type ID"),
|
| 249 |
+
db: Session = Depends(get_db)
|
| 250 |
+
):
|
| 251 |
+
"""
|
| 252 |
+
Get all priorities.
|
| 253 |
+
|
| 254 |
+
Returns a list of all available priorities, optionally filtered by customer type.
|
| 255 |
+
"""
|
| 256 |
+
service = ReferenceDataService(db)
|
| 257 |
+
return service.get_priorities(active_only=active_only, customer_type_id=customer_type_id)
|
| 258 |
+
|
| 259 |
+
@router.post("/states", response_model=StateOut, status_code=status.HTTP_201_CREATED)
|
| 260 |
+
def create_state(
|
| 261 |
+
data: StateCreateIn,
|
| 262 |
+
db: Session = Depends(get_db),
|
| 263 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 264 |
+
):
|
| 265 |
+
"""
|
| 266 |
+
Create a new state/province.
|
| 267 |
+
|
| 268 |
+
Requires authentication for production use.
|
| 269 |
+
"""
|
| 270 |
+
service = ReferenceDataService(db)
|
| 271 |
+
return service.create_state(data)
|
| 272 |
+
|
| 273 |
+
@router.put("/states/{state_id}", response_model=StateOut)
|
| 274 |
+
def update_state(
|
| 275 |
+
state_id: int,
|
| 276 |
+
data: StateUpdateIn,
|
| 277 |
+
db: Session = Depends(get_db),
|
| 278 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 279 |
+
):
|
| 280 |
+
"""
|
| 281 |
+
Update an existing state/province.
|
| 282 |
+
|
| 283 |
+
Requires authentication for production use.
|
| 284 |
+
"""
|
| 285 |
+
service = ReferenceDataService(db)
|
| 286 |
+
result = service.update_state(state_id, data)
|
| 287 |
+
if not result:
|
| 288 |
+
raise HTTPException(status_code=404, detail="State not found")
|
| 289 |
+
return result
|
| 290 |
+
|
| 291 |
+
@router.post("/countries", response_model=CountryOut, status_code=status.HTTP_201_CREATED)
|
| 292 |
+
def create_country(
|
| 293 |
+
data: CountryCreateIn,
|
| 294 |
+
db: Session = Depends(get_db),
|
| 295 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 296 |
+
):
|
| 297 |
+
"""
|
| 298 |
+
Create a new country.
|
| 299 |
+
|
| 300 |
+
Requires authentication for production use.
|
| 301 |
+
"""
|
| 302 |
+
service = ReferenceDataService(db)
|
| 303 |
+
return service.create_country(data)
|
| 304 |
+
|
| 305 |
+
@router.put("/countries/{country_id}", response_model=CountryOut)
|
| 306 |
+
def update_country(
|
| 307 |
+
country_id: int,
|
| 308 |
+
data: CountryUpdateIn,
|
| 309 |
+
db: Session = Depends(get_db),
|
| 310 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 311 |
+
):
|
| 312 |
+
"""
|
| 313 |
+
Update an existing country.
|
| 314 |
+
|
| 315 |
+
Requires authentication for production use.
|
| 316 |
+
"""
|
| 317 |
+
service = ReferenceDataService(db)
|
| 318 |
+
result = service.update_country(country_id, data)
|
| 319 |
+
if not result:
|
| 320 |
+
raise HTTPException(status_code=404, detail="Country not found")
|
| 321 |
+
return result
|
| 322 |
+
|
| 323 |
+
@router.post("/company-types", response_model=CompanyTypeOut, status_code=status.HTTP_201_CREATED)
|
| 324 |
+
def create_company_type(
|
| 325 |
+
data: CompanyTypeCreateIn,
|
| 326 |
+
db: Session = Depends(get_db),
|
| 327 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 328 |
+
):
|
| 329 |
+
"""
|
| 330 |
+
Create a new company type.
|
| 331 |
+
|
| 332 |
+
Requires authentication for production use.
|
| 333 |
+
"""
|
| 334 |
+
service = ReferenceDataService(db)
|
| 335 |
+
return service.create_company_type(data)
|
| 336 |
+
|
| 337 |
+
@router.put("/company-types/{company_type_id}", response_model=CompanyTypeOut)
|
| 338 |
+
def update_company_type(
|
| 339 |
+
company_type_id: int,
|
| 340 |
+
data: CompanyTypeUpdateIn,
|
| 341 |
+
db: Session = Depends(get_db),
|
| 342 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 343 |
+
):
|
| 344 |
+
"""
|
| 345 |
+
Update an existing company type.
|
| 346 |
+
|
| 347 |
+
Requires authentication for production use.
|
| 348 |
+
"""
|
| 349 |
+
service = ReferenceDataService(db)
|
| 350 |
+
result = service.update_company_type(company_type_id, data)
|
| 351 |
+
if not result:
|
| 352 |
+
raise HTTPException(status_code=404, detail="Company type not found")
|
| 353 |
+
return result
|
| 354 |
+
|
| 355 |
+
@router.post("/lead-sources", response_model=LeadSourceOut, status_code=status.HTTP_201_CREATED)
|
| 356 |
+
def create_lead_source(
|
| 357 |
+
data: LeadSourceCreateIn,
|
| 358 |
+
db: Session = Depends(get_db),
|
| 359 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 360 |
+
):
|
| 361 |
+
"""
|
| 362 |
+
Create a new lead source.
|
| 363 |
+
|
| 364 |
+
Requires authentication for production use.
|
| 365 |
+
"""
|
| 366 |
+
service = ReferenceDataService(db)
|
| 367 |
+
return service.create_lead_source(data)
|
| 368 |
+
|
| 369 |
+
@router.put("/lead-sources/{lead_generated_from_id}", response_model=LeadSourceOut)
|
| 370 |
+
def update_lead_source(
|
| 371 |
+
lead_generated_from_id: int,
|
| 372 |
+
data: LeadSourceUpdateIn,
|
| 373 |
+
db: Session = Depends(get_db),
|
| 374 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 375 |
+
):
|
| 376 |
+
"""
|
| 377 |
+
Update an existing lead source.
|
| 378 |
+
|
| 379 |
+
Requires authentication for production use.
|
| 380 |
+
"""
|
| 381 |
+
service = ReferenceDataService(db)
|
| 382 |
+
result = service.update_lead_source(lead_generated_from_id, data)
|
| 383 |
+
if not result:
|
| 384 |
+
raise HTTPException(status_code=404, detail="Lead source not found")
|
| 385 |
+
return result
|
| 386 |
+
|
| 387 |
+
@router.post("/payment-terms", response_model=PaymentTermOut, status_code=status.HTTP_201_CREATED)
|
| 388 |
+
def create_payment_term(
|
| 389 |
+
data: PaymentTermCreateIn,
|
| 390 |
+
db: Session = Depends(get_db),
|
| 391 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 392 |
+
):
|
| 393 |
+
"""
|
| 394 |
+
Create a new payment term.
|
| 395 |
+
|
| 396 |
+
Requires authentication for production use.
|
| 397 |
+
"""
|
| 398 |
+
service = ReferenceDataService(db)
|
| 399 |
+
return service.create_payment_term(data)
|
| 400 |
+
|
| 401 |
+
@router.put("/payment-terms/{payment_term_id}", response_model=PaymentTermOut)
|
| 402 |
+
def update_payment_term(
|
| 403 |
+
payment_term_id: int,
|
| 404 |
+
data: PaymentTermUpdateIn,
|
| 405 |
+
db: Session = Depends(get_db),
|
| 406 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 407 |
+
):
|
| 408 |
+
"""
|
| 409 |
+
Update an existing payment term.
|
| 410 |
+
|
| 411 |
+
Requires authentication for production use.
|
| 412 |
+
"""
|
| 413 |
+
service = ReferenceDataService(db)
|
| 414 |
+
result = service.update_payment_term(payment_term_id, data)
|
| 415 |
+
if not result:
|
| 416 |
+
raise HTTPException(status_code=404, detail="Payment term not found")
|
| 417 |
+
return result
|
| 418 |
+
|
| 419 |
+
@router.post("/purchase-prices", response_model=PurchasePriceOut, status_code=status.HTTP_201_CREATED)
|
| 420 |
+
def create_purchase_price(
|
| 421 |
+
data: PurchasePriceCreateIn,
|
| 422 |
+
db: Session = Depends(get_db),
|
| 423 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 424 |
+
):
|
| 425 |
+
"""
|
| 426 |
+
Create a new purchase price.
|
| 427 |
+
|
| 428 |
+
Requires authentication for production use.
|
| 429 |
+
"""
|
| 430 |
+
service = ReferenceDataService(db)
|
| 431 |
+
return service.create_purchase_price(data)
|
| 432 |
+
|
| 433 |
+
@router.put("/purchase-prices/{purchase_price_id}", response_model=PurchasePriceOut)
|
| 434 |
+
def update_purchase_price(
|
| 435 |
+
purchase_price_id: int,
|
| 436 |
+
data: PurchasePriceUpdateIn,
|
| 437 |
+
db: Session = Depends(get_db),
|
| 438 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 439 |
+
):
|
| 440 |
+
"""
|
| 441 |
+
Update an existing purchase price.
|
| 442 |
+
|
| 443 |
+
Requires authentication for production use.
|
| 444 |
+
"""
|
| 445 |
+
service = ReferenceDataService(db)
|
| 446 |
+
result = service.update_purchase_price(purchase_price_id, data)
|
| 447 |
+
if not result:
|
| 448 |
+
raise HTTPException(status_code=404, detail="Purchase price not found")
|
| 449 |
+
return result
|
| 450 |
+
|
| 451 |
+
@router.post("/rental-prices", response_model=RentalPriceOut, status_code=status.HTTP_201_CREATED)
|
| 452 |
+
def create_rental_price(
|
| 453 |
+
data: RentalPriceCreateIn,
|
| 454 |
+
db: Session = Depends(get_db),
|
| 455 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 456 |
+
):
|
| 457 |
+
"""
|
| 458 |
+
Create a new rental price.
|
| 459 |
+
|
| 460 |
+
Requires authentication for production use.
|
| 461 |
+
"""
|
| 462 |
+
service = ReferenceDataService(db)
|
| 463 |
+
return service.create_rental_price(data)
|
| 464 |
+
|
| 465 |
+
@router.put("/rental-prices/{rental_price_id}", response_model=RentalPriceOut)
|
| 466 |
+
def update_rental_price(
|
| 467 |
+
rental_price_id: int,
|
| 468 |
+
data: RentalPriceUpdateIn,
|
| 469 |
+
db: Session = Depends(get_db),
|
| 470 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 471 |
+
):
|
| 472 |
+
"""
|
| 473 |
+
Update an existing rental price.
|
| 474 |
+
|
| 475 |
+
Requires authentication for production use.
|
| 476 |
+
"""
|
| 477 |
+
service = ReferenceDataService(db)
|
| 478 |
+
result = service.update_rental_price(rental_price_id, data)
|
| 479 |
+
if not result:
|
| 480 |
+
raise HTTPException(status_code=404, detail="Rental price not found")
|
| 481 |
+
return result
|
| 482 |
+
|
| 483 |
+
@router.post("/barrier-sizes", response_model=BarrierSizeOut, status_code=status.HTTP_201_CREATED)
|
| 484 |
+
def create_barrier_size(
|
| 485 |
+
data: BarrierSizeCreateIn,
|
| 486 |
+
db: Session = Depends(get_db),
|
| 487 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 488 |
+
):
|
| 489 |
+
"""
|
| 490 |
+
Create a new barrier size.
|
| 491 |
+
|
| 492 |
+
Requires authentication for production use.
|
| 493 |
+
"""
|
| 494 |
+
service = ReferenceDataService(db)
|
| 495 |
+
return service.create_barrier_size(data)
|
| 496 |
+
|
| 497 |
+
@router.put("/barrier-sizes/{barrier_size_id}", response_model=BarrierSizeOut)
|
| 498 |
+
def update_barrier_size(
|
| 499 |
+
barrier_size_id: int,
|
| 500 |
+
data: BarrierSizeUpdateIn,
|
| 501 |
+
db: Session = Depends(get_db),
|
| 502 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 503 |
+
):
|
| 504 |
+
"""
|
| 505 |
+
Update an existing barrier size.
|
| 506 |
+
|
| 507 |
+
Requires authentication for production use.
|
| 508 |
+
"""
|
| 509 |
+
service = ReferenceDataService(db)
|
| 510 |
+
result = service.update_barrier_size(barrier_size_id, data)
|
| 511 |
+
if not result:
|
| 512 |
+
raise HTTPException(status_code=404, detail="Barrier size not found")
|
| 513 |
+
return result
|
| 514 |
+
|
| 515 |
+
@router.post("/product-applications", response_model=ProductApplicationOut, status_code=status.HTTP_201_CREATED)
|
| 516 |
+
def create_product_application(
|
| 517 |
+
data: ProductApplicationCreateIn,
|
| 518 |
+
db: Session = Depends(get_db),
|
| 519 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 520 |
+
):
|
| 521 |
+
"""
|
| 522 |
+
Create a new product application.
|
| 523 |
+
|
| 524 |
+
Requires authentication for production use.
|
| 525 |
+
"""
|
| 526 |
+
service = ReferenceDataService(db)
|
| 527 |
+
return service.create_product_application(data)
|
| 528 |
+
|
| 529 |
+
@router.put("/product-applications/{application_id}", response_model=ProductApplicationOut)
|
| 530 |
+
def update_product_application(
|
| 531 |
+
application_id: int,
|
| 532 |
+
data: ProductApplicationUpdateIn,
|
| 533 |
+
db: Session = Depends(get_db),
|
| 534 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 535 |
+
):
|
| 536 |
+
"""
|
| 537 |
+
Update an existing product application.
|
| 538 |
+
|
| 539 |
+
Requires authentication for production use.
|
| 540 |
+
"""
|
| 541 |
+
service = ReferenceDataService(db)
|
| 542 |
+
result = service.update_product_application(application_id, data)
|
| 543 |
+
if not result:
|
| 544 |
+
raise HTTPException(status_code=404, detail="Product application not found")
|
| 545 |
+
return result
|
| 546 |
+
|
| 547 |
+
@router.post("/fobs", response_model=FOBOut, status_code=status.HTTP_201_CREATED)
|
| 548 |
+
def create_fob(
|
| 549 |
+
data: FOBCreateIn,
|
| 550 |
+
db: Session = Depends(get_db),
|
| 551 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 552 |
+
):
|
| 553 |
+
"""
|
| 554 |
+
Create a new FOB term.
|
| 555 |
+
|
| 556 |
+
Requires authentication for production use.
|
| 557 |
+
"""
|
| 558 |
+
service = ReferenceDataService(db)
|
| 559 |
+
return service.create_fob(data)
|
| 560 |
+
|
| 561 |
+
@router.put("/fobs/{fob_id}", response_model=FOBOut)
|
| 562 |
+
def update_fob(
|
| 563 |
+
fob_id: int,
|
| 564 |
+
data: FOBUpdateIn,
|
| 565 |
+
db: Session = Depends(get_db),
|
| 566 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 567 |
+
):
|
| 568 |
+
"""
|
| 569 |
+
Update an existing FOB term.
|
| 570 |
+
|
| 571 |
+
Requires authentication for production use.
|
| 572 |
+
"""
|
| 573 |
+
service = ReferenceDataService(db)
|
| 574 |
+
result = service.update_fob(fob_id, data)
|
| 575 |
+
if not result:
|
| 576 |
+
raise HTTPException(status_code=404, detail="FOB not found")
|
| 577 |
+
return result
|
| 578 |
+
|
| 579 |
+
@router.post("/est-ship-dates", response_model=EstShipDateOut, status_code=status.HTTP_201_CREATED)
|
| 580 |
+
def create_est_ship_date(
|
| 581 |
+
data: EstShipDateCreateIn,
|
| 582 |
+
db: Session = Depends(get_db),
|
| 583 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 584 |
+
):
|
| 585 |
+
"""
|
| 586 |
+
Create a new estimated ship date option.
|
| 587 |
+
|
| 588 |
+
Requires authentication for production use.
|
| 589 |
+
"""
|
| 590 |
+
service = ReferenceDataService(db)
|
| 591 |
+
return service.create_est_ship_date(data)
|
| 592 |
+
|
| 593 |
+
@router.put("/est-ship-dates/{est_ship_date_id}", response_model=EstShipDateOut)
|
| 594 |
+
def update_est_ship_date(
|
| 595 |
+
est_ship_date_id: int,
|
| 596 |
+
data: EstShipDateUpdateIn,
|
| 597 |
+
db: Session = Depends(get_db),
|
| 598 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 599 |
+
):
|
| 600 |
+
"""
|
| 601 |
+
Update an existing estimated ship date option.
|
| 602 |
+
|
| 603 |
+
Requires authentication for production use.
|
| 604 |
+
"""
|
| 605 |
+
service = ReferenceDataService(db)
|
| 606 |
+
result = service.update_est_ship_date(est_ship_date_id, data)
|
| 607 |
+
if not result:
|
| 608 |
+
raise HTTPException(status_code=404, detail="Estimated ship date not found")
|
| 609 |
+
return result
|
| 610 |
+
|
| 611 |
+
@router.post("/est-freights", response_model=EstFreightOut, status_code=status.HTTP_201_CREATED)
|
| 612 |
+
def create_est_freight(
|
| 613 |
+
data: EstFreightCreateIn,
|
| 614 |
+
db: Session = Depends(get_db),
|
| 615 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 616 |
+
):
|
| 617 |
+
"""
|
| 618 |
+
Create a new estimated freight option.
|
| 619 |
+
|
| 620 |
+
Requires authentication for production use.
|
| 621 |
+
"""
|
| 622 |
+
service = ReferenceDataService(db)
|
| 623 |
+
return service.create_est_freight(data)
|
| 624 |
+
|
| 625 |
+
@router.put("/est-freights/{est_freight_id}", response_model=EstFreightOut)
|
| 626 |
+
def update_est_freight(
|
| 627 |
+
est_freight_id: int,
|
| 628 |
+
data: EstFreightUpdateIn,
|
| 629 |
+
db: Session = Depends(get_db),
|
| 630 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 631 |
+
):
|
| 632 |
+
"""
|
| 633 |
+
Update an existing estimated freight option.
|
| 634 |
+
|
| 635 |
+
Requires authentication for production use.
|
| 636 |
+
"""
|
| 637 |
+
service = ReferenceDataService(db)
|
| 638 |
+
result = service.update_est_freight(est_freight_id, data)
|
| 639 |
+
if not result:
|
| 640 |
+
raise HTTPException(status_code=404, detail="Estimated freight not found")
|
| 641 |
+
return result
|
| 642 |
+
|
| 643 |
+
@router.post("/status-info", response_model=StatusInfoOut, status_code=status.HTTP_201_CREATED)
|
| 644 |
+
def create_status_info(
|
| 645 |
+
data: StatusInfoCreateIn,
|
| 646 |
+
db: Session = Depends(get_db),
|
| 647 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 648 |
+
):
|
| 649 |
+
"""
|
| 650 |
+
Create a new status info record.
|
| 651 |
+
|
| 652 |
+
Requires authentication for production use.
|
| 653 |
+
"""
|
| 654 |
+
service = ReferenceDataService(db)
|
| 655 |
+
return service.create_status_info(data)
|
| 656 |
+
|
| 657 |
+
@router.put("/status-info/{status_info_id}", response_model=StatusInfoOut)
|
| 658 |
+
def update_status_info(
|
| 659 |
+
status_info_id: int,
|
| 660 |
+
data: StatusInfoUpdateIn,
|
| 661 |
+
db: Session = Depends(get_db),
|
| 662 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 663 |
+
):
|
| 664 |
+
"""
|
| 665 |
+
Update an existing status info record.
|
| 666 |
+
|
| 667 |
+
Requires authentication for production use.
|
| 668 |
+
"""
|
| 669 |
+
service = ReferenceDataService(db)
|
| 670 |
+
result = service.update_status_info(status_info_id, data)
|
| 671 |
+
if not result:
|
| 672 |
+
raise HTTPException(status_code=404, detail="Status info not found")
|
| 673 |
+
return result
|
| 674 |
+
|
| 675 |
+
@router.post("/priorities", response_model=PriorityOut, status_code=status.HTTP_201_CREATED)
|
| 676 |
+
def create_priority(
|
| 677 |
+
data: PriorityCreateIn,
|
| 678 |
+
db: Session = Depends(get_db),
|
| 679 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 680 |
+
):
|
| 681 |
+
"""
|
| 682 |
+
Create a new priority.
|
| 683 |
+
|
| 684 |
+
Requires authentication for production use.
|
| 685 |
+
"""
|
| 686 |
+
service = ReferenceDataService(db)
|
| 687 |
+
return service.create_priority(data)
|
| 688 |
+
|
| 689 |
+
@router.put("/priorities/{priority_id}", response_model=PriorityOut)
|
| 690 |
+
def update_priority(
|
| 691 |
+
priority_id: int,
|
| 692 |
+
data: PriorityUpdateIn,
|
| 693 |
+
db: Session = Depends(get_db),
|
| 694 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 695 |
+
):
|
| 696 |
+
"""
|
| 697 |
+
Update an existing priority.
|
| 698 |
+
|
| 699 |
+
Requires authentication for production use.
|
| 700 |
+
"""
|
| 701 |
+
service = ReferenceDataService(db)
|
| 702 |
+
result = service.update_priority(priority_id, data)
|
| 703 |
+
if not result:
|
| 704 |
+
raise HTTPException(status_code=404, detail="Priority not found")
|
| 705 |
+
return result
|
| 706 |
+
|
| 707 |
+
@router.delete("/states/{state_id}", status_code=204)
|
| 708 |
+
def delete_state(
|
| 709 |
+
state_id: int,
|
| 710 |
+
db: Session = Depends(get_db),
|
| 711 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 712 |
+
):
|
| 713 |
+
"""Delete a state"""
|
| 714 |
+
service = ReferenceDataService(db)
|
| 715 |
+
success = service.delete_state(state_id)
|
| 716 |
+
if not success:
|
| 717 |
+
raise HTTPException(status_code=404, detail="State not found")
|
| 718 |
+
return Response(status_code=204)
|
| 719 |
+
|
| 720 |
+
@router.delete("/countries/{country_id}", status_code=204)
|
| 721 |
+
def delete_country(
|
| 722 |
+
country_id: int,
|
| 723 |
+
db: Session = Depends(get_db),
|
| 724 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 725 |
+
):
|
| 726 |
+
"""Delete a country"""
|
| 727 |
+
service = ReferenceDataService(db)
|
| 728 |
+
success = service.delete_country(country_id)
|
| 729 |
+
if not success:
|
| 730 |
+
raise HTTPException(status_code=404, detail="Country not found")
|
| 731 |
+
return Response(status_code=204)
|
| 732 |
+
|
| 733 |
+
@router.delete("/company-types/{company_type_id}", status_code=204)
|
| 734 |
+
def delete_company_type(
|
| 735 |
+
company_type_id: int,
|
| 736 |
+
db: Session = Depends(get_db),
|
| 737 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 738 |
+
):
|
| 739 |
+
"""Delete a company type"""
|
| 740 |
+
service = ReferenceDataService(db)
|
| 741 |
+
success = service.delete_company_type(company_type_id)
|
| 742 |
+
if not success:
|
| 743 |
+
raise HTTPException(status_code=404, detail="Company type not found")
|
| 744 |
+
return Response(status_code=204)
|
| 745 |
+
|
| 746 |
+
@router.delete("/lead-sources/{lead_generated_from_id}", status_code=204)
|
| 747 |
+
def delete_lead_source(
|
| 748 |
+
lead_generated_from_id: int,
|
| 749 |
+
db: Session = Depends(get_db),
|
| 750 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 751 |
+
):
|
| 752 |
+
"""Delete a lead source"""
|
| 753 |
+
service = ReferenceDataService(db)
|
| 754 |
+
success = service.delete_lead_source(lead_generated_from_id)
|
| 755 |
+
if not success:
|
| 756 |
+
raise HTTPException(status_code=404, detail="Lead source not found")
|
| 757 |
+
return Response(status_code=204)
|
| 758 |
+
|
| 759 |
+
@router.delete("/payment-terms/{payment_term_id}", status_code=204)
|
| 760 |
+
def delete_payment_term(
|
| 761 |
+
payment_term_id: int,
|
| 762 |
+
db: Session = Depends(get_db),
|
| 763 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 764 |
+
):
|
| 765 |
+
"""Delete a payment term"""
|
| 766 |
+
service = ReferenceDataService(db)
|
| 767 |
+
success = service.delete_payment_term(payment_term_id)
|
| 768 |
+
if not success:
|
| 769 |
+
raise HTTPException(status_code=404, detail="Payment term not found")
|
| 770 |
+
return Response(status_code=204)
|
| 771 |
+
|
| 772 |
+
@router.delete("/purchase-prices/{purchase_price_id}", status_code=204)
|
| 773 |
+
def delete_purchase_price(
|
| 774 |
+
purchase_price_id: int,
|
| 775 |
+
db: Session = Depends(get_db),
|
| 776 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 777 |
+
):
|
| 778 |
+
"""Delete a purchase price"""
|
| 779 |
+
service = ReferenceDataService(db)
|
| 780 |
+
success = service.delete_purchase_price(purchase_price_id)
|
| 781 |
+
if not success:
|
| 782 |
+
raise HTTPException(status_code=404, detail="Purchase price not found")
|
| 783 |
+
return Response(status_code=204)
|
| 784 |
+
|
| 785 |
+
@router.delete("/rental-prices/{rental_price_id}", status_code=204)
|
| 786 |
+
def delete_rental_price(
|
| 787 |
+
rental_price_id: int,
|
| 788 |
+
db: Session = Depends(get_db),
|
| 789 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 790 |
+
):
|
| 791 |
+
"""Delete a rental price"""
|
| 792 |
+
service = ReferenceDataService(db)
|
| 793 |
+
success = service.delete_rental_price(rental_price_id)
|
| 794 |
+
if not success:
|
| 795 |
+
raise HTTPException(status_code=404, detail="Rental price not found")
|
| 796 |
+
return Response(status_code=204)
|
| 797 |
+
|
| 798 |
+
@router.delete("/barrier-sizes/{barrier_size_id}", status_code=204)
|
| 799 |
+
def delete_barrier_size(
|
| 800 |
+
barrier_size_id: int,
|
| 801 |
+
db: Session = Depends(get_db),
|
| 802 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 803 |
+
):
|
| 804 |
+
"""Delete a barrier size"""
|
| 805 |
+
service = ReferenceDataService(db)
|
| 806 |
+
success = service.delete_barrier_size(barrier_size_id)
|
| 807 |
+
if not success:
|
| 808 |
+
raise HTTPException(status_code=404, detail="Barrier size not found")
|
| 809 |
+
return Response(status_code=204)
|
| 810 |
+
|
| 811 |
+
@router.delete("/product-applications/{application_id}", status_code=204)
|
| 812 |
+
def delete_product_application(
|
| 813 |
+
application_id: int,
|
| 814 |
+
db: Session = Depends(get_db),
|
| 815 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 816 |
+
):
|
| 817 |
+
"""Delete a product application"""
|
| 818 |
+
service = ReferenceDataService(db)
|
| 819 |
+
success = service.delete_product_application(application_id)
|
| 820 |
+
if not success:
|
| 821 |
+
raise HTTPException(status_code=404, detail="Product application not found")
|
| 822 |
+
return Response(status_code=204)
|
| 823 |
+
|
| 824 |
+
@router.delete("/fobs/{fob_id}", status_code=204)
|
| 825 |
+
def delete_fob(
|
| 826 |
+
fob_id: int,
|
| 827 |
+
db: Session = Depends(get_db),
|
| 828 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 829 |
+
):
|
| 830 |
+
"""Delete a FOB"""
|
| 831 |
+
service = ReferenceDataService(db)
|
| 832 |
+
success = service.delete_fob(fob_id)
|
| 833 |
+
if not success:
|
| 834 |
+
raise HTTPException(status_code=404, detail="FOB not found")
|
| 835 |
+
return Response(status_code=204)
|
| 836 |
+
|
| 837 |
+
@router.delete("/est-ship-dates/{est_ship_date_id}", status_code=204)
|
| 838 |
+
def delete_est_ship_date(
|
| 839 |
+
est_ship_date_id: int,
|
| 840 |
+
db: Session = Depends(get_db),
|
| 841 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 842 |
+
):
|
| 843 |
+
"""Delete an estimated ship date"""
|
| 844 |
+
service = ReferenceDataService(db)
|
| 845 |
+
success = service.delete_est_ship_date(est_ship_date_id)
|
| 846 |
+
if not success:
|
| 847 |
+
raise HTTPException(status_code=404, detail="Estimated ship date not found")
|
| 848 |
+
return Response(status_code=204)
|
| 849 |
+
|
| 850 |
+
@router.delete("/est-freights/{est_freight_id}", status_code=204)
|
| 851 |
+
def delete_est_freight(
|
| 852 |
+
est_freight_id: int,
|
| 853 |
+
db: Session = Depends(get_db),
|
| 854 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 855 |
+
):
|
| 856 |
+
"""Delete an estimated freight"""
|
| 857 |
+
service = ReferenceDataService(db)
|
| 858 |
+
success = service.delete_est_freight(est_freight_id)
|
| 859 |
+
if not success:
|
| 860 |
+
raise HTTPException(status_code=404, detail="Estimated freight not found")
|
| 861 |
+
return Response(status_code=204)
|
| 862 |
+
|
| 863 |
+
@router.delete("/status-info/{status_info_id}", status_code=204)
|
| 864 |
+
def delete_status_info(
|
| 865 |
+
status_info_id: int,
|
| 866 |
+
db: Session = Depends(get_db),
|
| 867 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 868 |
+
):
|
| 869 |
+
"""Delete a status info"""
|
| 870 |
+
service = ReferenceDataService(db)
|
| 871 |
+
success = service.delete_status_info(status_info_id)
|
| 872 |
+
if not success:
|
| 873 |
+
raise HTTPException(status_code=404, detail="Status info not found")
|
| 874 |
+
return Response(status_code=204)
|
| 875 |
+
|
| 876 |
+
@router.delete("/priorities/{priority_id}", status_code=204)
|
| 877 |
+
def delete_priority(
|
| 878 |
+
priority_id: int,
|
| 879 |
+
db: Session = Depends(get_db),
|
| 880 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 881 |
+
):
|
| 882 |
+
"""Delete a priority"""
|
| 883 |
+
service = ReferenceDataService(db)
|
| 884 |
+
success = service.delete_priority(priority_id)
|
| 885 |
+
if not success:
|
| 886 |
+
raise HTTPException(status_code=404, detail="Priority not found")
|
| 887 |
+
return Response(status_code=204)
|
| 888 |
+
|
| 889 |
+
@router.get("/phone-types", response_model=List[PhoneTypeOut])
|
| 890 |
+
def get_phone_types(
|
| 891 |
+
active_only: bool = Query(False, description="Return only active records"),
|
| 892 |
+
db: Session = Depends(get_db),
|
| 893 |
+
current_user: Optional[CurrentUser] = Depends(get_current_user_optional)
|
| 894 |
+
):
|
| 895 |
+
"""Get all phone types"""
|
| 896 |
+
service = ReferenceDataService(db)
|
| 897 |
+
return service.get_phone_types(active_only)
|
| 898 |
+
|
| 899 |
+
@router.post("/phone-types", response_model=PhoneTypeOut, status_code=201)
|
| 900 |
+
def create_phone_type(
|
| 901 |
+
data: PhoneTypeCreateIn,
|
| 902 |
+
db: Session = Depends(get_db),
|
| 903 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 904 |
+
):
|
| 905 |
+
"""Create a new phone type"""
|
| 906 |
+
service = ReferenceDataService(db)
|
| 907 |
+
return service.create_phone_type(data)
|
| 908 |
+
|
| 909 |
+
@router.put("/phone-types/{phone_type_id}", response_model=PhoneTypeOut)
|
| 910 |
+
def update_phone_type(
|
| 911 |
+
phone_type_id: int,
|
| 912 |
+
data: PhoneTypeUpdateIn,
|
| 913 |
+
db: Session = Depends(get_db),
|
| 914 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 915 |
+
):
|
| 916 |
+
"""Update an existing phone type"""
|
| 917 |
+
service = ReferenceDataService(db)
|
| 918 |
+
result = service.update_phone_type(phone_type_id, data)
|
| 919 |
+
if not result:
|
| 920 |
+
raise HTTPException(status_code=404, detail="Phone type not found")
|
| 921 |
+
return result
|
| 922 |
+
|
| 923 |
+
@router.delete("/phone-types/{phone_type_id}", status_code=204)
|
| 924 |
+
def delete_phone_type(
|
| 925 |
+
phone_type_id: int,
|
| 926 |
+
db: Session = Depends(get_db),
|
| 927 |
+
current_user: CurrentUser = Depends(get_current_user_optional)
|
| 928 |
+
):
|
| 929 |
+
"""Delete a phone type"""
|
| 930 |
+
service = ReferenceDataService(db)
|
| 931 |
+
success = service.delete_phone_type(phone_type_id)
|
| 932 |
+
if not success:
|
| 933 |
+
raise HTTPException(status_code=404, detail="Phone type not found")
|
| 934 |
+
return Response(status_code=204)
|
app/controllers/reports.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Response
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.db.session import get_db
|
| 4 |
+
from app.services.report_service import ReportService
|
| 5 |
+
from app.schemas.report import ReportOut
|
| 6 |
+
from typing import List
|
| 7 |
+
import csv
|
| 8 |
+
import io
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/api/v1/reports", tags=["reports"])
|
| 11 |
+
|
| 12 |
+
@router.get("/projects", response_model=List[ReportOut])
|
| 13 |
+
def get_reports(from_: str, to: str, type: str = "json", db: Session = Depends(get_db)):
|
| 14 |
+
# For demo, just return all reports
|
| 15 |
+
service = ReportService(db)
|
| 16 |
+
reports = db.query(ReportService.db.query(ReportService.db)).all()
|
| 17 |
+
if type == "csv":
|
| 18 |
+
output = io.StringIO()
|
| 19 |
+
writer = csv.writer(output)
|
| 20 |
+
writer.writerow(["id", "project_id", "type", "status", "created_at", "completed"])
|
| 21 |
+
for r in reports:
|
| 22 |
+
writer.writerow([r.id, r.project_id, r.type, r.status, r.created_at, r.completed])
|
| 23 |
+
response = Response(content=output.getvalue(), media_type="text/csv")
|
| 24 |
+
response.headers["Content-Disposition"] = "attachment; filename=reports.csv"
|
| 25 |
+
return response
|
| 26 |
+
return reports
|
app/core/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (1.46 kB). View file
|
|
|
app/core/__pycache__/dependencies.cpython-313.pyc
ADDED
|
Binary file (4.24 kB). View file
|
|
|
app/core/__pycache__/exceptions.cpython-313.pyc
ADDED
|
Binary file (2.28 kB). View file
|
|
|
app/core/__pycache__/logging.cpython-313.pyc
ADDED
|
Binary file (1.42 kB). View file
|
|
|
app/core/__pycache__/security.cpython-313.pyc
ADDED
|
Binary file (2.64 kB). View file
|
|
|
app/core/__pycache__/timing.cpython-313.pyc
ADDED
|
Binary file (5.36 kB). View file
|
|
|
app/core/config.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
class Settings(BaseSettings):
|
| 5 |
+
APP_NAME: str = "AquaBarrier Core API"
|
| 6 |
+
API_V1_STR: str = "/api/v1"
|
| 7 |
+
SECRET_KEY: str
|
| 8 |
+
JWT_ALGORITHM: str = "HS256"
|
| 9 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
| 10 |
+
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
| 11 |
+
SQLSERVER_USER: str
|
| 12 |
+
SQLSERVER_PASSWORD: str
|
| 13 |
+
SQLSERVER_HOST: str
|
| 14 |
+
SQLSERVER_PORT: int
|
| 15 |
+
SQLSERVER_DB: str
|
| 16 |
+
SQLSERVER_DRIVER: str = "ODBC Driver 18 for SQL Server"
|
| 17 |
+
CORS_ORIGINS: Optional[str] = "*"
|
| 18 |
+
LOG_LEVEL: str = "INFO"
|
| 19 |
+
class Config:
|
| 20 |
+
env_file = ".env"
|
| 21 |
+
env_file_encoding = "utf-8"
|
| 22 |
+
settings = Settings()
|
app/core/dependencies.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Depends, HTTPException, status
|
| 2 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from app.db.session import get_db
|
| 5 |
+
from app.services.auth_service import AuthService
|
| 6 |
+
from app.schemas.auth import CurrentUser
|
| 7 |
+
from app.core.exceptions import AuthException
|
| 8 |
+
from typing import Optional
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
security = HTTPBearer()
|
| 14 |
+
|
| 15 |
+
async def get_current_user(
|
| 16 |
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 17 |
+
db: Session = Depends(get_db)
|
| 18 |
+
) -> CurrentUser:
|
| 19 |
+
"""
|
| 20 |
+
Dependency to get the current authenticated user from JWT token.
|
| 21 |
+
Works for both regular users and employees.
|
| 22 |
+
"""
|
| 23 |
+
credentials_exception = HTTPException(
|
| 24 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 25 |
+
detail="Could not validate credentials",
|
| 26 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
token = credentials.credentials
|
| 31 |
+
auth_service = AuthService(db)
|
| 32 |
+
user_data = auth_service.get_current_user_from_token(token)
|
| 33 |
+
return CurrentUser(**user_data)
|
| 34 |
+
except AuthException as e:
|
| 35 |
+
logger.warning(f"Authentication failed: {e}")
|
| 36 |
+
raise credentials_exception
|
| 37 |
+
except Exception as e:
|
| 38 |
+
logger.error(f"Unexpected error in authentication: {e}")
|
| 39 |
+
raise credentials_exception
|
| 40 |
+
|
| 41 |
+
async def get_current_employee(
|
| 42 |
+
current_user: CurrentUser = Depends(get_current_user)
|
| 43 |
+
) -> CurrentUser:
|
| 44 |
+
"""
|
| 45 |
+
Dependency that ensures the current user is an employee.
|
| 46 |
+
"""
|
| 47 |
+
if current_user.user_type != "employee":
|
| 48 |
+
raise HTTPException(
|
| 49 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 50 |
+
detail="Employee access required"
|
| 51 |
+
)
|
| 52 |
+
return current_user
|
| 53 |
+
|
| 54 |
+
async def get_current_regular_user(
|
| 55 |
+
current_user: CurrentUser = Depends(get_current_user)
|
| 56 |
+
) -> CurrentUser:
|
| 57 |
+
"""
|
| 58 |
+
Dependency that ensures the current user is a regular user (not employee).
|
| 59 |
+
"""
|
| 60 |
+
if current_user.user_type != "user":
|
| 61 |
+
raise HTTPException(
|
| 62 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 63 |
+
detail="Regular user access required"
|
| 64 |
+
)
|
| 65 |
+
return current_user
|
| 66 |
+
|
| 67 |
+
async def get_current_admin_employee(
|
| 68 |
+
current_user: CurrentUser = Depends(get_current_employee)
|
| 69 |
+
) -> CurrentUser:
|
| 70 |
+
"""
|
| 71 |
+
Dependency that ensures the current user is an admin employee.
|
| 72 |
+
Checks EmployeeType for admin privileges.
|
| 73 |
+
"""
|
| 74 |
+
employee_type = current_user.token_payload.get("employee_type")
|
| 75 |
+
# Assuming EmployeeType 1 = Admin (adjust based on your business logic)
|
| 76 |
+
if employee_type != 1:
|
| 77 |
+
raise HTTPException(
|
| 78 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 79 |
+
detail="Admin privileges required"
|
| 80 |
+
)
|
| 81 |
+
return current_user
|
| 82 |
+
|
| 83 |
+
# Optional dependency for routes that work with or without authentication
|
| 84 |
+
async def get_current_user_optional(
|
| 85 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
|
| 86 |
+
db: Session = Depends(get_db)
|
| 87 |
+
) -> Optional[CurrentUser]:
|
| 88 |
+
"""
|
| 89 |
+
Optional dependency that returns user if authenticated, None otherwise.
|
| 90 |
+
Useful for routes that have different behavior for authenticated vs anonymous users.
|
| 91 |
+
"""
|
| 92 |
+
if not credentials:
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
token = credentials.credentials
|
| 97 |
+
auth_service = AuthService(db)
|
| 98 |
+
user_data = auth_service.get_current_user_from_token(token)
|
| 99 |
+
return CurrentUser(**user_data)
|
| 100 |
+
except Exception:
|
| 101 |
+
return None
|
app/core/exceptions.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException, status
|
| 2 |
+
|
| 3 |
+
class AuthException(HTTPException):
|
| 4 |
+
def __init__(self, detail: str = "Authentication failed"):
|
| 5 |
+
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)
|
| 6 |
+
|
| 7 |
+
class NotFoundException(HTTPException):
|
| 8 |
+
def __init__(self, detail: str = "Resource not found"):
|
| 9 |
+
super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
|
| 10 |
+
|
| 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/core/logging.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
|
| 5 |
+
|
| 6 |
+
def setup_logging(level: str = "INFO"):
|
| 7 |
+
"""Configure application logging with query timing support.
|
| 8 |
+
|
| 9 |
+
Args:
|
| 10 |
+
level: Base logging level (INFO, DEBUG, WARNING, etc.)
|
| 11 |
+
|
| 12 |
+
The query timing logger can be independently controlled via:
|
| 13 |
+
- INFO: Shows medium and slow queries (>500ms)
|
| 14 |
+
- DEBUG: Shows all queries with timing
|
| 15 |
+
- WARNING: Shows only slow queries (>1s)
|
| 16 |
+
"""
|
| 17 |
+
logging.basicConfig(
|
| 18 |
+
level=level,
|
| 19 |
+
format=LOG_FORMAT,
|
| 20 |
+
stream=sys.stdout
|
| 21 |
+
)
|
| 22 |
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
| 23 |
+
|
| 24 |
+
# Configure query timing logger
|
| 25 |
+
# Set to INFO to see medium/slow queries, DEBUG to see all queries
|
| 26 |
+
query_logger = logging.getLogger("sqlalchemy.query.timing")
|
| 27 |
+
query_logger.setLevel(logging.INFO) # Change to DEBUG to see all query timings
|
| 28 |
+
|
| 29 |
+
# Suppress SQLAlchemy's default echo to avoid duplicate logs
|
| 30 |
+
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
app/core/security.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from passlib.context import CryptContext
|
| 2 |
+
from jose import jwt, JWTError
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from app.core.config import settings
|
| 5 |
+
|
| 6 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 7 |
+
|
| 8 |
+
def hash_password(password: str) -> str:
|
| 9 |
+
return pwd_context.hash(password)
|
| 10 |
+
|
| 11 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 12 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 13 |
+
|
| 14 |
+
def create_access_token(data: dict, expires_delta: timedelta = None):
|
| 15 |
+
to_encode = data.copy()
|
| 16 |
+
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
| 17 |
+
to_encode.update({"exp": expire})
|
| 18 |
+
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 19 |
+
|
| 20 |
+
def create_refresh_token(data: dict, expires_delta: timedelta = None):
|
| 21 |
+
to_encode = data.copy()
|
| 22 |
+
expire = datetime.utcnow() + (expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS))
|
| 23 |
+
to_encode.update({"exp": expire})
|
| 24 |
+
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 25 |
+
|
| 26 |
+
def decode_token(token: str):
|
| 27 |
+
try:
|
| 28 |
+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
| 29 |
+
return payload
|
| 30 |
+
except JWTError:
|
| 31 |
+
return None
|
app/core/timing.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Timing utilities for performance monitoring."""
|
| 2 |
+
import time
|
| 3 |
+
import logging
|
| 4 |
+
from contextlib import contextmanager
|
| 5 |
+
from functools import wraps
|
| 6 |
+
from typing import Callable, Any
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@contextmanager
|
| 12 |
+
def timer(operation_name: str, log_level: int = logging.INFO):
|
| 13 |
+
"""Context manager for timing operations.
|
| 14 |
+
|
| 15 |
+
Usage:
|
| 16 |
+
with timer("Database query"):
|
| 17 |
+
result = db.query(...).all()
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
operation_name: Description of the operation being timed
|
| 21 |
+
log_level: Logging level (default: INFO)
|
| 22 |
+
"""
|
| 23 |
+
start = time.time()
|
| 24 |
+
try:
|
| 25 |
+
yield
|
| 26 |
+
finally:
|
| 27 |
+
elapsed = time.time() - start
|
| 28 |
+
logger.log(log_level, f"{operation_name} took {elapsed:.3f}s")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def timed(operation_name: str = None):
|
| 32 |
+
"""Decorator to time function execution.
|
| 33 |
+
|
| 34 |
+
Usage:
|
| 35 |
+
@timed("Processing customer data")
|
| 36 |
+
def process_customers(data):
|
| 37 |
+
...
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
operation_name: Description of the operation (defaults to function name)
|
| 41 |
+
"""
|
| 42 |
+
def decorator(func: Callable) -> Callable:
|
| 43 |
+
name = operation_name or f"{func.__module__}.{func.__name__}"
|
| 44 |
+
|
| 45 |
+
@wraps(func)
|
| 46 |
+
def wrapper(*args, **kwargs) -> Any:
|
| 47 |
+
start = time.time()
|
| 48 |
+
try:
|
| 49 |
+
result = func(*args, **kwargs)
|
| 50 |
+
return result
|
| 51 |
+
finally:
|
| 52 |
+
elapsed = time.time() - start
|
| 53 |
+
if elapsed > 1.0:
|
| 54 |
+
logger.warning(f"{name} took {elapsed:.3f}s")
|
| 55 |
+
elif elapsed > 0.5:
|
| 56 |
+
logger.info(f"{name} took {elapsed:.3f}s")
|
| 57 |
+
else:
|
| 58 |
+
logger.debug(f"{name} took {elapsed:.3f}s")
|
| 59 |
+
|
| 60 |
+
return wrapper
|
| 61 |
+
return decorator
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class QueryTimer:
|
| 65 |
+
"""Manual query timer for tracking specific query performance.
|
| 66 |
+
|
| 67 |
+
Usage:
|
| 68 |
+
qt = QueryTimer()
|
| 69 |
+
qt.start("Customer list query")
|
| 70 |
+
result = db.query(...).all()
|
| 71 |
+
qt.stop()
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
def __init__(self):
|
| 75 |
+
self.start_time = None
|
| 76 |
+
self.operation_name = None
|
| 77 |
+
self.logger = logging.getLogger("app.query.timing")
|
| 78 |
+
|
| 79 |
+
def start(self, operation_name: str):
|
| 80 |
+
"""Start timing an operation."""
|
| 81 |
+
self.operation_name = operation_name
|
| 82 |
+
self.start_time = time.time()
|
| 83 |
+
self.logger.debug(f"Started: {operation_name}")
|
| 84 |
+
|
| 85 |
+
def stop(self) -> float:
|
| 86 |
+
"""Stop timing and log the result. Returns elapsed time."""
|
| 87 |
+
if self.start_time is None:
|
| 88 |
+
self.logger.warning("QueryTimer.stop() called without start()")
|
| 89 |
+
return 0.0
|
| 90 |
+
|
| 91 |
+
elapsed = time.time() - self.start_time
|
| 92 |
+
|
| 93 |
+
if elapsed > 1.0:
|
| 94 |
+
self.logger.warning(f"SLOW: {self.operation_name} took {elapsed:.3f}s")
|
| 95 |
+
elif elapsed > 0.5:
|
| 96 |
+
self.logger.info(f"{self.operation_name} took {elapsed:.3f}s")
|
| 97 |
+
else:
|
| 98 |
+
self.logger.debug(f"{self.operation_name} took {elapsed:.3f}s")
|
| 99 |
+
|
| 100 |
+
self.start_time = None
|
| 101 |
+
self.operation_name = None
|
| 102 |
+
return elapsed
|
app/db/__pycache__/base.cpython-313.pyc
ADDED
|
Binary file (794 Bytes). View file
|
|
|
app/db/__pycache__/session.cpython-313.pyc
ADDED
|
Binary file (4.66 kB). View file
|
|
|
app/db/base.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database base class compatible with multiple SQLAlchemy versions.
|
| 2 |
+
|
| 3 |
+
Prefer DeclarativeBase (SQLAlchemy 2.x). If unavailable, fall back to
|
| 4 |
+
declarative_base for older releases.
|
| 5 |
+
"""
|
| 6 |
+
try:
|
| 7 |
+
from sqlalchemy.orm import DeclarativeBase
|
| 8 |
+
|
| 9 |
+
class Base(DeclarativeBase):
|
| 10 |
+
pass
|
| 11 |
+
except Exception:
|
| 12 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 13 |
+
|
| 14 |
+
Base = declarative_base()
|
app/db/migrations/env.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from logging.config import fileConfig
|
| 2 |
+
from sqlalchemy import engine_from_config, pool
|
| 3 |
+
from alembic import context
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
|
| 7 |
+
from app.db.base import Base
|
| 8 |
+
from app.db.models import user, customer, project, report
|
| 9 |
+
from app.core.config import settings
|
| 10 |
+
|
| 11 |
+
config = context.config
|
| 12 |
+
fileConfig(config.config_file_name)
|
| 13 |
+
target_metadata = Base.metadata
|
| 14 |
+
|
| 15 |
+
config.set_main_option('sqlalchemy.url', (
|
| 16 |
+
f"mssql+pyodbc://{settings.SQLSERVER_USER}:{settings.SQLSERVER_PASSWORD}"
|
| 17 |
+
f"@{settings.SQLSERVER_HOST}:{settings.SQLSERVER_PORT}/{settings.SQLSERVER_DB}?driver={settings.SQLSERVER_DRIVER.replace(' ', '+')}"
|
| 18 |
+
))
|
| 19 |
+
|
| 20 |
+
def run_migrations_offline():
|
| 21 |
+
context.configure(url=config.get_main_option("sqlalchemy.url"), target_metadata=target_metadata, literal_binds=True)
|
| 22 |
+
with context.begin_transaction():
|
| 23 |
+
context.run_migrations()
|
| 24 |
+
|
| 25 |
+
def run_migrations_online():
|
| 26 |
+
connectable = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool)
|
| 27 |
+
with connectable.connect() as connection:
|
| 28 |
+
context.configure(connection=connection, target_metadata=target_metadata)
|
| 29 |
+
with context.begin_transaction():
|
| 30 |
+
context.run_migrations()
|
| 31 |
+
|
| 32 |
+
if context.is_offline_mode():
|
| 33 |
+
run_migrations_offline()
|
| 34 |
+
else:
|
| 35 |
+
run_migrations_online()
|
app/db/models/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Import all models to ensure they are registered with SQLAlchemy
|
| 2 |
+
from .address import *
|
| 3 |
+
from .barrier_size import * # Use the main BarrierSizes model
|
| 4 |
+
from .bidder import *
|
| 5 |
+
from .bidder_contact import * # Make sure BidderContact is imported
|
| 6 |
+
from .bidders_barrier_sizes import *
|
| 7 |
+
from .contact import *
|
| 8 |
+
from .customer import *
|
| 9 |
+
from .distributor import *
|
| 10 |
+
from .employee import *
|
| 11 |
+
from .project import *
|
| 12 |
+
from .project_related import *
|
| 13 |
+
# Skip reference.py to avoid BarrierSize duplicate
|
| 14 |
+
# from .reference import * # Commented out due to BarrierSizes table conflict
|
| 15 |
+
from .report import *
|
| 16 |
+
from .user import *
|
app/db/models/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (494 Bytes). View file
|
|
|
app/db/models/__pycache__/address.cpython-313.pyc
ADDED
|
Binary file (1.03 kB). View file
|
|
|