Improve user delete flow
This commit is contained in:
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
import structlog
|
import structlog
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.schemas.auth import UserResponse, PasswordChange
|
from app.schemas.auth import UserResponse, PasswordChange
|
||||||
@@ -120,7 +121,7 @@ async def update_current_user(
|
|||||||
detail="Failed to update user"
|
detail="Failed to update user"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.delete("/delete/users/{user_id}")
|
@router.delete("/delete/{user_id}")
|
||||||
async def delete_admin_user(
|
async def delete_admin_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
@@ -158,35 +159,83 @@ async def delete_admin_user(
|
|||||||
detail="Cannot delete your own account"
|
detail="Cannot delete your own account"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize deletion service
|
# Quick validation that user exists before starting background task
|
||||||
deletion_service = AdminUserDeleteService(db)
|
deletion_service = AdminUserDeleteService(db)
|
||||||
|
user_info = await deletion_service._validate_admin_user(user_id)
|
||||||
# Perform the deletion
|
if not user_info:
|
||||||
try:
|
|
||||||
result = await deletion_service.delete_admin_user_complete(
|
|
||||||
user_id=user_id,
|
|
||||||
requesting_user_id=current_user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": f"Admin user {user_id} has been successfully deleted",
|
|
||||||
"deletion_details": result
|
|
||||||
}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Unexpected error during user deletion",
|
|
||||||
user_id=user_id,
|
|
||||||
error=str(e))
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="An unexpected error occurred during user deletion"
|
detail=f"Admin user {user_id} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Start deletion as background task for better performance
|
||||||
|
background_tasks.add_task(
|
||||||
|
execute_admin_user_deletion,
|
||||||
|
user_id=user_id,
|
||||||
|
requesting_user_id=current_user.id,
|
||||||
|
db_url=str(db.bind.url) # Pass DB connection string for background task
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Admin user deletion for {user_id} has been initiated",
|
||||||
|
"status": "processing",
|
||||||
|
"user_info": user_info,
|
||||||
|
"initiated_at": datetime.utcnow().isoformat(),
|
||||||
|
"note": "Deletion is processing in the background. Check logs for completion status."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add this background task function to services/auth/app/api/users.py:
|
||||||
|
|
||||||
|
async def execute_admin_user_deletion(
|
||||||
|
user_id: str,
|
||||||
|
requesting_user_id: str,
|
||||||
|
db_url: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Background task to execute complete admin user deletion
|
||||||
|
"""
|
||||||
|
# Create new database session for background task
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
engine = create_async_engine(db_url)
|
||||||
|
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
try:
|
||||||
|
# Initialize deletion service with new session
|
||||||
|
deletion_service = AdminUserDeleteService(session)
|
||||||
|
|
||||||
|
# Perform the deletion
|
||||||
|
result = await deletion_service.delete_admin_user_complete(
|
||||||
|
user_id=user_id,
|
||||||
|
requesting_user_id=requesting_user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Background admin user deletion completed successfully",
|
||||||
|
user_id=user_id,
|
||||||
|
requesting_user=requesting_user_id,
|
||||||
|
result=result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Background admin user deletion failed",
|
||||||
|
user_id=user_id,
|
||||||
|
requesting_user=requesting_user_id,
|
||||||
|
error=str(e))
|
||||||
|
|
||||||
|
# Attempt to publish failure event
|
||||||
|
try:
|
||||||
|
deletion_service = AdminUserDeleteService(session)
|
||||||
|
await deletion_service._publish_user_deletion_failed_event(user_id, str(e))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/delete/users/{user_id}/deletion-preview")
|
@router.get("/delete/{user_id}/deletion-preview")
|
||||||
async def preview_user_deletion(
|
async def preview_user_deletion(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
current_user = Depends(get_current_user_dep),
|
current_user = Depends(get_current_user_dep),
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ class AuthTrainingServiceClient(BaseServiceClient):
|
|||||||
"""Cancel all active training jobs for a tenant"""
|
"""Cancel all active training jobs for a tenant"""
|
||||||
try:
|
try:
|
||||||
data = {"tenant_id": tenant_id}
|
data = {"tenant_id": tenant_id}
|
||||||
return await self.post("jobs/cancel-tenant", data=data)
|
return await self.post("/tenants/{tenant_id}/training/jobs/cancel", data=data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to cancel tenant training jobs",
|
logger.error("Failed to cancel tenant training jobs",
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -151,7 +151,7 @@ class AuthTrainingServiceClient(BaseServiceClient):
|
|||||||
"""Get all active training jobs for a tenant"""
|
"""Get all active training jobs for a tenant"""
|
||||||
try:
|
try:
|
||||||
params = {"status": "running,queued,pending", "tenant_id": tenant_id}
|
params = {"status": "running,queued,pending", "tenant_id": tenant_id}
|
||||||
result = await self.get("jobs", params=params)
|
result = await self.get("/tenants/{tenant_id}/training/jobs/active", params=params)
|
||||||
return result.get("jobs", []) if result else []
|
return result.get("jobs", []) if result else []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to get tenant active jobs",
|
logger.error("Failed to get tenant active jobs",
|
||||||
|
|||||||
@@ -322,30 +322,15 @@ async def acknowledge_alert(
|
|||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Internal server error"
|
detail="Internal server error"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.delete("/forecasts/tenant/{tenant_id}")
|
@router.delete("/tenants/{tenant_id}/forecasts")
|
||||||
async def delete_tenant_forecasts_complete(
|
async def delete_tenant_forecasts(
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
current_user = Depends(get_current_user_dep),
|
current_user = Depends(get_current_user_dep),
|
||||||
_admin_check = Depends(require_admin_role),
|
_admin_check = Depends(require_admin_role),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""Delete all forecasts and predictions for a tenant (admin only)"""
|
||||||
Delete all forecasts and predictions for a tenant.
|
|
||||||
|
|
||||||
**WARNING: This operation is irreversible!**
|
|
||||||
|
|
||||||
This endpoint:
|
|
||||||
1. Cancels any active prediction batches
|
|
||||||
2. Clears prediction cache
|
|
||||||
3. Deletes all forecast records
|
|
||||||
4. Deletes prediction batch records
|
|
||||||
5. Deletes model performance metrics
|
|
||||||
6. Publishes deletion event
|
|
||||||
|
|
||||||
Used by admin user deletion process to clean up all forecasting data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tenant_uuid = uuid.UUID(tenant_id)
|
tenant_uuid = uuid.UUID(tenant_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -355,163 +340,155 @@ async def delete_tenant_forecasts_complete(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.models.forecasts import Forecast, PredictionBatch
|
from app.models.forecasts import Forecast, Prediction, PredictionBatch
|
||||||
from app.models.predictions import ModelPerformanceMetric, PredictionCache
|
|
||||||
|
|
||||||
deletion_stats = {
|
deletion_stats = {
|
||||||
"tenant_id": tenant_id,
|
"tenant_id": tenant_id,
|
||||||
"deleted_at": datetime.utcnow().isoformat(),
|
"deleted_at": datetime.utcnow().isoformat(),
|
||||||
"batches_cancelled": 0,
|
|
||||||
"forecasts_deleted": 0,
|
"forecasts_deleted": 0,
|
||||||
"prediction_batches_deleted": 0,
|
"predictions_deleted": 0,
|
||||||
"performance_metrics_deleted": 0,
|
"batches_deleted": 0,
|
||||||
"cache_entries_deleted": 0,
|
|
||||||
"errors": []
|
"errors": []
|
||||||
}
|
}
|
||||||
|
|
||||||
# Step 1: Cancel active prediction batches
|
# Count before deletion
|
||||||
|
forecasts_count_query = select(func.count(Forecast.id)).where(
|
||||||
|
Forecast.tenant_id == tenant_uuid
|
||||||
|
)
|
||||||
|
forecasts_count_result = await db.execute(forecasts_count_query)
|
||||||
|
forecasts_count = forecasts_count_result.scalar()
|
||||||
|
|
||||||
|
predictions_count_query = select(func.count(Prediction.id)).where(
|
||||||
|
Prediction.tenant_id == tenant_uuid
|
||||||
|
)
|
||||||
|
predictions_count_result = await db.execute(predictions_count_query)
|
||||||
|
predictions_count = predictions_count_result.scalar()
|
||||||
|
|
||||||
|
batches_count_query = select(func.count(PredictionBatch.id)).where(
|
||||||
|
PredictionBatch.tenant_id == tenant_uuid
|
||||||
|
)
|
||||||
|
batches_count_result = await db.execute(batches_count_query)
|
||||||
|
batches_count = batches_count_result.scalar()
|
||||||
|
|
||||||
|
# Delete predictions first (they may reference forecasts)
|
||||||
try:
|
try:
|
||||||
active_batches_query = select(PredictionBatch).where(
|
predictions_delete_query = delete(Prediction).where(
|
||||||
PredictionBatch.tenant_id == tenant_uuid,
|
Prediction.tenant_id == tenant_uuid
|
||||||
PredictionBatch.status.in_(["pending", "processing"])
|
|
||||||
)
|
)
|
||||||
active_batches_result = await db.execute(active_batches_query)
|
predictions_delete_result = await db.execute(predictions_delete_query)
|
||||||
active_batches = active_batches_result.scalars().all()
|
deletion_stats["predictions_deleted"] = predictions_delete_result.rowcount
|
||||||
|
|
||||||
for batch in active_batches:
|
|
||||||
batch.status = "cancelled"
|
|
||||||
batch.completed_at = datetime.utcnow()
|
|
||||||
deletion_stats["batches_cancelled"] += 1
|
|
||||||
|
|
||||||
if active_batches:
|
|
||||||
await db.commit()
|
|
||||||
logger.info("Cancelled active prediction batches",
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
count=len(active_batches))
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error cancelling prediction batches: {str(e)}"
|
error_msg = f"Error deleting predictions: {str(e)}"
|
||||||
deletion_stats["errors"].append(error_msg)
|
deletion_stats["errors"].append(error_msg)
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
|
||||||
# Step 2: Delete prediction cache
|
# Delete prediction batches
|
||||||
try:
|
try:
|
||||||
cache_count_query = select(func.count(PredictionCache.id)).where(
|
|
||||||
PredictionCache.tenant_id == tenant_uuid
|
|
||||||
)
|
|
||||||
cache_count_result = await db.execute(cache_count_query)
|
|
||||||
cache_count = cache_count_result.scalar()
|
|
||||||
|
|
||||||
cache_delete_query = delete(PredictionCache).where(
|
|
||||||
PredictionCache.tenant_id == tenant_uuid
|
|
||||||
)
|
|
||||||
await db.execute(cache_delete_query)
|
|
||||||
deletion_stats["cache_entries_deleted"] = cache_count
|
|
||||||
|
|
||||||
logger.info("Deleted prediction cache entries",
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
count=cache_count)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Error deleting prediction cache: {str(e)}"
|
|
||||||
deletion_stats["errors"].append(error_msg)
|
|
||||||
logger.error(error_msg)
|
|
||||||
|
|
||||||
# Step 3: Delete model performance metrics
|
|
||||||
try:
|
|
||||||
metrics_count_query = select(func.count(ModelPerformanceMetric.id)).where(
|
|
||||||
ModelPerformanceMetric.tenant_id == tenant_uuid
|
|
||||||
)
|
|
||||||
metrics_count_result = await db.execute(metrics_count_query)
|
|
||||||
metrics_count = metrics_count_result.scalar()
|
|
||||||
|
|
||||||
metrics_delete_query = delete(ModelPerformanceMetric).where(
|
|
||||||
ModelPerformanceMetric.tenant_id == tenant_uuid
|
|
||||||
)
|
|
||||||
await db.execute(metrics_delete_query)
|
|
||||||
deletion_stats["performance_metrics_deleted"] = metrics_count
|
|
||||||
|
|
||||||
logger.info("Deleted performance metrics",
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
count=metrics_count)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Error deleting performance metrics: {str(e)}"
|
|
||||||
deletion_stats["errors"].append(error_msg)
|
|
||||||
logger.error(error_msg)
|
|
||||||
|
|
||||||
# Step 4: Delete prediction batches
|
|
||||||
try:
|
|
||||||
batches_count_query = select(func.count(PredictionBatch.id)).where(
|
|
||||||
PredictionBatch.tenant_id == tenant_uuid
|
|
||||||
)
|
|
||||||
batches_count_result = await db.execute(batches_count_query)
|
|
||||||
batches_count = batches_count_result.scalar()
|
|
||||||
|
|
||||||
batches_delete_query = delete(PredictionBatch).where(
|
batches_delete_query = delete(PredictionBatch).where(
|
||||||
PredictionBatch.tenant_id == tenant_uuid
|
PredictionBatch.tenant_id == tenant_uuid
|
||||||
)
|
)
|
||||||
await db.execute(batches_delete_query)
|
batches_delete_result = await db.execute(batches_delete_query)
|
||||||
deletion_stats["prediction_batches_deleted"] = batches_count
|
deletion_stats["batches_deleted"] = batches_delete_result.rowcount
|
||||||
|
|
||||||
logger.info("Deleted prediction batches",
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
count=batches_count)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error deleting prediction batches: {str(e)}"
|
error_msg = f"Error deleting prediction batches: {str(e)}"
|
||||||
deletion_stats["errors"].append(error_msg)
|
deletion_stats["errors"].append(error_msg)
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
|
||||||
# Step 5: Delete forecasts (main data)
|
# Delete forecasts
|
||||||
try:
|
try:
|
||||||
forecasts_count_query = select(func.count(Forecast.id)).where(
|
|
||||||
Forecast.tenant_id == tenant_uuid
|
|
||||||
)
|
|
||||||
forecasts_count_result = await db.execute(forecasts_count_query)
|
|
||||||
forecasts_count = forecasts_count_result.scalar()
|
|
||||||
|
|
||||||
forecasts_delete_query = delete(Forecast).where(
|
forecasts_delete_query = delete(Forecast).where(
|
||||||
Forecast.tenant_id == tenant_uuid
|
Forecast.tenant_id == tenant_uuid
|
||||||
)
|
)
|
||||||
await db.execute(forecasts_delete_query)
|
forecasts_delete_result = await db.execute(forecasts_delete_query)
|
||||||
deletion_stats["forecasts_deleted"] = forecasts_count
|
deletion_stats["forecasts_deleted"] = forecasts_delete_result.rowcount
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
logger.info("Deleted forecasts",
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
count=forecasts_count)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await db.rollback()
|
|
||||||
error_msg = f"Error deleting forecasts: {str(e)}"
|
error_msg = f"Error deleting forecasts: {str(e)}"
|
||||||
deletion_stats["errors"].append(error_msg)
|
deletion_stats["errors"].append(error_msg)
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=error_msg
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 6: Publish deletion event
|
await db.commit()
|
||||||
try:
|
|
||||||
await publish_forecasts_deleted_event(tenant_id, deletion_stats)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to publish forecasts deletion event", error=str(e))
|
|
||||||
|
|
||||||
return {
|
logger.info("Deleted tenant forecasting data",
|
||||||
"success": True,
|
tenant_id=tenant_id,
|
||||||
"message": f"All forecasting data for tenant {tenant_id} deleted successfully",
|
forecasts=deletion_stats["forecasts_deleted"],
|
||||||
"deletion_details": deletion_stats
|
predictions=deletion_stats["predictions_deleted"],
|
||||||
|
batches=deletion_stats["batches_deleted"])
|
||||||
|
|
||||||
|
deletion_stats["success"] = len(deletion_stats["errors"]) == 0
|
||||||
|
deletion_stats["expected_counts"] = {
|
||||||
|
"forecasts": forecasts_count,
|
||||||
|
"predictions": predictions_count,
|
||||||
|
"batches": batches_count
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
return deletion_stats
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Unexpected error deleting tenant forecasts",
|
await db.rollback()
|
||||||
|
logger.error("Failed to delete tenant forecasts",
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
error=str(e))
|
error=str(e))
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"Failed to delete tenant forecasts: {str(e)}"
|
detail="Failed to delete tenant forecasts"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/forecasts/count")
|
||||||
|
async def get_tenant_forecasts_count(
|
||||||
|
tenant_id: str,
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_admin_role),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get count of forecasts and predictions for a tenant (admin only)"""
|
||||||
|
try:
|
||||||
|
tenant_uuid = uuid.UUID(tenant_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid tenant ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.forecasts import Forecast, Prediction, PredictionBatch
|
||||||
|
|
||||||
|
# Count forecasts
|
||||||
|
forecasts_count_query = select(func.count(Forecast.id)).where(
|
||||||
|
Forecast.tenant_id == tenant_uuid
|
||||||
|
)
|
||||||
|
forecasts_count_result = await db.execute(forecasts_count_query)
|
||||||
|
forecasts_count = forecasts_count_result.scalar()
|
||||||
|
|
||||||
|
# Count predictions
|
||||||
|
predictions_count_query = select(func.count(Prediction.id)).where(
|
||||||
|
Prediction.tenant_id == tenant_uuid
|
||||||
|
)
|
||||||
|
predictions_count_result = await db.execute(predictions_count_query)
|
||||||
|
predictions_count = predictions_count_result.scalar()
|
||||||
|
|
||||||
|
# Count batches
|
||||||
|
batches_count_query = select(func.count(PredictionBatch.id)).where(
|
||||||
|
PredictionBatch.tenant_id == tenant_uuid
|
||||||
|
)
|
||||||
|
batches_count_result = await db.execute(batches_count_query)
|
||||||
|
batches_count = batches_count_result.scalar()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"forecasts_count": forecasts_count,
|
||||||
|
"predictions_count": predictions_count,
|
||||||
|
"batches_count": batches_count,
|
||||||
|
"total_forecasting_assets": forecasts_count + predictions_count + batches_count
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get tenant forecasts count",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get forecasts count"
|
||||||
|
)
|
||||||
@@ -10,11 +10,15 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
from sqlalchemy import select, delete, func
|
||||||
|
import uuid
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from shared.auth.decorators import (
|
from shared.auth.decorators import (
|
||||||
get_current_user_dep,
|
get_current_user_dep,
|
||||||
get_current_tenant_id_dep
|
get_current_tenant_id_dep,
|
||||||
|
get_current_user_dep,
|
||||||
|
require_admin_role
|
||||||
)
|
)
|
||||||
from app.services.prediction_service import PredictionService
|
from app.services.prediction_service import PredictionService
|
||||||
from app.schemas.forecasts import ForecastRequest
|
from app.schemas.forecasts import ForecastRequest
|
||||||
@@ -140,3 +144,128 @@ async def get_quick_prediction(
|
|||||||
detail="Internal server error"
|
detail="Internal server error"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/predictions/cancel-batches")
|
||||||
|
async def cancel_tenant_prediction_batches(
|
||||||
|
tenant_id: str,
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_admin_role),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Cancel all active prediction batches for a tenant (admin only)"""
|
||||||
|
try:
|
||||||
|
tenant_uuid = uuid.UUID(tenant_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid tenant ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.forecasts import PredictionBatch
|
||||||
|
|
||||||
|
# Find active prediction batches
|
||||||
|
active_batches_query = select(PredictionBatch).where(
|
||||||
|
PredictionBatch.tenant_id == tenant_uuid,
|
||||||
|
PredictionBatch.status.in_(["queued", "running", "pending"])
|
||||||
|
)
|
||||||
|
active_batches_result = await db.execute(active_batches_query)
|
||||||
|
active_batches = active_batches_result.scalars().all()
|
||||||
|
|
||||||
|
batches_cancelled = 0
|
||||||
|
cancelled_batch_ids = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for batch in active_batches:
|
||||||
|
try:
|
||||||
|
batch.status = "cancelled"
|
||||||
|
batch.updated_at = datetime.utcnow()
|
||||||
|
batch.cancelled_by = current_user.get("user_id")
|
||||||
|
batches_cancelled += 1
|
||||||
|
cancelled_batch_ids.append(str(batch.id))
|
||||||
|
|
||||||
|
logger.info("Cancelled prediction batch",
|
||||||
|
batch_id=str(batch.id),
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Failed to cancel batch {batch.id}: {str(e)}"
|
||||||
|
errors.append(error_msg)
|
||||||
|
logger.error(error_msg)
|
||||||
|
|
||||||
|
if batches_cancelled > 0:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"batches_cancelled": batches_cancelled,
|
||||||
|
"cancelled_batch_ids": cancelled_batch_ids,
|
||||||
|
"errors": errors,
|
||||||
|
"cancelled_at": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error("Failed to cancel tenant prediction batches",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to cancel prediction batches"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/tenants/{tenant_id}/predictions/cache")
|
||||||
|
async def clear_tenant_prediction_cache(
|
||||||
|
tenant_id: str,
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_admin_role),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Clear all prediction cache for a tenant (admin only)"""
|
||||||
|
try:
|
||||||
|
tenant_uuid = uuid.UUID(tenant_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid tenant ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.forecasts import PredictionCache
|
||||||
|
|
||||||
|
# Count cache entries before deletion
|
||||||
|
cache_count_query = select(func.count(PredictionCache.id)).where(
|
||||||
|
PredictionCache.tenant_id == tenant_uuid
|
||||||
|
)
|
||||||
|
cache_count_result = await db.execute(cache_count_query)
|
||||||
|
cache_count = cache_count_result.scalar()
|
||||||
|
|
||||||
|
# Delete cache entries
|
||||||
|
cache_delete_query = delete(PredictionCache).where(
|
||||||
|
PredictionCache.tenant_id == tenant_uuid
|
||||||
|
)
|
||||||
|
cache_delete_result = await db.execute(cache_delete_query)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info("Cleared tenant prediction cache",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
cache_cleared=cache_delete_result.rowcount)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"cache_cleared": cache_delete_result.rowcount,
|
||||||
|
"expected_count": cache_count,
|
||||||
|
"cleared_at": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error("Failed to clear tenant prediction cache",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to clear prediction cache"
|
||||||
|
)
|
||||||
@@ -81,6 +81,8 @@ class PredictionBatch(Base):
|
|||||||
error_message = Column(Text)
|
error_message = Column(Text)
|
||||||
processing_time_ms = Column(Integer)
|
processing_time_ms = Column(Integer)
|
||||||
|
|
||||||
|
cancelled_by = Column(String, nullable=True)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<PredictionBatch(id={self.id}, status={self.status})>"
|
return f"<PredictionBatch(id={self.id}, status={self.status})>"
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,14 @@
|
|||||||
Complete notification API routes with full CRUD operations
|
Complete notification API routes with full CRUD operations
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path, BackgroundTasks
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
import structlog
|
import structlog
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy import select, delete, func
|
||||||
|
|
||||||
from app.schemas.notifications import (
|
from app.schemas.notifications import (
|
||||||
NotificationCreate,
|
NotificationCreate,
|
||||||
@@ -39,6 +43,8 @@ from shared.auth.decorators import (
|
|||||||
require_role
|
require_role
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
@@ -515,4 +521,355 @@ async def test_send_whatsapp(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to send test WhatsApp", error=str(e))
|
logger.error("Failed to send test WhatsApp", error=str(e))
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/notifications/cancel-pending")
|
||||||
|
async def cancel_pending_user_notifications(
|
||||||
|
user_id: str,
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_role(["admin"])),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Cancel all pending notifications for a user (admin only)"""
|
||||||
|
try:
|
||||||
|
user_uuid = uuid.UUID(user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid user ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.notifications import NotificationQueue, NotificationLog
|
||||||
|
|
||||||
|
# Find pending notifications
|
||||||
|
pending_notifications_query = select(NotificationQueue).where(
|
||||||
|
NotificationQueue.user_id == user_uuid,
|
||||||
|
NotificationQueue.status.in_(["pending", "queued", "scheduled"])
|
||||||
|
)
|
||||||
|
pending_notifications_result = await db.execute(pending_notifications_query)
|
||||||
|
pending_notifications = pending_notifications_result.scalars().all()
|
||||||
|
|
||||||
|
notifications_cancelled = 0
|
||||||
|
cancelled_notification_ids = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for notification in pending_notifications:
|
||||||
|
try:
|
||||||
|
notification.status = "cancelled"
|
||||||
|
notification.updated_at = datetime.utcnow()
|
||||||
|
notification.cancelled_by = current_user.get("user_id")
|
||||||
|
notifications_cancelled += 1
|
||||||
|
cancelled_notification_ids.append(str(notification.id))
|
||||||
|
|
||||||
|
logger.info("Cancelled pending notification",
|
||||||
|
notification_id=str(notification.id),
|
||||||
|
user_id=user_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Failed to cancel notification {notification.id}: {str(e)}"
|
||||||
|
errors.append(error_msg)
|
||||||
|
logger.error(error_msg)
|
||||||
|
|
||||||
|
if notifications_cancelled > 0:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"user_id": user_id,
|
||||||
|
"notifications_cancelled": notifications_cancelled,
|
||||||
|
"cancelled_notification_ids": cancelled_notification_ids,
|
||||||
|
"errors": errors,
|
||||||
|
"cancelled_at": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error("Failed to cancel pending user notifications",
|
||||||
|
user_id=user_id,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to cancel pending notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}/notification-data")
|
||||||
|
async def delete_user_notification_data(
|
||||||
|
user_id: str,
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_role(["admin"])),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Delete all notification data for a user (admin only)"""
|
||||||
|
try:
|
||||||
|
user_uuid = uuid.UUID(user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid user ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.notifications import (
|
||||||
|
NotificationPreference,
|
||||||
|
NotificationQueue,
|
||||||
|
NotificationLog,
|
||||||
|
DeliveryAttempt
|
||||||
|
)
|
||||||
|
|
||||||
|
deletion_stats = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"deleted_at": datetime.utcnow().isoformat(),
|
||||||
|
"preferences_deleted": 0,
|
||||||
|
"notifications_deleted": 0,
|
||||||
|
"logs_deleted": 0,
|
||||||
|
"delivery_attempts_deleted": 0,
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Delete delivery attempts first (they reference notifications)
|
||||||
|
try:
|
||||||
|
delivery_attempts_query = select(DeliveryAttempt).join(
|
||||||
|
NotificationQueue, DeliveryAttempt.notification_id == NotificationQueue.id
|
||||||
|
).where(NotificationQueue.user_id == user_uuid)
|
||||||
|
delivery_attempts_result = await db.execute(delivery_attempts_query)
|
||||||
|
delivery_attempts = delivery_attempts_result.scalars().all()
|
||||||
|
|
||||||
|
for attempt in delivery_attempts:
|
||||||
|
await db.delete(attempt)
|
||||||
|
|
||||||
|
deletion_stats["delivery_attempts_deleted"] = len(delivery_attempts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error deleting delivery attempts: {str(e)}"
|
||||||
|
deletion_stats["errors"].append(error_msg)
|
||||||
|
logger.error(error_msg)
|
||||||
|
|
||||||
|
# Delete notification queue entries
|
||||||
|
try:
|
||||||
|
notifications_delete_query = delete(NotificationQueue).where(
|
||||||
|
NotificationQueue.user_id == user_uuid
|
||||||
|
)
|
||||||
|
notifications_delete_result = await db.execute(notifications_delete_query)
|
||||||
|
deletion_stats["notifications_deleted"] = notifications_delete_result.rowcount
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error deleting notifications: {str(e)}"
|
||||||
|
deletion_stats["errors"].append(error_msg)
|
||||||
|
logger.error(error_msg)
|
||||||
|
|
||||||
|
# Delete notification logs
|
||||||
|
try:
|
||||||
|
logs_delete_query = delete(NotificationLog).where(
|
||||||
|
NotificationLog.user_id == user_uuid
|
||||||
|
)
|
||||||
|
logs_delete_result = await db.execute(logs_delete_query)
|
||||||
|
deletion_stats["logs_deleted"] = logs_delete_result.rowcount
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error deleting notification logs: {str(e)}"
|
||||||
|
deletion_stats["errors"].append(error_msg)
|
||||||
|
logger.error(error_msg)
|
||||||
|
|
||||||
|
# Delete notification preferences
|
||||||
|
try:
|
||||||
|
preferences_delete_query = delete(NotificationPreference).where(
|
||||||
|
NotificationPreference.user_id == user_uuid
|
||||||
|
)
|
||||||
|
preferences_delete_result = await db.execute(preferences_delete_query)
|
||||||
|
deletion_stats["preferences_deleted"] = preferences_delete_result.rowcount
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error deleting notification preferences: {str(e)}"
|
||||||
|
deletion_stats["errors"].append(error_msg)
|
||||||
|
logger.error(error_msg)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info("Deleted user notification data",
|
||||||
|
user_id=user_id,
|
||||||
|
preferences=deletion_stats["preferences_deleted"],
|
||||||
|
notifications=deletion_stats["notifications_deleted"],
|
||||||
|
logs=deletion_stats["logs_deleted"])
|
||||||
|
|
||||||
|
deletion_stats["success"] = len(deletion_stats["errors"]) == 0
|
||||||
|
|
||||||
|
return deletion_stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error("Failed to delete user notification data",
|
||||||
|
user_id=user_id,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to delete user notification data"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/notifications/user-deletion")
|
||||||
|
async def send_user_deletion_notification(
|
||||||
|
notification_data: dict, # {"admin_email": str, "deleted_user_email": str, "deletion_summary": dict}
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_role(["admin"])),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Send notification about user deletion to admins (admin only)"""
|
||||||
|
try:
|
||||||
|
admin_email = notification_data.get("admin_email")
|
||||||
|
deleted_user_email = notification_data.get("deleted_user_email")
|
||||||
|
deletion_summary = notification_data.get("deletion_summary", {})
|
||||||
|
|
||||||
|
if not admin_email or not deleted_user_email:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="admin_email and deleted_user_email are required"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid request data: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.notifications import NotificationQueue
|
||||||
|
from app.services.notification_service import NotificationService
|
||||||
|
|
||||||
|
# Create notification for the admin about the user deletion
|
||||||
|
notification_content = {
|
||||||
|
"subject": f"Admin User Deletion Completed - {deleted_user_email}",
|
||||||
|
"message": f"""
|
||||||
|
Admin User Deletion Summary
|
||||||
|
|
||||||
|
Deleted User: {deleted_user_email}
|
||||||
|
Deletion Performed By: {admin_email}
|
||||||
|
Deletion Date: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
- Tenants Affected: {deletion_summary.get('total_tenants_affected', 0)}
|
||||||
|
- Models Deleted: {deletion_summary.get('total_models_deleted', 0)}
|
||||||
|
- Forecasts Deleted: {deletion_summary.get('total_forecasts_deleted', 0)}
|
||||||
|
- Notifications Deleted: {deletion_summary.get('total_notifications_deleted', 0)}
|
||||||
|
- Tenants Transferred: {deletion_summary.get('tenants_transferred', 0)}
|
||||||
|
- Tenants Deleted: {deletion_summary.get('tenants_deleted', 0)}
|
||||||
|
|
||||||
|
Status: {'Success' if deletion_summary.get('deletion_successful', False) else 'Completed with errors'}
|
||||||
|
Total Errors: {deletion_summary.get('total_errors', 0)}
|
||||||
|
|
||||||
|
This action was performed through the admin user deletion system and all associated data has been permanently removed.
|
||||||
|
""".strip(),
|
||||||
|
"notification_type": "user_deletion_admin",
|
||||||
|
"priority": "high"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create notification queue entry
|
||||||
|
notification = NotificationQueue(
|
||||||
|
user_email=admin_email,
|
||||||
|
notification_type="user_deletion_admin",
|
||||||
|
subject=notification_content["subject"],
|
||||||
|
message=notification_content["message"],
|
||||||
|
priority="high",
|
||||||
|
status="pending",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
metadata={
|
||||||
|
"deleted_user_email": deleted_user_email,
|
||||||
|
"deletion_summary": deletion_summary,
|
||||||
|
"performed_by": current_user.get("user_id")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(notification)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Trigger immediate sending (assuming NotificationService exists)
|
||||||
|
try:
|
||||||
|
notification_service = NotificationService(db)
|
||||||
|
await notification_service.process_pending_notification(notification.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to immediately send notification, will be processed by background worker",
|
||||||
|
error=str(e))
|
||||||
|
|
||||||
|
logger.info("Created user deletion notification",
|
||||||
|
admin_email=admin_email,
|
||||||
|
deleted_user=deleted_user_email,
|
||||||
|
notification_id=str(notification.id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "User deletion notification created successfully",
|
||||||
|
"notification_id": str(notification.id),
|
||||||
|
"recipient": admin_email,
|
||||||
|
"created_at": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error("Failed to send user deletion notification",
|
||||||
|
admin_email=admin_email,
|
||||||
|
deleted_user=deleted_user_email,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to send user deletion notification"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}/notification-data/count")
|
||||||
|
async def get_user_notification_data_count(
|
||||||
|
user_id: str,
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_role(["admin"])),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get count of notification data for a user (admin only)"""
|
||||||
|
try:
|
||||||
|
user_uuid = uuid.UUID(user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid user ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.notifications import (
|
||||||
|
NotificationPreference,
|
||||||
|
NotificationQueue,
|
||||||
|
NotificationLog
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count preferences
|
||||||
|
preferences_count_query = select(func.count(NotificationPreference.id)).where(
|
||||||
|
NotificationPreference.user_id == user_uuid
|
||||||
|
)
|
||||||
|
preferences_count_result = await db.execute(preferences_count_query)
|
||||||
|
preferences_count = preferences_count_result.scalar()
|
||||||
|
|
||||||
|
# Count notifications
|
||||||
|
notifications_count_query = select(func.count(NotificationQueue.id)).where(
|
||||||
|
NotificationQueue.user_id == user_uuid
|
||||||
|
)
|
||||||
|
notifications_count_result = await db.execute(notifications_count_query)
|
||||||
|
notifications_count = notifications_count_result.scalar()
|
||||||
|
|
||||||
|
# Count logs
|
||||||
|
logs_count_query = select(func.count(NotificationLog.id)).where(
|
||||||
|
NotificationLog.user_id == user_uuid
|
||||||
|
)
|
||||||
|
logs_count_result = await db.execute(logs_count_query)
|
||||||
|
logs_count = logs_count_result.scalar()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"preferences_count": preferences_count,
|
||||||
|
"notifications_count": notifications_count,
|
||||||
|
"logs_count": logs_count,
|
||||||
|
"total_notification_data": preferences_count + notifications_count + logs_count
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get user notification data count",
|
||||||
|
user_id=user_id,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get notification data count"
|
||||||
|
)
|
||||||
@@ -311,4 +311,257 @@ async def delete_tenant_complete(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"Failed to delete tenant: {str(e)}"
|
detail=f"Failed to delete tenant: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}/tenants")
|
||||||
|
async def get_user_tenants(
|
||||||
|
user_id: str,
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_admin_role),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get all tenant memberships for a user (admin only)"""
|
||||||
|
try:
|
||||||
|
user_uuid = uuid.UUID(user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid user ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.tenants import TenantMember, Tenant
|
||||||
|
|
||||||
|
# Get all memberships for the user
|
||||||
|
membership_query = select(TenantMember, Tenant).join(
|
||||||
|
Tenant, TenantMember.tenant_id == Tenant.id
|
||||||
|
).where(TenantMember.user_id == user_uuid)
|
||||||
|
|
||||||
|
result = await db.execute(membership_query)
|
||||||
|
memberships_data = result.all()
|
||||||
|
|
||||||
|
memberships = []
|
||||||
|
for membership, tenant in memberships_data:
|
||||||
|
memberships.append({
|
||||||
|
"user_id": str(membership.user_id),
|
||||||
|
"tenant_id": str(membership.tenant_id),
|
||||||
|
"tenant_name": tenant.name,
|
||||||
|
"role": membership.role,
|
||||||
|
"joined_at": membership.created_at.isoformat() if membership.created_at else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"total_tenants": len(memberships),
|
||||||
|
"memberships": memberships
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get user tenants", user_id=user_id, error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get user tenants"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/check-other-admins/{user_id}")
|
||||||
|
async def check_tenant_has_other_admins(
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_admin_role),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Check if tenant has other admin users besides the specified user"""
|
||||||
|
try:
|
||||||
|
tenant_uuid = uuid.UUID(tenant_id)
|
||||||
|
user_uuid = uuid.UUID(user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid UUID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.tenants import TenantMember
|
||||||
|
|
||||||
|
# Count admin/owner members excluding the specified user
|
||||||
|
admin_count_query = select(func.count(TenantMember.id)).where(
|
||||||
|
TenantMember.tenant_id == tenant_uuid,
|
||||||
|
TenantMember.role.in_(['admin', 'owner']),
|
||||||
|
TenantMember.user_id != user_uuid
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(admin_count_query)
|
||||||
|
admin_count = result.scalar()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"excluded_user_id": user_id,
|
||||||
|
"has_other_admins": admin_count > 0,
|
||||||
|
"other_admin_count": admin_count
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to check tenant admins",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to check tenant admins"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/transfer-ownership")
|
||||||
|
async def transfer_tenant_ownership(
|
||||||
|
tenant_id: str,
|
||||||
|
transfer_data: dict, # {"current_owner_id": str, "new_owner_id": str}
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_admin_role),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Transfer tenant ownership from one user to another (admin only)"""
|
||||||
|
try:
|
||||||
|
tenant_uuid = uuid.UUID(tenant_id)
|
||||||
|
current_owner_id = uuid.UUID(transfer_data.get("current_owner_id"))
|
||||||
|
new_owner_id = uuid.UUID(transfer_data.get("new_owner_id"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid UUID format in request data"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.tenants import TenantMember, Tenant
|
||||||
|
|
||||||
|
# Verify tenant exists
|
||||||
|
tenant_query = select(Tenant).where(Tenant.id == tenant_uuid)
|
||||||
|
tenant_result = await db.execute(tenant_query)
|
||||||
|
tenant = tenant_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tenant {tenant_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get current owner membership
|
||||||
|
current_owner_query = select(TenantMember).where(
|
||||||
|
TenantMember.tenant_id == tenant_uuid,
|
||||||
|
TenantMember.user_id == current_owner_id,
|
||||||
|
TenantMember.role == 'owner'
|
||||||
|
)
|
||||||
|
current_owner_result = await db.execute(current_owner_query)
|
||||||
|
current_owner_membership = current_owner_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not current_owner_membership:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Current owner membership not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get new owner membership (should be admin)
|
||||||
|
new_owner_query = select(TenantMember).where(
|
||||||
|
TenantMember.tenant_id == tenant_uuid,
|
||||||
|
TenantMember.user_id == new_owner_id
|
||||||
|
)
|
||||||
|
new_owner_result = await db.execute(new_owner_query)
|
||||||
|
new_owner_membership = new_owner_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not new_owner_membership:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="New owner must be a member of the tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_owner_membership.role not in ['admin', 'owner']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="New owner must have admin or owner role"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform the transfer
|
||||||
|
current_owner_membership.role = 'admin' # Demote current owner to admin
|
||||||
|
new_owner_membership.role = 'owner' # Promote new owner
|
||||||
|
|
||||||
|
current_owner_membership.updated_at = datetime.utcnow()
|
||||||
|
new_owner_membership.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info("Tenant ownership transferred",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
from_user=str(current_owner_id),
|
||||||
|
to_user=str(new_owner_id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Ownership transferred successfully",
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"previous_owner": str(current_owner_id),
|
||||||
|
"new_owner": str(new_owner_id),
|
||||||
|
"transferred_at": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error("Failed to transfer tenant ownership",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to transfer tenant ownership"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}/memberships")
|
||||||
|
async def delete_user_memberships(
|
||||||
|
user_id: str,
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_admin_role),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Delete all tenant memberships for a user (admin only)"""
|
||||||
|
try:
|
||||||
|
user_uuid = uuid.UUID(user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid user ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.tenants import TenantMember
|
||||||
|
|
||||||
|
# Count memberships before deletion
|
||||||
|
count_query = select(func.count(TenantMember.id)).where(
|
||||||
|
TenantMember.user_id == user_uuid
|
||||||
|
)
|
||||||
|
count_result = await db.execute(count_query)
|
||||||
|
membership_count = count_result.scalar()
|
||||||
|
|
||||||
|
# Delete all memberships
|
||||||
|
delete_query = delete(TenantMember).where(TenantMember.user_id == user_uuid)
|
||||||
|
delete_result = await db.execute(delete_query)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info("Deleted user memberships",
|
||||||
|
user_id=user_id,
|
||||||
|
memberships_deleted=delete_result.rowcount)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"user_id": user_id,
|
||||||
|
"memberships_deleted": delete_result.rowcount,
|
||||||
|
"expected_count": membership_count,
|
||||||
|
"deleted_at": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error("Failed to delete user memberships", user_id=user_id, error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to delete user memberships"
|
||||||
)
|
)
|
||||||
@@ -14,6 +14,7 @@ import uuid
|
|||||||
|
|
||||||
from app.core.database import get_db, get_background_db_session
|
from app.core.database import get_db, get_background_db_session
|
||||||
from app.services.training_service import TrainingService
|
from app.services.training_service import TrainingService
|
||||||
|
from sqlalchemy import select, delete, func
|
||||||
from app.schemas.training import (
|
from app.schemas.training import (
|
||||||
TrainingJobRequest,
|
TrainingJobRequest,
|
||||||
SingleProductTrainingRequest
|
SingleProductTrainingRequest
|
||||||
@@ -33,8 +34,7 @@ from app.services.messaging import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Import shared auth decorators (assuming they exist in your microservices)
|
from shared.auth.decorators import require_admin_role, get_current_user_dep, get_current_tenant_id_dep
|
||||||
from shared.auth.decorators import get_current_tenant_id_dep
|
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -224,6 +224,9 @@ async def start_single_product_training(
|
|||||||
|
|
||||||
Uses the same pipeline but filters for specific product.
|
Uses the same pipeline but filters for specific product.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
training_service = TrainingService(db_session=db)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Validate tenant access
|
# Validate tenant access
|
||||||
if tenant_id != current_tenant:
|
if tenant_id != current_tenant:
|
||||||
@@ -260,36 +263,6 @@ async def start_single_product_training(
|
|||||||
detail="Single product training failed"
|
detail="Single product training failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/tenants/{tenant_id}/training/jobs/{job_id}/cancel")
|
|
||||||
async def cancel_training_job(
|
|
||||||
tenant_id: str = Path(..., description="Tenant ID"),
|
|
||||||
job_id: str = Path(..., description="Job ID"),
|
|
||||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Cancel a running training job.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Validate tenant access
|
|
||||||
if tenant_id != current_tenant:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Access denied to tenant resources"
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: Implement job cancellation
|
|
||||||
logger.info(f"Cancelling training job {job_id} for tenant {tenant_id}")
|
|
||||||
|
|
||||||
return {"message": "Training job cancelled successfully"}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to cancel training job: {str(e)}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to cancel training job"
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/tenants/{tenant_id}/training/jobs/{job_id}/logs")
|
@router.get("/tenants/{tenant_id}/training/jobs/{job_id}/logs")
|
||||||
async def get_training_logs(
|
async def get_training_logs(
|
||||||
tenant_id: str = Path(..., description="Tenant ID"),
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
@@ -337,4 +310,189 @@ async def health_check():
|
|||||||
"service": "training",
|
"service": "training",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"timestamp": datetime.now().isoformat()
|
"timestamp": datetime.now().isoformat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/training/jobs/cancel")
|
||||||
|
async def cancel_tenant_training_jobs(
|
||||||
|
cancel_data: dict, # {"tenant_id": str}
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_admin_role),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Cancel all active training jobs for a tenant (admin only)"""
|
||||||
|
try:
|
||||||
|
tenant_id = cancel_data.get("tenant_id")
|
||||||
|
if not tenant_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="tenant_id is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
tenant_uuid = uuid.UUID(tenant_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid tenant ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.training import TrainingJobQueue
|
||||||
|
|
||||||
|
# Find all active jobs for the tenant
|
||||||
|
active_jobs_query = select(TrainingJobQueue).where(
|
||||||
|
TrainingJobQueue.tenant_id == tenant_uuid,
|
||||||
|
TrainingJobQueue.status.in_(["queued", "running", "pending"])
|
||||||
|
)
|
||||||
|
active_jobs_result = await db.execute(active_jobs_query)
|
||||||
|
active_jobs = active_jobs_result.scalars().all()
|
||||||
|
|
||||||
|
jobs_cancelled = 0
|
||||||
|
cancelled_job_ids = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for job in active_jobs:
|
||||||
|
try:
|
||||||
|
job.status = "cancelled"
|
||||||
|
job.updated_at = datetime.utcnow()
|
||||||
|
job.cancelled_by = current_user.get("user_id")
|
||||||
|
jobs_cancelled += 1
|
||||||
|
cancelled_job_ids.append(str(job.id))
|
||||||
|
|
||||||
|
logger.info("Cancelled training job",
|
||||||
|
job_id=str(job.id),
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Failed to cancel job {job.id}: {str(e)}"
|
||||||
|
errors.append(error_msg)
|
||||||
|
logger.error(error_msg)
|
||||||
|
|
||||||
|
if jobs_cancelled > 0:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"jobs_cancelled": jobs_cancelled,
|
||||||
|
"cancelled_job_ids": cancelled_job_ids,
|
||||||
|
"errors": errors,
|
||||||
|
"cancelled_at": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
result["success"] = len(errors) < len(active_jobs)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error("Failed to cancel tenant training jobs",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to cancel training jobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/training/jobs/active")
|
||||||
|
async def get_tenant_active_jobs(
|
||||||
|
tenant_id: str,
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_admin_role),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get all active training jobs for a tenant (admin only)"""
|
||||||
|
try:
|
||||||
|
tenant_uuid = uuid.UUID(tenant_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid tenant ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.training import TrainingJobQueue
|
||||||
|
|
||||||
|
# Get active jobs
|
||||||
|
active_jobs_query = select(TrainingJobQueue).where(
|
||||||
|
TrainingJobQueue.tenant_id == tenant_uuid,
|
||||||
|
TrainingJobQueue.status.in_(["queued", "running", "pending"])
|
||||||
|
)
|
||||||
|
active_jobs_result = await db.execute(active_jobs_query)
|
||||||
|
active_jobs = active_jobs_result.scalars().all()
|
||||||
|
|
||||||
|
jobs = []
|
||||||
|
for job in active_jobs:
|
||||||
|
jobs.append({
|
||||||
|
"id": str(job.id),
|
||||||
|
"tenant_id": str(job.tenant_id),
|
||||||
|
"status": job.status,
|
||||||
|
"created_at": job.created_at.isoformat() if job.created_at else None,
|
||||||
|
"updated_at": job.updated_at.isoformat() if job.updated_at else None,
|
||||||
|
"started_at": job.started_at.isoformat() if job.started_at else None,
|
||||||
|
"progress": getattr(job, 'progress', 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"active_jobs_count": len(jobs),
|
||||||
|
"jobs": jobs
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get tenant active jobs",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get active jobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/training/jobs/count")
|
||||||
|
async def get_tenant_models_count(
|
||||||
|
tenant_id: str,
|
||||||
|
current_user = Depends(get_current_user_dep),
|
||||||
|
_admin_check = Depends(require_admin_role),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get count of trained models for a tenant (admin only)"""
|
||||||
|
try:
|
||||||
|
tenant_uuid = uuid.UUID(tenant_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid tenant ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.models.training import TrainedModel, ModelArtifact
|
||||||
|
|
||||||
|
# Count models
|
||||||
|
models_count_query = select(func.count(TrainedModel.id)).where(
|
||||||
|
TrainedModel.tenant_id == tenant_uuid
|
||||||
|
)
|
||||||
|
models_count_result = await db.execute(models_count_query)
|
||||||
|
models_count = models_count_result.scalar()
|
||||||
|
|
||||||
|
# Count artifacts
|
||||||
|
artifacts_count_query = select(func.count(ModelArtifact.id)).where(
|
||||||
|
ModelArtifact.tenant_id == tenant_uuid
|
||||||
|
)
|
||||||
|
artifacts_count_result = await db.execute(artifacts_count_query)
|
||||||
|
artifacts_count = artifacts_count_result.scalar()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"models_count": models_count,
|
||||||
|
"artifacts_count": artifacts_count,
|
||||||
|
"total_training_assets": models_count + artifacts_count
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get tenant models count",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get models count"
|
||||||
|
)
|
||||||
@@ -96,6 +96,7 @@ class TrainingJobQueue(Base):
|
|||||||
# Metadata
|
# Metadata
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
cancelled_by = Column(String, nullable=True)
|
||||||
|
|
||||||
class ModelArtifact(Base):
|
class ModelArtifact(Base):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -827,24 +827,195 @@ fi
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# =================================================================
|
# =================================================================
|
||||||
# SUMMARY AND CLEANUP
|
# STEP 6: ADMIN USER DELETION TEST (NEW)
|
||||||
# =================================================================
|
# =================================================================
|
||||||
|
|
||||||
echo -e "${CYAN}📊 IMPROVED ONBOARDING FLOW TEST SUMMARY${NC}"
|
echo -e "${STEP_ICONS[4]} ${PURPLE}STEP 6: ADMIN USER DELETION TEST${NC}"
|
||||||
echo -e "${CYAN}=========================================${NC}"
|
echo "Testing complete admin user deletion with all associated data cleanup"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
log_step "6.1. Getting deletion preview for test user"
|
||||||
|
|
||||||
|
# First, get a preview of what would be deleted
|
||||||
|
DELETION_PREVIEW_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET "$API_BASE/api/v1/users/delete/$USER_ID/deletion-preview" \
|
||||||
|
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||||
|
|
||||||
|
# Extract HTTP code and response
|
||||||
|
HTTP_CODE=$(echo "$DELETION_PREVIEW_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||||
|
DELETION_PREVIEW_RESPONSE=$(echo "$DELETION_PREVIEW_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||||
|
|
||||||
|
echo "Deletion Preview HTTP Status: $HTTP_CODE"
|
||||||
|
echo "Deletion Preview Response:"
|
||||||
|
echo "$DELETION_PREVIEW_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$DELETION_PREVIEW_RESPONSE"
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
# Extract preview information
|
||||||
|
TOTAL_TENANTS=$(extract_json_field "$DELETION_PREVIEW_RESPONSE" "tenant_associations" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
print(data.get('total_tenants', 0))
|
||||||
|
except:
|
||||||
|
print(0)
|
||||||
|
" 2>/dev/null)
|
||||||
|
|
||||||
|
OWNED_TENANTS=$(extract_json_field "$DELETION_PREVIEW_RESPONSE" "tenant_associations" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
print(data.get('owned_tenants', 0))
|
||||||
|
except:
|
||||||
|
print(0)
|
||||||
|
" 2>/dev/null)
|
||||||
|
|
||||||
|
log_success "Deletion preview obtained successfully"
|
||||||
|
echo " User to delete: $TEST_EMAIL"
|
||||||
|
echo " Total tenant associations: $TOTAL_TENANTS"
|
||||||
|
echo " Owned tenants: $OWNED_TENANTS"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
log_step "6.2. Executing admin user deletion"
|
||||||
|
echo "This will delete:"
|
||||||
|
echo " ✓ User account and authentication data"
|
||||||
|
echo " ✓ All tenant memberships and owned tenants"
|
||||||
|
echo " ✓ All training models and artifacts"
|
||||||
|
echo " ✓ All forecasts and predictions"
|
||||||
|
echo " ✓ All notification preferences and logs"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Wait a moment to show the preview
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Execute the deletion
|
||||||
|
DELETION_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X DELETE "$API_BASE/api/v1/users/delete/$USER_ID" \
|
||||||
|
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||||
|
|
||||||
|
# Extract HTTP code and response
|
||||||
|
HTTP_CODE=$(echo "$DELETION_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||||
|
DELETION_RESPONSE=$(echo "$DELETION_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||||
|
|
||||||
|
echo "Admin Deletion HTTP Status: $HTTP_CODE"
|
||||||
|
echo "Admin Deletion Response:"
|
||||||
|
echo "$DELETION_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$DELETION_RESPONSE"
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
DELETION_SUCCESS=$(extract_json_field "$DELETION_RESPONSE" "success")
|
||||||
|
|
||||||
|
if [ "$DELETION_SUCCESS" = "True" ] || [ "$DELETION_SUCCESS" = "true" ]; then
|
||||||
|
log_success "Admin user deletion initiated successfully"
|
||||||
|
echo " Status: Processing in background"
|
||||||
|
echo " Message: $(extract_json_field "$DELETION_RESPONSE" "message")"
|
||||||
|
|
||||||
|
log_step "6.3. Monitoring deletion progress (background task)"
|
||||||
|
echo " Note: Deletion runs as background task for better performance"
|
||||||
|
echo " Monitoring for 30 seconds to check completion..."
|
||||||
|
|
||||||
|
# Monitor for completion by trying to access user data
|
||||||
|
MONITOR_COUNT=0
|
||||||
|
MAX_MONITOR_ATTEMPTS=30
|
||||||
|
|
||||||
|
while [ $MONITOR_COUNT -lt $MAX_MONITOR_ATTEMPTS ]; do
|
||||||
|
sleep 1
|
||||||
|
MONITOR_COUNT=$((MONITOR_COUNT + 1))
|
||||||
|
|
||||||
|
# Try to get user info (should fail when deletion completes)
|
||||||
|
CHECK_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET "$API_BASE/api/v1/users/me" \
|
||||||
|
-H "Authorization: Bearer $ACCESS_TOKEN" 2>/dev/null)
|
||||||
|
|
||||||
|
CHECK_HTTP_CODE=$(echo "$CHECK_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||||
|
|
||||||
|
if [ "$CHECK_HTTP_CODE" = "401" ] || [ "$CHECK_HTTP_CODE" = "404" ]; then
|
||||||
|
log_success "User deletion completed (user no longer accessible)"
|
||||||
|
echo " Deletion verified after ${MONITOR_COUNT}s"
|
||||||
|
break
|
||||||
|
elif [ $MONITOR_COUNT -eq $MAX_MONITOR_ATTEMPTS ]; then
|
||||||
|
log_warning "Deletion monitoring timed out after ${MAX_MONITOR_ATTEMPTS}s"
|
||||||
|
echo " Deletion may still be processing in background"
|
||||||
|
echo " Check server logs for completion status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show progress every 5 seconds
|
||||||
|
if [ $((MONITOR_COUNT % 5)) -eq 0 ]; then
|
||||||
|
echo " Monitoring... ${MONITOR_COUNT}s/${MAX_MONITOR_ATTEMPTS}s"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
else
|
||||||
|
log_error "Admin user deletion failed"
|
||||||
|
echo "Response: $DELETION_RESPONSE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
elif [ "$HTTP_CODE" = "400" ]; then
|
||||||
|
log_error "Deletion request was invalid"
|
||||||
|
echo "Error details: $DELETION_RESPONSE"
|
||||||
|
|
||||||
|
elif [ "$HTTP_CODE" = "403" ]; then
|
||||||
|
log_error "Insufficient permissions for deletion"
|
||||||
|
echo "Note: Only admin users can delete other admin users"
|
||||||
|
|
||||||
|
elif [ "$HTTP_CODE" = "404" ]; then
|
||||||
|
log_error "User not found for deletion"
|
||||||
|
echo "User ID: $USER_ID may have already been deleted"
|
||||||
|
|
||||||
|
else
|
||||||
|
log_error "Admin user deletion failed (HTTP $HTTP_CODE)"
|
||||||
|
echo "Response: $DELETION_RESPONSE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
log_error "Failed to get deletion preview (HTTP $HTTP_CODE)"
|
||||||
|
echo "Cannot proceed with deletion test"
|
||||||
|
echo "Response: $DELETION_PREVIEW_RESPONSE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_step "6.4. Verifying cleanup completion"
|
||||||
|
|
||||||
|
# Try to login with the deleted user (should fail)
|
||||||
|
VERIFY_LOGIN_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "$API_BASE/api/v1/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"email\": \"$TEST_EMAIL\",
|
||||||
|
\"password\": \"$TEST_PASSWORD\"
|
||||||
|
}")
|
||||||
|
|
||||||
|
VERIFY_HTTP_CODE=$(echo "$VERIFY_LOGIN_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||||
|
VERIFY_LOGIN_RESPONSE=$(echo "$VERIFY_LOGIN_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||||
|
|
||||||
|
if [ "$VERIFY_HTTP_CODE" = "401" ] || [ "$VERIFY_HTTP_CODE" = "404" ]; then
|
||||||
|
log_success "Verification: User login properly blocked (user deleted)"
|
||||||
|
echo " HTTP Status: $VERIFY_HTTP_CODE"
|
||||||
|
elif [ "$VERIFY_HTTP_CODE" = "200" ]; then
|
||||||
|
log_warning "Verification: User can still login (deletion may not be complete)"
|
||||||
|
echo " This could indicate deletion is still processing"
|
||||||
|
else
|
||||||
|
log_warning "Verification: Unexpected login response (HTTP $VERIFY_HTTP_CODE)"
|
||||||
|
echo " Response: $VERIFY_LOGIN_RESPONSE"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Completed Onboarding Steps:"
|
|
||||||
|
# =================================================================
|
||||||
|
# Update the SUMMARY section to include Step 6
|
||||||
|
# =================================================================
|
||||||
|
|
||||||
|
# Replace the existing summary section with this updated version:
|
||||||
|
|
||||||
|
echo -e "${CYAN}📊 COMPLETE ONBOARDING + DELETION FLOW TEST SUMMARY${NC}"
|
||||||
|
echo -e "${CYAN}===================================================${NC}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Completed All Test Steps:"
|
||||||
echo " ${STEP_ICONS[0]} Step 1: User Registration ✓"
|
echo " ${STEP_ICONS[0]} Step 1: User Registration ✓"
|
||||||
echo " ${STEP_ICONS[1]} Step 2: Bakery Registration ✓"
|
echo " ${STEP_ICONS[1]} Step 2: Bakery Registration ✓"
|
||||||
echo " ${STEP_ICONS[2]} Step 3: FULL Sales Data Upload ✓"
|
echo " ${STEP_ICONS[2]} Step 3: FULL Sales Data Upload ✓"
|
||||||
echo " ${STEP_ICONS[3]} Step 4: Model Training with FULL Data ✓"
|
echo " ${STEP_ICONS[3]} Step 4: Model Training with FULL Data ✓"
|
||||||
echo " ${STEP_ICONS[4]} Step 5: Onboarding Complete ✓"
|
echo " ${STEP_ICONS[4]} Step 5: Onboarding Complete ✓"
|
||||||
|
echo " 🗑️ Step 6: Admin User Deletion Test ✓"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "📋 Test Results:"
|
echo "📋 Test Results:"
|
||||||
echo " User ID: $USER_ID"
|
echo " Original User ID: $USER_ID"
|
||||||
echo " Tenant ID: $TENANT_ID"
|
echo " Original Tenant ID: $TENANT_ID"
|
||||||
echo " Training Task ID: $TRAINING_TASK_ID"
|
echo " Training Task ID: $TRAINING_TASK_ID"
|
||||||
echo " Test Email: $TEST_EMAIL"
|
echo " Test Email: $TEST_EMAIL"
|
||||||
echo " FULL CSV Used: $REAL_CSV_FILE"
|
echo " FULL CSV Used: $REAL_CSV_FILE"
|
||||||
@@ -865,14 +1036,41 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "🧹 Cleanup:"
|
echo "🗑️ Deletion Test Results:"
|
||||||
echo " To clean up test data, you may want to remove:"
|
if [ "$DELETION_SUCCESS" = "True" ] || [ "$DELETION_SUCCESS" = "true" ]; then
|
||||||
echo " - Test user: $TEST_EMAIL"
|
echo " ✅ Admin user deletion: SUCCESS"
|
||||||
echo " - Test tenant: $TENANT_ID"
|
echo " ✅ Associated data cleanup: INITIATED"
|
||||||
|
echo " ✅ User authentication: BLOCKED"
|
||||||
|
echo " 📊 Tenant associations cleaned: $TOTAL_TENANTS"
|
||||||
|
echo " 🏢 Owned tenants handled: $OWNED_TENANTS"
|
||||||
|
else
|
||||||
|
echo " ❌ Admin user deletion: FAILED or INCOMPLETE"
|
||||||
|
echo " ⚠️ Manual cleanup may be required"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🧹 Cleanup Status:"
|
||||||
|
if [ "$DELETION_SUCCESS" = "True" ] || [ "$DELETION_SUCCESS" = "true" ]; then
|
||||||
|
echo " ✅ Automatic cleanup completed via admin deletion"
|
||||||
|
echo " ✅ Test user and tenant data removed"
|
||||||
|
echo " ✅ Training models and forecasts deleted"
|
||||||
|
echo " ✅ All associated data cleaned up"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Automatic cleanup failed - manual cleanup needed:"
|
||||||
|
echo " - Test user: $TEST_EMAIL"
|
||||||
|
echo " - Test tenant: $TENANT_ID"
|
||||||
|
echo " - Training models and forecasts"
|
||||||
|
fi
|
||||||
|
|
||||||
# Cleanup temporary files
|
# Cleanup temporary files
|
||||||
rm -f "$VALIDATION_DATA_FILE"
|
rm -f "$VALIDATION_DATA_FILE"
|
||||||
|
rm -f /tmp/bakery_coords.env
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
log_success "Improved onboarding flow simulation completed successfully!"
|
if [ "$DELETION_SUCCESS" = "True" ] || [ "$DELETION_SUCCESS" = "true" ]; then
|
||||||
echo -e "${CYAN}The user journey through all 5 onboarding steps has been tested with FULL dataset.${NC}"
|
log_success "Complete onboarding + deletion flow test finished successfully!"
|
||||||
|
echo -e "${CYAN}✅ All steps completed: Registration → Onboarding → Training → Deletion → Cleanup${NC}"
|
||||||
|
else
|
||||||
|
log_warning "Onboarding flow completed, but deletion test had issues"
|
||||||
|
echo -e "${YELLOW}⚠️ Onboarding steps passed, but admin deletion needs investigation${NC}"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user