refactor: migrate templates and static files to self-contained modules

Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -3,11 +3,64 @@
Tenancy module services.
Business logic for platform, company, vendor, and admin user management.
Currently services remain in app/services/ - this package is a placeholder
for future migration.
Services:
- vendor_service: Vendor operations and product catalog
- admin_service: Admin user and vendor management
- admin_platform_service: Admin-platform assignments
- vendor_team_service: Team member management
- vendor_domain_service: Custom domain management
- company_service: Company CRUD operations
- platform_service: Platform operations
- team_service: Team operations
"""
# Services will be migrated here from app/services/
# For now, import from legacy location if needed:
# from app.services.vendor_service import vendor_service
# from app.services.company_service import company_service
from app.modules.tenancy.services.admin_platform_service import (
AdminPlatformService,
admin_platform_service,
)
from app.modules.tenancy.services.admin_service import AdminService, admin_service
from app.modules.tenancy.services.company_service import CompanyService, company_service
from app.modules.tenancy.services.platform_service import (
PlatformService,
PlatformStats,
platform_service,
)
from app.modules.tenancy.services.team_service import TeamService, team_service
from app.modules.tenancy.services.vendor_domain_service import (
VendorDomainService,
vendor_domain_service,
)
from app.modules.tenancy.services.vendor_service import VendorService, vendor_service
from app.modules.tenancy.services.vendor_team_service import (
VendorTeamService,
vendor_team_service,
)
__all__ = [
# Vendor
"VendorService",
"vendor_service",
# Admin
"AdminService",
"admin_service",
# Admin Platform
"AdminPlatformService",
"admin_platform_service",
# Vendor Team
"VendorTeamService",
"vendor_team_service",
# Vendor Domain
"VendorDomainService",
"vendor_domain_service",
# Company
"CompanyService",
"company_service",
# Platform
"PlatformService",
"PlatformStats",
"platform_service",
# Team
"TeamService",
"team_service",
]

View File

@@ -0,0 +1,663 @@
# app/modules/tenancy/services/admin_platform_service.py
"""
Admin Platform service for managing admin-platform assignments.
This module provides:
- Assigning platform admins to platforms
- Removing platform admin access
- Listing platforms for an admin
- Listing admins for a platform
- Promoting/demoting super admin status
"""
import logging
from datetime import UTC, datetime
from sqlalchemy.orm import Session, joinedload
from app.exceptions import ValidationException
from app.modules.tenancy.exceptions import (
AdminOperationException,
CannotModifySelfException,
)
from models.database.admin_platform import AdminPlatform
from models.database.platform import Platform
from models.database.user import User
logger = logging.getLogger(__name__)
class AdminPlatformService:
"""Service class for admin-platform assignment operations."""
# ============================================================================
# ADMIN-PLATFORM ASSIGNMENTS
# ============================================================================
def assign_admin_to_platform(
self,
db: Session,
admin_user_id: int,
platform_id: int,
assigned_by_user_id: int,
) -> AdminPlatform:
"""
Assign a platform admin to a platform.
Args:
db: Database session
admin_user_id: User ID of the admin to assign
platform_id: Platform ID to assign to
assigned_by_user_id: Super admin making the assignment
Returns:
AdminPlatform: The created assignment
Raises:
ValidationException: If user is not an admin or is a super admin
AdminOperationException: If assignment already exists
"""
# Verify target user exists and is an admin
user = db.query(User).filter(User.id == admin_user_id).first()
if not user:
raise ValidationException("User not found", field="admin_user_id")
if not user.is_admin:
raise ValidationException(
"User must be an admin to be assigned to platforms",
field="admin_user_id",
)
if user.is_super_admin:
raise ValidationException(
"Super admins don't need platform assignments - they have access to all platforms",
field="admin_user_id",
)
# Verify platform exists
platform = db.query(Platform).filter(Platform.id == platform_id).first()
if not platform:
raise ValidationException("Platform not found", field="platform_id")
# Check if assignment already exists
existing = (
db.query(AdminPlatform)
.filter(
AdminPlatform.user_id == admin_user_id,
AdminPlatform.platform_id == platform_id,
)
.first()
)
if existing:
if existing.is_active:
raise AdminOperationException(
operation="assign_admin_to_platform",
reason=f"Admin already assigned to platform '{platform.code}'",
)
# Reactivate existing assignment
existing.is_active = True
existing.assigned_at = datetime.now(UTC)
existing.assigned_by_user_id = assigned_by_user_id
existing.updated_at = datetime.now(UTC)
db.flush()
db.refresh(existing)
logger.info(
f"Reactivated admin {admin_user_id} access to platform {platform.code} "
f"by admin {assigned_by_user_id}"
)
return existing
# Create new assignment
assignment = AdminPlatform(
user_id=admin_user_id,
platform_id=platform_id,
assigned_by_user_id=assigned_by_user_id,
is_active=True,
)
db.add(assignment)
db.flush()
db.refresh(assignment)
logger.info(
f"Assigned admin {admin_user_id} to platform {platform.code} "
f"by admin {assigned_by_user_id}"
)
return assignment
def remove_admin_from_platform(
self,
db: Session,
admin_user_id: int,
platform_id: int,
removed_by_user_id: int,
) -> None:
"""
Remove admin's access to a platform.
This soft-deletes by setting is_active=False for audit purposes.
Args:
db: Database session
admin_user_id: User ID of the admin to remove
platform_id: Platform ID to remove from
removed_by_user_id: Super admin making the removal
Raises:
ValidationException: If assignment doesn't exist
"""
assignment = (
db.query(AdminPlatform)
.filter(
AdminPlatform.user_id == admin_user_id,
AdminPlatform.platform_id == platform_id,
)
.first()
)
if not assignment:
raise ValidationException(
"Admin is not assigned to this platform",
field="platform_id",
)
assignment.is_active = False
assignment.updated_at = datetime.now(UTC)
db.flush()
logger.info(
f"Removed admin {admin_user_id} from platform {platform_id} "
f"by admin {removed_by_user_id}"
)
def get_platforms_for_admin(
self,
db: Session,
admin_user_id: int,
include_inactive: bool = False,
) -> list[Platform]:
"""
Get all platforms an admin can access.
Args:
db: Database session
admin_user_id: User ID of the admin
include_inactive: Whether to include inactive assignments
Returns:
List of Platform objects the admin can access
"""
query = (
db.query(Platform)
.join(AdminPlatform)
.filter(AdminPlatform.user_id == admin_user_id)
)
if not include_inactive:
query = query.filter(AdminPlatform.is_active == True)
return query.all()
def get_all_active_platforms(self, db: Session) -> list[Platform]:
"""
Get all active platforms (for super admin access).
Args:
db: Database session
Returns:
List of all active Platform objects
"""
return db.query(Platform).filter(Platform.is_active == True).all()
def get_platform_by_id(self, db: Session, platform_id: int) -> Platform | None:
"""
Get a platform by ID.
Args:
db: Database session
platform_id: Platform ID
Returns:
Platform object or None if not found
"""
return db.query(Platform).filter(Platform.id == platform_id).first()
def validate_admin_platform_access(
self,
user: User,
platform_id: int,
) -> None:
"""
Validate that an admin has access to a platform.
Args:
user: User object
platform_id: Platform ID to check
Raises:
InsufficientPermissionsException: If user doesn't have access
"""
from app.modules.tenancy.exceptions import InsufficientPermissionsException
if not user.can_access_platform(platform_id):
raise InsufficientPermissionsException(
"You don't have access to this platform"
)
def get_admins_for_platform(
self,
db: Session,
platform_id: int,
include_inactive: bool = False,
) -> list[User]:
"""
Get all admins assigned to a platform.
Args:
db: Database session
platform_id: Platform ID
include_inactive: Whether to include inactive assignments
Returns:
List of User objects assigned to the platform
"""
# Explicit join condition needed because AdminPlatform has two FKs to User
# (user_id and assigned_by_user_id)
query = (
db.query(User)
.join(AdminPlatform, AdminPlatform.user_id == User.id)
.filter(AdminPlatform.platform_id == platform_id)
)
if not include_inactive:
query = query.filter(AdminPlatform.is_active == True)
return query.all()
def get_admin_assignments(
self,
db: Session,
admin_user_id: int,
) -> list[AdminPlatform]:
"""
Get all platform assignments for an admin with platform details.
Args:
db: Database session
admin_user_id: User ID of the admin
Returns:
List of AdminPlatform objects with platform relationship loaded
"""
return (
db.query(AdminPlatform)
.options(joinedload(AdminPlatform.platform))
.filter(
AdminPlatform.user_id == admin_user_id,
AdminPlatform.is_active == True,
)
.all()
)
# ============================================================================
# SUPER ADMIN MANAGEMENT
# ============================================================================
def toggle_super_admin(
self,
db: Session,
user_id: int,
is_super_admin: bool,
current_admin_id: int,
) -> User:
"""
Promote or demote a user to/from super admin.
When demoting from super admin, the admin will have no platform access
until explicitly assigned via assign_admin_to_platform.
Args:
db: Database session
user_id: User ID to modify
is_super_admin: True to promote, False to demote
current_admin_id: Super admin making the change
Returns:
Updated User object
Raises:
CannotModifySelfException: If trying to demote self
ValidationException: If user is not an admin
"""
if user_id == current_admin_id and not is_super_admin:
raise CannotModifySelfException(
user_id=user_id,
operation="demote from super admin",
)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValidationException("User not found", field="user_id")
if not user.is_admin:
raise ValidationException(
"User must be an admin to be promoted to super admin",
field="user_id",
)
old_status = user.is_super_admin
user.is_super_admin = is_super_admin
user.updated_at = datetime.now(UTC)
db.flush()
db.refresh(user)
action = "promoted to" if is_super_admin else "demoted from"
logger.info(
f"User {user.username} {action} super admin by admin {current_admin_id}"
)
return user
def create_platform_admin(
self,
db: Session,
email: str,
username: str,
password: str,
platform_ids: list[int],
created_by_user_id: int,
first_name: str | None = None,
last_name: str | None = None,
) -> tuple[User, list[AdminPlatform]]:
"""
Create a new platform admin with platform assignments.
Args:
db: Database session
email: Admin email
username: Admin username
password: Admin password
platform_ids: List of platform IDs to assign
created_by_user_id: Super admin creating the account
first_name: Optional first name
last_name: Optional last name
Returns:
Tuple of (User, list of AdminPlatform assignments)
"""
from middleware.auth import AuthManager
auth_manager = AuthManager()
# Check for existing user
existing = db.query(User).filter(
(User.email == email) | (User.username == username)
).first()
if existing:
field = "email" if existing.email == email else "username"
raise ValidationException(f"{field.capitalize()} already exists", field=field)
# Create admin user
user = User(
email=email,
username=username,
hashed_password=auth_manager.hash_password(password),
first_name=first_name,
last_name=last_name,
role="admin",
is_active=True,
is_super_admin=False, # Platform admin, not super admin
)
db.add(user)
db.flush()
# Create platform assignments
assignments = []
for platform_id in platform_ids:
assignment = AdminPlatform(
user_id=user.id,
platform_id=platform_id,
assigned_by_user_id=created_by_user_id,
is_active=True,
)
db.add(assignment)
assignments.append(assignment)
db.flush()
db.refresh(user)
logger.info(
f"Created platform admin {username} with access to platforms "
f"{platform_ids} by admin {created_by_user_id}"
)
return user, assignments
# ============================================================================
# ADMIN USER CRUD OPERATIONS
# ============================================================================
def list_admin_users(
self,
db: Session,
skip: int = 0,
limit: int = 100,
include_super_admins: bool = True,
is_active: bool | None = None,
search: str | None = None,
) -> tuple[list[User], int]:
"""
List all admin users with optional filtering.
Args:
db: Database session
skip: Number of records to skip
limit: Maximum records to return
include_super_admins: Whether to include super admins
is_active: Filter by active status
search: Search term for username/email/name
Returns:
Tuple of (list of User objects, total count)
"""
query = db.query(User).filter(User.role == "admin")
if not include_super_admins:
query = query.filter(User.is_super_admin == False)
if is_active is not None:
query = query.filter(User.is_active == is_active)
if search:
search_term = f"%{search}%"
query = query.filter(
(User.username.ilike(search_term))
| (User.email.ilike(search_term))
| (User.first_name.ilike(search_term))
| (User.last_name.ilike(search_term))
)
total = query.count()
admins = (
query.options(joinedload(User.admin_platforms))
.offset(skip)
.limit(limit)
.all()
)
return admins, total
def get_admin_user(
self,
db: Session,
user_id: int,
) -> User:
"""
Get a single admin user by ID with platform assignments.
Args:
db: Database session
user_id: User ID
Returns:
User object with admin_platforms loaded
Raises:
ValidationException: If user not found or not an admin
"""
admin = (
db.query(User)
.options(joinedload(User.admin_platforms))
.filter(User.id == user_id, User.role == "admin")
.first()
)
if not admin:
raise ValidationException("Admin user not found", field="user_id")
return admin
def create_super_admin(
self,
db: Session,
email: str,
username: str,
password: str,
created_by_user_id: int,
first_name: str | None = None,
last_name: str | None = None,
) -> User:
"""
Create a new super admin user.
Args:
db: Database session
email: Admin email
username: Admin username
password: Admin password
created_by_user_id: Super admin creating the account
first_name: Optional first name
last_name: Optional last name
Returns:
Created User object
"""
from app.core.security import get_password_hash
# Check for existing user
existing = (
db.query(User)
.filter((User.email == email) | (User.username == username))
.first()
)
if existing:
field = "email" if existing.email == email else "username"
raise ValidationException(f"{field.capitalize()} already exists", field=field)
user = User(
email=email,
username=username,
hashed_password=get_password_hash(password),
first_name=first_name,
last_name=last_name,
role="admin",
is_super_admin=True,
is_active=True,
)
db.add(user)
db.flush()
db.refresh(user)
logger.info(
f"Created super admin {username} by admin {created_by_user_id}"
)
return user
def toggle_admin_status(
self,
db: Session,
user_id: int,
current_admin_id: int,
) -> User:
"""
Toggle admin user active status.
Args:
db: Database session
user_id: User ID to toggle
current_admin_id: Super admin making the change
Returns:
Updated User object
Raises:
CannotModifySelfException: If trying to deactivate self
ValidationException: If user not found or not an admin
"""
if user_id == current_admin_id:
raise CannotModifySelfException(
user_id=user_id,
operation="deactivate own account",
)
admin = db.query(User).filter(User.id == user_id, User.role == "admin").first()
if not admin:
raise ValidationException("Admin user not found", field="user_id")
admin.is_active = not admin.is_active
admin.updated_at = datetime.now(UTC)
db.flush()
db.refresh(admin)
action = "activated" if admin.is_active else "deactivated"
logger.info(
f"Admin {admin.username} {action} by admin {current_admin_id}"
)
return admin
def delete_admin_user(
self,
db: Session,
user_id: int,
current_admin_id: int,
) -> None:
"""
Delete an admin user and their platform assignments.
Args:
db: Database session
user_id: User ID to delete
current_admin_id: Super admin making the deletion
Raises:
CannotModifySelfException: If trying to delete self
ValidationException: If user not found or not an admin
"""
if user_id == current_admin_id:
raise CannotModifySelfException(
user_id=user_id,
operation="delete own account",
)
admin = db.query(User).filter(User.id == user_id, User.role == "admin").first()
if not admin:
raise ValidationException("Admin user not found", field="user_id")
username = admin.username
# Delete admin platform assignments first
db.query(AdminPlatform).filter(AdminPlatform.user_id == user_id).delete()
# Delete the admin user
db.delete(admin)
db.flush()
logger.info(f"Admin {username} deleted by admin {current_admin_id}")
# Singleton instance
admin_platform_service = AdminPlatformService()

View File

@@ -0,0 +1,895 @@
# app/modules/tenancy/services/admin_service.py
"""
Admin service for managing users, vendors, and import jobs.
This module provides classes and functions for:
- User management and status control
- Vendor creation with owner user generation
- Vendor verification and activation
- Marketplace import job monitoring
- Platform statistics
"""
import logging
import secrets
import string
from datetime import UTC, datetime
from sqlalchemy import func, or_
from sqlalchemy.orm import Session, joinedload
from app.exceptions import ValidationException
from app.modules.tenancy.exceptions import (
AdminOperationException,
CannotModifySelfException,
UserAlreadyExistsException,
UserCannotBeDeletedException,
UserNotFoundException,
UserRoleChangeException,
UserStatusChangeException,
VendorAlreadyExistsException,
VendorNotFoundException,
VendorVerificationException,
)
from middleware.auth import AuthManager
from models.database.company import Company
from app.modules.marketplace.models import MarketplaceImportJob
from models.database.platform import Platform
from models.database.user import User
from models.database.vendor import Role, Vendor
from app.modules.marketplace.schemas import MarketplaceImportJobResponse
from models.schema.vendor import VendorCreate
logger = logging.getLogger(__name__)
class AdminService:
"""Service class for admin operations following the application's service pattern."""
# ============================================================================
# USER MANAGEMENT
# ============================================================================
def get_all_users(self, db: Session, skip: int = 0, limit: int = 100) -> list[User]:
"""Get paginated list of all users."""
try:
return db.query(User).offset(skip).limit(limit).all()
except Exception as e:
logger.error(f"Failed to retrieve users: {str(e)}")
raise AdminOperationException(
operation="get_all_users", reason="Database query failed"
)
def toggle_user_status(
self, db: Session, user_id: int, current_admin_id: int
) -> tuple[User, str]:
"""Toggle user active status."""
user = self._get_user_by_id_or_raise(db, user_id)
# Prevent self-modification
if user.id == current_admin_id:
raise CannotModifySelfException(user_id, "deactivate account")
# Check if user is another admin
if user.role == "admin" and user.id != current_admin_id:
raise UserStatusChangeException(
user_id=user_id,
current_status="admin",
attempted_action="toggle status",
reason="Cannot modify another admin user",
)
try:
original_status = user.is_active
user.is_active = not user.is_active
user.updated_at = datetime.now(UTC)
db.flush()
db.refresh(user)
status_action = "activated" if user.is_active else "deactivated"
message = f"User {user.username} has been {status_action}"
logger.info(f"{message} by admin {current_admin_id}")
return user, message
except Exception as e:
logger.error(f"Failed to toggle user {user_id} status: {str(e)}")
raise UserStatusChangeException(
user_id=user_id,
current_status="active" if original_status else "inactive",
attempted_action="toggle status",
reason="Database update failed",
)
def list_users(
self,
db: Session,
page: int = 1,
per_page: int = 10,
search: str | None = None,
role: str | None = None,
is_active: bool | None = None,
) -> tuple[list[User], int, int]:
"""
Get paginated list of users with filtering.
Returns:
Tuple of (users, total_count, total_pages)
"""
import math
query = db.query(User)
# Apply filters
if search:
search_term = f"%{search.lower()}%"
query = query.filter(
or_(
User.username.ilike(search_term),
User.email.ilike(search_term),
User.first_name.ilike(search_term),
User.last_name.ilike(search_term),
)
)
if role:
query = query.filter(User.role == role)
if is_active is not None:
query = query.filter(User.is_active == is_active)
# Get total count
total = query.count()
pages = math.ceil(total / per_page) if total > 0 else 1
# Apply pagination
skip = (page - 1) * per_page
users = (
query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all()
)
return users, total, pages
def create_user(
self,
db: Session,
email: str,
username: str,
password: str,
first_name: str | None = None,
last_name: str | None = None,
role: str = "customer",
current_admin_id: int | None = None,
) -> User:
"""
Create a new user.
Raises:
UserAlreadyExistsException: If email or username already exists
"""
# Check if email exists
if db.query(User).filter(User.email == email).first():
raise UserAlreadyExistsException("Email already registered", field="email")
# Check if username exists
if db.query(User).filter(User.username == username).first():
raise UserAlreadyExistsException("Username already taken", field="username")
# Create user
auth_manager = AuthManager()
user = User(
email=email,
username=username,
hashed_password=auth_manager.hash_password(password),
first_name=first_name,
last_name=last_name,
role=role,
is_active=True,
)
db.add(user)
db.flush()
db.refresh(user)
logger.info(f"Admin {current_admin_id} created user {user.username}")
return user
def get_user_details(self, db: Session, user_id: int) -> User:
"""
Get user with relationships loaded.
Raises:
UserNotFoundException: If user not found
"""
user = (
db.query(User)
.options(
joinedload(User.owned_companies), joinedload(User.vendor_memberships)
)
.filter(User.id == user_id)
.first()
)
if not user:
raise UserNotFoundException(str(user_id))
return user
def update_user(
self,
db: Session,
user_id: int,
current_admin_id: int,
email: str | None = None,
username: str | None = None,
first_name: str | None = None,
last_name: str | None = None,
role: str | None = None,
is_active: bool | None = None,
) -> User:
"""
Update user information.
Raises:
UserNotFoundException: If user not found
UserAlreadyExistsException: If email/username already taken
UserRoleChangeException: If trying to change own admin role
"""
user = self._get_user_by_id_or_raise(db, user_id)
# Prevent changing own admin status
if user.id == current_admin_id and role and role != "admin":
raise UserRoleChangeException(
user_id=user_id,
current_role=user.role,
target_role=role,
reason="Cannot change your own admin role",
)
# Check email uniqueness if changing
if email and email != user.email:
if db.query(User).filter(User.email == email).first():
raise UserAlreadyExistsException(
"Email already registered", field="email"
)
# Check username uniqueness if changing
if username and username != user.username:
if db.query(User).filter(User.username == username).first():
raise UserAlreadyExistsException(
"Username already taken", field="username"
)
# Update fields
if email is not None:
user.email = email
if username is not None:
user.username = username
if first_name is not None:
user.first_name = first_name
if last_name is not None:
user.last_name = last_name
if role is not None:
user.role = role
if is_active is not None:
user.is_active = is_active
user.updated_at = datetime.now(UTC)
db.flush()
db.refresh(user)
logger.info(f"Admin {current_admin_id} updated user {user.username}")
return user
def delete_user(self, db: Session, user_id: int, current_admin_id: int) -> str:
"""
Delete a user.
Raises:
UserNotFoundException: If user not found
CannotModifySelfException: If trying to delete yourself
UserCannotBeDeletedException: If user owns companies
"""
user = (
db.query(User)
.options(joinedload(User.owned_companies))
.filter(User.id == user_id)
.first()
)
if not user:
raise UserNotFoundException(str(user_id))
# Prevent deleting yourself
if user.id == current_admin_id:
raise CannotModifySelfException(user_id, "delete account")
# Prevent deleting users who own companies
if user.owned_companies:
raise UserCannotBeDeletedException(
user_id=user_id,
reason=f"User owns {len(user.owned_companies)} company(ies). Transfer ownership first.",
owned_count=len(user.owned_companies),
)
username = user.username
db.delete(user)
logger.info(f"Admin {current_admin_id} deleted user {username}")
return f"User {username} deleted successfully"
def search_users(
self,
db: Session,
query: str,
limit: int = 10,
) -> list[dict]:
"""
Search users by username or email.
Used for autocomplete in ownership transfer.
"""
search_term = f"%{query.lower()}%"
users = (
db.query(User)
.filter(
or_(User.username.ilike(search_term), User.email.ilike(search_term))
)
.limit(limit)
.all()
)
return [
{
"id": user.id,
"username": user.username,
"email": user.email,
"is_active": user.is_active,
}
for user in users
]
# ============================================================================
# VENDOR MANAGEMENT
# ============================================================================
def create_vendor(self, db: Session, vendor_data: VendorCreate) -> Vendor:
"""
Create a vendor (storefront/brand) under an existing company.
The vendor inherits owner and contact information from its parent company.
Args:
db: Database session
vendor_data: Vendor creation data including company_id
Returns:
The created Vendor object with company relationship loaded
Raises:
ValidationException: If company not found or vendor code/subdomain exists
AdminOperationException: If creation fails
"""
try:
# Validate company exists
company = (
db.query(Company).filter(Company.id == vendor_data.company_id).first()
)
if not company:
raise ValidationException(
f"Company with ID {vendor_data.company_id} not found"
)
# Check if vendor code already exists
existing_vendor = (
db.query(Vendor)
.filter(
func.upper(Vendor.vendor_code) == vendor_data.vendor_code.upper()
)
.first()
)
if existing_vendor:
raise VendorAlreadyExistsException(vendor_data.vendor_code)
# Check if subdomain already exists
existing_subdomain = (
db.query(Vendor)
.filter(func.lower(Vendor.subdomain) == vendor_data.subdomain.lower())
.first()
)
if existing_subdomain:
raise ValidationException(
f"Subdomain '{vendor_data.subdomain}' is already taken"
)
# Create vendor linked to company
vendor = Vendor(
company_id=company.id,
vendor_code=vendor_data.vendor_code.upper(),
subdomain=vendor_data.subdomain.lower(),
name=vendor_data.name,
description=vendor_data.description,
letzshop_csv_url_fr=vendor_data.letzshop_csv_url_fr,
letzshop_csv_url_en=vendor_data.letzshop_csv_url_en,
letzshop_csv_url_de=vendor_data.letzshop_csv_url_de,
is_active=True,
is_verified=False, # Needs verification by admin
)
db.add(vendor)
db.flush() # Get vendor.id
# Create default roles for vendor
self._create_default_roles(db, vendor.id)
# Assign vendor to platforms if provided
if vendor_data.platform_ids:
from models.database.vendor_platform import VendorPlatform
for platform_id in vendor_data.platform_ids:
# Verify platform exists
platform = db.query(Platform).filter(Platform.id == platform_id).first()
if platform:
vendor_platform = VendorPlatform(
vendor_id=vendor.id,
platform_id=platform_id,
is_active=True,
)
db.add(vendor_platform)
logger.debug(
f"Assigned vendor {vendor.vendor_code} to platform {platform.code}"
)
db.flush()
db.refresh(vendor)
logger.info(
f"Vendor {vendor.vendor_code} created under company {company.name} (ID: {company.id})"
)
return vendor
except (VendorAlreadyExistsException, ValidationException):
raise
except Exception as e:
logger.error(f"Failed to create vendor: {str(e)}")
raise AdminOperationException(
operation="create_vendor",
reason=f"Failed to create vendor: {str(e)}",
)
def get_all_vendors(
self,
db: Session,
skip: int = 0,
limit: int = 100,
search: str | None = None,
is_active: bool | None = None,
is_verified: bool | None = None,
) -> tuple[list[Vendor], int]:
"""Get paginated list of all vendors with filtering."""
try:
# Eagerly load company relationship to avoid N+1 queries
query = db.query(Vendor).options(joinedload(Vendor.company))
# Apply search filter
if search:
search_term = f"%{search}%"
query = query.filter(
or_(
Vendor.name.ilike(search_term),
Vendor.vendor_code.ilike(search_term),
Vendor.subdomain.ilike(search_term),
)
)
# Apply status filters
if is_active is not None:
query = query.filter(Vendor.is_active == is_active)
if is_verified is not None:
query = query.filter(Vendor.is_verified == is_verified)
# Get total count (without joinedload for performance)
count_query = db.query(Vendor)
if search:
search_term = f"%{search}%"
count_query = count_query.filter(
or_(
Vendor.name.ilike(search_term),
Vendor.vendor_code.ilike(search_term),
Vendor.subdomain.ilike(search_term),
)
)
if is_active is not None:
count_query = count_query.filter(Vendor.is_active == is_active)
if is_verified is not None:
count_query = count_query.filter(Vendor.is_verified == is_verified)
total = count_query.count()
# Get paginated results
vendors = query.offset(skip).limit(limit).all()
return vendors, total
except Exception as e:
logger.error(f"Failed to retrieve vendors: {str(e)}")
raise AdminOperationException(
operation="get_all_vendors", reason="Database query failed"
)
def get_vendor_by_id(self, db: Session, vendor_id: int) -> Vendor:
"""Get vendor by ID."""
return self._get_vendor_by_id_or_raise(db, vendor_id)
def verify_vendor(self, db: Session, vendor_id: int) -> tuple[Vendor, str]:
"""Toggle vendor verification status."""
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
try:
original_status = vendor.is_verified
vendor.is_verified = not vendor.is_verified
vendor.updated_at = datetime.now(UTC)
if vendor.is_verified:
vendor.verified_at = datetime.now(UTC)
db.flush()
db.refresh(vendor)
status_action = "verified" if vendor.is_verified else "unverified"
message = f"Vendor {vendor.vendor_code} has been {status_action}"
logger.info(message)
return vendor, message
except Exception as e:
logger.error(f"Failed to verify vendor {vendor_id}: {str(e)}")
raise VendorVerificationException(
vendor_id=vendor_id,
reason="Database update failed",
current_verification_status=original_status,
)
def toggle_vendor_status(self, db: Session, vendor_id: int) -> tuple[Vendor, str]:
"""Toggle vendor active status."""
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
try:
original_status = vendor.is_active
vendor.is_active = not vendor.is_active
vendor.updated_at = datetime.now(UTC)
db.flush()
db.refresh(vendor)
status_action = "activated" if vendor.is_active else "deactivated"
message = f"Vendor {vendor.vendor_code} has been {status_action}"
logger.info(message)
return vendor, message
except Exception as e:
logger.error(f"Failed to toggle vendor {vendor_id} status: {str(e)}")
raise AdminOperationException(
operation="toggle_vendor_status",
reason="Database update failed",
target_type="vendor",
target_id=str(vendor_id),
)
def delete_vendor(self, db: Session, vendor_id: int) -> str:
"""Delete vendor and all associated data."""
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
try:
vendor_code = vendor.vendor_code
# TODO: Delete associated data in correct order
# - Delete orders
# - Delete customers
# - Delete products
# - Delete team members
# - Delete roles
# - Delete import jobs
db.delete(vendor)
logger.warning(f"Vendor {vendor_code} and all associated data deleted")
return f"Vendor {vendor_code} successfully deleted"
except Exception as e:
logger.error(f"Failed to delete vendor {vendor_id}: {str(e)}")
raise AdminOperationException(
operation="delete_vendor", reason="Database deletion failed"
)
def update_vendor(
self,
db: Session,
vendor_id: int,
vendor_update, # VendorUpdate schema
) -> Vendor:
"""
Update vendor information (Admin only).
Can update:
- Vendor details (name, description, subdomain)
- Business contact info (contact_email, phone, etc.)
- Status (is_active, is_verified)
Cannot update:
- vendor_code (immutable)
- company_id (vendor cannot be moved between companies)
Note: Ownership is managed at the Company level.
Use company_service.transfer_ownership() for ownership changes.
Args:
db: Database session
vendor_id: ID of vendor to update
vendor_update: VendorUpdate schema with updated data
Returns:
Updated vendor object
Raises:
VendorNotFoundException: If vendor not found
ValidationException: If subdomain already taken
"""
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
try:
# Get update data
update_data = vendor_update.model_dump(exclude_unset=True)
# Handle reset_contact_to_company flag
if update_data.pop("reset_contact_to_company", False):
# Reset all contact fields to None (inherit from company)
update_data["contact_email"] = None
update_data["contact_phone"] = None
update_data["website"] = None
update_data["business_address"] = None
update_data["tax_number"] = None
# Convert empty strings to None for contact fields (empty = inherit)
contact_fields = [
"contact_email",
"contact_phone",
"website",
"business_address",
"tax_number",
]
for field in contact_fields:
if field in update_data and update_data[field] == "":
update_data[field] = None
# Check subdomain uniqueness if changing
if (
"subdomain" in update_data
and update_data["subdomain"] != vendor.subdomain
):
existing = (
db.query(Vendor)
.filter(
Vendor.subdomain == update_data["subdomain"],
Vendor.id != vendor_id,
)
.first()
)
if existing:
raise ValidationException(
f"Subdomain '{update_data['subdomain']}' is already taken"
)
# Update vendor fields
for field, value in update_data.items():
setattr(vendor, field, value)
vendor.updated_at = datetime.now(UTC)
db.flush()
db.refresh(vendor)
logger.info(
f"Vendor {vendor_id} ({vendor.vendor_code}) updated by admin. "
f"Fields updated: {', '.join(update_data.keys())}"
)
return vendor
except ValidationException:
raise
except Exception as e:
logger.error(f"Failed to update vendor {vendor_id}: {str(e)}")
raise AdminOperationException(
operation="update_vendor", reason=f"Database update failed: {str(e)}"
)
# NOTE: Vendor ownership transfer is now handled at the Company level.
# Use company_service.transfer_ownership() instead.
# ============================================================================
# MARKETPLACE IMPORT JOBS
# ============================================================================
def get_marketplace_import_jobs(
self,
db: Session,
marketplace: str | None = None,
vendor_name: str | None = None,
status: str | None = None,
skip: int = 0,
limit: int = 100,
) -> list[MarketplaceImportJobResponse]:
"""Get filtered and paginated marketplace import jobs."""
try:
query = db.query(MarketplaceImportJob)
if marketplace:
query = query.filter(
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
)
if vendor_name:
query = query.filter(
MarketplaceImportJob.vendor_name.ilike(f"%{vendor_name}%")
)
if status:
query = query.filter(MarketplaceImportJob.status == status)
jobs = (
query.order_by(MarketplaceImportJob.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [self._convert_job_to_response(job) for job in jobs]
except Exception as e:
logger.error(f"Failed to retrieve marketplace import jobs: {str(e)}")
raise AdminOperationException(
operation="get_marketplace_import_jobs", reason="Database query failed"
)
# ============================================================================
# STATISTICS
# ============================================================================
def get_recent_vendors(self, db: Session, limit: int = 5) -> list[dict]:
"""Get recently created vendors."""
try:
vendors = (
db.query(Vendor).order_by(Vendor.created_at.desc()).limit(limit).all()
)
return [
{
"id": v.id,
"vendor_code": v.vendor_code,
"name": v.name,
"subdomain": v.subdomain,
"is_active": v.is_active,
"is_verified": v.is_verified,
"created_at": v.created_at,
}
for v in vendors
]
except Exception as e:
logger.error(f"Failed to get recent vendors: {str(e)}")
return []
def get_recent_import_jobs(self, db: Session, limit: int = 10) -> list[dict]:
"""Get recent marketplace import jobs."""
try:
jobs = (
db.query(MarketplaceImportJob)
.order_by(MarketplaceImportJob.created_at.desc())
.limit(limit)
.all()
)
return [
{
"id": j.id,
"marketplace": j.marketplace,
"vendor_name": j.vendor_name,
"status": j.status,
"total_processed": j.total_processed or 0,
"created_at": j.created_at,
}
for j in jobs
]
except Exception as e:
logger.error(f"Failed to get recent import jobs: {str(e)}")
return []
# ============================================================================
# PRIVATE HELPER METHODS
# ============================================================================
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()
if not user:
raise UserNotFoundException(str(user_id))
return user
def _get_vendor_by_id_or_raise(self, db: Session, vendor_id: int) -> Vendor:
"""Get vendor by ID or raise VendorNotFoundException."""
vendor = (
db.query(Vendor)
.options(joinedload(Vendor.company).joinedload(Company.owner))
.filter(Vendor.id == vendor_id)
.first()
)
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
return vendor
def _generate_temp_password(self, length: int = 12) -> str:
"""Generate secure temporary password."""
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
return "".join(secrets.choice(alphabet) for _ in range(length))
def _create_default_roles(self, db: Session, vendor_id: int):
"""Create default roles for a new vendor."""
default_roles = [
{"name": "Owner", "permissions": ["*"]}, # Full access
{
"name": "Manager",
"permissions": [
"products.*",
"orders.*",
"customers.view",
"inventory.*",
"team.view",
],
},
{
"name": "Editor",
"permissions": [
"products.view",
"products.edit",
"orders.view",
"inventory.view",
],
},
{
"name": "Viewer",
"permissions": [
"products.view",
"orders.view",
"customers.view",
"inventory.view",
],
},
]
for role_data in default_roles:
role = Role(
vendor_id=vendor_id,
name=role_data["name"],
permissions=role_data["permissions"],
)
db.add(role)
def _convert_job_to_response(
self, job: MarketplaceImportJob
) -> MarketplaceImportJobResponse:
"""Convert database model to response schema."""
return MarketplaceImportJobResponse(
job_id=job.id,
status=job.status,
marketplace=job.marketplace,
source_url=job.source_url,
vendor_id=job.vendor.id if job.vendor else None,
vendor_code=job.vendor.vendor_code if job.vendor else None,
vendor_name=job.vendor.name if job.vendor else None,
imported=job.imported_count or 0,
updated=job.updated_count or 0,
total_processed=job.total_processed or 0,
error_count=job.error_count or 0,
error_message=job.error_message,
created_at=job.created_at,
started_at=job.started_at,
completed_at=job.completed_at,
)
# Create service instance
admin_service = AdminService()

View File

@@ -0,0 +1,330 @@
# app/modules/tenancy/services/company_service.py
"""
Company service for managing company operations.
This service handles CRUD operations for companies and company-vendor relationships.
"""
import logging
import secrets
import string
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload
from app.modules.tenancy.exceptions import CompanyNotFoundException, UserNotFoundException
from models.database.company import Company
from models.database.user import User
from models.schema.company import CompanyCreate, CompanyTransferOwnership, CompanyUpdate
logger = logging.getLogger(__name__)
class CompanyService:
"""Service for managing companies."""
def __init__(self):
"""Initialize company service."""
def create_company_with_owner(
self, db: Session, company_data: CompanyCreate
) -> tuple[Company, User, str]:
"""
Create a new company with an owner user account.
Args:
db: Database session
company_data: Company creation data
Returns:
Tuple of (company, owner_user, temporary_password)
"""
# Import AuthManager for password hashing (same pattern as admin_service)
from middleware.auth import AuthManager
auth_manager = AuthManager()
# Check if owner email already exists
existing_user = db.execute(
select(User).where(User.email == company_data.owner_email)
).scalar_one_or_none()
if existing_user:
# Use existing user as owner
owner_user = existing_user
temp_password = None
logger.info(f"Using existing user {owner_user.email} as company owner")
else:
# Generate temporary password for owner
temp_password = self._generate_temp_password()
# Create new owner user
owner_user = User(
username=company_data.owner_email.split("@")[0],
email=company_data.owner_email,
hashed_password=auth_manager.hash_password(temp_password),
role="user",
is_active=True,
is_email_verified=True,
)
db.add(owner_user)
db.flush() # Get owner_user.id
logger.info(f"Created new owner user: {owner_user.email}")
# Create company
company = Company(
name=company_data.name,
description=company_data.description,
owner_user_id=owner_user.id,
contact_email=company_data.contact_email,
contact_phone=company_data.contact_phone,
website=company_data.website,
business_address=company_data.business_address,
tax_number=company_data.tax_number,
is_active=True,
is_verified=False,
)
db.add(company)
db.flush()
logger.info(f"Created company: {company.name} (ID: {company.id})")
return company, owner_user, temp_password
def get_company_by_id(self, db: Session, company_id: int) -> Company:
"""
Get company by ID.
Args:
db: Database session
company_id: Company ID
Returns:
Company object
Raises:
CompanyNotFoundException: If company not found
"""
company = (
db.execute(
select(Company)
.where(Company.id == company_id)
.options(joinedload(Company.vendors))
)
.unique()
.scalar_one_or_none()
)
if not company:
raise CompanyNotFoundException(company_id)
return company
def get_companies(
self,
db: Session,
skip: int = 0,
limit: int = 100,
search: str | None = None,
is_active: bool | None = None,
is_verified: bool | None = None,
) -> tuple[list[Company], int]:
"""
Get paginated list of companies with optional filters.
Args:
db: Database session
skip: Number of records to skip
limit: Maximum number of records to return
search: Search term for company name
is_active: Filter by active status
is_verified: Filter by verified status
Returns:
Tuple of (companies list, total count)
"""
query = select(Company).options(joinedload(Company.vendors))
# Apply filters
if search:
query = query.where(Company.name.ilike(f"%{search}%"))
if is_active is not None:
query = query.where(Company.is_active == is_active)
if is_verified is not None:
query = query.where(Company.is_verified == is_verified)
# Get total count
count_query = select(func.count()).select_from(query.subquery())
total = db.execute(count_query).scalar()
# Apply pagination and order
query = query.order_by(Company.name).offset(skip).limit(limit)
# Use unique() when using joinedload with collections to avoid duplicate rows
companies = list(db.execute(query).scalars().unique().all())
return companies, total
def update_company(
self, db: Session, company_id: int, company_data: CompanyUpdate
) -> Company:
"""
Update company information.
Args:
db: Database session
company_id: Company ID
company_data: Updated company data
Returns:
Updated company
Raises:
CompanyNotFoundException: If company not found
"""
company = self.get_company_by_id(db, company_id)
# Update only provided fields
update_data = company_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(company, field, value)
db.flush()
logger.info(f"Updated company ID {company_id}")
return company
def delete_company(self, db: Session, company_id: int) -> None:
"""
Delete a company and all associated vendors.
Args:
db: Database session
company_id: Company ID
Raises:
CompanyNotFoundException: If company not found
"""
company = self.get_company_by_id(db, company_id)
# Due to cascade="all, delete-orphan", associated vendors will be deleted
db.delete(company)
db.flush()
logger.info(f"Deleted company ID {company_id} and associated vendors")
def toggle_verification(
self, db: Session, company_id: int, is_verified: bool
) -> Company:
"""
Toggle company verification status.
Args:
db: Database session
company_id: Company ID
is_verified: New verification status
Returns:
Updated company
Raises:
CompanyNotFoundException: If company not found
"""
company = self.get_company_by_id(db, company_id)
company.is_verified = is_verified
db.flush()
logger.info(f"Company ID {company_id} verification set to {is_verified}")
return company
def toggle_active(self, db: Session, company_id: int, is_active: bool) -> Company:
"""
Toggle company active status.
Args:
db: Database session
company_id: Company ID
is_active: New active status
Returns:
Updated company
Raises:
CompanyNotFoundException: If company not found
"""
company = self.get_company_by_id(db, company_id)
company.is_active = is_active
db.flush()
logger.info(f"Company ID {company_id} active status set to {is_active}")
return company
def transfer_ownership(
self,
db: Session,
company_id: int,
transfer_data: CompanyTransferOwnership,
) -> tuple[Company, User, User]:
"""
Transfer company ownership to another user.
This is a critical operation that:
- Changes the company's owner_user_id
- All vendors under the company automatically inherit the new owner
- Logs the transfer for audit purposes
Args:
db: Database session
company_id: Company ID
transfer_data: Transfer ownership data
Returns:
Tuple of (company, old_owner, new_owner)
Raises:
CompanyNotFoundException: If company not found
UserNotFoundException: If new owner user not found
ValueError: If trying to transfer to current owner
"""
# Get company
company = self.get_company_by_id(db, company_id)
old_owner_id = company.owner_user_id
# Get old owner
old_owner = db.execute(
select(User).where(User.id == old_owner_id)
).scalar_one_or_none()
if not old_owner:
raise UserNotFoundException(str(old_owner_id))
# Get new owner
new_owner = db.execute(
select(User).where(User.id == transfer_data.new_owner_user_id)
).scalar_one_or_none()
if not new_owner:
raise UserNotFoundException(str(transfer_data.new_owner_user_id))
# Prevent transferring to same owner
if old_owner_id == transfer_data.new_owner_user_id:
raise ValueError("Cannot transfer ownership to the current owner")
# Update company owner (vendors inherit ownership via company relationship)
company.owner_user_id = new_owner.id
db.flush()
logger.info(
f"Company {company.id} ({company.name}) ownership transferred "
f"from user {old_owner.id} ({old_owner.email}) "
f"to user {new_owner.id} ({new_owner.email}). "
f"Reason: {transfer_data.transfer_reason or 'Not specified'}"
)
return company, old_owner, new_owner
def _generate_temp_password(self, length: int = 12) -> str:
"""Generate secure temporary password."""
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
return "".join(secrets.choice(alphabet) for _ in range(length))
# Create service instance following the same pattern as other services
company_service = CompanyService()

View File

@@ -0,0 +1,310 @@
# app/modules/tenancy/services/platform_service.py
"""
Platform Service
Business logic for platform management in the Multi-Platform CMS.
Platforms represent different business offerings (OMS, Loyalty, Site Builder, Main Marketing).
Each platform has its own:
- Marketing pages (homepage, pricing, features)
- Vendor defaults (about, terms, privacy)
- Configuration and branding
"""
import logging
from dataclasses import dataclass
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.modules.tenancy.exceptions import (
PlatformNotFoundException,
)
from app.modules.cms.models import ContentPage
from models.database.platform import Platform
from models.database.vendor_platform import VendorPlatform
logger = logging.getLogger(__name__)
@dataclass
class PlatformStats:
"""Platform statistics."""
platform_id: int
platform_code: str
platform_name: str
vendor_count: int
platform_pages_count: int
vendor_defaults_count: int
vendor_overrides_count: int = 0
published_pages_count: int = 0
draft_pages_count: int = 0
class PlatformService:
"""Service for platform operations."""
@staticmethod
def get_platform_by_code(db: Session, code: str) -> Platform:
"""
Get platform by code.
Args:
db: Database session
code: Platform code (oms, loyalty, main, etc.)
Returns:
Platform object
Raises:
PlatformNotFoundException: If platform not found
"""
platform = db.query(Platform).filter(Platform.code == code).first()
if not platform:
raise PlatformNotFoundException(code)
return platform
@staticmethod
def get_platform_by_code_optional(db: Session, code: str) -> Platform | None:
"""
Get platform by code, returns None if not found.
Args:
db: Database session
code: Platform code
Returns:
Platform object or None
"""
return db.query(Platform).filter(Platform.code == code).first()
@staticmethod
def get_platform_by_id(db: Session, platform_id: int) -> Platform:
"""
Get platform by ID.
Args:
db: Database session
platform_id: Platform ID
Returns:
Platform object
Raises:
PlatformNotFoundException: If platform not found
"""
platform = db.query(Platform).filter(Platform.id == platform_id).first()
if not platform:
raise PlatformNotFoundException(str(platform_id))
return platform
@staticmethod
def list_platforms(
db: Session, include_inactive: bool = False
) -> list[Platform]:
"""
List all platforms.
Args:
db: Database session
include_inactive: Include inactive platforms
Returns:
List of Platform objects
"""
query = db.query(Platform)
if not include_inactive:
query = query.filter(Platform.is_active == True)
return query.order_by(Platform.id).all()
@staticmethod
def get_vendor_count(db: Session, platform_id: int) -> int:
"""
Get count of vendors on a platform.
Args:
db: Database session
platform_id: Platform ID
Returns:
Vendor count
"""
return (
db.query(func.count(VendorPlatform.vendor_id))
.filter(VendorPlatform.platform_id == platform_id)
.scalar()
or 0
)
@staticmethod
def get_platform_pages_count(db: Session, platform_id: int) -> int:
"""
Get count of platform marketing pages.
Args:
db: Database session
platform_id: Platform ID
Returns:
Platform pages count
"""
return (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform_id,
ContentPage.vendor_id == None,
ContentPage.is_platform_page == True,
)
.scalar()
or 0
)
@staticmethod
def get_vendor_defaults_count(db: Session, platform_id: int) -> int:
"""
Get count of vendor default pages.
Args:
db: Database session
platform_id: Platform ID
Returns:
Vendor defaults count
"""
return (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform_id,
ContentPage.vendor_id == None,
ContentPage.is_platform_page == False,
)
.scalar()
or 0
)
@staticmethod
def get_vendor_overrides_count(db: Session, platform_id: int) -> int:
"""
Get count of vendor override pages.
Args:
db: Database session
platform_id: Platform ID
Returns:
Vendor overrides count
"""
return (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform_id,
ContentPage.vendor_id != None,
)
.scalar()
or 0
)
@staticmethod
def get_published_pages_count(db: Session, platform_id: int) -> int:
"""
Get count of published pages on a platform.
Args:
db: Database session
platform_id: Platform ID
Returns:
Published pages count
"""
return (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform_id,
ContentPage.is_published == True,
)
.scalar()
or 0
)
@staticmethod
def get_draft_pages_count(db: Session, platform_id: int) -> int:
"""
Get count of draft pages on a platform.
Args:
db: Database session
platform_id: Platform ID
Returns:
Draft pages count
"""
return (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform_id,
ContentPage.is_published == False,
)
.scalar()
or 0
)
@classmethod
def get_platform_stats(cls, db: Session, platform: Platform) -> PlatformStats:
"""
Get comprehensive statistics for a platform.
Args:
db: Database session
platform: Platform object
Returns:
PlatformStats dataclass
"""
return PlatformStats(
platform_id=platform.id,
platform_code=platform.code,
platform_name=platform.name,
vendor_count=cls.get_vendor_count(db, platform.id),
platform_pages_count=cls.get_platform_pages_count(db, platform.id),
vendor_defaults_count=cls.get_vendor_defaults_count(db, platform.id),
vendor_overrides_count=cls.get_vendor_overrides_count(db, platform.id),
published_pages_count=cls.get_published_pages_count(db, platform.id),
draft_pages_count=cls.get_draft_pages_count(db, platform.id),
)
@staticmethod
def update_platform(
db: Session, platform: Platform, update_data: dict
) -> Platform:
"""
Update platform fields.
Note: This method does NOT commit the transaction.
The caller (API endpoint) is responsible for committing.
Args:
db: Database session
platform: Platform to update
update_data: Dictionary of fields to update
Returns:
Updated Platform object (with pending changes)
"""
for field, value in update_data.items():
if hasattr(platform, field):
setattr(platform, field, value)
logger.info(f"[PLATFORMS] Updated platform: {platform.code}")
return platform
# Singleton instance for convenience
platform_service = PlatformService()

