Improve the frontend and repository layer

This commit is contained in:
Urtzi Alfaro
2025-10-23 07:44:54 +02:00
parent 8d30172483
commit 07c33fa578
112 changed files with 14726 additions and 2733 deletions

View File

@@ -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",

View 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

View File

@@ -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)

View File

@@ -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"])

View 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
}
}

View File

@@ -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)}")

View File

@@ -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()

View 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

View File

@@ -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

View 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)

View File

@@ -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')