refactor(arch): move auth schemas to tenancy module and add cross-module service methods
Some checks failed
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 10s

Move all auth schemas (UserContext, UserLogin, LoginResponse, etc.) from
legacy models/schema/auth.py to app/modules/tenancy/schemas/auth.py per
MOD-019. Update 84 import sites across 14 modules. Legacy file now
re-exports for backwards compatibility.

Add missing tenancy service methods for cross-module consumers:
- merchant_service.get_merchant_by_owner_id()
- merchant_service.get_merchant_count_for_owner()
- admin_service.get_user_by_id() (public, was private-only)
- platform_service.get_active_store_count()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 23:57:04 +01:00
parent f95db7c0b1
commit 4aa6f76e46
93 changed files with 599 additions and 427 deletions

View File

@@ -56,10 +56,10 @@ from app.modules.tenancy.exceptions import (
)
from app.modules.tenancy.models import Store
from app.modules.tenancy.models import User as UserModel
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.store_service import store_service
from middleware.auth import AuthManager
from middleware.rate_limiter import RateLimiter
from models.schema.auth import UserContext
# Initialize dependencies
security = HTTPBearer(auto_error=False) # auto_error=False prevents automatic 403

View File

@@ -35,7 +35,7 @@ from app.modules.billing.services import (
subscription_service,
)
from app.modules.enums import FrontendType
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
logger = logging.getLogger(__name__)

View File

