Store team page: - Fix undefined user_id (API returns `id`, JS used `user_id`) - Fix wrong URL path in updateMember (remove redundant storeCode) - Fix update_member_role route passing wrong kwarg (new_role_id → new_role_name) - Add update_member() service method for role_id + is_active updates - Add :selected binding for role pre-selection in edit modal Merchant team page: - Add missing db.commit() on invite, update, and remove endpoints - Fix forward-reference string type annotation on MerchantTeamInvite - Add :selected binding for role pre-selection in edit modal Shared fixes: - Replace removed subscription_service.check_team_limit with usage_service - Replace removed subscription_service.get_current_tier in email service - Fix email config bool settings crashing on .lower() (value_type=boolean) Tests: 15 new integration tests for store team member API endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
346 lines
11 KiB
Python
346 lines
11 KiB
Python
# app/modules/tenancy/routes/api/merchant.py
|
|
"""
|
|
Tenancy module merchant API routes.
|
|
|
|
Aggregates all merchant tenancy routes:
|
|
- /auth/* - Merchant authentication (login, logout, /me)
|
|
- /account/* - Merchant account management (stores, profile)
|
|
|
|
Auto-discovered by the route system (merchant.py in routes/api/).
|
|
"""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import get_current_merchant_api, get_merchant_for_current_user
|
|
from app.core.database import get_db
|
|
from app.modules.tenancy.schemas import (
|
|
MerchantPortalProfileResponse,
|
|
MerchantPortalProfileUpdate,
|
|
MerchantPortalStoreListResponse,
|
|
MerchantStoreCreate,
|
|
MerchantStoreDetailResponse,
|
|
MerchantStoreUpdate,
|
|
)
|
|
from app.modules.tenancy.schemas.auth import UserContext
|
|
from app.modules.tenancy.schemas.team import MerchantTeamInvite
|
|
from app.modules.tenancy.services.merchant_service import merchant_service
|
|
from app.modules.tenancy.services.merchant_store_service import merchant_store_service
|
|
|
|
from .email_verification import email_verification_api_router
|
|
from .merchant_auth import merchant_auth_router
|
|
from .user_account import merchant_account_router
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
# Include auth routes (/auth/login, /auth/logout, /auth/me, /auth/forgot-password, /auth/reset-password)
|
|
router.include_router(merchant_auth_router, tags=["merchant-auth"])
|
|
|
|
# Include email verification routes (/resend-verification)
|
|
router.include_router(email_verification_api_router, tags=["email-verification"])
|
|
|
|
# Account routes are defined below with /account prefix
|
|
_account_router = APIRouter(prefix="/account")
|
|
|
|
|
|
# ============================================================================
|
|
# ACCOUNT ENDPOINTS
|
|
# ============================================================================
|
|
|
|
|
|
@_account_router.get("/stores", response_model=MerchantPortalStoreListResponse)
|
|
async def merchant_stores(
|
|
request: Request,
|
|
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
|
limit: int = Query(100, ge=1, le=200, description="Max records to return"),
|
|
merchant=Depends(get_merchant_for_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
List all stores belonging to the merchant.
|
|
|
|
Returns a paginated list of store summaries for the authenticated merchant.
|
|
"""
|
|
stores, total = merchant_service.get_merchant_stores(
|
|
db, merchant.id, skip=skip, limit=limit
|
|
)
|
|
|
|
can_create, _ = merchant_store_service.can_create_store(db, merchant.id)
|
|
|
|
return MerchantPortalStoreListResponse(
|
|
stores=stores,
|
|
total=total,
|
|
skip=skip,
|
|
limit=limit,
|
|
can_create_store=can_create,
|
|
)
|
|
|
|
|
|
@_account_router.post("/stores", response_model=MerchantStoreDetailResponse)
|
|
async def create_merchant_store(
|
|
store_data: MerchantStoreCreate,
|
|
current_user: UserContext = Depends(get_current_merchant_api),
|
|
merchant=Depends(get_merchant_for_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Create a new store under the merchant.
|
|
|
|
Checks subscription tier store limits before creation.
|
|
New stores are created with is_active=True, is_verified=False.
|
|
"""
|
|
# Service raises MaxStoresReachedException, StoreAlreadyExistsException,
|
|
# or StoreValidationException — all handled by global exception handler.
|
|
result = merchant_store_service.create_store(
|
|
db,
|
|
merchant.id,
|
|
store_data.model_dump(),
|
|
)
|
|
db.commit()
|
|
logger.info(
|
|
f"Merchant {merchant.id} ({current_user.username}) created store "
|
|
f"'{store_data.store_code}'"
|
|
)
|
|
return result
|
|
|
|
|
|
@_account_router.get("/stores/{store_id}", response_model=MerchantStoreDetailResponse)
|
|
async def get_merchant_store_detail(
|
|
store_id: int,
|
|
merchant=Depends(get_merchant_for_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get detailed store information with ownership validation.
|
|
|
|
Returns store details including platform assignments.
|
|
"""
|
|
# StoreNotFoundException handled by global exception handler
|
|
return merchant_store_service.get_store_detail(db, merchant.id, store_id)
|
|
|
|
|
|
@_account_router.put("/stores/{store_id}", response_model=MerchantStoreDetailResponse)
|
|
async def update_merchant_store(
|
|
store_id: int,
|
|
update_data: MerchantStoreUpdate,
|
|
current_user: UserContext = Depends(get_current_merchant_api),
|
|
merchant=Depends(get_merchant_for_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Update store details (merchant-allowed fields only).
|
|
|
|
Only name, description, and contact info fields can be updated.
|
|
"""
|
|
# StoreNotFoundException handled by global exception handler
|
|
result = merchant_store_service.update_store(
|
|
db,
|
|
merchant.id,
|
|
store_id,
|
|
update_data.model_dump(exclude_unset=True),
|
|
)
|
|
db.commit()
|
|
logger.info(
|
|
f"Merchant {merchant.id} ({current_user.username}) updated store {store_id}"
|
|
)
|
|
return result
|
|
|
|
|
|
@_account_router.get("/platforms")
|
|
async def get_merchant_platforms(
|
|
merchant=Depends(get_merchant_for_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get platforms available for the merchant (from active subscriptions).
|
|
|
|
Used by the store creation/edit UI to show platform selection options.
|
|
"""
|
|
return merchant_store_service.get_subscribed_platform_ids(db, merchant.id)
|
|
|
|
|
|
@_account_router.get("/team")
|
|
async def merchant_team_overview(
|
|
merchant=Depends(get_merchant_for_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get team members across all merchant stores (member-centric view).
|
|
|
|
Returns deduplicated members with per-store role info.
|
|
"""
|
|
return merchant_store_service.get_merchant_team_members(db, merchant.id)
|
|
|
|
|
|
@_account_router.get("/team/stores/{store_id}/roles")
|
|
async def merchant_team_store_roles(
|
|
store_id: int,
|
|
current_user: UserContext = Depends(get_current_merchant_api),
|
|
merchant=Depends(get_merchant_for_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get available roles for a specific store."""
|
|
from app.modules.tenancy.services.store_team_service import store_team_service
|
|
|
|
merchant_store_service.validate_store_ownership(db, merchant.id, store_id)
|
|
roles = store_team_service.get_store_roles(db, store_id)
|
|
return {"roles": roles, "total": len(roles)}
|
|
|
|
|
|
@_account_router.post("/team/invite")
|
|
async def merchant_team_invite(
|
|
data: MerchantTeamInvite,
|
|
current_user: UserContext = Depends(get_current_merchant_api),
|
|
merchant=Depends(get_merchant_for_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Invite a member to one or more merchant stores."""
|
|
from app.modules.tenancy.schemas.team import (
|
|
MerchantTeamInviteResponse,
|
|
MerchantTeamInviteResult,
|
|
)
|
|
from app.modules.tenancy.services.store_team_service import store_team_service
|
|
|
|
# Get the User ORM object (service needs it as inviter)
|
|
inviter = merchant_store_service.get_user(db, current_user.id)
|
|
|
|
results = []
|
|
for store_id in data.store_ids:
|
|
try:
|
|
store = merchant_store_service.validate_store_ownership(
|
|
db, merchant.id, store_id
|
|
)
|
|
store_team_service.invite_team_member(
|
|
db,
|
|
store=store,
|
|
inviter=inviter,
|
|
email=data.email,
|
|
role_name=data.role_name,
|
|
)
|
|
results.append(MerchantTeamInviteResult(
|
|
store_id=store.id,
|
|
store_name=store.name,
|
|
success=True,
|
|
))
|
|
except Exception as e:
|
|
results.append(MerchantTeamInviteResult(
|
|
store_id=store_id,
|
|
store_name=getattr(e, "store_name", str(store_id)),
|
|
success=False,
|
|
error=str(e),
|
|
))
|
|
|
|
db.commit()
|
|
success_count = sum(1 for r in results if r.success)
|
|
if success_count == len(results):
|
|
message = f"Invitation sent to {data.email} for {success_count} store(s)"
|
|
elif success_count > 0:
|
|
message = f"Invitation partially sent ({success_count}/{len(results)} stores)"
|
|
else:
|
|
message = "Invitation failed for all stores"
|
|
|
|
return MerchantTeamInviteResponse(
|
|
message=message,
|
|
email=data.email,
|
|
results=results,
|
|
)
|
|
|
|
|
|
@_account_router.put("/team/stores/{store_id}/members/{user_id}")
|
|
async def merchant_team_update_role(
|
|
store_id: int,
|
|
user_id: int,
|
|
role_name: str = Query(..., description="New role name"),
|
|
current_user: UserContext = Depends(get_current_merchant_api),
|
|
merchant=Depends(get_merchant_for_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update a member's role in a specific store."""
|
|
from app.modules.tenancy.services.store_team_service import store_team_service
|
|
|
|
store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id)
|
|
store_team_service.update_member_role(
|
|
db,
|
|
store=store,
|
|
user_id=user_id,
|
|
new_role_name=role_name,
|
|
actor_user_id=current_user.id,
|
|
)
|
|
db.commit()
|
|
return {"message": "Role updated successfully"}
|
|
|
|
|
|
@_account_router.delete("/team/stores/{store_id}/members/{user_id}", status_code=204)
|
|
async def merchant_team_remove_member(
|
|
store_id: int,
|
|
user_id: int,
|
|
current_user: UserContext = Depends(get_current_merchant_api),
|
|
merchant=Depends(get_merchant_for_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Remove a member from a specific store."""
|
|
from app.modules.tenancy.services.store_team_service import store_team_service
|
|
|
|
store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id)
|
|
store_team_service.remove_team_member(
|
|
db,
|
|
store=store,
|
|
user_id=user_id,
|
|
actor_user_id=current_user.id,
|
|
)
|
|
db.commit()
|
|
|
|
|
|
@_account_router.get("/profile", response_model=MerchantPortalProfileResponse)
|
|
async def merchant_profile(
|
|
request: Request,
|
|
merchant=Depends(get_merchant_for_current_user),
|
|
):
|
|
"""
|
|
Get the authenticated merchant's profile information.
|
|
|
|
Returns merchant details including contact info, business details,
|
|
and verification status.
|
|
"""
|
|
return merchant
|
|
|
|
|
|
@_account_router.put("/profile", response_model=MerchantPortalProfileResponse)
|
|
async def update_merchant_profile(
|
|
request: Request,
|
|
profile_data: MerchantPortalProfileUpdate,
|
|
current_user: UserContext = Depends(get_current_merchant_api),
|
|
merchant=Depends(get_merchant_for_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Update the authenticated merchant's profile information.
|
|
|
|
Accepts partial updates - only provided fields are changed.
|
|
"""
|
|
# Apply only the fields that were explicitly provided
|
|
update_data = profile_data.model_dump(exclude_unset=True)
|
|
for field_name, value in update_data.items():
|
|
setattr(merchant, field_name, value)
|
|
|
|
db.commit()
|
|
db.refresh(merchant)
|
|
|
|
logger.info(
|
|
f"Merchant profile updated: merchant_id={merchant.id}, "
|
|
f"user={current_user.username}, fields={list(update_data.keys())}"
|
|
)
|
|
|
|
return merchant
|
|
|
|
|
|
# Include account routes in main router
|
|
router.include_router(_account_router, tags=["merchant-account"])
|
|
|
|
# Include self-service user account routes
|
|
router.include_router(merchant_account_router, tags=["merchant-user-account"])
|