View File

@@ -0,0 +1,217 @@
# app/modules/tenancy/services/team_service.py
"""
Team service for vendor team management.
This module provides:
- Team member invitation
- Role management
- Team member CRUD operations
"""
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.exceptions import ValidationException
from models.database.user import User
from models.database.vendor import Role, VendorUser
logger = logging.getLogger(__name__)
class TeamService:
"""Service for team management operations."""
def get_team_members(
self, db: Session, vendor_id: int, current_user: User
) -> list[dict[str, Any]]:
"""
Get all team members for vendor.
Args:
db: Database session
vendor_id: Vendor ID
current_user: Current user
Returns:
List of team members
"""
try:
vendor_users = (
db.query(VendorUser)
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True)
.all()
)
members = []
for vu in vendor_users:
members.append(
{
"id": vu.user_id,
"email": vu.user.email,
"first_name": vu.user.first_name,
"last_name": vu.user.last_name,
"role": vu.role.name,
"role_id": vu.role_id,
"is_active": vu.is_active,
"joined_at": vu.created_at,
}
)
return members
except Exception as e:
logger.error(f"Error getting team members: {str(e)}")
raise ValidationException("Failed to retrieve team members")
def invite_team_member(
self, db: Session, vendor_id: int, invitation_data: dict, current_user: User
) -> dict[str, Any]:
"""
Invite a new team member.
Args:
db: Database session
vendor_id: Vendor ID
invitation_data: Invitation details
current_user: Current user
Returns:
Invitation result
"""
try:
# TODO: Implement full invitation flow with email
# For now, return placeholder
return {
"message": "Team invitation feature coming soon",
"email": invitation_data.get("email"),
"role": invitation_data.get("role"),
}
except Exception as e:
logger.error(f"Error inviting team member: {str(e)}")
raise ValidationException("Failed to invite team member")
def update_team_member(
self,
db: Session,
vendor_id: int,
user_id: int,
update_data: dict,
current_user: User,
) -> dict[str, Any]:
"""
Update team member role or status.
Args:
db: Database session
vendor_id: Vendor ID
user_id: User ID to update
update_data: Update data
current_user: Current user
Returns:
Updated member info
"""
try:
vendor_user = (
db.query(VendorUser)
.filter(
VendorUser.vendor_id == vendor_id, VendorUser.user_id == user_id
)
.first()
)
if not vendor_user:
raise ValidationException("Team member not found")
# Update fields
if "role_id" in update_data:
vendor_user.role_id = update_data["role_id"]
if "is_active" in update_data:
vendor_user.is_active = update_data["is_active"]
vendor_user.updated_at = datetime.now(UTC)
db.flush()
db.refresh(vendor_user)
return {
"message": "Team member updated successfully",
"user_id": user_id,
}
except Exception as e:
logger.error(f"Error updating team member: {str(e)}")
raise ValidationException("Failed to update team member")
def remove_team_member(
self, db: Session, vendor_id: int, user_id: int, current_user: User
) -> bool:
"""
Remove team member from vendor.
Args:
db: Database session
vendor_id: Vendor ID
user_id: User ID to remove
current_user: Current user
Returns:
True if removed
"""
try:
vendor_user = (
db.query(VendorUser)
.filter(
VendorUser.vendor_id == vendor_id, VendorUser.user_id == user_id
)
.first()
)
if not vendor_user:
raise ValidationException("Team member not found")
# Soft delete
vendor_user.is_active = False
vendor_user.updated_at = datetime.now(UTC)
logger.info(f"Removed user {user_id} from vendor {vendor_id}")
return True
except Exception as e:
logger.error(f"Error removing team member: {str(e)}")
raise ValidationException("Failed to remove team member")
def get_vendor_roles(self, db: Session, vendor_id: int) -> list[dict[str, Any]]:
"""
Get available roles for vendor.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
List of roles
"""
try:
roles = db.query(Role).filter(Role.vendor_id == vendor_id).all()
return [
{
"id": role.id,
"name": role.name,
"permissions": role.permissions,
}
for role in roles
]
except Exception as e:
logger.error(f"Error getting vendor roles: {str(e)}")
raise ValidationException("Failed to retrieve roles")
# Create service instance
team_service = TeamService()

