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

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