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:
@@ -1,25 +1,30 @@
|
||||
# app/api/v1/admin/users.py
|
||||
"""
|
||||
User management endpoints for admin.
|
||||
|
||||
All endpoints use the admin_service for business logic.
|
||||
Domain exceptions are raised by the service and converted to HTTP responses
|
||||
by the global exception handler.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.admin_service import admin_service
|
||||
from middleware.auth import AuthManager
|
||||
from app.services.stats_service import stats_service
|
||||
from models.database.user import User
|
||||
from models.schema.auth import (
|
||||
UserCreate,
|
||||
UserDeleteResponse,
|
||||
UserDetailResponse,
|
||||
UserListResponse,
|
||||
UserResponse,
|
||||
UserSearchResponse,
|
||||
UserStatusToggleResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
@@ -38,31 +43,19 @@ def get_all_users(
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get paginated list of all users (Admin only)."""
|
||||
query = db.query(User)
|
||||
|
||||
# Apply filters
|
||||
if search:
|
||||
search_term = f"%{search.lower()}%"
|
||||
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))
|
||||
)
|
||||
|
||||
if role:
|
||||
query = query.filter(User.role == role)
|
||||
|
||||
# Convert string params to proper types
|
||||
is_active_bool = None
|
||||
if is_active:
|
||||
query = query.filter(User.is_active == (is_active.lower() == "true"))
|
||||
is_active_bool = is_active.lower() == "true"
|
||||
|
||||
# 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()
|
||||
users, total, pages = admin_service.list_users(
|
||||
db=db,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
search=search if search else None,
|
||||
role=role if role else None,
|
||||
is_active=is_active_bool,
|
||||
)
|
||||
|
||||
return UserListResponse(
|
||||
items=[UserResponse.model_validate(user) for user in users],
|
||||
@@ -80,30 +73,16 @@ def create_user(
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Create a new user (Admin only)."""
|
||||
# Check if email exists
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# Check if username exists
|
||||
if db.query(User).filter(User.username == user_data.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
|
||||
# Create user
|
||||
auth_manager = AuthManager()
|
||||
user = User(
|
||||
user = admin_service.create_user(
|
||||
db=db,
|
||||
email=user_data.email,
|
||||
username=user_data.username,
|
||||
hashed_password=auth_manager.hash_password(user_data.password),
|
||||
password=user_data.password,
|
||||
first_name=user_data.first_name,
|
||||
last_name=user_data.last_name,
|
||||
role=user_data.role,
|
||||
is_active=True,
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"Admin {current_admin.username} created user {user.username}")
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
@@ -118,8 +97,8 @@ def create_user(
|
||||
last_name=user.last_name,
|
||||
full_name=user.full_name,
|
||||
is_email_verified=user.is_email_verified,
|
||||
owned_companies_count=len(user.owned_companies),
|
||||
vendor_memberships_count=len(user.vendor_memberships),
|
||||
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
|
||||
vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
|
||||
)
|
||||
|
||||
|
||||
@@ -132,7 +111,7 @@ def get_user_statistics(
|
||||
return stats_service.get_user_statistics(db)
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
@router.get("/search", response_model=UserSearchResponse)
|
||||
def search_users(
|
||||
q: str = Query(..., min_length=2, description="Search query (username or email)"),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
@@ -144,25 +123,8 @@ def search_users(
|
||||
|
||||
Used for autocomplete in ownership transfer.
|
||||
"""
|
||||
search_term = f"%{q.lower()}%"
|
||||
users = (
|
||||
db.query(User)
|
||||
.filter((User.username.ilike(search_term)) | (User.email.ilike(search_term)))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"users": [
|
||||
{
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
for user in users
|
||||
]
|
||||
}
|
||||
users = admin_service.search_users(db=db, query=q, limit=limit)
|
||||
return UserSearchResponse(users=users)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserDetailResponse)
|
||||
@@ -172,15 +134,7 @@ def get_user_details(
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get detailed user information (Admin only)."""
|
||||
user = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.owned_companies), joinedload(User.vendor_memberships))
|
||||
.filter(User.id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
user = admin_service.get_user_details(db=db, user_id=user_id)
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
@@ -195,8 +149,8 @@ def get_user_details(
|
||||
last_name=user.last_name,
|
||||
full_name=user.full_name,
|
||||
is_email_verified=user.is_email_verified,
|
||||
owned_companies_count=len(user.owned_companies),
|
||||
vendor_memberships_count=len(user.vendor_memberships),
|
||||
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
|
||||
vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
|
||||
)
|
||||
|
||||
|
||||
@@ -208,36 +162,19 @@ def update_user(
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Update user information (Admin only)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent changing own admin status
|
||||
if user.id == current_admin.id and user_update.role and user_update.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Cannot change your own admin role"
|
||||
)
|
||||
|
||||
# Check email uniqueness if changing
|
||||
if user_update.email and user_update.email != user.email:
|
||||
if db.query(User).filter(User.email == user_update.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# Check username uniqueness if changing
|
||||
if user_update.username and user_update.username != user.username:
|
||||
if db.query(User).filter(User.username == user_update.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
|
||||
# Update fields
|
||||
update_data = user_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"Admin {current_admin.username} updated user {user.username}")
|
||||
user = admin_service.update_user(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
current_admin_id=current_admin.id,
|
||||
email=update_data.get("email"),
|
||||
username=update_data.get("username"),
|
||||
first_name=update_data.get("first_name"),
|
||||
last_name=update_data.get("last_name"),
|
||||
role=update_data.get("role"),
|
||||
is_active=update_data.get("is_active"),
|
||||
)
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
@@ -252,68 +189,38 @@ def update_user(
|
||||
last_name=user.last_name,
|
||||
full_name=user.full_name,
|
||||
is_email_verified=user.is_email_verified,
|
||||
owned_companies_count=len(user.owned_companies),
|
||||
vendor_memberships_count=len(user.vendor_memberships),
|
||||
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
|
||||
vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}/status")
|
||||
@router.put("/{user_id}/status", response_model=UserStatusToggleResponse)
|
||||
def toggle_user_status(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Toggle user active status (Admin only)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
user, message = admin_service.toggle_user_status(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent deactivating yourself
|
||||
if user.id == current_admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
|
||||
|
||||
user.is_active = not user.is_active
|
||||
db.commit()
|
||||
|
||||
action = "activated" if user.is_active else "deactivated"
|
||||
logger.info(f"Admin {current_admin.username} {action} user {user.username}")
|
||||
|
||||
return {"message": f"User {action} successfully", "is_active": user.is_active}
|
||||
return UserStatusToggleResponse(message=message, is_active=user.is_active)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
@router.delete("/{user_id}", response_model=UserDeleteResponse)
|
||||
def delete_user(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Delete a user (Admin only)."""
|
||||
user = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.owned_companies))
|
||||
.filter(User.id == user_id)
|
||||
.first()
|
||||
message = admin_service.delete_user(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent deleting yourself
|
||||
if user.id == current_admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
# Prevent deleting users who own companies
|
||||
if user.owned_companies:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot delete user who owns {len(user.owned_companies)} company(ies). Transfer ownership first.",
|
||||
)
|
||||
|
||||
username = user.username
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Admin {current_admin.username} deleted user {username}")
|
||||
|
||||
return {"message": "User deleted successfully"}
|
||||
return UserDeleteResponse(message=message)
|
||||
|
||||
Reference in New Issue
Block a user