refactor: migrate vendor APIs to token-based context and consolidate architecture
## Vendor-in-Token Architecture (Complete Migration) - Migrate all vendor API endpoints from require_vendor_context() to token_vendor_id - Update permission dependencies to extract vendor from JWT token - Add vendor exceptions: VendorAccessDeniedException, VendorOwnerOnlyException, InsufficientVendorPermissionsException - Shop endpoints retain require_vendor_context() for URL-based detection - Add AUTH-004 architecture rule enforcing vendor context patterns - Fix marketplace router missing /marketplace prefix ## Exception Pattern Fixes (API-003/API-004) - Services raise domain exceptions, endpoints let them bubble up - Add code_quality and content_page exception modules - Move business logic from endpoints to services (admin, auth, content_page) - Fix exception handling in admin, shop, and vendor endpoints ## Tailwind CSS Consolidation - Consolidate CSS to per-area files (admin, vendor, shop, platform) - Remove shared/cdn-fallback.html and shared/css/tailwind.min.css - Update all templates to use area-specific Tailwind output files - Remove Node.js config (package.json, postcss.config.js, tailwind.config.js) ## Documentation & Cleanup - Update vendor-in-token-architecture.md with completed migration status - Update architecture-rules.md with new rules - Move migration docs to docs/development/migration/ - Remove duplicate/obsolete documentation files - Merge pytest.ini settings into pyproject.toml 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -21,13 +21,17 @@ from sqlalchemy.orm import Session, joinedload
|
||||
from app.exceptions import (
|
||||
AdminOperationException,
|
||||
CannotModifySelfException,
|
||||
UserCannotBeDeletedException,
|
||||
UserNotFoundException,
|
||||
UserRoleChangeException,
|
||||
UserStatusChangeException,
|
||||
ValidationException,
|
||||
VendorAlreadyExistsException,
|
||||
VendorNotFoundException,
|
||||
VendorVerificationException,
|
||||
)
|
||||
from app.exceptions.auth import UserAlreadyExistsException
|
||||
from middleware.auth import AuthManager
|
||||
from models.database.company import Company
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
from models.database.user import User
|
||||
@@ -97,6 +101,244 @@ class AdminService:
|
||||
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.commit()
|
||||
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.commit()
|
||||
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)
|
||||
db.commit()
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.exceptions import (
|
||||
)
|
||||
from middleware.auth import AuthManager
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor, VendorUser
|
||||
from models.schema.auth import UserLogin, UserRegister
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -214,6 +215,84 @@ class AuthService:
|
||||
logger.error(f"Error creating access token with data: {str(e)}")
|
||||
raise ValidationException("Failed to create access token")
|
||||
|
||||
def get_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor | None:
|
||||
"""
|
||||
Get active vendor by vendor code.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_code: Vendor code to look up
|
||||
|
||||
Returns:
|
||||
Vendor if found and active, None otherwise
|
||||
"""
|
||||
return (
|
||||
db.query(Vendor)
|
||||
.filter(Vendor.vendor_code == vendor_code.upper(), Vendor.is_active == True)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_user_vendor_role(
|
||||
self, db: Session, user: User, vendor: Vendor
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Check if user has access to vendor and return their role.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user: User to check
|
||||
vendor: Vendor to check access for
|
||||
|
||||
Returns:
|
||||
Tuple of (has_access: bool, role_name: str | None)
|
||||
"""
|
||||
# Check if user is vendor owner (via company ownership)
|
||||
if vendor.company and vendor.company.owner_user_id == user.id:
|
||||
return True, "Owner"
|
||||
|
||||
# Check if user is team member
|
||||
vendor_user = (
|
||||
db.query(VendorUser)
|
||||
.filter(
|
||||
VendorUser.user_id == user.id,
|
||||
VendorUser.vendor_id == vendor.id,
|
||||
VendorUser.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if vendor_user:
|
||||
return True, vendor_user.role.name
|
||||
|
||||
return False, None
|
||||
|
||||
def find_user_vendor(self, user: User) -> tuple[Vendor | None, str | None]:
|
||||
"""
|
||||
Find which vendor a user belongs to when no vendor context is provided.
|
||||
|
||||
Checks owned companies first, then vendor memberships.
|
||||
|
||||
Args:
|
||||
user: User to find vendor for
|
||||
|
||||
Returns:
|
||||
Tuple of (vendor: Vendor | None, role: str | None)
|
||||
"""
|
||||
# Check owned vendors first (via company ownership)
|
||||
for company in user.owned_companies:
|
||||
if company.vendors:
|
||||
return company.vendors[0], "Owner"
|
||||
|
||||
# Check vendor memberships
|
||||
if user.vendor_memberships:
|
||||
active_membership = next(
|
||||
(vm for vm in user.vendor_memberships if vm.is_active), None
|
||||
)
|
||||
if active_membership:
|
||||
return active_membership.vendor, active_membership.role.name
|
||||
|
||||
return None, None
|
||||
|
||||
# Private helper methods
|
||||
def _email_exists(self, db: Session, email: str) -> bool:
|
||||
"""Check if email already exists."""
|
||||
|
||||
@@ -11,6 +11,11 @@ from datetime import datetime
|
||||
from sqlalchemy import desc, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
ScanParseException,
|
||||
ScanTimeoutException,
|
||||
ViolationNotFoundException,
|
||||
)
|
||||
from models.database.architecture_scan import (
|
||||
ArchitectureScan,
|
||||
ArchitectureViolation,
|
||||
@@ -54,7 +59,7 @@ class CodeQualityService:
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Architecture scan timed out after 5 minutes")
|
||||
raise Exception("Scan timed out")
|
||||
raise ScanTimeoutException(timeout_seconds=300)
|
||||
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
@@ -77,7 +82,7 @@ class CodeQualityService:
|
||||
logger.error(f"Failed to parse validator output: {e}")
|
||||
logger.error(f"Stdout: {result.stdout}")
|
||||
logger.error(f"Stderr: {result.stderr}")
|
||||
raise Exception(f"Failed to parse scan results: {e}")
|
||||
raise ScanParseException(reason=str(e))
|
||||
|
||||
# Create scan record
|
||||
scan = ArchitectureScan(
|
||||
@@ -285,7 +290,7 @@ class CodeQualityService:
|
||||
"""
|
||||
violation = self.get_violation_by_id(db, violation_id)
|
||||
if not violation:
|
||||
raise ValueError(f"Violation {violation_id} not found")
|
||||
raise ViolationNotFoundException(violation_id)
|
||||
|
||||
violation.status = "resolved"
|
||||
violation.resolved_at = datetime.now()
|
||||
@@ -313,7 +318,7 @@ class CodeQualityService:
|
||||
"""
|
||||
violation = self.get_violation_by_id(db, violation_id)
|
||||
if not violation:
|
||||
raise ValueError(f"Violation {violation_id} not found")
|
||||
raise ViolationNotFoundException(violation_id)
|
||||
|
||||
violation.status = "ignored"
|
||||
violation.resolved_at = datetime.now()
|
||||
|
||||
@@ -22,6 +22,10 @@ from datetime import UTC, datetime
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions.content_page import (
|
||||
ContentPageNotFoundException,
|
||||
UnauthorizedContentPageAccessException,
|
||||
)
|
||||
from models.database.content_page import ContentPage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -319,6 +323,214 @@ class ContentPageService:
|
||||
"""Get content page by ID."""
|
||||
return db.query(ContentPage).filter(ContentPage.id == page_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_page_by_id_or_raise(db: Session, page_id: int) -> ContentPage:
|
||||
"""
|
||||
Get content page by ID or raise ContentPageNotFoundException.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
page_id: Page ID
|
||||
|
||||
Returns:
|
||||
ContentPage
|
||||
|
||||
Raises:
|
||||
ContentPageNotFoundException: If page not found
|
||||
"""
|
||||
page = db.query(ContentPage).filter(ContentPage.id == page_id).first()
|
||||
if not page:
|
||||
raise ContentPageNotFoundException(identifier=page_id)
|
||||
return page
|
||||
|
||||
@staticmethod
|
||||
def get_page_for_vendor_or_raise(
|
||||
db: Session,
|
||||
slug: str,
|
||||
vendor_id: int | None = None,
|
||||
include_unpublished: bool = False,
|
||||
) -> ContentPage:
|
||||
"""
|
||||
Get content page for a vendor with fallback to platform default.
|
||||
Raises ContentPageNotFoundException if not found.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
slug: Page slug
|
||||
vendor_id: Vendor ID
|
||||
include_unpublished: Include draft pages
|
||||
|
||||
Returns:
|
||||
ContentPage
|
||||
|
||||
Raises:
|
||||
ContentPageNotFoundException: If page not found
|
||||
"""
|
||||
page = ContentPageService.get_page_for_vendor(
|
||||
db, slug=slug, vendor_id=vendor_id, include_unpublished=include_unpublished
|
||||
)
|
||||
if not page:
|
||||
raise ContentPageNotFoundException(identifier=slug)
|
||||
return page
|
||||
|
||||
@staticmethod
|
||||
def update_page_or_raise(
|
||||
db: Session,
|
||||
page_id: int,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
content_format: str | None = None,
|
||||
template: str | None = None,
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
is_published: bool | None = None,
|
||||
show_in_footer: bool | None = None,
|
||||
show_in_header: bool | None = None,
|
||||
display_order: int | None = None,
|
||||
updated_by: int | None = None,
|
||||
) -> ContentPage:
|
||||
"""
|
||||
Update an existing content page or raise exception.
|
||||
|
||||
Raises:
|
||||
ContentPageNotFoundException: If page not found
|
||||
"""
|
||||
page = ContentPageService.update_page(
|
||||
db,
|
||||
page_id=page_id,
|
||||
title=title,
|
||||
content=content,
|
||||
content_format=content_format,
|
||||
template=template,
|
||||
meta_description=meta_description,
|
||||
meta_keywords=meta_keywords,
|
||||
is_published=is_published,
|
||||
show_in_footer=show_in_footer,
|
||||
show_in_header=show_in_header,
|
||||
display_order=display_order,
|
||||
updated_by=updated_by,
|
||||
)
|
||||
if not page:
|
||||
raise ContentPageNotFoundException(identifier=page_id)
|
||||
return page
|
||||
|
||||
@staticmethod
|
||||
def delete_page_or_raise(db: Session, page_id: int) -> None:
|
||||
"""
|
||||
Delete a content page or raise exception.
|
||||
|
||||
Raises:
|
||||
ContentPageNotFoundException: If page not found
|
||||
"""
|
||||
success = ContentPageService.delete_page(db, page_id)
|
||||
if not success:
|
||||
raise ContentPageNotFoundException(identifier=page_id)
|
||||
|
||||
@staticmethod
|
||||
def update_vendor_page(
|
||||
db: Session,
|
||||
page_id: int,
|
||||
vendor_id: int,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
content_format: str | None = None,
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
is_published: bool | None = None,
|
||||
show_in_footer: bool | None = None,
|
||||
show_in_header: bool | None = None,
|
||||
display_order: int | None = None,
|
||||
updated_by: int | None = None,
|
||||
) -> ContentPage:
|
||||
"""
|
||||
Update a vendor-specific content page with ownership check.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
page_id: Page ID
|
||||
vendor_id: Vendor ID (for ownership verification)
|
||||
... other fields
|
||||
|
||||
Returns:
|
||||
Updated ContentPage
|
||||
|
||||
Raises:
|
||||
ContentPageNotFoundException: If page not found
|
||||
UnauthorizedContentPageAccessException: If page doesn't belong to vendor
|
||||
"""
|
||||
page = ContentPageService.get_page_by_id_or_raise(db, page_id)
|
||||
|
||||
if page.vendor_id != vendor_id:
|
||||
raise UnauthorizedContentPageAccessException(action="edit")
|
||||
|
||||
return ContentPageService.update_page_or_raise(
|
||||
db,
|
||||
page_id=page_id,
|
||||
title=title,
|
||||
content=content,
|
||||
content_format=content_format,
|
||||
meta_description=meta_description,
|
||||
meta_keywords=meta_keywords,
|
||||
is_published=is_published,
|
||||
show_in_footer=show_in_footer,
|
||||
show_in_header=show_in_header,
|
||||
display_order=display_order,
|
||||
updated_by=updated_by,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_vendor_page(db: Session, page_id: int, vendor_id: int) -> None:
|
||||
"""
|
||||
Delete a vendor-specific content page with ownership check.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
page_id: Page ID
|
||||
vendor_id: Vendor ID (for ownership verification)
|
||||
|
||||
Raises:
|
||||
ContentPageNotFoundException: If page not found
|
||||
UnauthorizedContentPageAccessException: If page doesn't belong to vendor
|
||||
"""
|
||||
page = ContentPageService.get_page_by_id_or_raise(db, page_id)
|
||||
|
||||
if page.vendor_id != vendor_id:
|
||||
raise UnauthorizedContentPageAccessException(action="delete")
|
||||
|
||||
ContentPageService.delete_page_or_raise(db, page_id)
|
||||
|
||||
@staticmethod
|
||||
def list_all_pages(
|
||||
db: Session,
|
||||
vendor_id: int | None = None,
|
||||
include_unpublished: bool = False,
|
||||
) -> list[ContentPage]:
|
||||
"""
|
||||
List all content pages (platform defaults and vendor overrides).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Optional filter by vendor ID
|
||||
include_unpublished: Include draft pages
|
||||
|
||||
Returns:
|
||||
List of ContentPage objects
|
||||
"""
|
||||
filters = []
|
||||
|
||||
if vendor_id:
|
||||
filters.append(ContentPage.vendor_id == vendor_id)
|
||||
|
||||
if not include_unpublished:
|
||||
filters.append(ContentPage.is_published == True)
|
||||
|
||||
return (
|
||||
db.query(ContentPage)
|
||||
.filter(and_(*filters) if filters else True)
|
||||
.order_by(ContentPage.vendor_id, ContentPage.display_order, ContentPage.title)
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def list_all_vendor_pages(
|
||||
db: Session, vendor_id: int, include_unpublished: bool = False
|
||||
|
||||
Reference in New Issue
Block a user