View File

@@ -0,0 +1,427 @@
# app/modules/tenancy/services/vendor_domain_service.py
"""
Vendor domain service for managing custom domain operations.
This module provides classes and functions for:
- Adding and removing custom domains
- Domain verification via DNS
- Domain activation and deactivation
- Setting primary domains
- Domain validation and normalization
"""
import logging
import secrets
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.exceptions import ValidationException
from app.modules.tenancy.exceptions import (
DNSVerificationException,
DomainAlreadyVerifiedException,
DomainNotVerifiedException,
DomainVerificationFailedException,
InvalidDomainFormatException,
MaxDomainsReachedException,
ReservedDomainException,
VendorDomainAlreadyExistsException,
VendorDomainNotFoundException,
VendorNotFoundException,
)
from models.database.vendor import Vendor
from models.database.vendor_domain import VendorDomain
from models.schema.vendor_domain import VendorDomainCreate, VendorDomainUpdate
logger = logging.getLogger(__name__)
class VendorDomainService:
"""Service class for vendor domain operations."""
def __init__(self):
self.max_domains_per_vendor = 10 # Configure as needed
self.reserved_subdomains = [
"www",
"admin",
"api",
"mail",
"smtp",
"ftp",
"cpanel",
"webmail",
]
def add_domain(
self, db: Session, vendor_id: int, domain_data: VendorDomainCreate
) -> VendorDomain:
"""
Add a custom domain to vendor.
Args:
db: Database session
vendor_id: Vendor ID to add domain to
domain_data: Domain creation data
Returns:
Created VendorDomain object
Raises:
VendorNotFoundException: If vendor not found
VendorDomainAlreadyExistsException: If domain already registered
MaxDomainsReachedException: If vendor has reached max domains
InvalidDomainFormatException: If domain format is invalid
"""
try:
# Verify vendor exists
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
# Check domain limit
self._check_domain_limit(db, vendor_id)
# Normalize domain
normalized_domain = VendorDomain.normalize_domain(domain_data.domain)
# Validate domain format
self._validate_domain_format(normalized_domain)
# Check if domain already exists
if self._domain_exists(db, normalized_domain):
existing_domain = (
db.query(VendorDomain)
.filter(VendorDomain.domain == normalized_domain)
.first()
)
raise VendorDomainAlreadyExistsException(
normalized_domain,
existing_domain.vendor_id if existing_domain else None,
)
# If setting as primary, unset other primary domains
if domain_data.is_primary:
self._unset_primary_domains(db, vendor_id)
# Create domain record
new_domain = VendorDomain(
vendor_id=vendor_id,
domain=normalized_domain,
is_primary=domain_data.is_primary,
verification_token=secrets.token_urlsafe(32),
is_verified=False, # Requires DNS verification
is_active=False, # Cannot be active until verified
ssl_status="pending",
)
db.add(new_domain)
db.flush()
db.refresh(new_domain)
logger.info(f"Domain {normalized_domain} added to vendor {vendor_id}")
return new_domain
except (
VendorNotFoundException,
VendorDomainAlreadyExistsException,
MaxDomainsReachedException,
InvalidDomainFormatException,
ReservedDomainException,
):
raise
except Exception as e:
logger.error(f"Error adding domain: {str(e)}")
raise ValidationException("Failed to add domain")
def get_vendor_domains(self, db: Session, vendor_id: int) -> list[VendorDomain]:
"""
Get all domains for a vendor.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
List of VendorDomain objects
Raises:
VendorNotFoundException: If vendor not found
"""
try:
# Verify vendor exists
self._get_vendor_by_id_or_raise(db, vendor_id)
domains = (
db.query(VendorDomain)
.filter(VendorDomain.vendor_id == vendor_id)
.order_by(
VendorDomain.is_primary.desc(), VendorDomain.created_at.desc()
)
.all()
)
return domains
except VendorNotFoundException:
raise
except Exception as e:
logger.error(f"Error getting vendor domains: {str(e)}")
raise ValidationException("Failed to retrieve domains")
def get_domain_by_id(self, db: Session, domain_id: int) -> VendorDomain:
"""
Get domain by ID.
Args:
db: Database session
domain_id: Domain ID
Returns:
VendorDomain object
Raises:
VendorDomainNotFoundException: If domain not found
"""
domain = db.query(VendorDomain).filter(VendorDomain.id == domain_id).first()
if not domain:
raise VendorDomainNotFoundException(str(domain_id))
return domain
def update_domain(
self, db: Session, domain_id: int, domain_update: VendorDomainUpdate
) -> VendorDomain:
"""
Update domain settings.
Args:
db: Database session
domain_id: Domain ID
domain_update: Update data
Returns:
Updated VendorDomain object
Raises:
VendorDomainNotFoundException: If domain not found
DomainNotVerifiedException: If trying to activate unverified domain
"""
try:
domain = self.get_domain_by_id(db, domain_id)
# If setting as primary, unset other primary domains
if domain_update.is_primary:
self._unset_primary_domains(
db, domain.vendor_id, exclude_domain_id=domain_id
)
domain.is_primary = True
# If activating, check verification
if domain_update.is_active is True and not domain.is_verified:
raise DomainNotVerifiedException(domain_id, domain.domain)
# Update fields
if domain_update.is_active is not None:
domain.is_active = domain_update.is_active
db.flush()
db.refresh(domain)
logger.info(f"Domain {domain.domain} updated")
return domain
except (VendorDomainNotFoundException, DomainNotVerifiedException):
raise
except Exception as e:
logger.error(f"Error updating domain: {str(e)}")
raise ValidationException("Failed to update domain")
def delete_domain(self, db: Session, domain_id: int) -> str:
"""
Delete a custom domain.
Args:
db: Database session
domain_id: Domain ID
Returns:
Success message
Raises:
VendorDomainNotFoundException: If domain not found
"""
try:
domain = self.get_domain_by_id(db, domain_id)
domain_name = domain.domain
vendor_id = domain.vendor_id
db.delete(domain)
logger.info(f"Domain {domain_name} deleted from vendor {vendor_id}")
return f"Domain {domain_name} deleted successfully"
except VendorDomainNotFoundException:
raise
except Exception as e:
logger.error(f"Error deleting domain: {str(e)}")
raise ValidationException("Failed to delete domain")
def verify_domain(self, db: Session, domain_id: int) -> tuple[VendorDomain, str]:
"""
Verify domain ownership via DNS TXT record.
The vendor must add a TXT record:
Name: _wizamart-verify.{domain}
Value: {verification_token}
Args:
db: Database session
domain_id: Domain ID
Returns:
Tuple of (verified_domain, message)
Raises:
VendorDomainNotFoundException: If domain not found
DomainAlreadyVerifiedException: If already verified
DomainVerificationFailedException: If verification fails
"""
try:
import dns.resolver
domain = self.get_domain_by_id(db, domain_id)
# Check if already verified
if domain.is_verified:
raise DomainAlreadyVerifiedException(domain_id, domain.domain)
# Query DNS TXT records
try:
txt_records = dns.resolver.resolve(
f"_wizamart-verify.{domain.domain}", "TXT"
)
# Check if verification token is present
for txt in txt_records:
txt_value = txt.to_text().strip('"')
if txt_value == domain.verification_token:
# Verification successful
domain.is_verified = True
domain.verified_at = datetime.now(UTC)
db.flush()
db.refresh(domain)
logger.info(f"Domain {domain.domain} verified successfully")
return domain, f"Domain {domain.domain} verified successfully"
# Token not found
raise DomainVerificationFailedException(
domain.domain, "Verification token not found in DNS records"
)
except dns.resolver.NXDOMAIN:
raise DomainVerificationFailedException(
domain.domain,
f"DNS record _wizamart-verify.{domain.domain} not found",
)
except dns.resolver.NoAnswer:
raise DomainVerificationFailedException(
domain.domain, "No TXT records found for verification"
)
except Exception as dns_error:
raise DNSVerificationException(domain.domain, str(dns_error))
except (
VendorDomainNotFoundException,
DomainAlreadyVerifiedException,
DomainVerificationFailedException,
DNSVerificationException,
):
raise
except Exception as e:
logger.error(f"Error verifying domain: {str(e)}")
raise ValidationException("Failed to verify domain")
def get_verification_instructions(self, db: Session, domain_id: int) -> dict:
"""
Get DNS verification instructions for domain.
Args:
db: Database session
domain_id: Domain ID
Returns:
Dict with verification instructions
Raises:
VendorDomainNotFoundException: If domain not found
"""
domain = self.get_domain_by_id(db, domain_id)
return {
"domain": domain.domain,
"verification_token": domain.verification_token,
"instructions": {
"step1": "Go to your domain's DNS settings (at your domain registrar)",
"step2": "Add a new TXT record with the following values:",
"step3": "Wait for DNS propagation (5-15 minutes)",
"step4": "Click 'Verify Domain' button in admin panel",
},
"txt_record": {
"type": "TXT",
"name": "_wizamart-verify",
"value": domain.verification_token,
"ttl": 3600,
},
"common_registrars": {
"Cloudflare": "https://dash.cloudflare.com",
"GoDaddy": "https://dcc.godaddy.com/manage/dns",
"Namecheap": "https://www.namecheap.com/myaccount/domain-list/",
"Google Domains": "https://domains.google.com",
},
}
# Private helper methods
def _get_vendor_by_id_or_raise(self, db: Session, vendor_id: int) -> Vendor:
"""Get vendor by ID or raise exception."""
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
return vendor
def _check_domain_limit(self, db: Session, vendor_id: int) -> None:
"""Check if vendor has reached maximum domain limit."""
domain_count = (
db.query(VendorDomain).filter(VendorDomain.vendor_id == vendor_id).count()
)
if domain_count >= self.max_domains_per_vendor:
raise MaxDomainsReachedException(vendor_id, self.max_domains_per_vendor)
def _domain_exists(self, db: Session, domain: str) -> bool:
"""Check if domain already exists in system."""
return (
db.query(VendorDomain).filter(VendorDomain.domain == domain).first()
is not None
)
def _validate_domain_format(self, domain: str) -> None:
"""Validate domain format and check for reserved subdomains."""
# Check for reserved subdomains
first_part = domain.split(".")[0]
if first_part in self.reserved_subdomains:
raise ReservedDomainException(domain, first_part)
def _unset_primary_domains(
self, db: Session, vendor_id: int, exclude_domain_id: int | None = None
) -> None:
"""Unset all primary domains for vendor."""
query = db.query(VendorDomain).filter(
VendorDomain.vendor_id == vendor_id, VendorDomain.is_primary == True
)
if exclude_domain_id:
query = query.filter(VendorDomain.id != exclude_domain_id)
query.update({"is_primary": False})
# Create service instance
vendor_domain_service = VendorDomainService()

