Improve the frontend and repository layer
This commit is contained in:
@@ -26,6 +26,7 @@ from shared.routing.route_builder import RouteBuilder
|
||||
from shared.database.base import create_database_manager
|
||||
from shared.monitoring.metrics import track_endpoint_metrics
|
||||
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
||||
from shared.config.base import is_internal_service
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
@@ -64,7 +65,22 @@ def get_subscription_limit_service():
|
||||
try:
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
redis_client = get_tenant_redis_client()
|
||||
|
||||
# Get Redis client properly (it's an async function)
|
||||
import asyncio
|
||||
try:
|
||||
# Try to get the event loop, if we're in an async context
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# If we're in a running event loop, we can't use await here
|
||||
# So we'll pass None and handle Redis initialization in the service
|
||||
redis_client = None
|
||||
else:
|
||||
redis_client = asyncio.run(get_tenant_redis_client())
|
||||
except RuntimeError:
|
||||
# No event loop running, we can use async/await
|
||||
redis_client = asyncio.run(get_tenant_redis_client())
|
||||
|
||||
return SubscriptionLimitService(database_manager, redis_client)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create subscription limit service", error=str(e))
|
||||
@@ -204,9 +220,10 @@ async def verify_tenant_access(
|
||||
):
|
||||
"""Verify if user has access to tenant - Enhanced version with detailed permissions"""
|
||||
|
||||
# Check if this is a service request
|
||||
if user_id in ["training-service", "data-service", "forecasting-service", "auth-service"]:
|
||||
# Check if this is an internal service request using centralized registry
|
||||
if is_internal_service(user_id):
|
||||
# Services have access to all tenants for their operations
|
||||
logger.info("Service access granted", service=user_id, tenant_id=str(tenant_id))
|
||||
return TenantAccessResponse(
|
||||
has_access=True,
|
||||
role="service",
|
||||
|
||||
186
services/tenant/app/api/tenant_settings.py
Normal file
186
services/tenant/app/api/tenant_settings.py
Normal file
@@ -0,0 +1,186 @@
|
||||
# services/tenant/app/api/tenant_settings.py
|
||||
"""
|
||||
Tenant Settings API Endpoints
|
||||
REST API for managing tenant-specific operational settings
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from uuid import UUID
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.core.database import get_db
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
from ..services.tenant_settings_service import TenantSettingsService
|
||||
from ..schemas.tenant_settings import (
|
||||
TenantSettingsResponse,
|
||||
TenantSettingsUpdate,
|
||||
CategoryUpdateRequest,
|
||||
CategoryResetResponse
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
route_builder = RouteBuilder("tenants")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tenant_id}/settings",
|
||||
response_model=TenantSettingsResponse,
|
||||
summary="Get all tenant settings",
|
||||
description="Retrieve all operational settings for a tenant. Creates default settings if none exist."
|
||||
)
|
||||
async def get_tenant_settings(
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all settings for a tenant
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
|
||||
Returns all setting categories with their current values.
|
||||
If settings don't exist, default values are created and returned.
|
||||
"""
|
||||
service = TenantSettingsService(db)
|
||||
settings = await service.get_settings(tenant_id)
|
||||
return settings
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{tenant_id}/settings",
|
||||
response_model=TenantSettingsResponse,
|
||||
summary="Update tenant settings",
|
||||
description="Update one or more setting categories for a tenant. Only provided categories are updated."
|
||||
)
|
||||
async def update_tenant_settings(
|
||||
tenant_id: UUID,
|
||||
updates: TenantSettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update tenant settings
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **updates**: Object containing setting categories to update
|
||||
|
||||
Only provided categories will be updated. Omitted categories remain unchanged.
|
||||
All values are validated against min/max constraints.
|
||||
"""
|
||||
service = TenantSettingsService(db)
|
||||
settings = await service.update_settings(tenant_id, updates)
|
||||
return settings
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tenant_id}/settings/{category}",
|
||||
response_model=Dict[str, Any],
|
||||
summary="Get settings for a specific category",
|
||||
description="Retrieve settings for a single category (procurement, inventory, production, supplier, pos, or order)"
|
||||
)
|
||||
async def get_category_settings(
|
||||
tenant_id: UUID,
|
||||
category: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get settings for a specific category
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **category**: Category name (procurement, inventory, production, supplier, pos, order)
|
||||
|
||||
Returns settings for the specified category only.
|
||||
|
||||
Valid categories:
|
||||
- procurement: Auto-approval and procurement planning settings
|
||||
- inventory: Stock thresholds and temperature monitoring
|
||||
- production: Capacity, quality, and scheduling settings
|
||||
- supplier: Payment terms and performance thresholds
|
||||
- pos: POS integration sync settings
|
||||
- order: Discount and delivery settings
|
||||
"""
|
||||
service = TenantSettingsService(db)
|
||||
category_settings = await service.get_category(tenant_id, category)
|
||||
return {
|
||||
"tenant_id": str(tenant_id),
|
||||
"category": category,
|
||||
"settings": category_settings
|
||||
}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{tenant_id}/settings/{category}",
|
||||
response_model=TenantSettingsResponse,
|
||||
summary="Update settings for a specific category",
|
||||
description="Update all or some fields within a single category"
|
||||
)
|
||||
async def update_category_settings(
|
||||
tenant_id: UUID,
|
||||
category: str,
|
||||
request: CategoryUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update settings for a specific category
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **category**: Category name
|
||||
- **request**: Object containing the settings to update
|
||||
|
||||
Updates only the specified category. All values are validated.
|
||||
"""
|
||||
service = TenantSettingsService(db)
|
||||
settings = await service.update_category(tenant_id, category, request.settings)
|
||||
return settings
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tenant_id}/settings/{category}/reset",
|
||||
response_model=CategoryResetResponse,
|
||||
summary="Reset category to default values",
|
||||
description="Reset a specific category to its default values"
|
||||
)
|
||||
async def reset_category_settings(
|
||||
tenant_id: UUID,
|
||||
category: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Reset a category to default values
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **category**: Category name
|
||||
|
||||
Resets all settings in the specified category to their default values.
|
||||
This operation cannot be undone.
|
||||
"""
|
||||
service = TenantSettingsService(db)
|
||||
reset_settings = await service.reset_category(tenant_id, category)
|
||||
|
||||
return CategoryResetResponse(
|
||||
category=category,
|
||||
settings=reset_settings,
|
||||
message=f"Category '{category}' has been reset to default values"
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{tenant_id}/settings",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete tenant settings",
|
||||
description="Delete all settings for a tenant (used when tenant is deleted)"
|
||||
)
|
||||
async def delete_tenant_settings(
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete tenant settings
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
|
||||
This endpoint is typically called automatically when a tenant is deleted.
|
||||
It removes all setting data for the tenant.
|
||||
"""
|
||||
service = TenantSettingsService(db)
|
||||
await service.delete_settings(tenant_id)
|
||||
return None
|
||||
@@ -37,15 +37,36 @@ async def get_tenant(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Get tenant by ID - ATOMIC operation"""
|
||||
"""Get tenant by ID - ATOMIC operation - ENHANCED with logging"""
|
||||
|
||||
logger.info(
|
||||
"Tenant GET request received",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
user_type=current_user.get("type", "user"),
|
||||
is_service=current_user.get("type") == "service",
|
||||
role=current_user.get("role"),
|
||||
service_name=current_user.get("service", "none")
|
||||
)
|
||||
|
||||
tenant = await tenant_service.get_tenant_by_id(str(tenant_id))
|
||||
if not tenant:
|
||||
logger.warning(
|
||||
"Tenant not found",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Tenant GET request successful",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
@router.put(route_builder.build_base_route("{tenant_id}", include_tenant_prefix=False), response_model=TenantResponse)
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import FastAPI
|
||||
from sqlalchemy import text
|
||||
from app.core.config import settings
|
||||
from app.core.database import database_manager
|
||||
from app.api import tenants, tenant_members, tenant_operations, webhooks, internal_demo, plans, subscription
|
||||
from app.api import tenants, tenant_members, tenant_operations, webhooks, internal_demo, plans, subscription, tenant_settings
|
||||
from shared.service_base import StandardFastAPIService
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ class TenantService(StandardFastAPIService):
|
||||
"""Custom startup logic for tenant service"""
|
||||
# Import models to ensure they're registered with SQLAlchemy
|
||||
from app.models.tenants import Tenant, TenantMember, Subscription
|
||||
from app.models.tenant_settings import TenantSettings
|
||||
self.logger.info("Tenant models imported successfully")
|
||||
|
||||
async def on_shutdown(self, app: FastAPI):
|
||||
@@ -113,6 +114,8 @@ service.setup_custom_endpoints()
|
||||
# Include routers
|
||||
service.add_router(plans.router, tags=["subscription-plans"]) # Public endpoint
|
||||
service.add_router(subscription.router, tags=["subscription"])
|
||||
# Register settings router BEFORE tenants router to ensure proper route matching
|
||||
service.add_router(tenant_settings.router, prefix="/api/v1/tenants", tags=["tenant-settings"])
|
||||
service.add_router(tenants.router, tags=["tenants"])
|
||||
service.add_router(tenant_members.router, tags=["tenant-members"])
|
||||
service.add_router(tenant_operations.router, tags=["tenant-operations"])
|
||||
|
||||
195
services/tenant/app/models/tenant_settings.py
Normal file
195
services/tenant/app/models/tenant_settings.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# services/tenant/app/models/tenant_settings.py
|
||||
"""
|
||||
Tenant Settings Model
|
||||
Centralized configuration storage for all tenant-specific operational settings
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
from shared.database.base import Base
|
||||
|
||||
|
||||
class TenantSettings(Base):
|
||||
"""
|
||||
Centralized tenant settings model
|
||||
Stores all operational configurations for a tenant across all services
|
||||
"""
|
||||
__tablename__ = "tenant_settings"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
|
||||
|
||||
# Procurement & Auto-Approval Settings (Orders Service)
|
||||
procurement_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"auto_approve_enabled": True,
|
||||
"auto_approve_threshold_eur": 500.0,
|
||||
"auto_approve_min_supplier_score": 0.80,
|
||||
"require_approval_new_suppliers": True,
|
||||
"require_approval_critical_items": True,
|
||||
"procurement_lead_time_days": 3,
|
||||
"demand_forecast_days": 14,
|
||||
"safety_stock_percentage": 20.0,
|
||||
"po_approval_reminder_hours": 24,
|
||||
"po_critical_escalation_hours": 12
|
||||
})
|
||||
|
||||
# Inventory Management Settings (Inventory Service)
|
||||
inventory_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"low_stock_threshold": 10,
|
||||
"reorder_point": 20,
|
||||
"reorder_quantity": 50,
|
||||
"expiring_soon_days": 7,
|
||||
"expiration_warning_days": 3,
|
||||
"quality_score_threshold": 8.0,
|
||||
"temperature_monitoring_enabled": True,
|
||||
"refrigeration_temp_min": 1.0,
|
||||
"refrigeration_temp_max": 4.0,
|
||||
"freezer_temp_min": -20.0,
|
||||
"freezer_temp_max": -15.0,
|
||||
"room_temp_min": 18.0,
|
||||
"room_temp_max": 25.0,
|
||||
"temp_deviation_alert_minutes": 15,
|
||||
"critical_temp_deviation_minutes": 5
|
||||
})
|
||||
|
||||
# Production Settings (Production Service)
|
||||
production_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"planning_horizon_days": 7,
|
||||
"minimum_batch_size": 1.0,
|
||||
"maximum_batch_size": 100.0,
|
||||
"production_buffer_percentage": 10.0,
|
||||
"working_hours_per_day": 12,
|
||||
"max_overtime_hours": 4,
|
||||
"capacity_utilization_target": 0.85,
|
||||
"capacity_warning_threshold": 0.95,
|
||||
"quality_check_enabled": True,
|
||||
"minimum_yield_percentage": 85.0,
|
||||
"quality_score_threshold": 8.0,
|
||||
"schedule_optimization_enabled": True,
|
||||
"prep_time_buffer_minutes": 30,
|
||||
"cleanup_time_buffer_minutes": 15,
|
||||
"labor_cost_per_hour_eur": 15.0,
|
||||
"overhead_cost_percentage": 20.0
|
||||
})
|
||||
|
||||
# Supplier Settings (Suppliers Service)
|
||||
supplier_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"default_payment_terms_days": 30,
|
||||
"default_delivery_days": 3,
|
||||
"excellent_delivery_rate": 95.0,
|
||||
"good_delivery_rate": 90.0,
|
||||
"excellent_quality_rate": 98.0,
|
||||
"good_quality_rate": 95.0,
|
||||
"critical_delivery_delay_hours": 24,
|
||||
"critical_quality_rejection_rate": 10.0,
|
||||
"high_cost_variance_percentage": 15.0
|
||||
})
|
||||
|
||||
# POS Integration Settings (POS Service)
|
||||
pos_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"sync_interval_minutes": 5,
|
||||
"auto_sync_products": True,
|
||||
"auto_sync_transactions": True
|
||||
})
|
||||
|
||||
# Order & Business Rules Settings (Orders Service)
|
||||
order_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"max_discount_percentage": 50.0,
|
||||
"default_delivery_window_hours": 48,
|
||||
"dynamic_pricing_enabled": False,
|
||||
"discount_enabled": True,
|
||||
"delivery_tracking_enabled": True
|
||||
})
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", backref="settings")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TenantSettings(tenant_id={self.tenant_id})>"
|
||||
|
||||
@staticmethod
|
||||
def get_default_settings() -> dict:
|
||||
"""
|
||||
Get default settings for all categories
|
||||
Returns a dictionary with default values for all setting categories
|
||||
"""
|
||||
return {
|
||||
"procurement_settings": {
|
||||
"auto_approve_enabled": True,
|
||||
"auto_approve_threshold_eur": 500.0,
|
||||
"auto_approve_min_supplier_score": 0.80,
|
||||
"require_approval_new_suppliers": True,
|
||||
"require_approval_critical_items": True,
|
||||
"procurement_lead_time_days": 3,
|
||||
"demand_forecast_days": 14,
|
||||
"safety_stock_percentage": 20.0,
|
||||
"po_approval_reminder_hours": 24,
|
||||
"po_critical_escalation_hours": 12
|
||||
},
|
||||
"inventory_settings": {
|
||||
"low_stock_threshold": 10,
|
||||
"reorder_point": 20,
|
||||
"reorder_quantity": 50,
|
||||
"expiring_soon_days": 7,
|
||||
"expiration_warning_days": 3,
|
||||
"quality_score_threshold": 8.0,
|
||||
"temperature_monitoring_enabled": True,
|
||||
"refrigeration_temp_min": 1.0,
|
||||
"refrigeration_temp_max": 4.0,
|
||||
"freezer_temp_min": -20.0,
|
||||
"freezer_temp_max": -15.0,
|
||||
"room_temp_min": 18.0,
|
||||
"room_temp_max": 25.0,
|
||||
"temp_deviation_alert_minutes": 15,
|
||||
"critical_temp_deviation_minutes": 5
|
||||
},
|
||||
"production_settings": {
|
||||
"planning_horizon_days": 7,
|
||||
"minimum_batch_size": 1.0,
|
||||
"maximum_batch_size": 100.0,
|
||||
"production_buffer_percentage": 10.0,
|
||||
"working_hours_per_day": 12,
|
||||
"max_overtime_hours": 4,
|
||||
"capacity_utilization_target": 0.85,
|
||||
"capacity_warning_threshold": 0.95,
|
||||
"quality_check_enabled": True,
|
||||
"minimum_yield_percentage": 85.0,
|
||||
"quality_score_threshold": 8.0,
|
||||
"schedule_optimization_enabled": True,
|
||||
"prep_time_buffer_minutes": 30,
|
||||
"cleanup_time_buffer_minutes": 15,
|
||||
"labor_cost_per_hour_eur": 15.0,
|
||||
"overhead_cost_percentage": 20.0
|
||||
},
|
||||
"supplier_settings": {
|
||||
"default_payment_terms_days": 30,
|
||||
"default_delivery_days": 3,
|
||||
"excellent_delivery_rate": 95.0,
|
||||
"good_delivery_rate": 90.0,
|
||||
"excellent_quality_rate": 98.0,
|
||||
"good_quality_rate": 95.0,
|
||||
"critical_delivery_delay_hours": 24,
|
||||
"critical_quality_rejection_rate": 10.0,
|
||||
"high_cost_variance_percentage": 15.0
|
||||
},
|
||||
"pos_settings": {
|
||||
"sync_interval_minutes": 5,
|
||||
"auto_sync_products": True,
|
||||
"auto_sync_transactions": True
|
||||
},
|
||||
"order_settings": {
|
||||
"max_discount_percentage": 50.0,
|
||||
"default_delivery_window_hours": 48,
|
||||
"dynamic_pricing_enabled": False,
|
||||
"discount_enabled": True,
|
||||
"delivery_tracking_enabled": True
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import json
|
||||
from .base import TenantBaseRepository
|
||||
from app.models.tenants import TenantMember
|
||||
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
|
||||
from shared.config.base import is_internal_service
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -89,6 +90,25 @@ class TenantMemberRepository(TenantBaseRepository):
|
||||
async def get_membership(self, tenant_id: str, user_id: str) -> Optional[TenantMember]:
|
||||
"""Get specific membership by tenant and user"""
|
||||
try:
|
||||
# Validate that user_id is a proper UUID format for actual users
|
||||
# Service names like 'inventory-service' should be handled differently
|
||||
import uuid
|
||||
try:
|
||||
uuid.UUID(user_id)
|
||||
is_valid_uuid = True
|
||||
except ValueError:
|
||||
is_valid_uuid = False
|
||||
|
||||
# For internal service access, return None to indicate no user membership
|
||||
# Service access should be handled at the API layer
|
||||
if not is_valid_uuid and is_internal_service(user_id):
|
||||
# This is an internal service request, return None
|
||||
# Service access is granted at the API endpoint level
|
||||
logger.debug("Internal service detected in membership lookup",
|
||||
service=user_id,
|
||||
tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
memberships = await self.get_multi(
|
||||
filters={
|
||||
"tenant_id": tenant_id,
|
||||
@@ -444,4 +464,4 @@ class TenantMemberRepository(TenantBaseRepository):
|
||||
except Exception as e:
|
||||
logger.error("Failed to cleanup inactive memberships",
|
||||
error=str(e))
|
||||
raise DatabaseError(f"Cleanup failed: {str(e)}")
|
||||
raise DatabaseError(f"Cleanup failed: {str(e)}")
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# services/tenant/app/repositories/tenant_settings_repository.py
|
||||
"""
|
||||
Tenant Settings Repository
|
||||
Data access layer for tenant settings
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from ..models.tenant_settings import TenantSettings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class TenantSettingsRepository:
|
||||
"""Repository for TenantSettings data access"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_tenant_id(self, tenant_id: UUID) -> Optional[TenantSettings]:
|
||||
"""
|
||||
Get tenant settings by tenant ID
|
||||
|
||||
Args:
|
||||
tenant_id: UUID of the tenant
|
||||
|
||||
Returns:
|
||||
TenantSettings or None if not found
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(TenantSettings).where(TenantSettings.tenant_id == tenant_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, settings: TenantSettings) -> TenantSettings:
|
||||
"""
|
||||
Create new tenant settings
|
||||
|
||||
Args:
|
||||
settings: TenantSettings instance to create
|
||||
|
||||
Returns:
|
||||
Created TenantSettings instance
|
||||
"""
|
||||
self.db.add(settings)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(settings)
|
||||
return settings
|
||||
|
||||
async def update(self, settings: TenantSettings) -> TenantSettings:
|
||||
"""
|
||||
Update tenant settings
|
||||
|
||||
Args:
|
||||
settings: TenantSettings instance with updates
|
||||
|
||||
Returns:
|
||||
Updated TenantSettings instance
|
||||
"""
|
||||
await self.db.commit()
|
||||
await self.db.refresh(settings)
|
||||
return settings
|
||||
|
||||
async def delete(self, tenant_id: UUID) -> None:
|
||||
"""
|
||||
Delete tenant settings
|
||||
|
||||
Args:
|
||||
tenant_id: UUID of the tenant
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(TenantSettings).where(TenantSettings.tenant_id == tenant_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if settings:
|
||||
await self.db.delete(settings)
|
||||
await self.db.commit()
|
||||
181
services/tenant/app/schemas/tenant_settings.py
Normal file
181
services/tenant/app/schemas/tenant_settings.py
Normal file
@@ -0,0 +1,181 @@
|
||||
# services/tenant/app/schemas/tenant_settings.py
|
||||
"""
|
||||
Tenant Settings Schemas
|
||||
Pydantic models for API request/response validation
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
# ================================================================
|
||||
# SETTING CATEGORY SCHEMAS
|
||||
# ================================================================
|
||||
|
||||
class ProcurementSettings(BaseModel):
|
||||
"""Procurement and auto-approval settings"""
|
||||
auto_approve_enabled: bool = True
|
||||
auto_approve_threshold_eur: float = Field(500.0, ge=0, le=10000)
|
||||
auto_approve_min_supplier_score: float = Field(0.80, ge=0.0, le=1.0)
|
||||
require_approval_new_suppliers: bool = True
|
||||
require_approval_critical_items: bool = True
|
||||
procurement_lead_time_days: int = Field(3, ge=1, le=30)
|
||||
demand_forecast_days: int = Field(14, ge=1, le=90)
|
||||
safety_stock_percentage: float = Field(20.0, ge=0.0, le=100.0)
|
||||
po_approval_reminder_hours: int = Field(24, ge=1, le=168)
|
||||
po_critical_escalation_hours: int = Field(12, ge=1, le=72)
|
||||
|
||||
|
||||
class InventorySettings(BaseModel):
|
||||
"""Inventory management settings"""
|
||||
low_stock_threshold: int = Field(10, ge=1, le=1000)
|
||||
reorder_point: int = Field(20, ge=1, le=1000)
|
||||
reorder_quantity: int = Field(50, ge=1, le=1000)
|
||||
expiring_soon_days: int = Field(7, ge=1, le=30)
|
||||
expiration_warning_days: int = Field(3, ge=1, le=14)
|
||||
quality_score_threshold: float = Field(8.0, ge=0.0, le=10.0)
|
||||
temperature_monitoring_enabled: bool = True
|
||||
refrigeration_temp_min: float = Field(1.0, ge=-5.0, le=10.0)
|
||||
refrigeration_temp_max: float = Field(4.0, ge=-5.0, le=10.0)
|
||||
freezer_temp_min: float = Field(-20.0, ge=-30.0, le=0.0)
|
||||
freezer_temp_max: float = Field(-15.0, ge=-30.0, le=0.0)
|
||||
room_temp_min: float = Field(18.0, ge=10.0, le=35.0)
|
||||
room_temp_max: float = Field(25.0, ge=10.0, le=35.0)
|
||||
temp_deviation_alert_minutes: int = Field(15, ge=1, le=60)
|
||||
critical_temp_deviation_minutes: int = Field(5, ge=1, le=30)
|
||||
|
||||
@validator('refrigeration_temp_max')
|
||||
def validate_refrigeration_range(cls, v, values):
|
||||
if 'refrigeration_temp_min' in values and v <= values['refrigeration_temp_min']:
|
||||
raise ValueError('refrigeration_temp_max must be greater than refrigeration_temp_min')
|
||||
return v
|
||||
|
||||
@validator('freezer_temp_max')
|
||||
def validate_freezer_range(cls, v, values):
|
||||
if 'freezer_temp_min' in values and v <= values['freezer_temp_min']:
|
||||
raise ValueError('freezer_temp_max must be greater than freezer_temp_min')
|
||||
return v
|
||||
|
||||
@validator('room_temp_max')
|
||||
def validate_room_range(cls, v, values):
|
||||
if 'room_temp_min' in values and v <= values['room_temp_min']:
|
||||
raise ValueError('room_temp_max must be greater than room_temp_min')
|
||||
return v
|
||||
|
||||
|
||||
class ProductionSettings(BaseModel):
|
||||
"""Production settings"""
|
||||
planning_horizon_days: int = Field(7, ge=1, le=30)
|
||||
minimum_batch_size: float = Field(1.0, ge=0.1, le=100.0)
|
||||
maximum_batch_size: float = Field(100.0, ge=1.0, le=1000.0)
|
||||
production_buffer_percentage: float = Field(10.0, ge=0.0, le=50.0)
|
||||
working_hours_per_day: int = Field(12, ge=1, le=24)
|
||||
max_overtime_hours: int = Field(4, ge=0, le=12)
|
||||
capacity_utilization_target: float = Field(0.85, ge=0.5, le=1.0)
|
||||
capacity_warning_threshold: float = Field(0.95, ge=0.7, le=1.0)
|
||||
quality_check_enabled: bool = True
|
||||
minimum_yield_percentage: float = Field(85.0, ge=50.0, le=100.0)
|
||||
quality_score_threshold: float = Field(8.0, ge=0.0, le=10.0)
|
||||
schedule_optimization_enabled: bool = True
|
||||
prep_time_buffer_minutes: int = Field(30, ge=0, le=120)
|
||||
cleanup_time_buffer_minutes: int = Field(15, ge=0, le=120)
|
||||
labor_cost_per_hour_eur: float = Field(15.0, ge=5.0, le=100.0)
|
||||
overhead_cost_percentage: float = Field(20.0, ge=0.0, le=50.0)
|
||||
|
||||
@validator('maximum_batch_size')
|
||||
def validate_batch_size_range(cls, v, values):
|
||||
if 'minimum_batch_size' in values and v <= values['minimum_batch_size']:
|
||||
raise ValueError('maximum_batch_size must be greater than minimum_batch_size')
|
||||
return v
|
||||
|
||||
@validator('capacity_warning_threshold')
|
||||
def validate_capacity_threshold(cls, v, values):
|
||||
if 'capacity_utilization_target' in values and v <= values['capacity_utilization_target']:
|
||||
raise ValueError('capacity_warning_threshold must be greater than capacity_utilization_target')
|
||||
return v
|
||||
|
||||
|
||||
class SupplierSettings(BaseModel):
|
||||
"""Supplier management settings"""
|
||||
default_payment_terms_days: int = Field(30, ge=1, le=90)
|
||||
default_delivery_days: int = Field(3, ge=1, le=30)
|
||||
excellent_delivery_rate: float = Field(95.0, ge=90.0, le=100.0)
|
||||
good_delivery_rate: float = Field(90.0, ge=80.0, le=99.0)
|
||||
excellent_quality_rate: float = Field(98.0, ge=90.0, le=100.0)
|
||||
good_quality_rate: float = Field(95.0, ge=80.0, le=99.0)
|
||||
critical_delivery_delay_hours: int = Field(24, ge=1, le=168)
|
||||
critical_quality_rejection_rate: float = Field(10.0, ge=0.0, le=50.0)
|
||||
high_cost_variance_percentage: float = Field(15.0, ge=0.0, le=100.0)
|
||||
|
||||
@validator('good_delivery_rate')
|
||||
def validate_delivery_rates(cls, v, values):
|
||||
if 'excellent_delivery_rate' in values and v >= values['excellent_delivery_rate']:
|
||||
raise ValueError('good_delivery_rate must be less than excellent_delivery_rate')
|
||||
return v
|
||||
|
||||
@validator('good_quality_rate')
|
||||
def validate_quality_rates(cls, v, values):
|
||||
if 'excellent_quality_rate' in values and v >= values['excellent_quality_rate']:
|
||||
raise ValueError('good_quality_rate must be less than excellent_quality_rate')
|
||||
return v
|
||||
|
||||
|
||||
class POSSettings(BaseModel):
|
||||
"""POS integration settings"""
|
||||
sync_interval_minutes: int = Field(5, ge=1, le=60)
|
||||
auto_sync_products: bool = True
|
||||
auto_sync_transactions: bool = True
|
||||
|
||||
|
||||
class OrderSettings(BaseModel):
|
||||
"""Order and business rules settings"""
|
||||
max_discount_percentage: float = Field(50.0, ge=0.0, le=100.0)
|
||||
default_delivery_window_hours: int = Field(48, ge=1, le=168)
|
||||
dynamic_pricing_enabled: bool = False
|
||||
discount_enabled: bool = True
|
||||
delivery_tracking_enabled: bool = True
|
||||
|
||||
|
||||
# ================================================================
|
||||
# REQUEST/RESPONSE SCHEMAS
|
||||
# ================================================================
|
||||
|
||||
class TenantSettingsResponse(BaseModel):
|
||||
"""Response schema for tenant settings"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
procurement_settings: ProcurementSettings
|
||||
inventory_settings: InventorySettings
|
||||
production_settings: ProductionSettings
|
||||
supplier_settings: SupplierSettings
|
||||
pos_settings: POSSettings
|
||||
order_settings: OrderSettings
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TenantSettingsUpdate(BaseModel):
|
||||
"""Schema for updating tenant settings"""
|
||||
procurement_settings: Optional[ProcurementSettings] = None
|
||||
inventory_settings: Optional[InventorySettings] = None
|
||||
production_settings: Optional[ProductionSettings] = None
|
||||
supplier_settings: Optional[SupplierSettings] = None
|
||||
pos_settings: Optional[POSSettings] = None
|
||||
order_settings: Optional[OrderSettings] = None
|
||||
|
||||
|
||||
class CategoryUpdateRequest(BaseModel):
|
||||
"""Schema for updating a single category"""
|
||||
settings: dict
|
||||
|
||||
|
||||
class CategoryResetResponse(BaseModel):
|
||||
"""Response schema for category reset"""
|
||||
category: str
|
||||
settings: dict
|
||||
message: str
|
||||
@@ -8,13 +8,14 @@ from typing import Dict, Any, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fastapi import HTTPException, status
|
||||
from datetime import datetime, timezone
|
||||
import httpx
|
||||
|
||||
from app.repositories import SubscriptionRepository, TenantRepository, TenantMemberRepository
|
||||
from app.models.tenants import Subscription, Tenant, TenantMember
|
||||
from shared.database.exceptions import DatabaseError
|
||||
from shared.database.base import create_database_manager
|
||||
from shared.subscription.plans import SubscriptionPlanMetadata, get_training_job_quota, get_forecast_quota
|
||||
from shared.clients.recipes_client import create_recipes_client
|
||||
from shared.clients.suppliers_client import create_suppliers_client
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -459,50 +460,64 @@ class SubscriptionLimitService:
|
||||
return 0
|
||||
|
||||
async def _get_recipe_count(self, tenant_id: str) -> int:
|
||||
"""Get recipe count from recipes service"""
|
||||
"""Get recipe count from recipes service using shared client"""
|
||||
try:
|
||||
from app.core.config import settings
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.RECIPES_SERVICE_URL}/api/v1/tenants/{tenant_id}/recipes/count",
|
||||
headers={"X-Internal-Request": "true"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
count = data.get("count", 0)
|
||||
# Use the shared recipes client with proper authentication and resilience
|
||||
recipes_client = create_recipes_client(settings)
|
||||
count = await recipes_client.count_recipes(tenant_id)
|
||||
|
||||
logger.info("Retrieved recipe count", tenant_id=tenant_id, count=count)
|
||||
return count
|
||||
logger.info(
|
||||
"Retrieved recipe count via recipes client",
|
||||
tenant_id=tenant_id,
|
||||
count=count
|
||||
)
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting recipe count", tenant_id=tenant_id, error=str(e))
|
||||
logger.error(
|
||||
"Error getting recipe count via recipes client",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e)
|
||||
)
|
||||
# Return 0 as fallback to avoid breaking subscription display
|
||||
return 0
|
||||
|
||||
async def _get_supplier_count(self, tenant_id: str) -> int:
|
||||
"""Get supplier count from suppliers service"""
|
||||
"""Get supplier count from suppliers service using shared client"""
|
||||
try:
|
||||
from app.core.config import settings
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.SUPPLIERS_SERVICE_URL}/api/v1/tenants/{tenant_id}/suppliers/count",
|
||||
headers={"X-Internal-Request": "true"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
count = data.get("count", 0)
|
||||
# Use the shared suppliers client with proper authentication and resilience
|
||||
suppliers_client = create_suppliers_client(settings)
|
||||
count = await suppliers_client.count_suppliers(tenant_id)
|
||||
|
||||
logger.info("Retrieved supplier count", tenant_id=tenant_id, count=count)
|
||||
return count
|
||||
logger.info(
|
||||
"Retrieved supplier count via suppliers client",
|
||||
tenant_id=tenant_id,
|
||||
count=count
|
||||
)
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting supplier count", tenant_id=tenant_id, error=str(e))
|
||||
logger.error(
|
||||
"Error getting supplier count via suppliers client",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e)
|
||||
)
|
||||
# Return 0 as fallback to avoid breaking subscription display
|
||||
return 0
|
||||
|
||||
async def _get_redis_quota(self, quota_key: str) -> int:
|
||||
"""Get current count from Redis quota key"""
|
||||
try:
|
||||
if not self.redis:
|
||||
# Try to initialize Redis client if not available
|
||||
from app.core.config import settings
|
||||
import shared.redis_utils
|
||||
self.redis = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
|
||||
|
||||
if not self.redis:
|
||||
return 0
|
||||
|
||||
@@ -607,4 +622,4 @@ class SubscriptionLimitService:
|
||||
"""Get limit value from plan metadata"""
|
||||
plan_metadata = SubscriptionPlanMetadata.PLANS.get(plan, {})
|
||||
limit = plan_metadata.get('limits', {}).get(limit_key)
|
||||
return limit if limit != -1 else None
|
||||
return limit if limit != -1 else None
|
||||
|
||||
262
services/tenant/app/services/tenant_settings_service.py
Normal file
262
services/tenant/app/services/tenant_settings_service.py
Normal file
@@ -0,0 +1,262 @@
|
||||
# services/tenant/app/services/tenant_settings_service.py
|
||||
"""
|
||||
Tenant Settings Service
|
||||
Business logic for managing tenant-specific operational settings
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from uuid import UUID
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from ..models.tenant_settings import TenantSettings
|
||||
from ..repositories.tenant_settings_repository import TenantSettingsRepository
|
||||
from ..schemas.tenant_settings import (
|
||||
TenantSettingsUpdate,
|
||||
ProcurementSettings,
|
||||
InventorySettings,
|
||||
ProductionSettings,
|
||||
SupplierSettings,
|
||||
POSSettings,
|
||||
OrderSettings
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class TenantSettingsService:
|
||||
"""
|
||||
Service for managing tenant settings
|
||||
Handles validation, CRUD operations, and default value management
|
||||
"""
|
||||
|
||||
# Map category names to schema validators
|
||||
CATEGORY_SCHEMAS = {
|
||||
"procurement": ProcurementSettings,
|
||||
"inventory": InventorySettings,
|
||||
"production": ProductionSettings,
|
||||
"supplier": SupplierSettings,
|
||||
"pos": POSSettings,
|
||||
"order": OrderSettings
|
||||
}
|
||||
|
||||
# Map category names to database column names
|
||||
CATEGORY_COLUMNS = {
|
||||
"procurement": "procurement_settings",
|
||||
"inventory": "inventory_settings",
|
||||
"production": "production_settings",
|
||||
"supplier": "supplier_settings",
|
||||
"pos": "pos_settings",
|
||||
"order": "order_settings"
|
||||
}
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repository = TenantSettingsRepository(db)
|
||||
|
||||
async def get_settings(self, tenant_id: UUID) -> TenantSettings:
|
||||
"""
|
||||
Get tenant settings, creating defaults if they don't exist
|
||||
|
||||
Args:
|
||||
tenant_id: UUID of the tenant
|
||||
|
||||
Returns:
|
||||
TenantSettings object
|
||||
|
||||
Raises:
|
||||
HTTPException: If tenant not found
|
||||
"""
|
||||
try:
|
||||
# Try to get existing settings using repository
|
||||
settings = await self.repository.get_by_tenant_id(tenant_id)
|
||||
|
||||
logger.info(f"Existing settings lookup for tenant {tenant_id}: {'found' if settings else 'not found'}")
|
||||
|
||||
# Create default settings if they don't exist
|
||||
if not settings:
|
||||
logger.info(f"Creating default settings for tenant {tenant_id}")
|
||||
settings = await self._create_default_settings(tenant_id)
|
||||
logger.info(f"Successfully created default settings for tenant {tenant_id}")
|
||||
|
||||
return settings
|
||||
except Exception as e:
|
||||
logger.error("Failed to get or create tenant settings", tenant_id=tenant_id, error=str(e), exc_info=True)
|
||||
# Re-raise as HTTPException to match the expected behavior
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get tenant settings: {str(e)}"
|
||||
)
|
||||
|
||||
async def update_settings(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
updates: TenantSettingsUpdate
|
||||
) -> TenantSettings:
|
||||
"""
|
||||
Update tenant settings
|
||||
|
||||
Args:
|
||||
tenant_id: UUID of the tenant
|
||||
updates: TenantSettingsUpdate object with new values
|
||||
|
||||
Returns:
|
||||
Updated TenantSettings object
|
||||
"""
|
||||
settings = await self.get_settings(tenant_id)
|
||||
|
||||
# Update each category if provided
|
||||
if updates.procurement_settings is not None:
|
||||
settings.procurement_settings = updates.procurement_settings.dict()
|
||||
|
||||
if updates.inventory_settings is not None:
|
||||
settings.inventory_settings = updates.inventory_settings.dict()
|
||||
|
||||
if updates.production_settings is not None:
|
||||
settings.production_settings = updates.production_settings.dict()
|
||||
|
||||
if updates.supplier_settings is not None:
|
||||
settings.supplier_settings = updates.supplier_settings.dict()
|
||||
|
||||
if updates.pos_settings is not None:
|
||||
settings.pos_settings = updates.pos_settings.dict()
|
||||
|
||||
if updates.order_settings is not None:
|
||||
settings.order_settings = updates.order_settings.dict()
|
||||
|
||||
return await self.repository.update(settings)
|
||||
|
||||
async def get_category(self, tenant_id: UUID, category: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get settings for a specific category
|
||||
|
||||
Args:
|
||||
tenant_id: UUID of the tenant
|
||||
category: Category name (procurement, inventory, production, etc.)
|
||||
|
||||
Returns:
|
||||
Dictionary with category settings
|
||||
|
||||
Raises:
|
||||
HTTPException: If category is invalid
|
||||
"""
|
||||
if category not in self.CATEGORY_COLUMNS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid category: {category}. Valid categories: {', '.join(self.CATEGORY_COLUMNS.keys())}"
|
||||
)
|
||||
|
||||
settings = await self.get_settings(tenant_id)
|
||||
column_name = self.CATEGORY_COLUMNS[category]
|
||||
|
||||
return getattr(settings, column_name)
|
||||
|
||||
async def update_category(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
category: str,
|
||||
updates: Dict[str, Any]
|
||||
) -> TenantSettings:
|
||||
"""
|
||||
Update settings for a specific category
|
||||
|
||||
Args:
|
||||
tenant_id: UUID of the tenant
|
||||
category: Category name
|
||||
updates: Dictionary with new values
|
||||
|
||||
Returns:
|
||||
Updated TenantSettings object
|
||||
|
||||
Raises:
|
||||
HTTPException: If category is invalid or validation fails
|
||||
"""
|
||||
if category not in self.CATEGORY_COLUMNS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid category: {category}"
|
||||
)
|
||||
|
||||
# Validate updates using the appropriate schema
|
||||
schema = self.CATEGORY_SCHEMAS[category]
|
||||
try:
|
||||
validated_data = schema(**updates)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Validation error: {str(e)}"
|
||||
)
|
||||
|
||||
# Get existing settings and update the category
|
||||
settings = await self.get_settings(tenant_id)
|
||||
column_name = self.CATEGORY_COLUMNS[category]
|
||||
setattr(settings, column_name, validated_data.dict())
|
||||
|
||||
return await self.repository.update(settings)
|
||||
|
||||
async def reset_category(self, tenant_id: UUID, category: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Reset a category to default values
|
||||
|
||||
Args:
|
||||
tenant_id: UUID of the tenant
|
||||
category: Category name
|
||||
|
||||
Returns:
|
||||
Dictionary with reset category settings
|
||||
|
||||
Raises:
|
||||
HTTPException: If category is invalid
|
||||
"""
|
||||
if category not in self.CATEGORY_COLUMNS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid category: {category}"
|
||||
)
|
||||
|
||||
# Get default settings for the category
|
||||
defaults = TenantSettings.get_default_settings()
|
||||
column_name = self.CATEGORY_COLUMNS[category]
|
||||
default_category_settings = defaults[column_name]
|
||||
|
||||
# Update the category with defaults
|
||||
settings = await self.get_settings(tenant_id)
|
||||
setattr(settings, column_name, default_category_settings)
|
||||
|
||||
await self.repository.update(settings)
|
||||
|
||||
return default_category_settings
|
||||
|
||||
async def _create_default_settings(self, tenant_id: UUID) -> TenantSettings:
|
||||
"""
|
||||
Create default settings for a new tenant
|
||||
|
||||
Args:
|
||||
tenant_id: UUID of the tenant
|
||||
|
||||
Returns:
|
||||
Newly created TenantSettings object
|
||||
"""
|
||||
defaults = TenantSettings.get_default_settings()
|
||||
|
||||
settings = TenantSettings(
|
||||
tenant_id=tenant_id,
|
||||
procurement_settings=defaults["procurement_settings"],
|
||||
inventory_settings=defaults["inventory_settings"],
|
||||
production_settings=defaults["production_settings"],
|
||||
supplier_settings=defaults["supplier_settings"],
|
||||
pos_settings=defaults["pos_settings"],
|
||||
order_settings=defaults["order_settings"]
|
||||
)
|
||||
|
||||
return await self.repository.create(settings)
|
||||
|
||||
async def delete_settings(self, tenant_id: UUID) -> None:
|
||||
"""
|
||||
Delete tenant settings (used when tenant is deleted)
|
||||
|
||||
Args:
|
||||
tenant_id: UUID of the tenant
|
||||
"""
|
||||
await self.repository.delete(tenant_id)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""add tenant_settings
|
||||
|
||||
Revision ID: 20251022_0000
|
||||
Revises: 20251017_0000
|
||||
Create Date: 2025-10-22
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from uuid import uuid4
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20251022_0000'
|
||||
down_revision = '20251017_0000'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def get_default_settings():
|
||||
"""Get default settings for all categories"""
|
||||
return {
|
||||
"procurement_settings": {
|
||||
"auto_approve_enabled": True,
|
||||
"auto_approve_threshold_eur": 500.0,
|
||||
"auto_approve_min_supplier_score": 0.80,
|
||||
"require_approval_new_suppliers": True,
|
||||
"require_approval_critical_items": True,
|
||||
"procurement_lead_time_days": 3,
|
||||
"demand_forecast_days": 14,
|
||||
"safety_stock_percentage": 20.0,
|
||||
"po_approval_reminder_hours": 24,
|
||||
"po_critical_escalation_hours": 12
|
||||
},
|
||||
"inventory_settings": {
|
||||
"low_stock_threshold": 10,
|
||||
"reorder_point": 20,
|
||||
"reorder_quantity": 50,
|
||||
"expiring_soon_days": 7,
|
||||
"expiration_warning_days": 3,
|
||||
"quality_score_threshold": 8.0,
|
||||
"temperature_monitoring_enabled": True,
|
||||
"refrigeration_temp_min": 1.0,
|
||||
"refrigeration_temp_max": 4.0,
|
||||
"freezer_temp_min": -20.0,
|
||||
"freezer_temp_max": -15.0,
|
||||
"room_temp_min": 18.0,
|
||||
"room_temp_max": 25.0,
|
||||
"temp_deviation_alert_minutes": 15,
|
||||
"critical_temp_deviation_minutes": 5
|
||||
},
|
||||
"production_settings": {
|
||||
"planning_horizon_days": 7,
|
||||
"minimum_batch_size": 1.0,
|
||||
"maximum_batch_size": 100.0,
|
||||
"production_buffer_percentage": 10.0,
|
||||
"working_hours_per_day": 12,
|
||||
"max_overtime_hours": 4,
|
||||
"capacity_utilization_target": 0.85,
|
||||
"capacity_warning_threshold": 0.95,
|
||||
"quality_check_enabled": True,
|
||||
"minimum_yield_percentage": 85.0,
|
||||
"quality_score_threshold": 8.0,
|
||||
"schedule_optimization_enabled": True,
|
||||
"prep_time_buffer_minutes": 30,
|
||||
"cleanup_time_buffer_minutes": 15,
|
||||
"labor_cost_per_hour_eur": 15.0,
|
||||
"overhead_cost_percentage": 20.0
|
||||
},
|
||||
"supplier_settings": {
|
||||
"default_payment_terms_days": 30,
|
||||
"default_delivery_days": 3,
|
||||
"excellent_delivery_rate": 95.0,
|
||||
"good_delivery_rate": 90.0,
|
||||
"excellent_quality_rate": 98.0,
|
||||
"good_quality_rate": 95.0,
|
||||
"critical_delivery_delay_hours": 24,
|
||||
"critical_quality_rejection_rate": 10.0,
|
||||
"high_cost_variance_percentage": 15.0
|
||||
},
|
||||
"pos_settings": {
|
||||
"sync_interval_minutes": 5,
|
||||
"auto_sync_products": True,
|
||||
"auto_sync_transactions": True
|
||||
},
|
||||
"order_settings": {
|
||||
"max_discount_percentage": 50.0,
|
||||
"default_delivery_window_hours": 48,
|
||||
"dynamic_pricing_enabled": False,
|
||||
"discount_enabled": True,
|
||||
"delivery_tracking_enabled": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Create tenant_settings table and seed existing tenants"""
|
||||
# Create tenant_settings table
|
||||
op.create_table(
|
||||
'tenant_settings',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, default=uuid4),
|
||||
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('procurement_settings', postgresql.JSON(), nullable=False),
|
||||
sa.Column('inventory_settings', postgresql.JSON(), nullable=False),
|
||||
sa.Column('production_settings', postgresql.JSON(), nullable=False),
|
||||
sa.Column('supplier_settings', postgresql.JSON(), nullable=False),
|
||||
sa.Column('pos_settings', postgresql.JSON(), nullable=False),
|
||||
sa.Column('order_settings', postgresql.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.UniqueConstraint('tenant_id', name='uq_tenant_settings_tenant_id')
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
op.create_index('ix_tenant_settings_tenant_id', 'tenant_settings', ['tenant_id'])
|
||||
|
||||
# Seed existing tenants with default settings
|
||||
connection = op.get_bind()
|
||||
|
||||
# Get all existing tenant IDs
|
||||
result = connection.execute(sa.text("SELECT id FROM tenants"))
|
||||
tenant_ids = [row[0] for row in result]
|
||||
|
||||
# Insert default settings for each existing tenant
|
||||
defaults = get_default_settings()
|
||||
for tenant_id in tenant_ids:
|
||||
connection.execute(
|
||||
sa.text("""
|
||||
INSERT INTO tenant_settings (
|
||||
id, tenant_id, procurement_settings, inventory_settings,
|
||||
production_settings, supplier_settings, pos_settings, order_settings
|
||||
) VALUES (
|
||||
:id, :tenant_id, :procurement_settings::jsonb, :inventory_settings::jsonb,
|
||||
:production_settings::jsonb, :supplier_settings::jsonb,
|
||||
:pos_settings::jsonb, :order_settings::jsonb
|
||||
)
|
||||
"""),
|
||||
{
|
||||
"id": str(uuid4()),
|
||||
"tenant_id": tenant_id,
|
||||
"procurement_settings": str(defaults["procurement_settings"]).replace("'", '"').replace("True", "true").replace("False", "false"),
|
||||
"inventory_settings": str(defaults["inventory_settings"]).replace("'", '"').replace("True", "true").replace("False", "false"),
|
||||
"production_settings": str(defaults["production_settings"]).replace("'", '"').replace("True", "true").replace("False", "false"),
|
||||
"supplier_settings": str(defaults["supplier_settings"]).replace("'", '"').replace("True", "true").replace("False", "false"),
|
||||
"pos_settings": str(defaults["pos_settings"]).replace("'", '"').replace("True", "true").replace("False", "false"),
|
||||
"order_settings": str(defaults["order_settings"]).replace("'", '"').replace("True", "true").replace("False", "false")
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Drop tenant_settings table"""
|
||||
op.drop_index('ix_tenant_settings_tenant_id', table_name='tenant_settings')
|
||||
op.drop_table('tenant_settings')
|
||||
Reference in New Issue
Block a user