510 lines
14 KiB
Markdown
510 lines
14 KiB
Markdown
# Quick Start: Implementing Remaining Service Deletions
|
|
|
|
## Overview
|
|
|
|
**Time to complete per service:** 30-45 minutes
|
|
**Remaining services:** 3 (POS, External, Alert Processor)
|
|
**Pattern:** Copy → Customize → Test
|
|
|
|
---
|
|
|
|
## Step-by-Step Template
|
|
|
|
### 1. Create Deletion Service File
|
|
|
|
**Location:** `services/{service}/app/services/tenant_deletion_service.py`
|
|
|
|
**Template:**
|
|
|
|
```python
|
|
"""
|
|
{Service} Service - Tenant Data Deletion
|
|
Handles deletion of all {service}-related data for a tenant
|
|
"""
|
|
from typing import Dict
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, delete, func
|
|
import structlog
|
|
|
|
from shared.services.tenant_deletion import BaseTenantDataDeletionService, TenantDataDeletionResult
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
class {Service}TenantDeletionService(BaseTenantDataDeletionService):
|
|
"""Service for deleting all {service}-related data for a tenant"""
|
|
|
|
def __init__(self, db_session: AsyncSession):
|
|
super().__init__("{service}-service")
|
|
self.db = db_session
|
|
|
|
async def get_tenant_data_preview(self, tenant_id: str) -> Dict[str, int]:
|
|
"""Get counts of what would be deleted"""
|
|
|
|
try:
|
|
preview = {}
|
|
|
|
# Import models here to avoid circular imports
|
|
from app.models.{model_file} import Model1, Model2
|
|
|
|
# Count each model type
|
|
count1 = await self.db.scalar(
|
|
select(func.count(Model1.id)).where(Model1.tenant_id == tenant_id)
|
|
)
|
|
preview["model1_plural"] = count1 or 0
|
|
|
|
# Repeat for each model...
|
|
|
|
return preview
|
|
|
|
except Exception as e:
|
|
logger.error("Error getting deletion preview",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
return {}
|
|
|
|
async def delete_tenant_data(self, tenant_id: str) -> TenantDataDeletionResult:
|
|
"""Delete all data for a tenant"""
|
|
|
|
result = TenantDataDeletionResult(tenant_id, self.service_name)
|
|
|
|
try:
|
|
# Import models here
|
|
from app.models.{model_file} import Model1, Model2
|
|
|
|
# Delete in reverse dependency order (children first, then parents)
|
|
|
|
# Child models first
|
|
try:
|
|
child_delete = await self.db.execute(
|
|
delete(ChildModel).where(ChildModel.tenant_id == tenant_id)
|
|
)
|
|
result.add_deleted_items("child_models", child_delete.rowcount)
|
|
except Exception as e:
|
|
logger.error("Error deleting child models",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
result.add_error(f"Child model deletion: {str(e)}")
|
|
|
|
# Parent models last
|
|
try:
|
|
parent_delete = await self.db.execute(
|
|
delete(ParentModel).where(ParentModel.tenant_id == tenant_id)
|
|
)
|
|
result.add_deleted_items("parent_models", parent_delete.rowcount)
|
|
|
|
logger.info("Deleted parent models for tenant",
|
|
tenant_id=tenant_id,
|
|
count=parent_delete.rowcount)
|
|
except Exception as e:
|
|
logger.error("Error deleting parent models",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
result.add_error(f"Parent model deletion: {str(e)}")
|
|
|
|
# Commit all deletions
|
|
await self.db.commit()
|
|
|
|
logger.info("Tenant data deletion completed",
|
|
tenant_id=tenant_id,
|
|
deleted_counts=result.deleted_counts)
|
|
|
|
except Exception as e:
|
|
logger.error("Fatal error during tenant data deletion",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
await self.db.rollback()
|
|
result.add_error(f"Fatal error: {str(e)}")
|
|
|
|
return result
|
|
```
|
|
|
|
### 2. Add API Endpoints
|
|
|
|
**Location:** `services/{service}/app/api/{main_router}.py`
|
|
|
|
**Add at end of file:**
|
|
|
|
```python
|
|
# ===== Tenant Data Deletion Endpoints =====
|
|
|
|
@router.delete("/tenant/{tenant_id}")
|
|
async def delete_tenant_data(
|
|
tenant_id: str,
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Delete all {service}-related data for a tenant
|
|
Only accessible by internal services (called during tenant deletion)
|
|
"""
|
|
|
|
logger.info(f"Tenant data deletion request received for tenant: {tenant_id}")
|
|
|
|
# Only allow internal service calls
|
|
if current_user.get("type") != "service":
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="This endpoint is only accessible to internal services"
|
|
)
|
|
|
|
try:
|
|
from app.services.tenant_deletion_service import {Service}TenantDeletionService
|
|
|
|
deletion_service = {Service}TenantDeletionService(db)
|
|
result = await deletion_service.safe_delete_tenant_data(tenant_id)
|
|
|
|
return {
|
|
"message": "Tenant data deletion completed in {service}-service",
|
|
"summary": result.to_dict()
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Tenant data deletion failed for {tenant_id}: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to delete tenant data: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/tenant/{tenant_id}/deletion-preview")
|
|
async def preview_tenant_data_deletion(
|
|
tenant_id: str,
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Preview what data would be deleted for a tenant (dry-run)
|
|
Accessible by internal services and tenant admins
|
|
"""
|
|
|
|
# Allow internal services and admins
|
|
is_service = current_user.get("type") == "service"
|
|
is_admin = current_user.get("role") in ["owner", "admin"]
|
|
|
|
if not (is_service or is_admin):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Insufficient permissions"
|
|
)
|
|
|
|
try:
|
|
from app.services.tenant_deletion_service import {Service}TenantDeletionService
|
|
|
|
deletion_service = {Service}TenantDeletionService(db)
|
|
preview = await deletion_service.get_tenant_data_preview(tenant_id)
|
|
|
|
return {
|
|
"tenant_id": tenant_id,
|
|
"service": "{service}-service",
|
|
"data_counts": preview,
|
|
"total_items": sum(preview.values())
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Deletion preview failed for {tenant_id}: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to get deletion preview: {str(e)}"
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Remaining Services
|
|
|
|
### 1. POS Service
|
|
|
|
**Models to delete:**
|
|
- POSConfiguration
|
|
- POSTransaction
|
|
- POSSession
|
|
- POSDevice (if exists)
|
|
|
|
**Deletion order:**
|
|
1. POSTransaction (child)
|
|
2. POSSession (child)
|
|
3. POSDevice (if exists)
|
|
4. POSConfiguration (parent)
|
|
|
|
**Estimated time:** 30 minutes
|
|
|
|
### 2. External Service
|
|
|
|
**Models to delete:**
|
|
- ExternalDataCache
|
|
- APIKeyUsage
|
|
- ExternalAPILog (if exists)
|
|
|
|
**Deletion order:**
|
|
1. ExternalAPILog (if exists)
|
|
2. APIKeyUsage
|
|
3. ExternalDataCache
|
|
|
|
**Estimated time:** 30 minutes
|
|
|
|
### 3. Alert Processor Service
|
|
|
|
**Models to delete:**
|
|
- Alert
|
|
- AlertRule
|
|
- AlertHistory
|
|
- AlertNotification (if exists)
|
|
|
|
**Deletion order:**
|
|
1. AlertNotification (if exists, child)
|
|
2. AlertHistory (child)
|
|
3. Alert (child of AlertRule)
|
|
4. AlertRule (parent)
|
|
|
|
**Estimated time:** 30 minutes
|
|
|
|
---
|
|
|
|
## Testing Checklist
|
|
|
|
### Manual Testing (for each service):
|
|
|
|
```bash
|
|
# 1. Start the service
|
|
docker-compose up {service}-service
|
|
|
|
# 2. Test deletion preview (should return counts)
|
|
curl -X GET "http://localhost:8000/api/v1/{service}/tenant/{tenant_id}/deletion-preview" \
|
|
-H "Authorization: Bearer {token}" \
|
|
-H "X-Internal-Service: auth-service"
|
|
|
|
# 3. Test actual deletion
|
|
curl -X DELETE "http://localhost:8000/api/v1/{service}/tenant/{tenant_id}" \
|
|
-H "Authorization: Bearer {token}" \
|
|
-H "X-Internal-Service: auth-service"
|
|
|
|
# 4. Verify data is deleted
|
|
# Check database: SELECT COUNT(*) FROM {table} WHERE tenant_id = '{tenant_id}';
|
|
# Should return 0 for all tables
|
|
```
|
|
|
|
### Integration Testing:
|
|
|
|
```python
|
|
# Test via orchestrator
|
|
from services.auth.app.services.deletion_orchestrator import DeletionOrchestrator
|
|
|
|
orchestrator = DeletionOrchestrator()
|
|
job = await orchestrator.orchestrate_tenant_deletion(
|
|
tenant_id="test-tenant-123",
|
|
tenant_name="Test Bakery"
|
|
)
|
|
|
|
# Check results
|
|
print(job.to_dict())
|
|
# Should show:
|
|
# - services_completed: 12/12
|
|
# - services_failed: 0
|
|
# - total_items_deleted: > 0
|
|
```
|
|
|
|
---
|
|
|
|
## Common Patterns
|
|
|
|
### Pattern 1: Simple Service (1-2 models)
|
|
|
|
**Example:** Sales, External
|
|
|
|
```python
|
|
# Just delete the main model(s)
|
|
sales_delete = await self.db.execute(
|
|
delete(SalesData).where(SalesData.tenant_id == tenant_id)
|
|
)
|
|
result.add_deleted_items("sales_records", sales_delete.rowcount)
|
|
```
|
|
|
|
### Pattern 2: Parent-Child (CASCADE)
|
|
|
|
**Example:** Orders, Recipes
|
|
|
|
```python
|
|
# Delete parent, CASCADE handles children
|
|
order_delete = await self.db.execute(
|
|
delete(Order).where(Order.tenant_id == tenant_id)
|
|
)
|
|
# order_items, order_status_history deleted via CASCADE
|
|
result.add_deleted_items("orders", order_delete.rowcount)
|
|
result.add_deleted_items("order_items", preview["order_items"]) # From preview
|
|
```
|
|
|
|
### Pattern 3: Multiple Independent Models
|
|
|
|
**Example:** Inventory, Production
|
|
|
|
```python
|
|
# Delete each independently
|
|
for Model in [InventoryItem, InventoryTransaction, StockAlert]:
|
|
try:
|
|
deleted = await self.db.execute(
|
|
delete(Model).where(Model.tenant_id == tenant_id)
|
|
)
|
|
result.add_deleted_items(model_name, deleted.rowcount)
|
|
except Exception as e:
|
|
result.add_error(f"{model_name}: {str(e)}")
|
|
```
|
|
|
|
### Pattern 4: Complex Dependencies
|
|
|
|
**Example:** Suppliers
|
|
|
|
```python
|
|
# Delete in specific order
|
|
# 1. Children first
|
|
poi_delete = await self.db.execute(
|
|
delete(PurchaseOrderItem)
|
|
.where(PurchaseOrderItem.purchase_order_id.in_(
|
|
select(PurchaseOrder.id).where(PurchaseOrder.tenant_id == tenant_id)
|
|
))
|
|
)
|
|
|
|
# 2. Then intermediate
|
|
po_delete = await self.db.execute(
|
|
delete(PurchaseOrder).where(PurchaseOrder.tenant_id == tenant_id)
|
|
)
|
|
|
|
# 3. Finally parent
|
|
supplier_delete = await self.db.execute(
|
|
delete(Supplier).where(Supplier.tenant_id == tenant_id)
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Issue: "ModuleNotFoundError: No module named 'shared.services.tenant_deletion'"
|
|
|
|
**Solution:** Ensure shared module is in PYTHONPATH:
|
|
```python
|
|
# Add to service's __init__.py or main.py
|
|
import sys
|
|
sys.path.insert(0, "/path/to/services/shared")
|
|
```
|
|
|
|
### Issue: "Table doesn't exist"
|
|
|
|
**Solution:** Wrap in try-except:
|
|
```python
|
|
try:
|
|
count = await self.db.scalar(select(func.count(Model.id))...)
|
|
preview["models"] = count or 0
|
|
except Exception:
|
|
preview["models"] = 0 # Table doesn't exist, ignore
|
|
```
|
|
|
|
### Issue: "Foreign key constraint violation"
|
|
|
|
**Solution:** Delete in correct order (children before parents):
|
|
```python
|
|
# Wrong order:
|
|
await delete(Parent).where(...) # Fails!
|
|
await delete(Child).where(...)
|
|
|
|
# Correct order:
|
|
await delete(Child).where(...)
|
|
await delete(Parent).where(...) # Success!
|
|
```
|
|
|
|
### Issue: "Service timeout"
|
|
|
|
**Solution:** Increase timeout in orchestrator or implement chunked deletion:
|
|
```python
|
|
# In deletion_orchestrator.py, change:
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
# To:
|
|
async with httpx.AsyncClient(timeout=300.0) as client: # 5 minutes
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Tips
|
|
|
|
### 1. Batch Deletes for Large Datasets
|
|
|
|
```python
|
|
# Instead of:
|
|
for item in items:
|
|
await self.db.delete(item)
|
|
|
|
# Use:
|
|
await self.db.execute(
|
|
delete(Model).where(Model.tenant_id == tenant_id)
|
|
)
|
|
```
|
|
|
|
### 2. Use Indexes
|
|
|
|
Ensure `tenant_id` has an index on all tables:
|
|
```sql
|
|
CREATE INDEX idx_{table}_tenant_id ON {table}(tenant_id);
|
|
```
|
|
|
|
### 3. Disable Triggers Temporarily (for very large deletes)
|
|
|
|
```python
|
|
await self.db.execute(text("SET session_replication_role = replica"))
|
|
# ... do deletions ...
|
|
await self.db.execute(text("SET session_replication_role = DEFAULT"))
|
|
```
|
|
|
|
---
|
|
|
|
## Completion Checklist
|
|
|
|
- [ ] POS Service deletion service created
|
|
- [ ] POS Service API endpoints added
|
|
- [ ] POS Service manually tested
|
|
- [ ] External Service deletion service created
|
|
- [ ] External Service API endpoints added
|
|
- [ ] External Service manually tested
|
|
- [ ] Alert Processor deletion service created
|
|
- [ ] Alert Processor API endpoints added
|
|
- [ ] Alert Processor manually tested
|
|
- [ ] All services tested via orchestrator
|
|
- [ ] Load testing completed
|
|
- [ ] Documentation updated
|
|
|
|
---
|
|
|
|
## Next Steps After Completion
|
|
|
|
1. **Update DeletionOrchestrator** - Verify all endpoint URLs are correct
|
|
2. **Integration Testing** - Test complete tenant deletion end-to-end
|
|
3. **Performance Testing** - Test with large datasets
|
|
4. **Monitoring Setup** - Add Prometheus metrics
|
|
5. **Production Deployment** - Deploy with feature flag
|
|
|
|
**Total estimated time for all 3 services:** 1.5-2 hours
|
|
|
|
---
|
|
|
|
## Quick Reference: Completed Services
|
|
|
|
| Service | Status | Files | Lines |
|
|
|---------|--------|-------|-------|
|
|
| Tenant | ✅ | 2 API files + 1 service | 641 |
|
|
| Orders | ✅ | tenant_deletion_service.py + endpoints | 225 |
|
|
| Inventory | ✅ | tenant_deletion_service.py | 110 |
|
|
| Recipes | ✅ | tenant_deletion_service.py + endpoints | 217 |
|
|
| Sales | ✅ | tenant_deletion_service.py | 85 |
|
|
| Production | ✅ | tenant_deletion_service.py | 171 |
|
|
| Suppliers | ✅ | tenant_deletion_service.py | 195 |
|
|
| **POS** | ⏳ | - | - |
|
|
| **External** | ⏳ | - | - |
|
|
| **Alert Processor** | ⏳ | - | - |
|
|
| Forecasting | 🔄 | Needs refactor | - |
|
|
| Training | 🔄 | Needs refactor | - |
|
|
| Notification | 🔄 | Needs refactor | - |
|
|
|
|
**Legend:**
|
|
- ✅ Complete
|
|
- ⏳ Pending
|
|
- 🔄 Needs refactoring to standard pattern
|