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:
2025-12-04 22:24:45 +01:00
parent 76f8a59954
commit 8a367077e1
85 changed files with 21787 additions and 134978 deletions

View File

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

View File

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

View File

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

View File

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