View File

@@ -0,0 +1,708 @@
# app/modules/tenancy/services/vendor_service.py
"""
Vendor service for managing vendor operations and product catalog.
This module provides classes and functions for:
- Vendor creation and management
- Vendor access control and validation
- Vendor product catalog operations
- Vendor filtering and search
"""
import logging
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions import ValidationException
from app.modules.catalog.exceptions import ProductAlreadyExistsException
from app.modules.marketplace.exceptions import MarketplaceProductNotFoundException
from app.modules.tenancy.exceptions import (
InvalidVendorDataException,
UnauthorizedVendorAccessException,
VendorAlreadyExistsException,
VendorNotFoundException,
)
from app.modules.marketplace.models import MarketplaceProduct
from app.modules.catalog.models import Product
from models.database.user import User
from models.database.vendor import Vendor
from app.modules.catalog.schemas import ProductCreate
from models.schema.vendor import VendorCreate
logger = logging.getLogger(__name__)
class VendorService:
"""Service class for vendor operations following the application's service pattern."""
def create_vendor(
self, db: Session, vendor_data: VendorCreate, current_user: User
) -> Vendor:
"""
Create a new vendor under a company.
DEPRECATED: This method is for self-service vendor creation by company owners.
For admin operations, use admin_service.create_vendor() instead.
The new architecture:
- Companies are the business entities with owners and contact info
- Vendors are storefronts/brands under companies
- The company_id is required in vendor_data
Args:
db: Database session
vendor_data: Vendor creation data (must include company_id)
current_user: User creating the vendor (must be company owner or admin)
Returns:
Created vendor object
Raises:
VendorAlreadyExistsException: If vendor code already exists
UnauthorizedVendorAccessException: If user is not company owner
InvalidVendorDataException: If vendor data is invalid
"""
from models.database.company import Company
try:
# Validate company_id is provided
if not hasattr(vendor_data, "company_id") or not vendor_data.company_id:
raise InvalidVendorDataException(
"company_id is required to create a vendor", field="company_id"
)
# Get company and verify ownership
company = (
db.query(Company).filter(Company.id == vendor_data.company_id).first()
)
if not company:
raise InvalidVendorDataException(
f"Company with ID {vendor_data.company_id} not found",
field="company_id",
)
# Check if user is company owner or admin
if (
current_user.role != "admin"
and company.owner_user_id != current_user.id
):
raise UnauthorizedVendorAccessException(
f"company-{vendor_data.company_id}", current_user.id
)
# Normalize vendor code to uppercase
normalized_vendor_code = vendor_data.vendor_code.upper()
# Check if vendor code already exists (case-insensitive check)
if self._vendor_code_exists(db, normalized_vendor_code):
raise VendorAlreadyExistsException(normalized_vendor_code)
# Create vendor linked to company
new_vendor = Vendor(
company_id=company.id,
vendor_code=normalized_vendor_code,
subdomain=vendor_data.subdomain.lower(),
name=vendor_data.name,
description=vendor_data.description,
letzshop_csv_url_fr=vendor_data.letzshop_csv_url_fr,
letzshop_csv_url_en=vendor_data.letzshop_csv_url_en,
letzshop_csv_url_de=vendor_data.letzshop_csv_url_de,
is_active=True,
is_verified=(current_user.role == "admin"),
)
db.add(new_vendor)
db.flush() # Get ID without committing - endpoint handles commit
logger.info(
f"New vendor created: {new_vendor.vendor_code} under company {company.name} by {current_user.username}"
)
return new_vendor
except (
VendorAlreadyExistsException,
UnauthorizedVendorAccessException,
InvalidVendorDataException,
):
raise # Re-raise custom exceptions - endpoint handles rollback
except Exception as e:
logger.error(f"Error creating vendor: {str(e)}")
raise ValidationException("Failed to create vendor")
def get_vendors(
self,
db: Session,
current_user: User,
skip: int = 0,
limit: int = 100,
active_only: bool = True,
verified_only: bool = False,
) -> tuple[list[Vendor], int]:
"""
Get vendors with filtering.
Args:
db: Database session
current_user: Current user requesting vendors
skip: Number of records to skip
limit: Maximum number of records to return
active_only: Filter for active vendors only
verified_only: Filter for verified vendors only
Returns:
Tuple of (vendors_list, total_count)
"""
try:
query = db.query(Vendor)
# Non-admin users can only see active and verified vendors, plus their own
if current_user.role != "admin":
# Get vendor IDs the user owns through companies
from models.database.company import Company
owned_vendor_ids = (
db.query(Vendor.id)
.join(Company)
.filter(Company.owner_user_id == current_user.id)
.subquery()
)
query = query.filter(
(Vendor.is_active == True)
& ((Vendor.is_verified == True) | (Vendor.id.in_(owned_vendor_ids)))
)
else:
# Admin can apply filters
if active_only:
query = query.filter(Vendor.is_active == True)
if verified_only:
query = query.filter(Vendor.is_verified == True)
total = query.count()
vendors = query.offset(skip).limit(limit).all()
return vendors, total
except Exception as e:
logger.error(f"Error getting vendors: {str(e)}")
raise ValidationException("Failed to retrieve vendors")
def get_vendor_by_code(
self, db: Session, vendor_code: str, current_user: User
) -> Vendor:
"""
Get vendor by vendor code with access control.
Args:
db: Database session
vendor_code: Vendor code to find
current_user: Current user requesting the vendor
Returns:
Vendor object
Raises:
VendorNotFoundException: If vendor not found
UnauthorizedVendorAccessException: If access denied
"""
try:
vendor = (
db.query(Vendor)
.filter(func.upper(Vendor.vendor_code) == vendor_code.upper())
.first()
)
if not vendor:
raise VendorNotFoundException(vendor_code)
# Check access permissions
if not self._can_access_vendor(vendor, current_user):
raise UnauthorizedVendorAccessException(vendor_code, current_user.id)
return vendor
except (VendorNotFoundException, UnauthorizedVendorAccessException):
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting vendor {vendor_code}: {str(e)}")
raise ValidationException("Failed to retrieve vendor")
def get_vendor_by_id(self, db: Session, vendor_id: int) -> Vendor:
"""
Get vendor by ID (admin use - no access control).
Args:
db: Database session
vendor_id: Vendor ID to find
Returns:
Vendor object with company and owner loaded
Raises:
VendorNotFoundException: If vendor not found
"""
from sqlalchemy.orm import joinedload
from models.database.company import Company
vendor = (
db.query(Vendor)
.options(joinedload(Vendor.company).joinedload(Company.owner))
.filter(Vendor.id == vendor_id)
.first()
)
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
return vendor
def get_vendor_by_id_optional(self, db: Session, vendor_id: int) -> Vendor | None:
"""
Get vendor by ID, returns None if not found.
Args:
db: Database session
vendor_id: Vendor ID to find
Returns:
Vendor object or None if not found
"""
return db.query(Vendor).filter(Vendor.id == vendor_id).first()
def get_active_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor:
"""
Get active vendor by vendor_code for public access (no auth required).
This method is specifically designed for public endpoints where:
- No authentication is required
- Only active vendors should be returned
- Inactive/disabled vendors are hidden
Args:
db: Database session
vendor_code: Vendor code (case-insensitive)
Returns:
Vendor object with company and owner loaded
Raises:
VendorNotFoundException: If vendor not found or inactive
"""
from sqlalchemy.orm import joinedload
from models.database.company import Company
vendor = (
db.query(Vendor)
.options(joinedload(Vendor.company).joinedload(Company.owner))
.filter(
func.upper(Vendor.vendor_code) == vendor_code.upper(),
Vendor.is_active == True,
)
.first()
)
if not vendor:
logger.warning(f"Vendor not found or inactive: {vendor_code}")
raise VendorNotFoundException(vendor_code, identifier_type="code")
return vendor
def get_vendor_by_identifier(self, db: Session, identifier: str) -> Vendor:
"""
Get vendor by ID or vendor_code (admin use - no access control).
Args:
db: Database session
identifier: Either vendor ID (int as string) or vendor_code (string)
Returns:
Vendor object with company and owner loaded
Raises:
VendorNotFoundException: If vendor not found
"""
from sqlalchemy.orm import joinedload
from models.database.company import Company
# Try as integer ID first
try:
vendor_id = int(identifier)
return self.get_vendor_by_id(db, vendor_id)
except (ValueError, TypeError):
pass # Not an integer, treat as vendor_code
except VendorNotFoundException:
pass # ID not found, try as vendor_code
# Try as vendor_code (case-insensitive)
vendor = (
db.query(Vendor)
.options(joinedload(Vendor.company).joinedload(Company.owner))
.filter(func.upper(Vendor.vendor_code) == identifier.upper())
.first()
)
if not vendor:
raise VendorNotFoundException(identifier, identifier_type="code")
return vendor
def toggle_verification(self, db: Session, vendor_id: int) -> tuple[Vendor, str]:
"""
Toggle vendor verification status.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
Tuple of (updated vendor, status message)
Raises:
VendorNotFoundException: If vendor not found
"""
vendor = self.get_vendor_by_id(db, vendor_id)
vendor.is_verified = not vendor.is_verified
# No commit here - endpoint handles transaction
status = "verified" if vendor.is_verified else "unverified"
logger.info(f"Vendor {vendor.vendor_code} {status}")
return vendor, f"Vendor {vendor.vendor_code} is now {status}"
def set_verification(
self, db: Session, vendor_id: int, is_verified: bool
) -> tuple[Vendor, str]:
"""
Set vendor verification status to specific value.
Args:
db: Database session
vendor_id: Vendor ID
is_verified: Target verification status
Returns:
Tuple of (updated vendor, status message)
Raises:
VendorNotFoundException: If vendor not found
"""
vendor = self.get_vendor_by_id(db, vendor_id)
vendor.is_verified = is_verified
# No commit here - endpoint handles transaction
status = "verified" if is_verified else "unverified"
logger.info(f"Vendor {vendor.vendor_code} set to {status}")
return vendor, f"Vendor {vendor.vendor_code} is now {status}"
def toggle_status(self, db: Session, vendor_id: int) -> tuple[Vendor, str]:
"""
Toggle vendor active status.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
Tuple of (updated vendor, status message)
Raises:
VendorNotFoundException: If vendor not found
"""
vendor = self.get_vendor_by_id(db, vendor_id)
vendor.is_active = not vendor.is_active
# No commit here - endpoint handles transaction
status = "active" if vendor.is_active else "inactive"
logger.info(f"Vendor {vendor.vendor_code} {status}")
return vendor, f"Vendor {vendor.vendor_code} is now {status}"
def set_status(
self, db: Session, vendor_id: int, is_active: bool
) -> tuple[Vendor, str]:
"""
Set vendor active status to specific value.
Args:
db: Database session
vendor_id: Vendor ID
is_active: Target active status
Returns:
Tuple of (updated vendor, status message)
Raises:
VendorNotFoundException: If vendor not found
"""
vendor = self.get_vendor_by_id(db, vendor_id)
vendor.is_active = is_active
# No commit here - endpoint handles transaction
status = "active" if is_active else "inactive"
logger.info(f"Vendor {vendor.vendor_code} set to {status}")
return vendor, f"Vendor {vendor.vendor_code} is now {status}"
def add_product_to_catalog(
self, db: Session, vendor: Vendor, product: ProductCreate
) -> Product:
"""
Add existing product to vendor catalog with vendor -specific settings.
Args:
db: Database session
vendor : Vendor to add product to
product: Vendor product data
Returns:
Created Product object
Raises:
MarketplaceProductNotFoundException: If product not found
ProductAlreadyExistsException: If product already in vendor
"""
try:
# Check if product exists
marketplace_product = self._get_product_by_id_or_raise(
db, product.marketplace_product_id
)
# Check if product already in vendor
if self._product_in_catalog(db, vendor.id, marketplace_product.id):
raise ProductAlreadyExistsException(
vendor.vendor_code, product.marketplace_product_id
)
# Create vendor-product association
new_product = Product(
vendor_id=vendor.id,
marketplace_product_id=marketplace_product.id,
**product.model_dump(exclude={"marketplace_product_id"}),
)
db.add(new_product)
db.flush() # Get ID without committing - endpoint handles commit
logger.info(
f"MarketplaceProduct {product.marketplace_product_id} added to vendor {vendor.vendor_code}"
)
return new_product
except (MarketplaceProductNotFoundException, ProductAlreadyExistsException):
raise # Re-raise custom exceptions - endpoint handles rollback
except Exception as e:
logger.error(f"Error adding product to vendor : {str(e)}")
raise ValidationException("Failed to add product to vendor ")
def get_products(
self,
db: Session,
vendor: Vendor,
current_user: User,
skip: int = 0,
limit: int = 100,
active_only: bool = True,
featured_only: bool = False,
) -> tuple[list[Product], int]:
"""
Get products in vendor catalog with filtering.
Args:
db: Database session
vendor : Vendor to get products from
current_user: Current user requesting products
skip: Number of records to skip
limit: Maximum number of records to return
active_only: Filter for active products only
featured_only: Filter for featured products only
Returns:
Tuple of (products_list, total_count)
Raises:
UnauthorizedVendorAccessException: If vendor access denied
"""
try:
# Check access permissions
if not self._can_access_vendor(vendor, current_user):
raise UnauthorizedVendorAccessException(
vendor.vendor_code, current_user.id
)
# Query vendor products
query = db.query(Product).filter(Product.vendor_id == vendor.id)
if active_only:
query = query.filter(Product.is_active == True)
if featured_only:
query = query.filter(Product.is_featured == True)
total = query.count()
products = query.offset(skip).limit(limit).all()
return products, total
except UnauthorizedVendorAccessException:
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting vendor products: {str(e)}")
raise ValidationException("Failed to retrieve vendor products")
# Private helper methods
def _vendor_code_exists(self, db: Session, vendor_code: str) -> bool:
"""Check if vendor code already exists (case-insensitive)."""
return (
db.query(Vendor)
.filter(func.upper(Vendor.vendor_code) == vendor_code.upper())
.first()
is not None
)
def _get_product_by_id_or_raise(
self, db: Session, marketplace_product_id: int
) -> MarketplaceProduct:
"""Get marketplace product by database ID or raise exception."""
product = (
db.query(MarketplaceProduct)
.filter(MarketplaceProduct.id == marketplace_product_id)
.first()
)
if not product:
raise MarketplaceProductNotFoundException(str(marketplace_product_id))
return product
def _product_in_catalog(
self, db: Session, vendor_id: int, marketplace_product_id: int
) -> bool:
"""Check if product is already in vendor."""
return (
db.query(Product)
.filter(
Product.vendor_id == vendor_id,
Product.marketplace_product_id == marketplace_product_id,
)
.first()
is not None
)
def _can_access_vendor(self, vendor: Vendor, user: User) -> bool:
"""Check if user can access vendor."""
# Admins can always access
if user.role == "admin":
return True
# Company owners can access their vendors
if vendor.company and vendor.company.owner_user_id == user.id:
return True
# Others can only access active and verified vendors
return vendor.is_active and vendor.is_verified
def _is_vendor_owner(self, vendor: Vendor, user: User) -> bool:
"""Check if user is vendor owner (via company ownership)."""
return vendor.company and vendor.company.owner_user_id == user.id
def can_update_vendor(self, vendor: Vendor, user: User) -> bool:
"""
Check if user has permission to update vendor settings.
Permission granted to:
- Admins (always)
- Vendor owners (company owner)
- Team members with appropriate role (owner role in VendorUser)
"""
# Admins can always update
if user.role == "admin":
return True
# Check if user is vendor owner via company
if self._is_vendor_owner(vendor, user):
return True
# Check if user is owner via VendorUser relationship
if user.is_owner_of(vendor.id):
return True
return False
def update_vendor(
self,
db: Session,
vendor_id: int,
vendor_update,
current_user: User,
) -> "Vendor":
"""
Update vendor profile with permission checking.
Raises:
VendorNotFoundException: If vendor not found
InsufficientPermissionsException: If user lacks permission
"""
from app.modules.tenancy.exceptions import InsufficientPermissionsException
vendor = self.get_vendor_by_id(db, vendor_id)
# Check permissions in service layer
if not self.can_update_vendor(vendor, current_user):
raise InsufficientPermissionsException(
required_permission="vendor:profile:update"
)
# Apply updates
update_data = vendor_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
if hasattr(vendor, field):
setattr(vendor, field, value)
db.add(vendor)
db.flush()
db.refresh(vendor)
return vendor
def update_marketplace_settings(
self,
db: Session,
vendor_id: int,
marketplace_config: dict,
current_user: User,
) -> dict:
"""
Update marketplace integration settings with permission checking.
Raises:
VendorNotFoundException: If vendor not found
InsufficientPermissionsException: If user lacks permission
"""
from app.modules.tenancy.exceptions import InsufficientPermissionsException
vendor = self.get_vendor_by_id(db, vendor_id)
# Check permissions in service layer
if not self.can_update_vendor(vendor, current_user):
raise InsufficientPermissionsException(
required_permission="vendor:settings:update"
)
# Update Letzshop URLs
if "letzshop_csv_url_fr" in marketplace_config:
vendor.letzshop_csv_url_fr = marketplace_config["letzshop_csv_url_fr"]
if "letzshop_csv_url_en" in marketplace_config:
vendor.letzshop_csv_url_en = marketplace_config["letzshop_csv_url_en"]
if "letzshop_csv_url_de" in marketplace_config:
vendor.letzshop_csv_url_de = marketplace_config["letzshop_csv_url_de"]
db.add(vendor)
db.flush()
db.refresh(vendor)
return {
"message": "Marketplace settings updated successfully",
"letzshop_csv_url_fr": vendor.letzshop_csv_url_fr,
"letzshop_csv_url_en": vendor.letzshop_csv_url_en,
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
}
# Create service instance following the same pattern as other services
vendor_service = VendorService()

