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:
@@ -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",
|
||||
]
|
||||
|
||||
663
app/modules/tenancy/services/admin_platform_service.py
Normal file
663
app/modules/tenancy/services/admin_platform_service.py
Normal 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()
|
||||
895
app/modules/tenancy/services/admin_service.py
Normal file
895
app/modules/tenancy/services/admin_service.py
Normal 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()
|
||||
330
app/modules/tenancy/services/company_service.py
Normal file
330
app/modules/tenancy/services/company_service.py
Normal 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()
|
||||
310
app/modules/tenancy/services/platform_service.py
Normal file
310
app/modules/tenancy/services/platform_service.py
Normal 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()
|
||||
217
app/modules/tenancy/services/team_service.py
Normal file
217
app/modules/tenancy/services/team_service.py
Normal 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()
|
||||
427
app/modules/tenancy/services/vendor_domain_service.py
Normal file
427
app/modules/tenancy/services/vendor_domain_service.py
Normal 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()
|
||||
708
app/modules/tenancy/services/vendor_service.py
Normal file
708
app/modules/tenancy/services/vendor_service.py
Normal 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()
|
||||
529
app/modules/tenancy/services/vendor_team_service.py
Normal file
529
app/modules/tenancy/services/vendor_team_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user