rajeshbms commited on
Commit
d5f727d
·
1 Parent(s): 9ca36dd

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
Files changed (50) hide show
  1. .gitignore +34 -0
  2. DATABASE_CHANGES_CONSOLIDATED.sql +322 -0
  3. Dockerfile +25 -0
  4. QUERY_TIMING.md +215 -0
  5. QUICKSTART_TIMING.md +140 -0
  6. README 2.md +511 -0
  7. app/Dockerfile +25 -0
  8. app/Makefile +14 -0
  9. app/README.md +55 -0
  10. app/__pycache__/app.cpython-313.pyc +0 -0
  11. app/app.py +98 -0
  12. app/controllers/__pycache__/auth.cpython-313.pyc +0 -0
  13. app/controllers/__pycache__/bidders.cpython-313.pyc +0 -0
  14. app/controllers/__pycache__/customers.cpython-313.pyc +0 -0
  15. app/controllers/__pycache__/dashboard.cpython-313.pyc +0 -0
  16. app/controllers/__pycache__/distributors.cpython-313.pyc +0 -0
  17. app/controllers/__pycache__/employees.cpython-313.pyc +0 -0
  18. app/controllers/__pycache__/notes.cpython-313.pyc +0 -0
  19. app/controllers/__pycache__/projects.cpython-313.pyc +0 -0
  20. app/controllers/__pycache__/reference.cpython-313.pyc +0 -0
  21. app/controllers/__pycache__/reports.cpython-313.pyc +0 -0
  22. app/controllers/auth.py +86 -0
  23. app/controllers/bidders.py +695 -0
  24. app/controllers/customers.py +417 -0
  25. app/controllers/dashboard.py +104 -0
  26. app/controllers/distributors.py +300 -0
  27. app/controllers/employees.py +102 -0
  28. app/controllers/notes.py +136 -0
  29. app/controllers/projects.py +131 -0
  30. app/controllers/reference.py +934 -0
  31. app/controllers/reports.py +26 -0
  32. app/core/__pycache__/config.cpython-313.pyc +0 -0
  33. app/core/__pycache__/dependencies.cpython-313.pyc +0 -0
  34. app/core/__pycache__/exceptions.cpython-313.pyc +0 -0
  35. app/core/__pycache__/logging.cpython-313.pyc +0 -0
  36. app/core/__pycache__/security.cpython-313.pyc +0 -0
  37. app/core/__pycache__/timing.cpython-313.pyc +0 -0
  38. app/core/config.py +22 -0
  39. app/core/dependencies.py +101 -0
  40. app/core/exceptions.py +17 -0
  41. app/core/logging.py +30 -0
  42. app/core/security.py +31 -0
  43. app/core/timing.py +102 -0
  44. app/db/__pycache__/base.cpython-313.pyc +0 -0
  45. app/db/__pycache__/session.cpython-313.pyc +0 -0
  46. app/db/base.py +14 -0
  47. app/db/migrations/env.py +35 -0
  48. app/db/models/__init__.py +16 -0
  49. app/db/models/__pycache__/__init__.cpython-313.pyc +0 -0
  50. 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