View File

@@ -0,0 +1,529 @@
# app/modules/tenancy/services/vendor_team_service.py
"""
Vendor team management service.
Handles:
- Team member invitations
- Invitation acceptance
- Role assignment
- Permission management
"""
import logging
import secrets
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy.orm import Session
from app.core.permissions import get_preset_permissions
from app.modules.tenancy.exceptions import (
CannotRemoveOwnerException,
InvalidInvitationTokenException,
TeamInvitationAlreadyAcceptedException,
TeamMemberAlreadyExistsException,
UserNotFoundException,
)
from app.modules.billing.exceptions import TierLimitExceededException
from middleware.auth import AuthManager
from models.database.user import User
from models.database.vendor import Role, Vendor, VendorUser, VendorUserType
logger = logging.getLogger(__name__)
class VendorTeamService:
"""Service for managing vendor team members."""
def __init__(self):
self.auth_manager = AuthManager()
def invite_team_member(
self,
db: Session,
vendor: Vendor,
inviter: User,
email: str,
role_name: str,
custom_permissions: list[str] | None = None,
) -> dict[str, Any]:
"""
Invite a new team member to a vendor.
Creates:
1. User account (if doesn't exist)
2. Role (if custom permissions provided)
3. VendorUser relationship with invitation token
Args:
db: Database session
vendor: Vendor to invite to
inviter: User sending the invitation
email: Email of person to invite
role_name: Role name (manager, staff, support, etc.)
custom_permissions: Optional custom permissions (overrides preset)
Returns:
Dict with invitation details
"""
try:
# Check team size limit from subscription
from app.modules.billing.services import subscription_service
subscription_service.check_team_limit(db, vendor.id)
# Check if user already exists
user = db.query(User).filter(User.email == email).first()
if user:
# Check if already a member
existing_membership = (
db.query(VendorUser)
.filter(
VendorUser.vendor_id == vendor.id,
VendorUser.user_id == user.id,
)
.first()
)
if existing_membership:
if existing_membership.is_active:
raise TeamMemberAlreadyExistsException(
email, vendor.vendor_code
)
# Reactivate old membership
existing_membership.is_active = (
False # Will be activated on acceptance
)
existing_membership.invitation_token = (
self._generate_invitation_token()
)
existing_membership.invitation_sent_at = datetime.utcnow()
existing_membership.invitation_accepted_at = None
db.flush()
logger.info(
f"Re-invited user {email} to vendor {vendor.vendor_code}"
)
return {
"invitation_token": existing_membership.invitation_token,
"email": email,
"existing_user": True,
}
else:
# Create new user account (inactive until invitation accepted)
username = email.split("@")[0]
# Ensure unique username
base_username = username
counter = 1
while db.query(User).filter(User.username == username).first():
username = f"{base_username}{counter}"
counter += 1
# Generate temporary password (user will set real one on activation)
temp_password = secrets.token_urlsafe(16)
user = User(
email=email,
username=username,
hashed_password=self.auth_manager.hash_password(temp_password),
role="vendor", # Platform role
is_active=False, # Will be activated when invitation accepted
is_email_verified=False,
)
db.add(user)
db.flush() # Get user.id
logger.info(f"Created new user account for invitation: {email}")
# Get or create role
role = self._get_or_create_role(
db=db,
vendor=vendor,
role_name=role_name,
custom_permissions=custom_permissions,
)
# Create vendor membership with invitation
invitation_token = self._generate_invitation_token()
vendor_user = VendorUser(
vendor_id=vendor.id,
user_id=user.id,
user_type=VendorUserType.TEAM_MEMBER.value,
role_id=role.id,
invited_by=inviter.id,
invitation_token=invitation_token,
invitation_sent_at=datetime.utcnow(),
is_active=False, # Will be activated on acceptance
)
db.add(vendor_user)
db.flush()
logger.info(
f"Invited {email} to vendor {vendor.vendor_code} "
f"as {role_name} by {inviter.username}"
)
# TODO: Send invitation email
# self._send_invitation_email(email, vendor, invitation_token)
return {
"invitation_token": invitation_token,
"email": email,
"role": role_name,
"existing_user": user.is_active,
}
except (TeamMemberAlreadyExistsException, TierLimitExceededException):
raise
except Exception as e:
logger.error(f"Error inviting team member: {str(e)}")
raise
def accept_invitation(
self,
db: Session,
invitation_token: str,
password: str,
first_name: str | None = None,
last_name: str | None = None,
) -> dict[str, Any]:
"""
Accept a team invitation and activate account.
Args:
db: Database session
invitation_token: Invitation token from email
password: New password to set
first_name: Optional first name
last_name: Optional last name
Returns:
Dict with user and vendor info
"""
try:
# Find invitation
vendor_user = (
db.query(VendorUser)
.filter(
VendorUser.invitation_token == invitation_token,
)
.first()
)
if not vendor_user:
raise InvalidInvitationTokenException()
# Check if already accepted
if vendor_user.invitation_accepted_at is not None:
raise TeamInvitationAlreadyAcceptedException()
# Check token expiration (7 days)
if vendor_user.invitation_sent_at:
expiry_date = vendor_user.invitation_sent_at + timedelta(days=7)
if datetime.utcnow() > expiry_date:
raise InvalidInvitationTokenException("Invitation has expired")
user = vendor_user.user
vendor = vendor_user.vendor
# Update user
user.hashed_password = self.auth_manager.hash_password(password)
user.is_active = True
user.is_email_verified = True
if first_name:
user.first_name = first_name
if last_name:
user.last_name = last_name
# Activate membership
vendor_user.is_active = True
vendor_user.invitation_accepted_at = datetime.utcnow()
vendor_user.invitation_token = None # Clear token
db.flush()
logger.info(
f"User {user.email} accepted invitation to vendor {vendor.vendor_code}"
)
return {
"user": user,
"vendor": vendor,
"role": vendor_user.role.name if vendor_user.role else "member",
}
except (
InvalidInvitationTokenException,
TeamInvitationAlreadyAcceptedException,
):
raise
except Exception as e:
logger.error(f"Error accepting invitation: {str(e)}")
raise
def remove_team_member(
self,
db: Session,
vendor: Vendor,
user_id: int,
) -> bool:
"""
Remove a team member from a vendor.
Cannot remove owner.
Args:
db: Database session
vendor: Vendor to remove from
user_id: User ID to remove
Returns:
True if removed
"""
try:
vendor_user = (
db.query(VendorUser)
.filter(
VendorUser.vendor_id == vendor.id,
VendorUser.user_id == user_id,
)
.first()
)
if not vendor_user:
raise UserNotFoundException(str(user_id))
# Cannot remove owner
if vendor_user.is_owner:
raise CannotRemoveOwnerException(vendor.vendor_code)
# Soft delete - just deactivate
vendor_user.is_active = False
logger.info(f"Removed user {user_id} from vendor {vendor.vendor_code}")
return True
except (UserNotFoundException, CannotRemoveOwnerException):
raise
except Exception as e:
logger.error(f"Error removing team member: {str(e)}")
raise
def update_member_role(
self,
db: Session,
vendor: Vendor,
user_id: int,
new_role_name: str,
custom_permissions: list[str] | None = None,
) -> VendorUser:
"""
Update a team member's role.
Args:
db: Database session
vendor: Vendor
user_id: User ID
new_role_name: New role name
custom_permissions: Optional custom permissions
Returns:
Updated VendorUser
"""
try:
vendor_user = (
db.query(VendorUser)
.filter(
VendorUser.vendor_id == vendor.id,
VendorUser.user_id == user_id,
)
.first()
)
if not vendor_user:
raise UserNotFoundException(str(user_id))
# Cannot change owner's role
if vendor_user.is_owner:
raise CannotRemoveOwnerException(vendor.vendor_code)
# Get or create new role
new_role = self._get_or_create_role(
db=db,
vendor=vendor,
role_name=new_role_name,
custom_permissions=custom_permissions,
)
vendor_user.role_id = new_role.id
db.flush()
logger.info(
f"Updated role for user {user_id} in vendor {vendor.vendor_code} "
f"to {new_role_name}"
)
return vendor_user
except (UserNotFoundException, CannotRemoveOwnerException):
raise
except Exception as e:
logger.error(f"Error updating member role: {str(e)}")
raise
def get_team_members(
self,
db: Session,
vendor: Vendor,
include_inactive: bool = False,
) -> list[dict[str, Any]]:
"""
Get all team members for a vendor.
Args:
db: Database session
vendor: Vendor
include_inactive: Include inactive members
Returns:
List of team member info
"""
query = db.query(VendorUser).filter(
VendorUser.vendor_id == vendor.id,
)
if not include_inactive:
query = query.filter(VendorUser.is_active == True)
vendor_users = query.all()
members = []
for vu in vendor_users:
members.append(
{
"id": vu.user.id,
"email": vu.user.email,
"username": vu.user.username,
"first_name": vu.user.first_name,
"last_name": vu.user.last_name,
"full_name": vu.user.full_name,
"user_type": vu.user_type,
"role_name": vu.role.name if vu.role else "owner",
"role_id": vu.role.id if vu.role else None,
"permissions": vu.get_all_permissions(),
"is_active": vu.is_active,
"is_owner": vu.is_owner,
"invitation_pending": vu.is_invitation_pending,
"invited_at": vu.invitation_sent_at,
"accepted_at": vu.invitation_accepted_at,
"joined_at": vu.invitation_accepted_at or vu.created_at or vu.user.created_at,
}
)
return members
def get_vendor_roles(self, db: Session, vendor_id: int) -> list[dict[str, Any]]:
"""
Get all roles for a vendor.
Creates default preset roles if none exist.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
List of role info dicts
"""
roles = db.query(Role).filter(Role.vendor_id == vendor_id).all()
# Create default roles if none exist
if not roles:
default_role_names = ["manager", "staff", "support", "viewer", "marketing"]
for role_name in default_role_names:
permissions = list(get_preset_permissions(role_name))
role = Role(
vendor_id=vendor_id,
name=role_name,
permissions=permissions,
)
db.add(role)
db.flush() # Flush to get IDs without committing (endpoint commits)
roles = db.query(Role).filter(Role.vendor_id == vendor_id).all()
return [
{
"id": role.id,
"name": role.name,
"permissions": role.permissions or [],
"vendor_id": role.vendor_id,
"created_at": role.created_at,
"updated_at": role.updated_at,
}
for role in roles
]
# Private helper methods
def _generate_invitation_token(self) -> str:
"""Generate a secure invitation token."""
return secrets.token_urlsafe(32)
def _get_or_create_role(
self,
db: Session,
vendor: Vendor,
role_name: str,
custom_permissions: list[str] | None = None,
) -> Role:
"""Get existing role or create new one with preset/custom permissions."""
# Try to find existing role with same name
role = (
db.query(Role)
.filter(
Role.vendor_id == vendor.id,
Role.name == role_name,
)
.first()
)
if role and custom_permissions is None:
# Use existing role
return role
# Determine permissions
if custom_permissions:
permissions = custom_permissions
else:
# Get preset permissions
permissions = list(get_preset_permissions(role_name))
if role:
# Update existing role with new permissions
role.permissions = permissions
else:
# Create new role
role = Role(
vendor_id=vendor.id,
name=role_name,
permissions=permissions,
)
db.add(role)
db.flush()
return role
def _send_invitation_email(self, email: str, vendor: Vendor, token: str):
"""Send invitation email (TODO: implement)."""
# TODO: Implement email sending
# Should include:
# - Link to accept invitation: /vendor/invitation/accept?token={token}
# - Vendor name
# - Inviter name
# - Expiry date
# Create service instance
vendor_team_service = VendorTeamService()