@@ -28,7 +28,7 @@ from app.modules.billing.schemas import (
from app.modules.billing.services.feature_aggregator import feature_aggregator
from app.modules.billing.services.feature_service import feature_service
from app.modules.enums import FrontendType
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_features_router = APIRouter(
prefix="/features",

View File

@@ -23,7 +23,7 @@ from app.modules.billing.schemas.billing import (
)
from app.modules.billing.services import billing_service, subscription_service
from app.modules.enums import FrontendType
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
logger = logging.getLogger(__name__)

View File

@@ -22,7 +22,7 @@ from app.core.config import settings
from app.core.database import get_db
from app.modules.billing.services import billing_service
from app.modules.enums import FrontendType
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
store_addons_router = APIRouter(
prefix="/addons",

View File

@@ -34,7 +34,7 @@ from app.modules.billing.schemas.billing import (
from app.modules.billing.services import billing_service
from app.modules.billing.services.subscription_service import subscription_service
from app.modules.enums import FrontendType
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
store_checkout_router = APIRouter(
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))],

View File

@@ -38,7 +38,7 @@ from app.modules.billing.services.feature_aggregator import feature_aggregator
from app.modules.billing.services.feature_service import feature_service
from app.modules.billing.services.subscription_service import subscription_service
from app.modules.enums import FrontendType
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
store_features_router = APIRouter(
prefix="/features",

View File

@@ -20,7 +20,7 @@ from app.api.deps import get_current_store_api, require_module_access
from app.core.database import get_db
from app.modules.billing.services.usage_service import usage_service
from app.modules.enums import FrontendType
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
store_usage_router = APIRouter(
prefix="/usage",

View File

@@ -24,8 +24,8 @@ from app.api.deps import get_current_merchant_from_cookie_or_header
from app.core.database import get_db
from app.modules.core.utils.page_context import get_context_for_frontend
from app.modules.enums import FrontendType
from app.modules.tenancy.schemas.auth import UserContext
from app.templates_config import templates
from models.schema.auth import UserContext
ROUTE_CONFIG = {
"prefix": "/billing",

View File

@@ -23,8 +23,8 @@ from app.modules.billing.models import (
SubscriptionTier,
)
from app.modules.tenancy.models import Merchant, Platform, User
from app.modules.tenancy.schemas.auth import UserContext
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures

View File

@@ -32,7 +32,7 @@ from app.modules.catalog.schemas import (
)
from app.modules.catalog.services.store_product_service import store_product_service
from app.modules.enums import FrontendType
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_router = APIRouter(
prefix="/store-products",

View File

@@ -30,7 +30,7 @@ from app.modules.catalog.schemas import (
from app.modules.catalog.services.product_service import product_service
from app.modules.catalog.services.store_product_service import store_product_service
from app.modules.enums import FrontendType
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
store_router = APIRouter(
prefix="/products",

View File

@@ -15,7 +15,7 @@ from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.cms.schemas.image import ImageStorageStats
from app.modules.cms.services.media_service import media_service
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_images_router = APIRouter(prefix="/images")
logger = logging.getLogger(__name__)

View File

@@ -19,7 +19,7 @@ from app.modules.cms.schemas.media import (
MediaUploadResponse,
)
from app.modules.cms.services.media_service import media_service
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_media_router = APIRouter(prefix="/media")
logger = logging.getLogger(__name__)

View File

@@ -26,7 +26,7 @@ from app.modules.cms.schemas.store_theme import (
ThemePresetResponse,
)
from app.modules.cms.services.store_theme_service import store_theme_service
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_store_themes_router = APIRouter(prefix="/store-themes")
logger = logging.getLogger(__name__)

View File

@@ -27,7 +27,7 @@ from app.modules.cms.schemas.media import (
UploadedFileInfo,
)
from app.modules.cms.services.media_service import media_service
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
store_media_router = APIRouter(prefix="/media")
logger = logging.getLogger(__name__)

View File

@@ -31,7 +31,7 @@ from app.modules.core.schemas.dashboard import (
)
from app.modules.core.services.stats_aggregator import stats_aggregator
from app.modules.core.services.widget_aggregator import widget_aggregator
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_dashboard_router = APIRouter(prefix="/dashboard")
logger = logging.getLogger(__name__)

View File

@@ -29,9 +29,9 @@ from app.api.deps import (
)
from app.modules.core.services.menu_service import MenuItemConfig, menu_service
from app.modules.enums import FrontendType # API-007 - Enum for type safety
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.platform_service import platform_service
from app.utils.i18n import DEFAULT_LANGUAGE, translate
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/menu-config")

View File

@@ -32,7 +32,7 @@ from app.modules.tenancy.schemas.admin import (
RowsPerPageResponse,
RowsPerPageUpdateResponse,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_settings_router = APIRouter(prefix="/settings")
logger = logging.getLogger(__name__)

View File

@@ -20,8 +20,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_merchant_from_cookie_or_header, get_db
from app.modules.core.services.menu_service import menu_service
from app.modules.enums import FrontendType
from app.modules.tenancy.schemas.auth import UserContext
from app.utils.i18n import translate
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)

View File

@@ -26,8 +26,8 @@ from app.modules.core.schemas.dashboard import (
)
from app.modules.core.services.stats_aggregator import stats_aggregator
from app.modules.tenancy.exceptions import StoreNotActiveException
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.store_service import store_service
from models.schema.auth import UserContext
store_dashboard_router = APIRouter(prefix="/dashboard")
logger = logging.getLogger(__name__)

View File

@@ -21,9 +21,9 @@ from app.api.deps import get_current_store_api, get_user_permissions
from app.core.database import get_db
from app.modules.core.services.menu_service import menu_service
from app.modules.enums import FrontendType
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.store_service import store_service
from app.utils.i18n import translate
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)

View File

@@ -17,8 +17,8 @@ from app.core.database import get_db
from app.modules.core.services.platform_settings_service import (
platform_settings_service,
)
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.store_service import store_service
from models.schema.auth import UserContext
store_settings_router = APIRouter(prefix="/settings")
logger = logging.getLogger(__name__)

View File

@@ -22,8 +22,8 @@ from app.api.deps import (
)
from app.modules.core.utils.page_context import get_context_for_frontend
from app.modules.enums import FrontendType
from app.modules.tenancy.schemas.auth import UserContext
from app.templates_config import templates
from models.schema.auth import UserContext
ROUTE_CONFIG = {
"prefix": "",

View File

@@ -23,8 +23,8 @@ from app.modules.tenancy.exceptions import (
UserNotActiveException,
)
from app.modules.tenancy.models import Store, StoreUser, User
from app.modules.tenancy.schemas.auth import UserLogin
from middleware.auth import AuthManager
from models.schema.auth import UserLogin
logger = logging.getLogger(__name__)

View File

@@ -19,8 +19,8 @@ from app.modules.billing.models import (
)
from app.modules.customers.models.customer import Customer
from app.modules.tenancy.models import Merchant, Platform, Store, StoreUser, User
from app.modules.tenancy.schemas.auth import UserContext
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures

View File

@@ -26,8 +26,8 @@ from app.modules.billing.models import (
)
from app.modules.tenancy.models import Merchant, Platform, User
from app.modules.tenancy.models.platform_module import PlatformModule
from app.modules.tenancy.schemas.auth import UserContext
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures

View File

@@ -19,8 +19,8 @@ import pytest
from app.api.deps import get_current_store_from_cookie_or_header
from app.modules.tenancy.models import Merchant, Platform, Store, User
from app.modules.tenancy.models.store_platform import StorePlatform
from app.modules.tenancy.schemas.auth import UserContext
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures

View File

@@ -18,7 +18,7 @@ from app.modules.customers.schemas import (
)
from app.modules.customers.services import admin_customer_service
from app.modules.enums import FrontendType
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
# Create module-aware router
admin_router = APIRouter(

View File

@@ -22,7 +22,7 @@ from app.modules.customers.schemas import (
)
from app.modules.customers.services import customer_service
from app.modules.enums import FrontendType
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
# Create module-aware router
store_router = APIRouter(
@@ -150,4 +150,3 @@ def toggle_customer_status(
status = "activated" if customer.is_active else "deactivated"
return CustomerMessageResponse(message=f"Customer {status} successfully")

View File

@@ -47,7 +47,7 @@ from app.modules.messaging.services.email_service import (
EmailService, # MOD-004 - Core email service
)
from app.modules.tenancy.exceptions import StoreNotFoundException
from models.schema.auth import (
from app.modules.tenancy.schemas.auth import (
LogoutResponse,
PasswordResetRequestResponse,
PasswordResetResponse,

View File

@@ -46,7 +46,7 @@ from app.modules.inventory.services.inventory_service import inventory_service
from app.modules.inventory.services.inventory_transaction_service import (
inventory_transaction_service,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_router = APIRouter(
prefix="/inventory",

View File

@@ -32,7 +32,7 @@ from app.modules.inventory.services.inventory_service import inventory_service
from app.modules.inventory.services.inventory_transaction_service import (
inventory_transaction_service,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
store_router = APIRouter(
prefix="/inventory",

View File

@@ -26,8 +26,8 @@ from app.modules.core.utils.page_context import get_context_for_frontend
from app.modules.enums import FrontendType
from app.modules.loyalty.services import program_service
from app.modules.tenancy.models import Merchant
from app.modules.tenancy.schemas.auth import UserContext
from app.templates_config import templates
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)

View File

@@ -15,8 +15,8 @@ from app.modules.loyalty.models.loyalty_program import LoyaltyType
from app.modules.tenancy.models import Merchant, Platform, Store, User
from app.modules.tenancy.models.store import StoreUser
from app.modules.tenancy.models.store_platform import StorePlatform
from app.modules.tenancy.schemas.auth import UserContext
from main import app
from models.schema.auth import UserContext
@pytest.fixture

View File

@@ -64,7 +64,7 @@ from app.modules.orders.exceptions import OrderHasUnresolvedExceptionsException
from app.modules.orders.services.order_item_exception_service import (
order_item_exception_service,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_letzshop_router = APIRouter(
prefix="/letzshop",

View File

@@ -27,8 +27,8 @@ from app.modules.marketplace.schemas import (
from app.modules.marketplace.services.marketplace_import_job_service import (
marketplace_import_job_service,
)
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.store_service import store_service
from models.schema.auth import UserContext
admin_marketplace_router = APIRouter(
prefix="/marketplace-import-jobs",

View File

@@ -24,7 +24,7 @@ from app.modules.enums import FrontendType
from app.modules.marketplace.services.marketplace_product_service import (
marketplace_product_service,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_products_router = APIRouter(
prefix="/products",

View File

@@ -55,7 +55,7 @@ from app.modules.orders.exceptions import OrderHasUnresolvedExceptionsException
from app.modules.orders.services.order_item_exception_service import (
order_item_exception_service,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
store_letzshop_router = APIRouter(
prefix="/letzshop",

View File

@@ -23,9 +23,9 @@ from app.modules.marketplace.schemas import (
from app.modules.marketplace.services.marketplace_import_job_service import (
marketplace_import_job_service,
)
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.store_service import store_service
from middleware.decorators import rate_limit
from models.schema.auth import UserContext
store_marketplace_router = APIRouter(
prefix="/marketplace",

View File

@@ -38,7 +38,7 @@ from app.modules.marketplace.schemas import (
ProductImportConfigResponse,
)
from app.modules.marketplace.services.onboarding_service import OnboardingService
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
store_onboarding_router = APIRouter(
prefix="/onboarding",

View File

@@ -25,8 +25,8 @@ from app.modules.marketplace.models import OnboardingStatus, StoreOnboarding
from app.modules.tenancy.models import Merchant, Platform, Store, User
from app.modules.tenancy.models.platform_module import PlatformModule
from app.modules.tenancy.models.store_platform import StorePlatform
from app.modules.tenancy.schemas.auth import UserContext
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures

View File

@@ -19,7 +19,7 @@ from app.modules.marketplace.routes.pages.store import (
store_marketplace_page,
store_onboarding_page,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
def _make_user_context(store_id: int = 1, store_code: str = "teststore") -> UserContext:

View File

@@ -21,7 +21,7 @@ from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.messaging.services.email_service import EmailService
from app.modules.messaging.services.email_template_service import EmailTemplateService
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_email_templates_router = APIRouter(prefix="/email-templates")
logger = logging.getLogger(__name__)

View File

@@ -47,7 +47,7 @@ from app.modules.messaging.services.message_attachment_service import (
message_attachment_service,
)
from app.modules.messaging.services.messaging_service import messaging_service
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_messages_router = APIRouter(prefix="/messages")
logger = logging.getLogger(__name__)

View File

@@ -33,7 +33,7 @@ from app.modules.tenancy.schemas.admin import (
PlatformAlertResolve,
PlatformAlertResponse,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_notifications_router = APIRouter(prefix="/notifications")
logger = logging.getLogger(__name__)

View File

@@ -24,7 +24,7 @@ from app.modules.billing.services.subscription_service import subscription_servi
from app.modules.messaging.services.store_email_settings_service import (
store_email_settings_service,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
store_email_settings_router = APIRouter(prefix="/email-settings")
logger = logging.getLogger(__name__)

View File

@@ -19,8 +19,8 @@ from app.api.deps import get_current_store_api
from app.core.database import get_db
from app.modules.messaging.services.email_service import EmailService
from app.modules.messaging.services.email_template_service import EmailTemplateService
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.store_service import store_service
from models.schema.auth import UserContext
store_email_templates_router = APIRouter(prefix="/email-templates")
logger = logging.getLogger(__name__)

View File

@@ -49,7 +49,7 @@ from app.modules.messaging.services.message_attachment_service import (
message_attachment_service,
)
from app.modules.messaging.services.messaging_service import messaging_service
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
store_messages_router = APIRouter(prefix="/messages")
logger = logging.getLogger(__name__)

View File

@@ -23,8 +23,8 @@ from app.modules.messaging.schemas import (
TestNotificationRequest,
UnreadCountResponse,
)
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.store_service import store_service
from models.schema.auth import UserContext
store_notifications_router = APIRouter(prefix="/notifications")
logger = logging.getLogger(__name__)

View File

@@ -22,7 +22,7 @@ from app.modules.tenancy.schemas.admin import (
AdminAuditLogListResponse,
AdminAuditLogResponse,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_audit_router = APIRouter(prefix="/audit")
logger = logging.getLogger(__name__)

View File

@@ -24,7 +24,7 @@ from app.modules.monitoring.exceptions import (
ScanNotFoundException,
ViolationNotFoundException,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_code_quality_router = APIRouter(prefix="/code-quality")

View File

@@ -35,7 +35,7 @@ from app.modules.tenancy.schemas.admin import (
LogSettingsUpdateResponse,
LogStatistics,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_logs_router = APIRouter(prefix="/logs")
logger = logging.getLogger(__name__)

View File

@@ -19,7 +19,7 @@ from app.core.database import get_db
from app.modules.monitoring.services.platform_health_service import (
platform_health_service,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_platform_health_router = APIRouter(prefix="/platform")
logger = logging.getLogger(__name__)

View File

@@ -15,7 +15,7 @@ from app.core.database import get_db
from app.modules.monitoring.services.background_tasks_service import (
background_tasks_service,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_tasks_router = APIRouter(prefix="/tasks")

View File

@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.dev_tools.services.test_runner_service import test_runner_service
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
admin_tests_router = APIRouter(prefix="/tests")

View File

@@ -33,7 +33,7 @@ from app.modules.orders.schemas import (
ShippingLabelInfo,
)
from app.modules.orders.services.order_service import order_service
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
# Base router for orders
_orders_router = APIRouter(

View File

@@ -29,7 +29,7 @@ from app.modules.orders.schemas import (
from app.modules.orders.services.order_item_exception_service import (
order_item_exception_service,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
logger = logging.getLogger(__name__)

View File

@@ -25,7 +25,7 @@ from app.modules.orders.schemas import (
)
from app.modules.orders.services.order_inventory_service import order_inventory_service
from app.modules.orders.services.order_service import order_service
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
# Base router for orders
_orders_router = APIRouter(

View File

@@ -21,7 +21,7 @@ from app.core.database import get_db
from app.modules.enums import FrontendType
from app.modules.orders.services.customer_order_service import customer_order_service
from app.modules.orders.services.order_metrics import order_metrics_provider
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
logger = logging.getLogger(__name__)

View File

@@ -28,7 +28,7 @@ from app.modules.orders.schemas import (
from app.modules.orders.services.order_item_exception_service import (
order_item_exception_service,
)
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
logger = logging.getLogger(__name__)

View File

@@ -51,7 +51,7 @@ from app.modules.orders.schemas import (
StoreInvoiceSettingsUpdate,
)
from app.modules.orders.services.invoice_service import invoice_service
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
store_invoices_router = APIRouter(
prefix="/invoices",

View File

@@ -39,8 +39,8 @@ from app.modules.payments.schemas import (
from app.modules.payments.schemas import (
PaymentRefundResponse as RefundResponse,
)
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.store_service import store_service
from models.schema.auth import UserContext
store_router = APIRouter(
prefix="/payments",

View File

@@ -21,9 +21,7 @@ from app.modules.core.services.auth_service import auth_service
from app.modules.tenancy.exceptions import (
InvalidCredentialsException,
)
from app.modules.tenancy.services.admin_platform_service import admin_platform_service
from middleware.auth import AuthManager
from models.schema.auth import (
from app.modules.tenancy.schemas.auth import (
LoginResponse,
LogoutResponse,
PlatformSelectResponse,
@@ -31,6 +29,8 @@ from models.schema.auth import (
UserLogin,
UserResponse,
)
from app.modules.tenancy.services.admin_platform_service import admin_platform_service
from middleware.auth import AuthManager
admin_auth_router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)

View File

@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.schemas.merchant_domain import (
MerchantDomainCreate,
MerchantDomainDeletionResponse,
@@ -30,7 +31,6 @@ from app.modules.tenancy.schemas.store_domain import (
from app.modules.tenancy.services.merchant_domain_service import (
merchant_domain_service,
)
from models.schema.auth import UserContext
admin_merchant_domains_router = APIRouter(prefix="/merchants")
logger = logging.getLogger(__name__)

View File

@@ -16,6 +16,7 @@ from app.modules.tenancy.exceptions import (
ConfirmationRequiredException,
MerchantHasStoresException,
)
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.schemas.merchant import (
MerchantCreate,
MerchantCreateResponse,
@@ -27,7 +28,6 @@ from app.modules.tenancy.schemas.merchant import (
MerchantUpdate,
)
from app.modules.tenancy.services.merchant_service import merchant_service
from models.schema.auth import UserContext
admin_merchants_router = APIRouter(prefix="/merchants")
logger = logging.getLogger(__name__)

View File

@@ -21,8 +21,8 @@ from app.api.deps import get_current_super_admin, get_db
from app.exceptions import ValidationException
from app.modules.registry import MODULES
from app.modules.service import module_service
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.platform_service import platform_service
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/module-config")

View File

@@ -21,8 +21,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_super_admin, get_db
from app.modules.registry import MODULES, get_core_module_codes
from app.modules.service import module_service
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.platform_service import platform_service
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/modules")

View File

@@ -15,8 +15,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.core.services.stats_aggregator import stats_aggregator
from app.modules.tenancy.services.admin_service import admin_service
from models.schema.auth import (
from app.modules.tenancy.schemas.auth import (
OwnedMerchantSummary,
StoreMembershipSummary,
UserContext,
@@ -29,6 +28,7 @@ from models.schema.auth import (
UserStatusToggleResponse,
UserUpdate,
)
from app.modules.tenancy.services.admin_service import admin_service
admin_platform_users_router = APIRouter(prefix="/users")
logger = logging.getLogger(__name__)

View File

@@ -22,8 +22,8 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_from_cookie_or_header, get_db
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.platform_service import platform_service
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
admin_platforms_router = APIRouter(prefix="/platforms")

View File

@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.schemas.store_domain import (
DomainDeletionResponse,
DomainVerificationInstructions,
@@ -27,7 +28,6 @@ from app.modules.tenancy.schemas.store_domain import (
)
from app.modules.tenancy.services.store_domain_service import store_domain_service
from app.modules.tenancy.services.store_service import store_service
from models.schema.auth import UserContext
admin_store_domains_router = APIRouter(prefix="/stores")
logger = logging.getLogger(__name__)

View File

@@ -21,6 +21,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.schemas.team import (
PermissionCatalogResponse,
RoleCreate,
@@ -33,7 +34,6 @@ from app.modules.tenancy.services.permission_discovery_service import (
)
from app.modules.tenancy.services.store_team_service import store_team_service
from app.utils.i18n import translate
from models.schema.auth import UserContext
admin_store_roles_router = APIRouter(prefix="/store-roles")
logger = logging.getLogger(__name__)

View File

@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.tenancy.exceptions import ConfirmationRequiredException
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.schemas.store import (
StoreCreate,
StoreCreateResponse,
@@ -26,7 +27,6 @@ from app.modules.tenancy.schemas.store import (
)
from app.modules.tenancy.services.admin_service import admin_service
from app.modules.tenancy.services.store_service import store_service
from models.schema.auth import UserContext
admin_stores_router = APIRouter(prefix="/stores")
logger = logging.getLogger(__name__)

View File

@@ -24,8 +24,8 @@ from app.exceptions import ValidationException
from app.modules.tenancy.models import (
User, # API-007 - Internal helper uses User model
)
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.admin_platform_service import admin_platform_service
from models.schema.auth import UserContext
admin_users_router = APIRouter(prefix="/admin-users")
logger = logging.getLogger(__name__)

View File

@@ -21,8 +21,8 @@ from app.modules.tenancy.schemas import (
MerchantPortalProfileUpdate,
MerchantPortalStoreListResponse,
)
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.merchant_service import merchant_service
from models.schema.auth import UserContext
from .email_verification import email_verification_api_router
from .merchant_auth import merchant_auth_router

View File

@@ -22,14 +22,14 @@ from app.modules.core.services.auth_service import auth_service
from app.modules.tenancy.models.user_password_reset_token import (
UserPasswordResetToken, # noqa: API-007
)
from app.modules.tenancy.services.user_auth_service import user_auth_service
from models.schema.auth import (
from app.modules.tenancy.schemas.auth import (
LoginResponse,
LogoutResponse,
UserContext,
UserLogin,
UserResponse,
)
from app.modules.tenancy.services.user_auth_service import user_auth_service
merchant_auth_router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)

View File

@@ -26,10 +26,15 @@ from app.modules.tenancy.exceptions import InvalidCredentialsException
from app.modules.tenancy.models.user_password_reset_token import (
UserPasswordResetToken, # noqa: API-007
)
from app.modules.tenancy.schemas.auth import (
LogoutResponse,
StoreUserResponse,
UserContext,
UserLogin,
)
from app.modules.tenancy.services.user_auth_service import user_auth_service
from middleware.platform_context import get_current_platform
from middleware.store_context import get_current_store
from models.schema.auth import LogoutResponse, StoreUserResponse, UserContext, UserLogin
store_auth_router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)

View File

@@ -13,9 +13,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_store_api
from app.core.database import get_db
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.schemas.store import StoreResponse, StoreUpdate
from app.modules.tenancy.services.store_service import store_service
from models.schema.auth import UserContext
store_profile_router = APIRouter(prefix="/profile")
logger = logging.getLogger(__name__)

View File

@@ -22,6 +22,7 @@ from app.api.deps import (
require_store_permission,
)
from app.core.database import get_db
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.schemas.team import (
BulkRemoveRequest,
BulkRemoveResponse,
@@ -48,7 +49,6 @@ from app.modules.tenancy.services.permission_discovery_service import (
)
from app.modules.tenancy.services.store_team_service import store_team_service
from app.utils.i18n import translate
from models.schema.auth import UserContext
store_team_router = APIRouter(prefix="/team")
logger = logging.getLogger(__name__)

View File

@@ -16,8 +16,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_merchant_from_cookie_or_header, get_db
from app.modules.core.utils.page_context import get_context_for_frontend
from app.modules.enums import FrontendType
from app.modules.tenancy.schemas.auth import UserContext
from app.templates_config import templates
from models.schema.auth import UserContext
router = APIRouter()

View File

@@ -5,7 +5,7 @@ Tenancy module Pydantic schemas.
Request/response schemas for platform, merchant, store, admin user, and team management.
"""
# Merchant schemas
# Auth schemas
# Admin schemas
from app.modules.tenancy.schemas.admin import (
AdminAuditLogFilters,
@@ -49,6 +49,27 @@ from app.modules.tenancy.schemas.admin import (
RowsPerPageUpdateResponse,
SystemHealthResponse,
)
from app.modules.tenancy.schemas.auth import (
LoginResponse,
LogoutResponse,
OwnedMerchantSummary,
PasswordResetRequestResponse,
PasswordResetResponse,
PlatformSelectResponse,
StoreMembershipSummary,
StoreUserResponse,
UserContext,
UserCreate,
UserDeleteResponse,
UserDetailResponse,
UserListResponse,
UserLogin,
UserResponse,
UserSearchItem,
UserSearchResponse,
UserStatusToggleResponse,
UserUpdate,
)
from app.modules.tenancy.schemas.merchant import (
MerchantBase,
MerchantCreate,
@@ -112,6 +133,26 @@ from app.modules.tenancy.schemas.team import (
)
__all__ = [
# Auth
"LoginResponse",
"LogoutResponse",
"OwnedMerchantSummary",
"PasswordResetRequestResponse",
"PasswordResetResponse",
"PlatformSelectResponse",
"StoreMembershipSummary",
"StoreUserResponse",
"UserContext",
"UserCreate",
"UserDeleteResponse",
"UserDetailResponse",
"UserListResponse",
"UserLogin",
"UserResponse",
"UserSearchItem",
"UserSearchResponse",
"UserStatusToggleResponse",
"UserUpdate",
# Merchant
"MerchantBase",
"MerchantCreate",

View File

@@ -0,0 +1,343 @@
# app/modules/tenancy/schemas/auth.py
"""
Authentication and user context schemas.
UserContext is the primary schema for dependency injection in API endpoints,
replacing direct use of the User database model in routes.
Migrated from models/schema/auth.py per MOD-019 / MOD-025.
"""
import re
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
class UserLogin(BaseModel):
email_or_username: str = Field(..., description="Username or email address")
password: str = Field(..., description="Password")
store_code: str | None = Field(
None, description="Optional store code for context"
)
@field_validator("email_or_username")
@classmethod
def validate_email_or_username(cls, v):
return v.strip()
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: str
username: str
role: str
is_active: bool
preferred_language: str | None = None
last_login: datetime | None = None
created_at: datetime
updated_at: datetime
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
user: UserResponse
class PlatformSelectResponse(BaseModel):
"""Response for platform selection (no user data - client already has it)."""
access_token: str
token_type: str = "bearer"
expires_in: int
platform_id: int
platform_code: str
class OwnedMerchantSummary(BaseModel):
"""Summary of a merchant owned by a user."""
id: int
name: str
is_active: bool
store_count: int
class StoreMembershipSummary(BaseModel):
"""Summary of a user's store membership."""
store_id: int
store_code: str
store_name: str
role: str
is_active: bool
class UserDetailResponse(UserResponse):
"""Extended user response with additional details."""
first_name: str | None = None
last_name: str | None = None
full_name: str | None = None
is_email_verified: bool = False
owned_merchants_count: int = 0
store_memberships_count: int = 0
owned_merchants: list[OwnedMerchantSummary] = []
store_memberships: list[StoreMembershipSummary] = []
class UserUpdate(BaseModel):
"""Schema for updating user information."""
username: str | None = Field(None, min_length=3, max_length=50)
email: EmailStr | None = None
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
role: str | None = Field(None, pattern="^(super_admin|platform_admin|merchant_owner|store_member)$")
is_active: bool | None = None
is_email_verified: bool | None = None
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
@field_validator("username")
@classmethod
def validate_username(cls, v):
if v and not re.match(r"^[a-zA-Z0-9_]+$", v):
raise ValueError(
"Username must contain only letters, numbers, or underscores"
)
return v.lower().strip() if v else v
class UserCreate(BaseModel):
"""Schema for creating a new user (admin only)."""
email: EmailStr = Field(..., description="Valid email address")
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6, description="Password")
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
role: str = Field(default="store_member", pattern="^(super_admin|platform_admin|merchant_owner|store_member)$")
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
@field_validator("username")
@classmethod
def validate_username(cls, v):
if not re.match(r"^[a-zA-Z0-9_]+$", v):
raise ValueError(
"Username must contain only letters, numbers, or underscores"
)
return v.lower().strip()
class UserListResponse(BaseModel):
"""Schema for paginated user list."""
items: list[UserResponse]
total: int
page: int
per_page: int
pages: int
class UserSearchItem(BaseModel):
"""Schema for a single user search result."""
id: int
username: str
email: str
is_active: bool
class UserSearchResponse(BaseModel):
"""Schema for user search results."""
users: list[UserSearchItem]
class UserStatusToggleResponse(BaseModel):
"""Schema for user status toggle response."""
message: str
is_active: bool
class UserDeleteResponse(BaseModel):
"""Schema for user delete response."""
message: str
class LogoutResponse(BaseModel):
"""Schema for logout response."""
message: str
class PasswordResetRequestResponse(BaseModel):
"""Schema for password reset request response."""
message: str
class PasswordResetResponse(BaseModel):
"""Schema for password reset response."""
message: str
class StoreUserResponse(BaseModel):
"""Schema for store user info in auth context."""
id: int
username: str
email: str
role: str
is_active: bool
model_config = {"from_attributes": True}
class UserContext(BaseModel):
"""
User context for dependency injection in API endpoints.
This schema replaces direct use of the User database model in API routes,
following the principle that routes should not import database models directly.
Used by:
- get_current_admin_api / get_current_admin_from_cookie_or_header
- get_current_store_api / get_current_store_from_cookie_or_header
- get_current_super_admin
For admin users:
- is_super_admin indicates full platform access
- accessible_platform_ids is None for super admins (all platforms)
- accessible_platform_ids is a list for platform admins
For store users:
- token_store_id/code/role come from JWT token
- These indicate which store context the user is operating in
"""
# Core user fields
id: int
email: str
username: str
role: str # super_admin, platform_admin, merchant_owner, or store_member
is_active: bool = True
# Admin-specific fields
accessible_platform_ids: list[int] | None = None # None = all platforms (super admin)
# Admin platform context (from JWT token after platform selection)
token_platform_id: int | None = None
token_platform_code: str | None = None
# Store-specific fields (from JWT token)
token_store_id: int | None = None
token_store_code: str | None = None
token_store_role: str | None = None
# Optional profile fields
first_name: str | None = None
last_name: str | None = None
preferred_language: str | None = None
model_config = ConfigDict(from_attributes=True)
@property
def full_name(self) -> str:
"""Returns the full name of the user."""
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.username
@property
def is_super_admin(self) -> bool:
"""Check if user is a super admin."""
return self.role == "super_admin"
@property
def is_admin(self) -> bool:
"""Check if user is an admin (super_admin or platform_admin)."""
return self.role in ("super_admin", "platform_admin")
@property
def is_store_user(self) -> bool:
"""Check if user is a store user (merchant_owner or store_member)."""
return self.role in ("merchant_owner", "store_member")
def can_access_platform(self, platform_id: int) -> bool:
"""
Check if user can access a specific platform.
Super admins (accessible_platform_ids=None) can access all platforms.
Platform admins can only access their assigned platforms.
"""
if self.is_super_admin:
return True
if self.accessible_platform_ids is None:
return True # Super admin fallback
return platform_id in self.accessible_platform_ids
def get_accessible_platform_ids(self) -> list[int] | None:
"""
Get list of platform IDs this user can access.
Returns None for super admins (all platforms accessible).
Returns list of platform IDs for platform admins.
"""
return self.accessible_platform_ids
@classmethod
def from_user(cls, user, include_store_context: bool = True) -> "UserContext":
"""
Create UserContext from a User database model.
Args:
user: User database model instance
include_store_context: Whether to include token_store_* fields
Returns:
UserContext instance
"""
data = {
"id": user.id,
"email": user.email,
"username": user.username,
"role": user.role,
"is_active": user.is_active,
"first_name": getattr(user, "first_name", None),
"last_name": getattr(user, "last_name", None),
"preferred_language": getattr(user, "preferred_language", None),
}
# Add admin platform access info
if user.is_admin:
if user.is_super_admin:
data["accessible_platform_ids"] = None # All platforms
else:
# Get platform IDs from admin_platforms relationship
admin_platforms = getattr(user, "admin_platforms", [])
data["accessible_platform_ids"] = [
ap.platform_id for ap in admin_platforms if ap.is_active
]
# Add platform context from JWT token (for platform admins after selection)
data["token_platform_id"] = getattr(user, "token_platform_id", None)
data["token_platform_code"] = getattr(user, "token_platform_code", None)
# Add store context from JWT token if present
if include_store_context:
data["token_store_id"] = getattr(user, "token_store_id", None)
data["token_store_code"] = getattr(user, "token_store_code", None)
data["token_store_role"] = getattr(user, "token_store_role", None)
return cls(**data)

View File

@@ -23,7 +23,7 @@ from app.modules.tenancy.exceptions import (
UserNotFoundException,
)
from app.modules.tenancy.models import AdminPlatform, Platform, User
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
logger = logging.getLogger(__name__)

View File

@@ -786,6 +786,22 @@ class AdminService:
# PRIVATE HELPER METHODS
# ============================================================================
def get_user_by_id(self, db: Session, user_id: int) -> User | None:
"""
Get user by ID.
Public method for cross-module consumers that need to look up a user.
Returns None if not found (does not raise).
Args:
db: Database session
user_id: User ID
Returns:
User object or None
"""
return db.query(User).filter(User.id == user_id).first()
def _get_user_by_id_or_raise(self, db: Session, user_id: int) -> User:
"""Get user by ID or raise UserNotFoundException."""
user = db.query(User).filter(User.id == user_id).first()

View File

@@ -329,6 +329,49 @@ class MerchantService:
return merchant, old_owner, new_owner
def get_merchant_by_owner_id(
self, db: Session, owner_user_id: int
) -> Merchant | None:
"""
Get merchant by owner user ID.
Args:
db: Database session
owner_user_id: Owner user ID
Returns:
First active merchant owned by the user, or None
"""
return (
db.query(Merchant)
.filter(
Merchant.owner_user_id == owner_user_id,
Merchant.is_active == True, # noqa: E712
)
.first()
)
def get_merchant_count_for_owner(
self, db: Session, owner_user_id: int, active_only: bool = True
) -> int:
"""
Count merchants owned by a user.
Args:
db: Database session
owner_user_id: Owner user ID
active_only: Only count active merchants
Returns:
Number of merchants
"""
query = db.query(func.count(Merchant.id)).filter(
Merchant.owner_user_id == owner_user_id
)
if active_only:
query = query.filter(Merchant.is_active == True) # noqa: E712
return query.scalar() or 0
def get_merchant_stores(
self, db: Session, merchant_id: int, skip: int = 0, limit: int = 100
) -> tuple[list, int]:

View File

@@ -142,6 +142,31 @@ class PlatformService:
or 0
)
@staticmethod
def get_active_store_count(db: Session, platform_id: int) -> int:
"""
Get count of active stores on a platform.
Args:
db: Database session
platform_id: Platform ID
Returns:
Active store count
"""
from app.modules.tenancy.models import Store
return (
db.query(func.count(StorePlatform.store_id))
.join(Store, Store.id == StorePlatform.store_id)
.filter(
StorePlatform.platform_id == platform_id,
Store.is_active == True, # noqa: E712
)
.scalar()
or 0
)
@staticmethod
def get_platform_pages_count(db: Session, platform_id: int) -> int:
"""

View File

@@ -15,8 +15,8 @@ import pytest
from app.api.deps import get_current_merchant_api, get_merchant_for_current_user
from app.modules.tenancy.models import Merchant, Store, User
from app.modules.tenancy.schemas.auth import UserContext
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures

View File

@@ -16,8 +16,8 @@ import pytest
from app.api.deps import get_current_store_from_cookie_or_header
from app.modules.tenancy.models import Merchant, Role, Store, StoreUser, User
from app.modules.tenancy.schemas.auth import UserContext
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures

View File

@@ -17,7 +17,7 @@ Routes should not import database models directly (architecture rule API-007). I
```python
# CORRECT: Use UserContext from dependencies
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.auth import UserContext
@router.get("/endpoint")
def my_endpoint(current_user: UserContext = Depends(get_current_admin_api)):

View File

@@ -1,334 +1,34 @@
# auth.py - Keep security-critical validation
import re
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
class UserLogin(BaseModel):
email_or_username: str = Field(..., description="Username or email address")
password: str = Field(..., description="Password")
store_code: str | None = Field(
None, description="Optional store code for context"
)
@field_validator("email_or_username")
@classmethod
def validate_email_or_username(cls, v):
return v.strip()
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: str
username: str
role: str
is_active: bool
preferred_language: str | None = None
last_login: datetime | None = None
created_at: datetime
updated_at: datetime
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
user: UserResponse
class PlatformSelectResponse(BaseModel):
"""Response for platform selection (no user data - client already has it)."""
access_token: str
token_type: str = "bearer"
expires_in: int
platform_id: int
platform_code: str
class OwnedMerchantSummary(BaseModel):
"""Summary of a merchant owned by a user."""
id: int
name: str
is_active: bool
store_count: int
class StoreMembershipSummary(BaseModel):
"""Summary of a user's store membership."""
store_id: int
store_code: str
store_name: str
role: str
is_active: bool
class UserDetailResponse(UserResponse):
"""Extended user response with additional details."""
first_name: str | None = None
last_name: str | None = None
full_name: str | None = None
is_email_verified: bool = False
owned_merchants_count: int = 0
store_memberships_count: int = 0
owned_merchants: list[OwnedMerchantSummary] = []
store_memberships: list[StoreMembershipSummary] = []
class UserUpdate(BaseModel):
"""Schema for updating user information."""
username: str | None = Field(None, min_length=3, max_length=50)
email: EmailStr | None = None
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
role: str | None = Field(None, pattern="^(super_admin|platform_admin|merchant_owner|store_member)$")
is_active: bool | None = None
is_email_verified: bool | None = None
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
@field_validator("username")
@classmethod
def validate_username(cls, v):
if v and not re.match(r"^[a-zA-Z0-9_]+$", v):
raise ValueError(
"Username must contain only letters, numbers, or underscores"
)
return v.lower().strip() if v else v
class UserCreate(BaseModel):
"""Schema for creating a new user (admin only)."""
email: EmailStr = Field(..., description="Valid email address")
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6, description="Password")
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
role: str = Field(default="store_member", pattern="^(super_admin|platform_admin|merchant_owner|store_member)$")
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
@field_validator("username")
@classmethod
def validate_username(cls, v):
if not re.match(r"^[a-zA-Z0-9_]+$", v):
raise ValueError(
"Username must contain only letters, numbers, or underscores"
)
return v.lower().strip()
class UserListResponse(BaseModel):
"""Schema for paginated user list."""
items: list[UserResponse]
total: int
page: int
per_page: int
pages: int
class UserSearchItem(BaseModel):
"""Schema for a single user search result."""
id: int
username: str
email: str
is_active: bool
class UserSearchResponse(BaseModel):
"""Schema for user search results."""
users: list[UserSearchItem]
class UserStatusToggleResponse(BaseModel):
"""Schema for user status toggle response."""
message: str
is_active: bool
class UserDeleteResponse(BaseModel):
"""Schema for user delete response."""
message: str
class LogoutResponse(BaseModel):
"""Schema for logout response."""
message: str
class PasswordResetRequestResponse(BaseModel):
"""Schema for password reset request response."""
message: str
class PasswordResetResponse(BaseModel):
"""Schema for password reset response."""
message: str
class StoreUserResponse(BaseModel):
"""Schema for store user info in auth context."""
id: int
username: str
email: str
role: str
is_active: bool
model_config = {"from_attributes": True}
class UserContext(BaseModel):
"""
User context for dependency injection in API endpoints.
This schema replaces direct use of the User database model in API routes,
following the principle that routes should not import database models directly.
Used by:
- get_current_admin_api / get_current_admin_from_cookie_or_header
- get_current_store_api / get_current_store_from_cookie_or_header
- get_current_super_admin
For admin users:
- is_super_admin indicates full platform access
- accessible_platform_ids is None for super admins (all platforms)
- accessible_platform_ids is a list for platform admins
For store users:
- token_store_id/code/role come from JWT token
- These indicate which store context the user is operating in
"""
# Core user fields
id: int
email: str
username: str
role: str # super_admin, platform_admin, merchant_owner, or store_member
is_active: bool = True
# Admin-specific fields
accessible_platform_ids: list[int] | None = None # None = all platforms (super admin)
# Admin platform context (from JWT token after platform selection)
token_platform_id: int | None = None
token_platform_code: str | None = None
# Store-specific fields (from JWT token)
token_store_id: int | None = None
token_store_code: str | None = None
token_store_role: str | None = None
# Optional profile fields
first_name: str | None = None
last_name: str | None = None
preferred_language: str | None = None
model_config = ConfigDict(from_attributes=True)
@property
def full_name(self) -> str:
"""Returns the full name of the user."""
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.username
@property
def is_super_admin(self) -> bool:
"""Check if user is a super admin."""
return self.role == "super_admin"
@property
def is_admin(self) -> bool:
"""Check if user is an admin (super_admin or platform_admin)."""
return self.role in ("super_admin", "platform_admin")
@property
def is_store_user(self) -> bool:
"""Check if user is a store user (merchant_owner or store_member)."""
return self.role in ("merchant_owner", "store_member")
def can_access_platform(self, platform_id: int) -> bool:
"""
Check if user can access a specific platform.
Super admins (accessible_platform_ids=None) can access all platforms.
Platform admins can only access their assigned platforms.
"""
if self.is_super_admin:
return True
if self.accessible_platform_ids is None:
return True # Super admin fallback
return platform_id in self.accessible_platform_ids
def get_accessible_platform_ids(self) -> list[int] | None:
"""
Get list of platform IDs this user can access.
Returns None for super admins (all platforms accessible).
Returns list of platform IDs for platform admins.
"""
return self.accessible_platform_ids
@classmethod
def from_user(cls, user, include_store_context: bool = True) -> "UserContext":
"""
Create UserContext from a User database model.
Args:
user: User database model instance
include_store_context: Whether to include token_store_* fields
Returns:
UserContext instance
"""
data = {
"id": user.id,
"email": user.email,
"username": user.username,
"role": user.role,
"is_active": user.is_active,
"first_name": getattr(user, "first_name", None),
"last_name": getattr(user, "last_name", None),
"preferred_language": getattr(user, "preferred_language", None),
}
# Add admin platform access info
if user.is_admin:
if user.is_super_admin:
data["accessible_platform_ids"] = None # All platforms
else:
# Get platform IDs from admin_platforms relationship
admin_platforms = getattr(user, "admin_platforms", [])
data["accessible_platform_ids"] = [
ap.platform_id for ap in admin_platforms if ap.is_active
]
# Add platform context from JWT token (for platform admins after selection)
data["token_platform_id"] = getattr(user, "token_platform_id", None)
data["token_platform_code"] = getattr(user, "token_platform_code", None)
# Add store context from JWT token if present
if include_store_context:
data["token_store_id"] = getattr(user, "token_store_id", None)
data["token_store_code"] = getattr(user, "token_store_code", None)
data["token_store_role"] = getattr(user, "token_store_role", None)
return cls(**data)
# models/schema/auth.py
"""
LEGACY LOCATION — re-exports from canonical location.
All auth schemas have been moved to app/modules/tenancy/schemas/auth.py
per MOD-019 (schemas belong in their module).
This file provides backwards compatibility re-exports.
New code should import from: app.modules.tenancy.schemas.auth
Schemas use Pydantic Field and field_validator for input validation.
"""
from app.modules.tenancy.schemas.auth import ( # noqa: F401
LoginResponse,
LogoutResponse,
OwnedMerchantSummary,
PasswordResetRequestResponse,
PasswordResetResponse,
PlatformSelectResponse,
StoreMembershipSummary,
StoreUserResponse,
UserContext,
UserCreate,
UserDeleteResponse,
UserDetailResponse,
UserListResponse,
UserLogin,
UserResponse,
UserSearchItem,
UserSearchResponse,
UserStatusToggleResponse,
UserUpdate,
)

View File

@@ -52,8 +52,8 @@ from app.modules.tenancy.exceptions import (
UnauthorizedStoreAccessException,
)
from app.modules.tenancy.models import User
from app.modules.tenancy.schemas.auth import UserContext
from middleware.auth import AuthManager
from models.schema.auth import UserContext
# ============================================================================
# Fixtures

View File

@@ -4,7 +4,7 @@
import pytest
from pydantic import ValidationError
from models.schema.auth import (
from app.modules.tenancy.schemas.auth import (
UserCreate,
UserLogin,
UserResponse,

View File

@@ -8,7 +8,7 @@ from app.modules.tenancy.exceptions import (
InvalidCredentialsException,
UserNotActiveException,
)
from models.schema.auth import UserLogin
from app.modules.tenancy.schemas.auth import UserLogin
@pytest.mark.unit