Spaces:
Running
Running
Commit ·
85e4fac
1
Parent(s): 163b56e
feat(staff): implement staff list endpoint with projection support
Browse filesrefactor(response): standardize error response format
test: add tests for error handling and staff list endpoint
docs: remove outdated documentation files
feat(middleware): add request logging middleware
- POS_COLLECTIONS.md +0 -100
- POS_ENDPOINT_PROTECTION_SUMMARY.md +0 -212
- POS_SYNC_IMPLEMENTATION.md +0 -310
- app/main.py +123 -2
- app/middleware/logging_middleware.py +68 -0
- app/staff/controllers/router.py +30 -42
- app/staff/schemas/staff_schema.py +3 -3
- app/staff/services/staff_service.py +26 -54
- app/utils/response.py +32 -20
- requirements.txt +1 -0
- tests/test_error_handling.py +34 -0
- tests/test_staff_list.py +72 -0
POS_COLLECTIONS.md
DELETED
|
@@ -1,100 +0,0 @@
|
|
| 1 |
-
# POS MongoDB Collections
|
| 2 |
-
|
| 3 |
-
## Collection Naming Convention
|
| 4 |
-
|
| 5 |
-
All POS MongoDB collections use the `pos_*` prefix for better organization and to avoid conflicts with other microservices.
|
| 6 |
-
|
| 7 |
-
## Collection List
|
| 8 |
-
|
| 9 |
-
| Collection Name | Purpose | Key Fields |
|
| 10 |
-
|----------------|---------|------------|
|
| 11 |
-
| `pos_customers` | Customer data | customer_id, merchant_id, name, phone, email |
|
| 12 |
-
| `pos_staff` | Staff/employee data | staff_id, merchant_id, name, role, specializations |
|
| 13 |
-
| `pos_catalogue_services` | Service catalog | service_id, merchant_id, name, code, pricing |
|
| 14 |
-
| `pos_appointments` | Appointment bookings | appointment_id, merchant_id, customer_id, services |
|
| 15 |
-
| `pos_sales` | Sales transactions | sale_id, merchant_id, customer_id, items, payments |
|
| 16 |
-
| `pos_sales_items` | Individual sale items | sale_item_id, sale_id, service_id, staff_id |
|
| 17 |
-
| `pos_payments` | Payment records | payment_id, sale_id, amount, payment_mode |
|
| 18 |
-
| `pos_refunds` | Refund records | refund_id, sale_id, amount, reason |
|
| 19 |
-
|
| 20 |
-
## Index Strategy
|
| 21 |
-
|
| 22 |
-
Each collection includes standard indexes:
|
| 23 |
-
- `merchant_id` - For tenant isolation
|
| 24 |
-
- `created_at` - For time-based queries
|
| 25 |
-
- Entity-specific indexes (e.g., `customer_id`, `staff_id`)
|
| 26 |
-
|
| 27 |
-
## Usage in Code
|
| 28 |
-
|
| 29 |
-
### Service Layer
|
| 30 |
-
```python
|
| 31 |
-
# Catalogue services
|
| 32 |
-
CATALOGUE_SERVICES_COLLECTION = "pos_catalogue_services"
|
| 33 |
-
db = get_database()
|
| 34 |
-
services = await db[CATALOGUE_SERVICES_COLLECTION].find(query).to_list(length=limit)
|
| 35 |
-
|
| 36 |
-
# Customers
|
| 37 |
-
customers = await db.pos_customers.find({"merchant_id": merchant_id}).to_list(length=None)
|
| 38 |
-
|
| 39 |
-
# Staff
|
| 40 |
-
staff = await db.pos_staff.find({"merchant_id": merchant_id}).to_list(length=None)
|
| 41 |
-
```
|
| 42 |
-
|
| 43 |
-
### Sync Operations
|
| 44 |
-
```python
|
| 45 |
-
# MongoDB to PostgreSQL sync
|
| 46 |
-
mongo_customers = await mongo_db.pos_customers.find({"merchant_id": merchant_id}).to_list(length=None)
|
| 47 |
-
mongo_staff = await mongo_db.pos_staff.find({"merchant_id": merchant_id}).to_list(length=None)
|
| 48 |
-
mongo_services = await mongo_db.pos_catalogue_services.find({"merchant_id": merchant_id}).to_list(length=None)
|
| 49 |
-
```
|
| 50 |
-
|
| 51 |
-
## Migration Notes
|
| 52 |
-
|
| 53 |
-
If you have existing data in the old collection names, you'll need to migrate:
|
| 54 |
-
|
| 55 |
-
```javascript
|
| 56 |
-
// MongoDB migration script
|
| 57 |
-
use cuatrolabs;
|
| 58 |
-
|
| 59 |
-
// Migrate customers
|
| 60 |
-
db.customers.find({}).forEach(function(doc) {
|
| 61 |
-
db.pos_customers.insert(doc);
|
| 62 |
-
});
|
| 63 |
-
|
| 64 |
-
// Migrate staff
|
| 65 |
-
db.staff.find({}).forEach(function(doc) {
|
| 66 |
-
db.pos_staff.insert(doc);
|
| 67 |
-
});
|
| 68 |
-
|
| 69 |
-
// Migrate catalogue services
|
| 70 |
-
db.catalogue_services.find({}).forEach(function(doc) {
|
| 71 |
-
db.pos_catalogue_services.insert(doc);
|
| 72 |
-
});
|
| 73 |
-
|
| 74 |
-
// Verify counts match
|
| 75 |
-
print("Original customers:", db.customers.count());
|
| 76 |
-
print("New pos_customers:", db.pos_customers.count());
|
| 77 |
-
|
| 78 |
-
print("Original staff:", db.staff.count());
|
| 79 |
-
print("New pos_staff:", db.pos_staff.count());
|
| 80 |
-
|
| 81 |
-
print("Original catalogue_services:", db.catalogue_services.count());
|
| 82 |
-
print("New pos_catalogue_services:", db.pos_catalogue_services.count());
|
| 83 |
-
```
|
| 84 |
-
|
| 85 |
-
## Benefits
|
| 86 |
-
|
| 87 |
-
1. **Clear Separation**: Easy to identify POS-specific collections
|
| 88 |
-
2. **No Conflicts**: Avoids naming conflicts with SCM or other microservices
|
| 89 |
-
3. **Better Organization**: Logical grouping of related collections
|
| 90 |
-
4. **Easier Maintenance**: Clear ownership and responsibility
|
| 91 |
-
5. **Scalability**: Easy to add new POS collections following the same pattern
|
| 92 |
-
|
| 93 |
-
## Future Collections
|
| 94 |
-
|
| 95 |
-
When adding new POS collections, follow the naming convention:
|
| 96 |
-
- `pos_inventory` - Inventory tracking
|
| 97 |
-
- `pos_promotions` - Promotional campaigns
|
| 98 |
-
- `pos_loyalty` - Loyalty program data
|
| 99 |
-
- `pos_analytics` - Analytics and reporting data
|
| 100 |
-
- `pos_settings` - POS-specific configuration
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
POS_ENDPOINT_PROTECTION_SUMMARY.md
DELETED
|
@@ -1,212 +0,0 @@
|
|
| 1 |
-
# POS Microservice Endpoint Protection Implementation
|
| 2 |
-
|
| 3 |
-
## Overview
|
| 4 |
-
All POS microservice endpoints have been protected with role-based permissions similar to the SCM microservice pattern. The implementation uses JWT token validation and permission checking against the `scm_access_roles` collection.
|
| 5 |
-
|
| 6 |
-
## Protection System Components
|
| 7 |
-
|
| 8 |
-
### 1. POS Permissions Module
|
| 9 |
-
**File:** `app/dependencies/pos_permissions.py`
|
| 10 |
-
|
| 11 |
-
- `POSPermissionChecker`: Core class for permission validation
|
| 12 |
-
- `require_pos_permission(module, action)`: Dependency factory for single permission
|
| 13 |
-
- `require_pos_permissions(permissions_list)`: Dependency factory for multiple permissions
|
| 14 |
-
- `get_user_permissions()`: Utility to get user's full permission set
|
| 15 |
-
|
| 16 |
-
### 2. Permission Modules
|
| 17 |
-
The following POS modules are protected:
|
| 18 |
-
|
| 19 |
-
- **sales**: Sales orders, retail sales, invoicing
|
| 20 |
-
- **customers**: Customer management
|
| 21 |
-
- **staff**: Staff management
|
| 22 |
-
- **appointments**: Appointment booking and management
|
| 23 |
-
- **catalogue**: Service catalogue management
|
| 24 |
-
|
| 25 |
-
### 3. Permission Actions
|
| 26 |
-
Standard CRUD actions are supported:
|
| 27 |
-
|
| 28 |
-
- **view**: Read access to resources
|
| 29 |
-
- **create**: Create new resources
|
| 30 |
-
- **update**: Modify existing resources
|
| 31 |
-
- **delete**: Remove resources
|
| 32 |
-
|
| 33 |
-
## Protected Endpoints
|
| 34 |
-
|
| 35 |
-
### Sales Orders (`/api/v1/sales/`)
|
| 36 |
-
- `POST /order/list` - requires `sales.view`
|
| 37 |
-
- `GET /order/{id}` - requires `sales.view`
|
| 38 |
-
- `POST /order` - requires `sales.create`
|
| 39 |
-
- `PUT /order/{id}` - requires `sales.update`
|
| 40 |
-
- `POST /order/{id}/invoice` - requires `sales.create`
|
| 41 |
-
- `GET /info/widgets` - requires `sales.view`
|
| 42 |
-
|
| 43 |
-
### Customers (`/api/v1/customers/`)
|
| 44 |
-
- `POST /` - requires `customers.create`
|
| 45 |
-
- `GET /{id}` - requires `customers.view`
|
| 46 |
-
- `POST /list` - requires `customers.view` (converted from GET to POST)
|
| 47 |
-
- `PUT /{id}` - requires `customers.update`
|
| 48 |
-
- `DELETE /{id}` - requires `customers.delete`
|
| 49 |
-
|
| 50 |
-
### Staff (`/api/v1/staff/`)
|
| 51 |
-
- `POST /` - requires `staff.create`
|
| 52 |
-
- `POST /list` - requires `staff.view` (converted from GET to POST)
|
| 53 |
-
- `GET /code/{code}` - requires `staff.view`
|
| 54 |
-
- `GET /{id}` - requires `staff.view`
|
| 55 |
-
- `PUT /{id}` - requires `staff.update`
|
| 56 |
-
- `PATCH /{id}/status` - requires `staff.update`
|
| 57 |
-
- `DELETE /{id}` - requires `staff.delete`
|
| 58 |
-
|
| 59 |
-
### Appointments (`/api/v1/pos/appointments/`)
|
| 60 |
-
- `POST /` - requires `appointments.create`
|
| 61 |
-
- `POST /list` - requires `appointments.view` (converted from GET to POST)
|
| 62 |
-
- `GET /{id}` - requires `appointments.view`
|
| 63 |
-
- `PUT /{id}` - requires `appointments.update`
|
| 64 |
-
- `PATCH /{id}/status` - requires `appointments.update`
|
| 65 |
-
- `POST /{id}/cancel` - requires `appointments.update`
|
| 66 |
-
- `POST /{id}/checkout` - requires `appointments.update`
|
| 67 |
-
|
| 68 |
-
### Catalogue Services (`/api/v1/pos/catalogue/services/`)
|
| 69 |
-
- `POST /` - requires `catalogue.create`
|
| 70 |
-
- `POST /list` - requires `catalogue.view` (converted from GET to POST)
|
| 71 |
-
- `GET /{id}` - requires `catalogue.view`
|
| 72 |
-
- `PUT /{id}` - requires `catalogue.update`
|
| 73 |
-
- `PATCH /{id}/status` - requires `catalogue.update`
|
| 74 |
-
- `DELETE /{id}` - requires `catalogue.delete`
|
| 75 |
-
|
| 76 |
-
### Retail Sales (`/api/v1/pos/sales/`)
|
| 77 |
-
- `POST /` - requires `sales.create`
|
| 78 |
-
- `GET /{id}` - requires `sales.view`
|
| 79 |
-
- `PUT /{id}/items` - requires `sales.update`
|
| 80 |
-
- `POST /{id}/payments` - requires `sales.update`
|
| 81 |
-
- `POST /{id}/cancel` - requires `sales.update`
|
| 82 |
-
- `POST /{id}/refund` - requires `sales.update`
|
| 83 |
-
|
| 84 |
-
## API List Endpoint Standard Compliance
|
| 85 |
-
|
| 86 |
-
### Changes Made
|
| 87 |
-
1. **GET to POST Conversion**: All list endpoints converted from GET to POST method
|
| 88 |
-
2. **Consistent Naming**: All list endpoints use `/list` path
|
| 89 |
-
3. **Ready for Projection**: Endpoints prepared for projection_list parameter implementation
|
| 90 |
-
|
| 91 |
-
### Endpoints Converted
|
| 92 |
-
- `GET /customers/` → `POST /customers/list`
|
| 93 |
-
- `GET /staff/` → `POST /staff/list`
|
| 94 |
-
- `GET /pos/appointments/` → `POST /pos/appointments/list`
|
| 95 |
-
- `GET /pos/catalogue/services/` → `POST /pos/catalogue/services/list`
|
| 96 |
-
|
| 97 |
-
### Next Steps for Full Compliance
|
| 98 |
-
To complete the API list endpoint standard implementation:
|
| 99 |
-
|
| 100 |
-
1. **Add projection_list to schemas**: Update request schemas to include `projection_list: Optional[List[str]]`
|
| 101 |
-
2. **Update service layers**: Implement MongoDB projection logic in service methods
|
| 102 |
-
3. **Return type handling**: Return raw dict when projection used, Pydantic model otherwise
|
| 103 |
-
|
| 104 |
-
## Usage Examples
|
| 105 |
-
|
| 106 |
-
### Basic Permission Check
|
| 107 |
-
```python
|
| 108 |
-
@router.post("/orders")
|
| 109 |
-
async def create_order(
|
| 110 |
-
payload: OrderCreate,
|
| 111 |
-
current_user: TokenUser = Depends(require_pos_permission("sales", "create"))
|
| 112 |
-
):
|
| 113 |
-
# Only users with sales.create permission can access
|
| 114 |
-
return await OrderService.create_order(payload)
|
| 115 |
-
```
|
| 116 |
-
|
| 117 |
-
### Multiple Permission Check
|
| 118 |
-
```python
|
| 119 |
-
@router.post("/complex-operation")
|
| 120 |
-
async def complex_operation(
|
| 121 |
-
current_user: TokenUser = Depends(require_pos_permissions([
|
| 122 |
-
{"module": "sales", "action": "create"},
|
| 123 |
-
{"module": "customers", "action": "update"}
|
| 124 |
-
]))
|
| 125 |
-
):
|
| 126 |
-
# Requires both permissions
|
| 127 |
-
pass
|
| 128 |
-
```
|
| 129 |
-
|
| 130 |
-
## Role Configuration
|
| 131 |
-
|
| 132 |
-
### Sample POS Role in `scm_access_roles` Collection
|
| 133 |
-
```json
|
| 134 |
-
{
|
| 135 |
-
"role_id": "role_pos_manager",
|
| 136 |
-
"role_name": "POS Manager",
|
| 137 |
-
"is_active": true,
|
| 138 |
-
"permissions": {
|
| 139 |
-
"sales": ["view", "create", "update"],
|
| 140 |
-
"customers": ["view", "create", "update", "delete"],
|
| 141 |
-
"staff": ["view", "create", "update"],
|
| 142 |
-
"appointments": ["view", "create", "update"],
|
| 143 |
-
"catalogue": ["view", "create", "update"]
|
| 144 |
-
}
|
| 145 |
-
}
|
| 146 |
-
```
|
| 147 |
-
|
| 148 |
-
### Sample POS Cashier Role
|
| 149 |
-
```json
|
| 150 |
-
{
|
| 151 |
-
"role_id": "role_pos_cashier",
|
| 152 |
-
"role_name": "POS Cashier",
|
| 153 |
-
"is_active": true,
|
| 154 |
-
"permissions": {
|
| 155 |
-
"sales": ["view", "create"],
|
| 156 |
-
"customers": ["view", "create"],
|
| 157 |
-
"appointments": ["view", "create", "update"]
|
| 158 |
-
}
|
| 159 |
-
}
|
| 160 |
-
```
|
| 161 |
-
|
| 162 |
-
## Security Benefits
|
| 163 |
-
|
| 164 |
-
1. **Role-based Access Control**: Fine-grained permissions per module and action
|
| 165 |
-
2. **JWT Token Validation**: Secure token-based authentication
|
| 166 |
-
3. **Consistent Pattern**: Same security model as SCM microservice
|
| 167 |
-
4. **Audit Trail**: Permission checks are logged for security monitoring
|
| 168 |
-
5. **Flexible Permissions**: Easy to add new modules and actions
|
| 169 |
-
|
| 170 |
-
## Error Responses
|
| 171 |
-
|
| 172 |
-
### 401 Unauthorized
|
| 173 |
-
```json
|
| 174 |
-
{
|
| 175 |
-
"detail": "Could not validate credentials"
|
| 176 |
-
}
|
| 177 |
-
```
|
| 178 |
-
|
| 179 |
-
### 403 Forbidden
|
| 180 |
-
```json
|
| 181 |
-
{
|
| 182 |
-
"detail": "Access denied. Required permission: sales.create"
|
| 183 |
-
}
|
| 184 |
-
```
|
| 185 |
-
|
| 186 |
-
## Implementation Status
|
| 187 |
-
|
| 188 |
-
✅ **Completed:**
|
| 189 |
-
- POS permissions system created
|
| 190 |
-
- All existing endpoints protected
|
| 191 |
-
- List endpoints converted to POST method
|
| 192 |
-
- Consistent permission naming
|
| 193 |
-
|
| 194 |
-
🔄 **Next Steps:**
|
| 195 |
-
- Add projection_list support to schemas and services
|
| 196 |
-
- Create POS-specific role definitions
|
| 197 |
-
- Add integration tests for permission system
|
| 198 |
-
- Document role setup procedures
|
| 199 |
-
|
| 200 |
-
## Maintenance
|
| 201 |
-
|
| 202 |
-
### Adding New Endpoints
|
| 203 |
-
1. Import required dependencies
|
| 204 |
-
2. Add permission check to endpoint
|
| 205 |
-
3. Use appropriate module and action names
|
| 206 |
-
4. Follow POST method for list endpoints
|
| 207 |
-
|
| 208 |
-
### Adding New Modules
|
| 209 |
-
1. Define module name (e.g., "inventory", "reports")
|
| 210 |
-
2. Add to role permissions in database
|
| 211 |
-
3. Use in `require_pos_permission()` calls
|
| 212 |
-
4. Document in this file
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
POS_SYNC_IMPLEMENTATION.md
DELETED
|
@@ -1,310 +0,0 @@
|
|
| 1 |
-
# POS MongoDB to PostgreSQL Sync Implementation
|
| 2 |
-
|
| 3 |
-
## Overview
|
| 4 |
-
|
| 5 |
-
This document describes the MongoDB to PostgreSQL synchronization utility implemented for the POS microservice. The sync system ensures that critical POS data is available in both MongoDB (primary) and PostgreSQL (reference) for improved query performance and reporting capabilities.
|
| 6 |
-
|
| 7 |
-
## Architecture
|
| 8 |
-
|
| 9 |
-
### Components
|
| 10 |
-
|
| 11 |
-
1. **Sync Models** (`app/sync/models.py`)
|
| 12 |
-
- PostgreSQL table definitions for reference data
|
| 13 |
-
- Currently supports: `CustomerRef`, `StaffRef`
|
| 14 |
-
|
| 15 |
-
2. **Sync Services**
|
| 16 |
-
- `CustomerSyncService` - Handles customer data synchronization
|
| 17 |
-
- `StaffSyncService` - Handles staff data synchronization
|
| 18 |
-
- `POSSyncService` - Main coordinator service
|
| 19 |
-
|
| 20 |
-
3. **Sync Handler** (`app/sync/handler.py`)
|
| 21 |
-
- Event-driven sync operations
|
| 22 |
-
- Automatic session management
|
| 23 |
-
- Error handling and logging
|
| 24 |
-
|
| 25 |
-
4. **Utilities** (`app/sync/utils.py`)
|
| 26 |
-
- Migration tools for existing data
|
| 27 |
-
- Consistency verification
|
| 28 |
-
- Cleanup operations
|
| 29 |
-
|
| 30 |
-
## Supported Entities
|
| 31 |
-
|
| 32 |
-
### Customers
|
| 33 |
-
- **MongoDB Collection**: `customers`
|
| 34 |
-
- **PostgreSQL Table**: `pos.customer_ref`
|
| 35 |
-
- **Key Fields**: customer_id, merchant_id, name, phone, email, notes, status
|
| 36 |
-
|
| 37 |
-
### Staff
|
| 38 |
-
- **MongoDB Collection**: `staff`
|
| 39 |
-
- **PostgreSQL Table**: `pos.staff_ref`
|
| 40 |
-
- **Key Fields**: staff_id, merchant_id, name, phone, email, role, specializations, status
|
| 41 |
-
|
| 42 |
-
### Catalogue Services
|
| 43 |
-
- **MongoDB Collection**: `catalogue_services`
|
| 44 |
-
- **PostgreSQL Table**: `pos.catalogue_service_ref`
|
| 45 |
-
- **Key Fields**: service_id, merchant_id, service_name, service_code, category, duration_mins, price, gst_rate, status
|
| 46 |
-
|
| 47 |
-
## Usage
|
| 48 |
-
|
| 49 |
-
### 1. Basic Sync Operations
|
| 50 |
-
|
| 51 |
-
```python
|
| 52 |
-
from app.sync.handler import POSSyncHandler
|
| 53 |
-
from app.customers.models.model import CustomerModel
|
| 54 |
-
|
| 55 |
-
# Customer operations
|
| 56 |
-
customer = CustomerModel(...)
|
| 57 |
-
await POSSyncHandler.handle_customer_created(customer)
|
| 58 |
-
await POSSyncHandler.handle_customer_updated(customer_id, customer)
|
| 59 |
-
await POSSyncHandler.handle_customer_deleted(customer_id)
|
| 60 |
-
await POSSyncHandler.handle_customer_status_changed(customer_id, "inactive")
|
| 61 |
-
|
| 62 |
-
# Staff operations
|
| 63 |
-
staff_data = {...}
|
| 64 |
-
await POSSyncHandler.handle_staff_created(staff_data)
|
| 65 |
-
await POSSyncHandler.handle_staff_updated(staff_id, staff_data)
|
| 66 |
-
await POSSyncHandler.handle_staff_deleted(staff_id)
|
| 67 |
-
await POSSyncHandler.handle_staff_status_changed(staff_id, "inactive")
|
| 68 |
-
|
| 69 |
-
# Catalogue service operations
|
| 70 |
-
service_data = {...}
|
| 71 |
-
await POSSyncHandler.handle_catalogue_service_created(service_data)
|
| 72 |
-
await POSSyncHandler.handle_catalogue_service_updated(service_id, service_data)
|
| 73 |
-
await POSSyncHandler.handle_catalogue_service_deleted(service_id)
|
| 74 |
-
await POSSyncHandler.handle_catalogue_service_status_changed(service_id, "archived")
|
| 75 |
-
```
|
| 76 |
-
|
| 77 |
-
### 2. Integration with Service Layer
|
| 78 |
-
|
| 79 |
-
Add sync calls to your service methods:
|
| 80 |
-
|
| 81 |
-
```python
|
| 82 |
-
# In customers/services/service.py
|
| 83 |
-
from app.sync.handler import POSSyncHandler
|
| 84 |
-
|
| 85 |
-
async def create_customer(self, customer_data: dict):
|
| 86 |
-
# Create in MongoDB
|
| 87 |
-
customer = await self.mongo_collection.insert_one(customer_data)
|
| 88 |
-
|
| 89 |
-
# Sync to PostgreSQL
|
| 90 |
-
customer_model = CustomerModel(**customer_data)
|
| 91 |
-
await POSSyncHandler.handle_customer_created(customer_model)
|
| 92 |
-
|
| 93 |
-
return customer
|
| 94 |
-
|
| 95 |
-
async def update_customer(self, customer_id: str, update_data: dict):
|
| 96 |
-
# Update in MongoDB
|
| 97 |
-
result = await self.mongo_collection.update_one(
|
| 98 |
-
{"customer_id": customer_id},
|
| 99 |
-
{"$set": update_data}
|
| 100 |
-
)
|
| 101 |
-
|
| 102 |
-
# Sync to PostgreSQL
|
| 103 |
-
updated_customer = await self.get_customer(customer_id)
|
| 104 |
-
await POSSyncHandler.handle_customer_updated(customer_id, updated_customer)
|
| 105 |
-
|
| 106 |
-
return result
|
| 107 |
-
```
|
| 108 |
-
|
| 109 |
-
### 3. Migration and Maintenance
|
| 110 |
-
|
| 111 |
-
```bash
|
| 112 |
-
# Migrate existing customers
|
| 113 |
-
python -m app.sync.utils migrate_customers merchant_123 1000
|
| 114 |
-
|
| 115 |
-
# Migrate existing staff
|
| 116 |
-
python -m app.sync.utils migrate_staff merchant_123 1000
|
| 117 |
-
|
| 118 |
-
# Verify consistency
|
| 119 |
-
python -m app.sync.utils verify_consistency merchant_123 customers
|
| 120 |
-
|
| 121 |
-
# Clean up orphaned records
|
| 122 |
-
python -m app.sync.utils cleanup_orphaned merchant_123 customers
|
| 123 |
-
```
|
| 124 |
-
|
| 125 |
-
### 4. Health Monitoring
|
| 126 |
-
|
| 127 |
-
```python
|
| 128 |
-
from app.sync.handler import POSSyncHandler
|
| 129 |
-
|
| 130 |
-
# Check sync system health
|
| 131 |
-
health = await POSSyncHandler.check_sync_health()
|
| 132 |
-
print(health)
|
| 133 |
-
# {
|
| 134 |
-
# "overall_health": True,
|
| 135 |
-
# "customer_sync": True,
|
| 136 |
-
# "staff_sync": True,
|
| 137 |
-
# "timestamp": "..."
|
| 138 |
-
# }
|
| 139 |
-
```
|
| 140 |
-
|
| 141 |
-
## Database Schema
|
| 142 |
-
|
| 143 |
-
### PostgreSQL Tables
|
| 144 |
-
|
| 145 |
-
```sql
|
| 146 |
-
-- Customer reference table
|
| 147 |
-
CREATE TABLE pos.customer_ref (
|
| 148 |
-
customer_id VARCHAR PRIMARY KEY,
|
| 149 |
-
merchant_id VARCHAR NOT NULL,
|
| 150 |
-
name VARCHAR(150) NOT NULL,
|
| 151 |
-
phone VARCHAR(20),
|
| 152 |
-
email VARCHAR(255),
|
| 153 |
-
notes TEXT,
|
| 154 |
-
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
| 155 |
-
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 156 |
-
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 157 |
-
);
|
| 158 |
-
|
| 159 |
-
-- Staff reference table
|
| 160 |
-
CREATE TABLE pos.staff_ref (
|
| 161 |
-
staff_id VARCHAR PRIMARY KEY,
|
| 162 |
-
merchant_id VARCHAR NOT NULL,
|
| 163 |
-
name VARCHAR(150) NOT NULL,
|
| 164 |
-
phone VARCHAR(20),
|
| 165 |
-
email VARCHAR(255),
|
| 166 |
-
role VARCHAR(50),
|
| 167 |
-
specializations TEXT[],
|
| 168 |
-
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
| 169 |
-
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 170 |
-
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 171 |
-
);
|
| 172 |
-
```
|
| 173 |
-
|
| 174 |
-
### Indexes
|
| 175 |
-
|
| 176 |
-
```sql
|
| 177 |
-
-- Customer indexes
|
| 178 |
-
CREATE INDEX idx_customer_ref_merchant_id ON pos.customer_ref(merchant_id);
|
| 179 |
-
CREATE INDEX idx_customer_ref_phone ON pos.customer_ref(phone);
|
| 180 |
-
CREATE INDEX idx_customer_ref_email ON pos.customer_ref(email);
|
| 181 |
-
|
| 182 |
-
-- Staff indexes
|
| 183 |
-
CREATE INDEX idx_staff_ref_merchant_id ON pos.staff_ref(merchant_id);
|
| 184 |
-
CREATE INDEX idx_staff_ref_status ON pos.staff_ref(status);
|
| 185 |
-
```
|
| 186 |
-
|
| 187 |
-
## Testing
|
| 188 |
-
|
| 189 |
-
Run the test suite to verify sync functionality:
|
| 190 |
-
|
| 191 |
-
```bash
|
| 192 |
-
cd cuatrolabs-pos-ms
|
| 193 |
-
python test_pos_sync.py
|
| 194 |
-
```
|
| 195 |
-
|
| 196 |
-
The test suite covers:
|
| 197 |
-
- Customer CRUD sync operations
|
| 198 |
-
- Staff CRUD sync operations
|
| 199 |
-
- Bulk sync operations
|
| 200 |
-
- Health checks
|
| 201 |
-
- Error handling
|
| 202 |
-
|
| 203 |
-
## Error Handling
|
| 204 |
-
|
| 205 |
-
The sync system includes comprehensive error handling:
|
| 206 |
-
|
| 207 |
-
1. **Graceful Degradation**: MongoDB operations continue even if PostgreSQL sync fails
|
| 208 |
-
2. **Logging**: All sync operations are logged with appropriate levels
|
| 209 |
-
3. **Retry Logic**: Can be implemented at the service layer for critical operations
|
| 210 |
-
4. **Health Monitoring**: Regular health checks to detect sync issues
|
| 211 |
-
|
| 212 |
-
## Performance Considerations
|
| 213 |
-
|
| 214 |
-
1. **Async Operations**: All sync operations are asynchronous
|
| 215 |
-
2. **Bulk Operations**: Support for bulk sync to reduce overhead
|
| 216 |
-
3. **Selective Sync**: Only sync essential fields to PostgreSQL
|
| 217 |
-
4. **Connection Pooling**: Uses SQLAlchemy async sessions with connection pooling
|
| 218 |
-
|
| 219 |
-
## Best Practices
|
| 220 |
-
|
| 221 |
-
1. **Always sync after MongoDB operations**: Ensure data consistency
|
| 222 |
-
2. **Handle sync failures gracefully**: Don't fail the main operation if sync fails
|
| 223 |
-
3. **Monitor sync health**: Regular health checks and alerting
|
| 224 |
-
4. **Use bulk operations**: For large data migrations or batch updates
|
| 225 |
-
5. **Verify consistency**: Regular consistency checks between MongoDB and PostgreSQL
|
| 226 |
-
|
| 227 |
-
## Future Enhancements
|
| 228 |
-
|
| 229 |
-
1. **Event-Driven Sync**: Implement MongoDB change streams for real-time sync
|
| 230 |
-
2. **Conflict Resolution**: Handle conflicts when data differs between systems
|
| 231 |
-
3. **Selective Field Sync**: Configure which fields to sync per entity
|
| 232 |
-
4. **Audit Trail**: Track sync operations for debugging and compliance
|
| 233 |
-
5. **Performance Metrics**: Add metrics for sync operation performance
|
| 234 |
-
|
| 235 |
-
## Troubleshooting
|
| 236 |
-
|
| 237 |
-
### Common Issues
|
| 238 |
-
|
| 239 |
-
1. **Schema Creation Fails**
|
| 240 |
-
- Check PostgreSQL permissions
|
| 241 |
-
- Verify connection string
|
| 242 |
-
- Ensure schema exists
|
| 243 |
-
|
| 244 |
-
2. **Sync Operations Fail**
|
| 245 |
-
- Check PostgreSQL connectivity
|
| 246 |
-
- Verify table structure
|
| 247 |
-
- Review error logs
|
| 248 |
-
|
| 249 |
-
3. **Data Inconsistency**
|
| 250 |
-
- Run consistency verification
|
| 251 |
-
- Check for failed sync operations
|
| 252 |
-
- Review application logs
|
| 253 |
-
|
| 254 |
-
### Debug Commands
|
| 255 |
-
|
| 256 |
-
```python
|
| 257 |
-
# Check sync health
|
| 258 |
-
from app.sync.handler import POSSyncHandler
|
| 259 |
-
health = await POSSyncHandler.check_sync_health()
|
| 260 |
-
|
| 261 |
-
# Verify specific merchant data
|
| 262 |
-
from app.sync.utils import verify_sync_consistency
|
| 263 |
-
result = await verify_sync_consistency("merchant_123", "customers")
|
| 264 |
-
|
| 265 |
-
# Manual sync test
|
| 266 |
-
from app.sync.sync_service import POSSyncService
|
| 267 |
-
from app.sql import get_postgres_session
|
| 268 |
-
|
| 269 |
-
async with get_postgres_session() as session:
|
| 270 |
-
sync_service = POSSyncService(session)
|
| 271 |
-
# Test operations...
|
| 272 |
-
```
|
| 273 |
-
|
| 274 |
-
## Integration with Existing Code
|
| 275 |
-
|
| 276 |
-
To integrate with existing POS services:
|
| 277 |
-
|
| 278 |
-
1. Import the sync handler in your service files
|
| 279 |
-
2. Add sync calls after successful MongoDB operations
|
| 280 |
-
3. Handle sync failures appropriately (log but don't fail main operation)
|
| 281 |
-
4. Add health checks to your monitoring system
|
| 282 |
-
5. Set up regular consistency verification jobs
|
| 283 |
-
|
| 284 |
-
Example integration:
|
| 285 |
-
|
| 286 |
-
```python
|
| 287 |
-
# In your existing service
|
| 288 |
-
from app.sync.handler import POSSyncHandler
|
| 289 |
-
import logging
|
| 290 |
-
|
| 291 |
-
logger = logging.getLogger(__name__)
|
| 292 |
-
|
| 293 |
-
async def create_customer(self, customer_data):
|
| 294 |
-
try:
|
| 295 |
-
# Existing MongoDB operation
|
| 296 |
-
result = await self.collection.insert_one(customer_data)
|
| 297 |
-
|
| 298 |
-
# Add sync operation
|
| 299 |
-
try:
|
| 300 |
-
customer_model = CustomerModel(**customer_data)
|
| 301 |
-
await POSSyncHandler.handle_customer_created(customer_model)
|
| 302 |
-
except Exception as sync_error:
|
| 303 |
-
# Log but don't fail the main operation
|
| 304 |
-
logger.error(f"Customer sync failed: {sync_error}")
|
| 305 |
-
|
| 306 |
-
return result
|
| 307 |
-
except Exception as e:
|
| 308 |
-
logger.error(f"Customer creation failed: {e}")
|
| 309 |
-
raise
|
| 310 |
-
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/main.py
CHANGED
|
@@ -2,11 +2,17 @@
|
|
| 2 |
Main FastAPI application for POS Microservice.
|
| 3 |
"""
|
| 4 |
import logging
|
| 5 |
-
from fastapi import FastAPI
|
|
|
|
|
|
|
|
|
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
-
# # from insightfy_utils.logging import get_logger # TODO: Uncomment when package is available
|
| 8 |
import logging # TODO: Uncomment when package is available
|
|
|
|
|
|
|
| 9 |
from app.core.config import settings
|
|
|
|
|
|
|
| 10 |
|
| 11 |
from app.nosql import connect_to_mongo, close_mongo_connection
|
| 12 |
from app.sql import connect_to_database, disconnect_from_database
|
|
@@ -29,6 +35,9 @@ app = FastAPI(
|
|
| 29 |
redoc_url="/redoc",
|
| 30 |
)
|
| 31 |
|
|
|
|
|
|
|
|
|
|
| 32 |
# CORS middleware
|
| 33 |
app.add_middleware(
|
| 34 |
CORSMiddleware,
|
|
@@ -39,6 +48,118 @@ app.add_middleware(
|
|
| 39 |
)
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
# Startup and shutdown events
|
| 43 |
@app.on_event("startup")
|
| 44 |
async def startup_event():
|
|
|
|
| 2 |
Main FastAPI application for POS Microservice.
|
| 3 |
"""
|
| 4 |
import logging
|
| 5 |
+
from fastapi import FastAPI, Request, status
|
| 6 |
+
from fastapi.exceptions import RequestValidationError
|
| 7 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 8 |
+
from fastapi.responses import JSONResponse
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 10 |
import logging # TODO: Uncomment when package is available
|
| 11 |
+
from pymongo.errors import PyMongoError, ConnectionFailure
|
| 12 |
+
|
| 13 |
from app.core.config import settings
|
| 14 |
+
from app.utils.response import error_response
|
| 15 |
+
from app.middleware.logging_middleware import RequestLoggingMiddleware
|
| 16 |
|
| 17 |
from app.nosql import connect_to_mongo, close_mongo_connection
|
| 18 |
from app.sql import connect_to_database, disconnect_from_database
|
|
|
|
| 35 |
redoc_url="/redoc",
|
| 36 |
)
|
| 37 |
|
| 38 |
+
# Request logging middleware
|
| 39 |
+
app.add_middleware(RequestLoggingMiddleware)
|
| 40 |
+
|
| 41 |
# CORS middleware
|
| 42 |
app.add_middleware(
|
| 43 |
CORSMiddleware,
|
|
|
|
| 48 |
)
|
| 49 |
|
| 50 |
|
| 51 |
+
# Global Exception Handlers
|
| 52 |
+
@app.exception_handler(RequestValidationError)
|
| 53 |
+
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
| 54 |
+
"""Handle validation errors"""
|
| 55 |
+
errors = []
|
| 56 |
+
for e in exc.errors():
|
| 57 |
+
# Get field name from loc (last element), default to "body" or "unknown"
|
| 58 |
+
field = str(e["loc"][-1]) if e["loc"] else "unknown"
|
| 59 |
+
errors.append({
|
| 60 |
+
"field": field,
|
| 61 |
+
"message": e["msg"],
|
| 62 |
+
"type": e["type"]
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
return JSONResponse(
|
| 66 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 67 |
+
content=error_response(
|
| 68 |
+
error="Validation Error",
|
| 69 |
+
detail="The request contains invalid data",
|
| 70 |
+
errors=errors,
|
| 71 |
+
request_id=getattr(request.state, "request_id", None)
|
| 72 |
+
)
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@app.exception_handler(status.HTTP_404_NOT_FOUND)
|
| 77 |
+
async def not_found_exception_handler(request: Request, exc: Exception):
|
| 78 |
+
"""Handle 404 errors"""
|
| 79 |
+
return JSONResponse(
|
| 80 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 81 |
+
content=error_response(
|
| 82 |
+
error="Not Found",
|
| 83 |
+
detail="Resource not found",
|
| 84 |
+
request_id=getattr(request.state, "request_id", None)
|
| 85 |
+
)
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@app.exception_handler(StarletteHTTPException)
|
| 90 |
+
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
| 91 |
+
"""Handle standard HTTP exceptions"""
|
| 92 |
+
error_title = "Error"
|
| 93 |
+
headers = None
|
| 94 |
+
|
| 95 |
+
if exc.status_code == 401:
|
| 96 |
+
error_title = "Authentication Error"
|
| 97 |
+
headers = {"WWW-Authenticate": "Bearer"}
|
| 98 |
+
elif exc.status_code == 403:
|
| 99 |
+
error_title = "Permission Denied"
|
| 100 |
+
elif exc.status_code == 404:
|
| 101 |
+
error_title = "Not Found"
|
| 102 |
+
elif exc.status_code == 422:
|
| 103 |
+
error_title = "Validation Error"
|
| 104 |
+
|
| 105 |
+
return JSONResponse(
|
| 106 |
+
status_code=exc.status_code,
|
| 107 |
+
content=error_response(
|
| 108 |
+
error=error_title,
|
| 109 |
+
detail=str(exc.detail),
|
| 110 |
+
request_id=getattr(request.state, "request_id", None),
|
| 111 |
+
headers=headers
|
| 112 |
+
)
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@app.exception_handler(ConnectionFailure)
|
| 117 |
+
async def mongo_connection_exception_handler(request: Request, exc: ConnectionFailure):
|
| 118 |
+
"""Handle MongoDB connection errors"""
|
| 119 |
+
logger.error(f"Database connection error: {exc}", exc_info=True)
|
| 120 |
+
return JSONResponse(
|
| 121 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 122 |
+
content=error_response(
|
| 123 |
+
error="Database Connection Error",
|
| 124 |
+
detail="Unable to connect to the database. Please try again later.",
|
| 125 |
+
request_id=getattr(request.state, "request_id", None)
|
| 126 |
+
)
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@app.exception_handler(PyMongoError)
|
| 131 |
+
async def mongo_exception_handler(request: Request, exc: PyMongoError):
|
| 132 |
+
"""Handle general MongoDB errors"""
|
| 133 |
+
logger.error(f"Database error: {exc}", exc_info=True)
|
| 134 |
+
return JSONResponse(
|
| 135 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 136 |
+
content=error_response(
|
| 137 |
+
error="Database Error",
|
| 138 |
+
detail="An unexpected database error occurred.",
|
| 139 |
+
request_id=getattr(request.state, "request_id", None)
|
| 140 |
+
)
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# Handle generic HTTPException (catch-all for other HTTP exceptions)
|
| 145 |
+
@app.exception_handler(Exception)
|
| 146 |
+
async def generic_exception_handler(request: Request, exc: Exception):
|
| 147 |
+
"""
|
| 148 |
+
Handle all unhandled exceptions.
|
| 149 |
+
|
| 150 |
+
In production, we should not expose internal error details.
|
| 151 |
+
"""
|
| 152 |
+
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
| 153 |
+
return JSONResponse(
|
| 154 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 155 |
+
content=error_response(
|
| 156 |
+
error="Internal Server Error",
|
| 157 |
+
detail="An unexpected error occurred. Please try again later.",
|
| 158 |
+
request_id=getattr(request.state, "request_id", None)
|
| 159 |
+
)
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
# Startup and shutdown events
|
| 164 |
@app.on_event("startup")
|
| 165 |
async def startup_event():
|
app/middleware/logging_middleware.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import logging
|
| 3 |
+
from uuid import uuid4
|
| 4 |
+
from fastapi import Request
|
| 5 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("middleware")
|
| 8 |
+
|
| 9 |
+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
| 10 |
+
"""
|
| 11 |
+
Middleware to log request start and completion details.
|
| 12 |
+
"""
|
| 13 |
+
async def dispatch(self, request: Request, call_next):
|
| 14 |
+
request_id = request.headers.get("X-Request-ID", str(uuid4()))
|
| 15 |
+
# Store request_id in request state for access in routers/error handlers
|
| 16 |
+
request.state.request_id = request_id
|
| 17 |
+
|
| 18 |
+
start_time = time.time()
|
| 19 |
+
|
| 20 |
+
client_host = request.client.host if request.client else "unknown"
|
| 21 |
+
user_agent = request.headers.get("user-agent", "unknown")
|
| 22 |
+
|
| 23 |
+
# Log request start
|
| 24 |
+
logger.info(
|
| 25 |
+
f"Request started: {request.method} {request.url.path}",
|
| 26 |
+
extra={
|
| 27 |
+
"request_id": request_id,
|
| 28 |
+
"method": request.method,
|
| 29 |
+
"path": request.url.path,
|
| 30 |
+
"client": client_host,
|
| 31 |
+
"user_agent": user_agent
|
| 32 |
+
}
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
response = await call_next(request)
|
| 37 |
+
|
| 38 |
+
process_time = time.time() - start_time
|
| 39 |
+
|
| 40 |
+
# Log request completion
|
| 41 |
+
logger.info(
|
| 42 |
+
f"Request completed: {request.method} {request.url.path} - Status: {response.status_code}",
|
| 43 |
+
extra={
|
| 44 |
+
"request_id": request_id,
|
| 45 |
+
"method": request.method,
|
| 46 |
+
"path": request.url.path,
|
| 47 |
+
"status_code": response.status_code,
|
| 48 |
+
"process_time": f"{process_time:.3f}s"
|
| 49 |
+
}
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
response.headers["X-Request-ID"] = request_id
|
| 53 |
+
return response
|
| 54 |
+
|
| 55 |
+
except Exception as e:
|
| 56 |
+
process_time = time.time() - start_time
|
| 57 |
+
logger.error(
|
| 58 |
+
f"Request failed: {request.method} {request.url.path}",
|
| 59 |
+
extra={
|
| 60 |
+
"request_id": request_id,
|
| 61 |
+
"method": request.method,
|
| 62 |
+
"path": request.url.path,
|
| 63 |
+
"status_code": 500,
|
| 64 |
+
"process_time": f"{process_time:.3f}s",
|
| 65 |
+
"error": str(e)
|
| 66 |
+
}
|
| 67 |
+
)
|
| 68 |
+
raise e
|
app/staff/controllers/router.py
CHANGED
|
@@ -11,6 +11,7 @@ from app.staff.schemas.staff_schema import (
|
|
| 11 |
StaffCreateSchema,
|
| 12 |
StaffUpdateSchema,
|
| 13 |
StaffResponseSchema,
|
|
|
|
| 14 |
StaffListResponse,
|
| 15 |
EmployeeUpdate,
|
| 16 |
EmployeeResponse
|
|
@@ -198,63 +199,49 @@ async def update_employee(
|
|
| 198 |
return await StaffService.update_employee(user_id, payload, x_user_id)
|
| 199 |
|
| 200 |
|
| 201 |
-
@router.
|
| 202 |
-
"",
|
| 203 |
-
response_model=
|
| 204 |
summary="List staff",
|
| 205 |
description="List staff with optional filters and pagination"
|
| 206 |
)
|
| 207 |
async def list_staff(
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
description="Filter by designation/role"
|
| 211 |
-
),
|
| 212 |
-
manager_id: Optional[str] = Query(
|
| 213 |
-
None,
|
| 214 |
-
description="Filter by manager's user_id (direct reports)"
|
| 215 |
-
),
|
| 216 |
-
status_filter: Optional[stafftatus] = Query(
|
| 217 |
-
None,
|
| 218 |
-
alias="status",
|
| 219 |
-
description="Filter by employee status"
|
| 220 |
-
),
|
| 221 |
-
region: Optional[str] = Query(
|
| 222 |
-
None,
|
| 223 |
-
description="Filter by region"
|
| 224 |
-
),
|
| 225 |
-
skip: int = Query(
|
| 226 |
-
0,
|
| 227 |
-
ge=0,
|
| 228 |
-
description="Number of records to skip (pagination)"
|
| 229 |
-
),
|
| 230 |
-
limit: int = Query(
|
| 231 |
-
100,
|
| 232 |
-
ge=1,
|
| 233 |
-
le=500,
|
| 234 |
-
description="Maximum number of records to return"
|
| 235 |
-
)
|
| 236 |
-
) -> List[EmployeeResponse]:
|
| 237 |
"""
|
| 238 |
List staff with filters.
|
| 239 |
|
| 240 |
-
**
|
| 241 |
- `designation`: Filter by role (RSM, ASM, BDE, Trainer, etc.)
|
| 242 |
- `manager_id`: Show only direct reports of specific manager
|
| 243 |
- `status`: Filter by status (onboarding, active, inactive, suspended, terminated)
|
| 244 |
- `region`: Filter by assigned region
|
|
|
|
|
|
|
| 245 |
- `skip`: Pagination offset (default: 0)
|
| 246 |
- `limit`: Page size (default: 100, max: 500)
|
| 247 |
|
|
|
|
|
|
|
|
|
|
| 248 |
Returns:
|
| 249 |
-
List of staff matching the filters
|
| 250 |
"""
|
| 251 |
-
|
| 252 |
-
designation=designation,
|
| 253 |
-
manager_id=manager_id,
|
| 254 |
-
status_filter=
|
| 255 |
-
region=region,
|
| 256 |
-
skip=skip,
|
| 257 |
-
limit=limit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
)
|
| 259 |
|
| 260 |
|
|
@@ -319,12 +306,13 @@ async def get_employee_reports(
|
|
| 319 |
# First verify employee exists
|
| 320 |
await StaffService.get_employee(user_id)
|
| 321 |
|
| 322 |
-
|
| 323 |
manager_id=user_id,
|
| 324 |
status_filter=status_filter,
|
| 325 |
skip=skip,
|
| 326 |
limit=limit
|
| 327 |
)
|
|
|
|
| 328 |
|
| 329 |
|
| 330 |
@router.get(
|
|
|
|
| 11 |
StaffCreateSchema,
|
| 12 |
StaffUpdateSchema,
|
| 13 |
StaffResponseSchema,
|
| 14 |
+
StaffListRequest,
|
| 15 |
StaffListResponse,
|
| 16 |
EmployeeUpdate,
|
| 17 |
EmployeeResponse
|
|
|
|
| 199 |
return await StaffService.update_employee(user_id, payload, x_user_id)
|
| 200 |
|
| 201 |
|
| 202 |
+
@router.post(
|
| 203 |
+
"/list",
|
| 204 |
+
response_model=StaffListResponse,
|
| 205 |
summary="List staff",
|
| 206 |
description="List staff with optional filters and pagination"
|
| 207 |
)
|
| 208 |
async def list_staff(
|
| 209 |
+
payload: StaffListRequest
|
| 210 |
+
) -> StaffListResponse:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
"""
|
| 212 |
List staff with filters.
|
| 213 |
|
| 214 |
+
**Filters:**
|
| 215 |
- `designation`: Filter by role (RSM, ASM, BDE, Trainer, etc.)
|
| 216 |
- `manager_id`: Show only direct reports of specific manager
|
| 217 |
- `status`: Filter by status (onboarding, active, inactive, suspended, terminated)
|
| 218 |
- `region`: Filter by assigned region
|
| 219 |
+
|
| 220 |
+
**Pagination:**
|
| 221 |
- `skip`: Pagination offset (default: 0)
|
| 222 |
- `limit`: Page size (default: 100, max: 500)
|
| 223 |
|
| 224 |
+
**Projection:**
|
| 225 |
+
- `projection_list`: List of fields to include in response
|
| 226 |
+
|
| 227 |
Returns:
|
| 228 |
+
List of staff matching the filters and total count
|
| 229 |
"""
|
| 230 |
+
items, total = await StaffService.list_staff(
|
| 231 |
+
designation=payload.designation,
|
| 232 |
+
manager_id=payload.manager_id,
|
| 233 |
+
status_filter=payload.status,
|
| 234 |
+
region=payload.region,
|
| 235 |
+
skip=payload.skip,
|
| 236 |
+
limit=payload.limit,
|
| 237 |
+
projection_list=payload.projection_list
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
return StaffListResponse(
|
| 241 |
+
items=items,
|
| 242 |
+
total=total,
|
| 243 |
+
skip=payload.skip,
|
| 244 |
+
limit=payload.limit
|
| 245 |
)
|
| 246 |
|
| 247 |
|
|
|
|
| 306 |
# First verify employee exists
|
| 307 |
await StaffService.get_employee(user_id)
|
| 308 |
|
| 309 |
+
items, _ = await StaffService.list_staff(
|
| 310 |
manager_id=user_id,
|
| 311 |
status_filter=status_filter,
|
| 312 |
skip=skip,
|
| 313 |
limit=limit
|
| 314 |
)
|
| 315 |
+
return items
|
| 316 |
|
| 317 |
|
| 318 |
@router.get(
|
app/staff/schemas/staff_schema.py
CHANGED
|
@@ -103,9 +103,9 @@ class StaffResponseSchema(BaseModel):
|
|
| 103 |
class StaffListRequest(BaseModel):
|
| 104 |
"""Schema for staff list request with projection support."""
|
| 105 |
merchant_id: Optional[str] = Field(None, description="Merchant identifier (optional, defaults to token merchant)")
|
| 106 |
-
designation: Optional[
|
| 107 |
manager_id: Optional[str] = Field(None, description="Filter by manager's ID")
|
| 108 |
-
status: Optional[
|
| 109 |
role: Optional[str] = Field(None, description="Filter by role")
|
| 110 |
region: Optional[str] = Field(None, description="Filter by region")
|
| 111 |
skip: int = Field(0, ge=0, description="Number of records to skip")
|
|
@@ -117,7 +117,7 @@ class StaffListRequest(BaseModel):
|
|
| 117 |
|
| 118 |
class StaffListResponse(BaseModel):
|
| 119 |
"""Schema for staff list response."""
|
| 120 |
-
|
| 121 |
total: int
|
| 122 |
skip: int
|
| 123 |
limit: int
|
|
|
|
| 103 |
class StaffListRequest(BaseModel):
|
| 104 |
"""Schema for staff list request with projection support."""
|
| 105 |
merchant_id: Optional[str] = Field(None, description="Merchant identifier (optional, defaults to token merchant)")
|
| 106 |
+
designation: Optional[Designation] = Field(None, description="Filter by designation/role")
|
| 107 |
manager_id: Optional[str] = Field(None, description="Filter by manager's ID")
|
| 108 |
+
status: Optional[Union[stafftatus, List[stafftatus]]] = Field(None, description="Filter by status")
|
| 109 |
role: Optional[str] = Field(None, description="Filter by role")
|
| 110 |
region: Optional[str] = Field(None, description="Filter by region")
|
| 111 |
skip: int = Field(0, ge=0, description="Number of records to skip")
|
|
|
|
| 117 |
|
| 118 |
class StaffListResponse(BaseModel):
|
| 119 |
"""Schema for staff list response."""
|
| 120 |
+
items: List[Union[EmployeeResponse, dict]] # Can be full response or projected dict
|
| 121 |
total: int
|
| 122 |
skip: int
|
| 123 |
limit: int
|
app/staff/services/staff_service.py
CHANGED
|
@@ -3,7 +3,7 @@ Staff service layer - business logic and database operations.
|
|
| 3 |
Syncs staff data to both MongoDB and PostgreSQL (trans.pos_staff_ref).
|
| 4 |
"""
|
| 5 |
from datetime import datetime
|
| 6 |
-
from typing import Optional, List, Dict, Any
|
| 7 |
from fastapi import HTTPException, status
|
| 8 |
import logging
|
| 9 |
import secrets
|
|
@@ -190,54 +190,7 @@ class StaffService:
|
|
| 190 |
detail=f"Error updating staff: {str(e)}"
|
| 191 |
)
|
| 192 |
|
| 193 |
-
@staticmethod
|
| 194 |
-
async def list_staff(
|
| 195 |
-
merchant_id: Optional[str] = None,
|
| 196 |
-
status: Optional[str] = None,
|
| 197 |
-
role: Optional[str] = None,
|
| 198 |
-
skip: int = 0,
|
| 199 |
-
limit: int = 100
|
| 200 |
-
) -> tuple[List[StaffResponseSchema], int]:
|
| 201 |
-
"""
|
| 202 |
-
List staff with optional filters.
|
| 203 |
-
|
| 204 |
-
Args:
|
| 205 |
-
merchant_id: Filter by merchant
|
| 206 |
-
status: Filter by status
|
| 207 |
-
role: Filter by role
|
| 208 |
-
skip: Number of records to skip
|
| 209 |
-
limit: Maximum records to return
|
| 210 |
-
|
| 211 |
-
Returns:
|
| 212 |
-
Tuple of (staff list, total count)
|
| 213 |
-
"""
|
| 214 |
-
try:
|
| 215 |
-
# Build query
|
| 216 |
-
query = {}
|
| 217 |
-
if merchant_id:
|
| 218 |
-
query["merchant_id"] = merchant_id
|
| 219 |
-
if status:
|
| 220 |
-
query["status"] = status
|
| 221 |
-
if role:
|
| 222 |
-
query["role"] = role
|
| 223 |
|
| 224 |
-
# Get total count
|
| 225 |
-
total = await get_database()[POS_STAFF_COLLECTION].count_documents(query)
|
| 226 |
-
|
| 227 |
-
# Fetch staff
|
| 228 |
-
cursor = get_database()[POS_STAFF_COLLECTION].find(query).skip(skip).limit(limit)
|
| 229 |
-
staff_list = await cursor.to_list(length=limit)
|
| 230 |
-
|
| 231 |
-
staff_responses = [StaffResponseSchema(**staff) for staff in staff_list]
|
| 232 |
-
|
| 233 |
-
return staff_responses, total
|
| 234 |
-
|
| 235 |
-
except Exception as e:
|
| 236 |
-
logger.error(f"Error listing staff: {e}")
|
| 237 |
-
raise HTTPException(
|
| 238 |
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 239 |
-
detail="Error listing staff"
|
| 240 |
-
)
|
| 241 |
|
| 242 |
@staticmethod
|
| 243 |
async def delete_staff(staff_id: str) -> Dict[str, str]:
|
|
@@ -415,11 +368,12 @@ class StaffService:
|
|
| 415 |
async def list_staff(
|
| 416 |
designation: Optional[Designation] = None,
|
| 417 |
manager_id: Optional[str] = None,
|
| 418 |
-
status_filter: Optional[stafftatus] = None,
|
| 419 |
region: Optional[str] = None,
|
| 420 |
skip: int = 0,
|
| 421 |
-
limit: int = 100
|
| 422 |
-
|
|
|
|
| 423 |
"""List staff with filters (employee version)."""
|
| 424 |
try:
|
| 425 |
# Build query
|
|
@@ -428,16 +382,34 @@ class StaffService:
|
|
| 428 |
query["designation"] = designation
|
| 429 |
if manager_id:
|
| 430 |
query["manager_id"] = manager_id
|
|
|
|
| 431 |
if status_filter:
|
| 432 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
if region:
|
| 434 |
query["region"] = region
|
| 435 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
# Fetch staff
|
| 437 |
-
cursor = get_database()[POS_STAFF_COLLECTION].find(query).skip(skip).limit(limit)
|
| 438 |
staff_list = await cursor.to_list(length=limit)
|
| 439 |
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
| 441 |
|
| 442 |
except Exception as e:
|
| 443 |
logger.error(f"Error listing staff: {e}")
|
|
|
|
| 3 |
Syncs staff data to both MongoDB and PostgreSQL (trans.pos_staff_ref).
|
| 4 |
"""
|
| 5 |
from datetime import datetime
|
| 6 |
+
from typing import Optional, List, Dict, Any, Union
|
| 7 |
from fastapi import HTTPException, status
|
| 8 |
import logging
|
| 9 |
import secrets
|
|
|
|
| 190 |
detail=f"Error updating staff: {str(e)}"
|
| 191 |
)
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
@staticmethod
|
| 196 |
async def delete_staff(staff_id: str) -> Dict[str, str]:
|
|
|
|
| 368 |
async def list_staff(
|
| 369 |
designation: Optional[Designation] = None,
|
| 370 |
manager_id: Optional[str] = None,
|
| 371 |
+
status_filter: Optional[Union[stafftatus, List[stafftatus]]] = None,
|
| 372 |
region: Optional[str] = None,
|
| 373 |
skip: int = 0,
|
| 374 |
+
limit: int = 100,
|
| 375 |
+
projection_list: Optional[List[str]] = None
|
| 376 |
+
) -> tuple[List[Union[EmployeeResponse, dict]], int]:
|
| 377 |
"""List staff with filters (employee version)."""
|
| 378 |
try:
|
| 379 |
# Build query
|
|
|
|
| 382 |
query["designation"] = designation
|
| 383 |
if manager_id:
|
| 384 |
query["manager_id"] = manager_id
|
| 385 |
+
|
| 386 |
if status_filter:
|
| 387 |
+
if isinstance(status_filter, list):
|
| 388 |
+
query["status"] = {"$in": status_filter}
|
| 389 |
+
else:
|
| 390 |
+
query["status"] = status_filter
|
| 391 |
+
|
| 392 |
if region:
|
| 393 |
query["region"] = region
|
| 394 |
|
| 395 |
+
# Get total count
|
| 396 |
+
total = await get_database()[POS_STAFF_COLLECTION].count_documents(query)
|
| 397 |
+
|
| 398 |
+
# Build projection
|
| 399 |
+
projection = None
|
| 400 |
+
if projection_list:
|
| 401 |
+
projection = {field: 1 for field in projection_list}
|
| 402 |
+
if "_id" not in projection_list:
|
| 403 |
+
projection["_id"] = 0
|
| 404 |
+
|
| 405 |
# Fetch staff
|
| 406 |
+
cursor = get_database()[POS_STAFF_COLLECTION].find(query, projection).skip(skip).limit(limit)
|
| 407 |
staff_list = await cursor.to_list(length=limit)
|
| 408 |
|
| 409 |
+
if projection_list:
|
| 410 |
+
return staff_list, total
|
| 411 |
+
else:
|
| 412 |
+
return [EmployeeResponse(**staff) for staff in staff_list], total
|
| 413 |
|
| 414 |
except Exception as e:
|
| 415 |
logger.error(f"Error listing staff: {e}")
|
app/utils/response.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
Response formatting utilities for standardized API responses.
|
| 3 |
"""
|
| 4 |
-
from typing import Any, Dict, Optional
|
| 5 |
from datetime import datetime
|
| 6 |
from uuid import uuid4
|
| 7 |
|
|
@@ -23,7 +23,7 @@ def success_response(
|
|
| 23 |
Standardized success response dictionary
|
| 24 |
"""
|
| 25 |
return {
|
| 26 |
-
"
|
| 27 |
"data": data,
|
| 28 |
"message": message,
|
| 29 |
"correlation_id": correlation_id or str(uuid4()),
|
|
@@ -32,33 +32,45 @@ def success_response(
|
|
| 32 |
|
| 33 |
|
| 34 |
def error_response(
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
| 39 |
) -> Dict[str, Any]:
|
| 40 |
"""
|
| 41 |
-
Format an error API response.
|
| 42 |
|
| 43 |
Args:
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
| 48 |
|
| 49 |
Returns:
|
| 50 |
Standardized error response dictionary
|
| 51 |
"""
|
| 52 |
-
|
| 53 |
-
"
|
| 54 |
-
"error":
|
| 55 |
-
|
| 56 |
-
"message": message,
|
| 57 |
-
"details": details or {}
|
| 58 |
-
},
|
| 59 |
-
"correlation_id": correlation_id or str(uuid4()),
|
| 60 |
-
"timestamp": datetime.utcnow().isoformat() + "Z"
|
| 61 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
def paginated_response(
|
|
|
|
| 1 |
"""
|
| 2 |
Response formatting utilities for standardized API responses.
|
| 3 |
"""
|
| 4 |
+
from typing import Any, Dict, Optional, List
|
| 5 |
from datetime import datetime
|
| 6 |
from uuid import uuid4
|
| 7 |
|
|
|
|
| 23 |
Standardized success response dictionary
|
| 24 |
"""
|
| 25 |
return {
|
| 26 |
+
"success": True,
|
| 27 |
"data": data,
|
| 28 |
"message": message,
|
| 29 |
"correlation_id": correlation_id or str(uuid4()),
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
def error_response(
|
| 35 |
+
error: str,
|
| 36 |
+
detail: str,
|
| 37 |
+
errors: Optional[List[Dict[str, Any]]] = None,
|
| 38 |
+
code: Optional[str] = None, # Kept for backward compatibility if needed, but mapped to error title usually
|
| 39 |
+
request_id: Optional[str] = None,
|
| 40 |
+
headers: Optional[Dict[str, str]] = None
|
| 41 |
) -> Dict[str, Any]:
|
| 42 |
"""
|
| 43 |
+
Format an error API response according to the Error Handling Guide.
|
| 44 |
|
| 45 |
Args:
|
| 46 |
+
error: Error title (e.g., "Validation Error", "Authentication Error")
|
| 47 |
+
detail: Human-readable error message
|
| 48 |
+
errors: Optional list of specific errors (e.g., validation fields)
|
| 49 |
+
code: Optional error code (legacy support, can be ignored or used as error title)
|
| 50 |
+
request_id: Optional request ID
|
| 51 |
+
headers: Optional headers (included in body as per guide example)
|
| 52 |
|
| 53 |
Returns:
|
| 54 |
Standardized error response dictionary
|
| 55 |
"""
|
| 56 |
+
response = {
|
| 57 |
+
"success": False,
|
| 58 |
+
"error": error or code or "Error",
|
| 59 |
+
"detail": detail
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
+
|
| 62 |
+
if errors:
|
| 63 |
+
response["errors"] = errors
|
| 64 |
+
|
| 65 |
+
if request_id:
|
| 66 |
+
response["request_id"] = request_id
|
| 67 |
+
|
| 68 |
+
if headers:
|
| 69 |
+
response["headers"] = headers
|
| 70 |
+
|
| 71 |
+
response["timestamp"] = datetime.utcnow().isoformat() + "Z"
|
| 72 |
+
|
| 73 |
+
return response
|
| 74 |
|
| 75 |
|
| 76 |
def paginated_response(
|
requirements.txt
CHANGED
|
@@ -28,3 +28,4 @@ twilio==8.10.3
|
|
| 28 |
aiosmtplib==3.0.1
|
| 29 |
|
| 30 |
python-json-logger==2.0.7
|
|
|
|
|
|
| 28 |
aiosmtplib==3.0.1
|
| 29 |
|
| 30 |
python-json-logger==2.0.7
|
| 31 |
+
|
tests/test_error_handling.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from unittest.mock import patch, AsyncMock
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
|
| 4 |
+
# Mock database connections before importing app
|
| 5 |
+
with patch("app.main.connect_to_mongo", new_callable=AsyncMock), \
|
| 6 |
+
patch("app.main.connect_to_database", new_callable=AsyncMock):
|
| 7 |
+
from app.main import app
|
| 8 |
+
|
| 9 |
+
client = TestClient(app)
|
| 10 |
+
|
| 11 |
+
def test_404_error_handling():
|
| 12 |
+
response = client.get("/non-existent-route")
|
| 13 |
+
assert response.status_code == 404
|
| 14 |
+
data = response.json()
|
| 15 |
+
assert data["success"] is False
|
| 16 |
+
assert data["error"] == "Not Found"
|
| 17 |
+
assert data["detail"] == "Resource not found"
|
| 18 |
+
assert "request_id" in data
|
| 19 |
+
assert "timestamp" in data
|
| 20 |
+
|
| 21 |
+
def test_validation_error_handling():
|
| 22 |
+
# token query param is required for this endpoint
|
| 23 |
+
response = client.post("/debug/verify-token")
|
| 24 |
+
assert response.status_code == 422
|
| 25 |
+
data = response.json()
|
| 26 |
+
assert data["success"] is False
|
| 27 |
+
assert data["error"] == "Validation Error"
|
| 28 |
+
assert data["detail"] == "The request contains invalid data"
|
| 29 |
+
assert "errors" in data
|
| 30 |
+
assert isinstance(data["errors"], list)
|
| 31 |
+
assert len(data["errors"]) > 0
|
| 32 |
+
assert "field" in data["errors"][0]
|
| 33 |
+
assert "message" in data["errors"][0]
|
| 34 |
+
assert "type" in data["errors"][0]
|
tests/test_staff_list.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from unittest.mock import patch, AsyncMock
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
from app.constants.staff_types import Designation, stafftatus
|
| 4 |
+
|
| 5 |
+
# Mock database connections before importing app
|
| 6 |
+
with patch("app.main.connect_to_mongo", new_callable=AsyncMock), \
|
| 7 |
+
patch("app.main.connect_to_database", new_callable=AsyncMock):
|
| 8 |
+
from app.main import app
|
| 9 |
+
|
| 10 |
+
client = TestClient(app)
|
| 11 |
+
|
| 12 |
+
@patch("app.staff.services.staff_service.StaffService.list_staff")
|
| 13 |
+
def test_staff_list_endpoint(mock_list_staff):
|
| 14 |
+
# Setup mock return value
|
| 15 |
+
mock_items = [
|
| 16 |
+
{
|
| 17 |
+
"user_id": "u1",
|
| 18 |
+
"name": "Test User",
|
| 19 |
+
"designation": "Stylist",
|
| 20 |
+
"status": "active",
|
| 21 |
+
"email": "test@example.com",
|
| 22 |
+
"phone": "1234567890",
|
| 23 |
+
"role": "Stylist",
|
| 24 |
+
"merchant_id": "m1"
|
| 25 |
+
}
|
| 26 |
+
]
|
| 27 |
+
mock_list_staff.return_value = (mock_items, 1)
|
| 28 |
+
|
| 29 |
+
# Test payload
|
| 30 |
+
payload = {
|
| 31 |
+
"status": "active",
|
| 32 |
+
"skip": 0,
|
| 33 |
+
"limit": 10,
|
| 34 |
+
"projection_list": ["name", "email"]
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
response = client.post("/api/v1/staff/list", json=payload)
|
| 38 |
+
|
| 39 |
+
# Assertions
|
| 40 |
+
assert response.status_code == 200
|
| 41 |
+
data = response.json()
|
| 42 |
+
assert data["total"] == 1
|
| 43 |
+
assert len(data["items"]) == 1
|
| 44 |
+
assert data["items"][0]["name"] == "Test User"
|
| 45 |
+
|
| 46 |
+
# Verify mock call
|
| 47 |
+
mock_list_staff.assert_called_once()
|
| 48 |
+
call_kwargs = mock_list_staff.call_args.kwargs
|
| 49 |
+
assert call_kwargs["status_filter"] == "active"
|
| 50 |
+
assert call_kwargs["projection_list"] == ["name", "email"]
|
| 51 |
+
|
| 52 |
+
@patch("app.staff.services.staff_service.StaffService.list_staff")
|
| 53 |
+
def test_staff_list_with_list_status(mock_list_staff):
|
| 54 |
+
# Setup mock return value
|
| 55 |
+
mock_list_staff.return_value = ([], 0)
|
| 56 |
+
|
| 57 |
+
# Test payload with list of statuses
|
| 58 |
+
payload = {
|
| 59 |
+
"status": ["active", "inactive"],
|
| 60 |
+
"skip": 0,
|
| 61 |
+
"limit": 10
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
response = client.post("/api/v1/staff/list", json=payload)
|
| 65 |
+
|
| 66 |
+
# Assertions
|
| 67 |
+
assert response.status_code == 200
|
| 68 |
+
|
| 69 |
+
# Verify mock call
|
| 70 |
+
mock_list_staff.assert_called_once()
|
| 71 |
+
call_kwargs = mock_list_staff.call_args.kwargs
|
| 72 |
+
assert call_kwargs["status_filter"] == ["active", "inactive"]
|