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

@@ -20,7 +20,7 @@ from app.core.environment import should_use_secure_cookies
from app.exceptions import InvalidCredentialsException
from app.services.auth_service import auth_service
from models.database.user import User
from models.schema.auth import LoginResponse, UserLogin, UserResponse
from models.schema.auth import LoginResponse, LogoutResponse, UserLogin, UserResponse
router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)
@@ -97,7 +97,7 @@ def get_current_admin(current_user: User = Depends(get_current_admin_api)):
return current_user
@router.post("/logout")
@router.post("/logout", response_model=LogoutResponse)
def admin_logout(response: Response):
"""
Admin logout endpoint.
@@ -115,4 +115,4 @@ def admin_logout(response: Response):
logger.debug("Deleted admin_token cookie")
return {"message": "Logged out successfully"}
return LogoutResponse(message="Logged out successfully")

View File

@@ -5,12 +5,13 @@ RESTful API for architecture validation and violation management
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.exceptions import ViolationNotFoundException
from app.services.code_quality_service import code_quality_service
from models.database.user import User
@@ -136,25 +137,23 @@ async def trigger_scan(
Trigger a new architecture scan
Requires authentication. Runs the validator script and stores results.
Domain exceptions (ScanTimeoutException, ScanParseException) bubble up to global handler.
"""
try:
scan = code_quality_service.run_scan(
db, triggered_by=f"manual:{current_user.username}"
)
scan = code_quality_service.run_scan(
db, triggered_by=f"manual:{current_user.username}"
)
return ScanResponse(
id=scan.id,
timestamp=scan.timestamp.isoformat(),
total_files=scan.total_files,
total_violations=scan.total_violations,
errors=scan.errors,
warnings=scan.warnings,
duration_seconds=scan.duration_seconds,
triggered_by=scan.triggered_by,
git_commit_hash=scan.git_commit_hash,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Scan failed: {str(e)}")
return ScanResponse(
id=scan.id,
timestamp=scan.timestamp.isoformat(),
total_files=scan.total_files,
total_violations=scan.total_violations,
errors=scan.errors,
warnings=scan.warnings,
duration_seconds=scan.duration_seconds,
triggered_by=scan.triggered_by,
git_commit_hash=scan.git_commit_hash,
)
@router.get("/scans", response_model=list[ScanResponse])
@@ -269,7 +268,7 @@ async def get_violation(
violation = code_quality_service.get_violation_by_id(db, violation_id)
if not violation:
raise HTTPException(status_code=404, detail="Violation not found")
raise ViolationNotFoundException(violation_id)
# Format assignments
assignments = [
@@ -331,29 +330,26 @@ async def assign_violation(
Updates violation status to 'assigned'.
"""
try:
assignment = code_quality_service.assign_violation(
db,
violation_id=violation_id,
user_id=request.user_id,
assigned_by=current_user.id,
due_date=request.due_date,
priority=request.priority,
)
assignment = code_quality_service.assign_violation(
db,
violation_id=violation_id,
user_id=request.user_id,
assigned_by=current_user.id,
due_date=request.due_date,
priority=request.priority,
)
return {
"id": assignment.id,
"violation_id": assignment.violation_id,
"user_id": assignment.user_id,
"assigned_at": assignment.assigned_at.isoformat(),
"assigned_by": assignment.assigned_by,
"due_date": (
assignment.due_date.isoformat() if assignment.due_date else None
),
"priority": assignment.priority,
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return {
"id": assignment.id,
"violation_id": assignment.violation_id,
"user_id": assignment.user_id,
"assigned_at": assignment.assigned_at.isoformat(),
"assigned_by": assignment.assigned_by,
"due_date": (
assignment.due_date.isoformat() if assignment.due_date else None
),
"priority": assignment.priority,
}
@router.post("/violations/{violation_id}/resolve")
@@ -367,28 +363,24 @@ async def resolve_violation(
Mark violation as resolved
Records resolution timestamp and user.
ViolationNotFoundException bubbles up if violation doesn't exist.
"""
try:
violation = code_quality_service.resolve_violation(
db,
violation_id=violation_id,
resolved_by=current_user.id,
resolution_note=request.resolution_note,
)
violation = code_quality_service.resolve_violation(
db,
violation_id=violation_id,
resolved_by=current_user.id,
resolution_note=request.resolution_note,
)
return {
"id": violation.id,
"status": violation.status,
"resolved_at": (
violation.resolved_at.isoformat() if violation.resolved_at else None
),
"resolved_by": violation.resolved_by,
"resolution_note": violation.resolution_note,
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return {
"id": violation.id,
"status": violation.status,
"resolved_at": (
violation.resolved_at.isoformat() if violation.resolved_at else None
),
"resolved_by": violation.resolved_by,
"resolution_note": violation.resolution_note,
}
@router.post("/violations/{violation_id}/ignore")
@@ -402,28 +394,24 @@ async def ignore_violation(
Mark violation as ignored (won't fix)
Records reason for ignoring.
ViolationNotFoundException bubbles up if violation doesn't exist.
"""
try:
violation = code_quality_service.ignore_violation(
db,
violation_id=violation_id,
ignored_by=current_user.id,
reason=request.reason,
)
violation = code_quality_service.ignore_violation(
db,
violation_id=violation_id,
ignored_by=current_user.id,
reason=request.reason,
)
return {
"id": violation.id,
"status": violation.status,
"resolved_at": (
violation.resolved_at.isoformat() if violation.resolved_at else None
),
"resolved_by": violation.resolved_by,
"resolution_note": violation.resolution_note,
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return {
"id": violation.id,
"status": violation.status,
"resolved_at": (
violation.resolved_at.isoformat() if violation.resolved_at else None
),
"resolved_by": violation.resolved_by,
"resolution_note": violation.resolution_note,
}
@router.post("/violations/{violation_id}/comments")
@@ -438,23 +426,20 @@ async def add_comment(
For team collaboration and discussion.
"""
try:
comment = code_quality_service.add_comment(
db,
violation_id=violation_id,
user_id=current_user.id,
comment=request.comment,
)
comment = code_quality_service.add_comment(
db,
violation_id=violation_id,
user_id=current_user.id,
comment=request.comment,
)
return {
"id": comment.id,
"violation_id": comment.violation_id,
"user_id": comment.user_id,
"comment": comment.comment,
"created_at": comment.created_at.isoformat(),
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return {
"id": comment.id,
"violation_id": comment.violation_id,
"user_id": comment.user_id,
"comment": comment.comment,
"created_at": comment.created_at.isoformat(),
}
@router.get("/stats", response_model=DashboardStatsResponse)

View File

@@ -10,7 +10,7 @@ Platform administrators can:
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -170,28 +170,9 @@ def list_all_pages(
Filter by vendor_id to see specific vendor pages.
"""
if vendor_id:
pages = content_page_service.list_all_vendor_pages(
db, vendor_id=vendor_id, include_unpublished=include_unpublished
)
else:
# Get all pages (both platform and vendor)
from sqlalchemy import and_
from models.database.content_page import ContentPage
filters = []
if not include_unpublished:
filters.append(ContentPage.is_published == True)
pages = (
db.query(ContentPage)
.filter(and_(*filters) if filters else True)
.order_by(
ContentPage.vendor_id, ContentPage.display_order, ContentPage.title
)
.all()
)
pages = content_page_service.list_all_pages(
db, vendor_id=vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@@ -203,11 +184,7 @@ def get_page(
db: Session = Depends(get_db),
):
"""Get a specific content page by ID."""
page = content_page_service.get_page_by_id(db, page_id)
if not page:
raise HTTPException(status_code=404, detail="Content page not found")
page = content_page_service.get_page_by_id_or_raise(db, page_id)
return page.to_dict()
@@ -219,7 +196,7 @@ def update_page(
db: Session = Depends(get_db),
):
"""Update a content page (platform or vendor)."""
page = content_page_service.update_page(
page = content_page_service.update_page_or_raise(
db,
page_id=page_id,
title=page_data.title,
@@ -234,10 +211,6 @@ def update_page(
display_order=page_data.display_order,
updated_by=current_user.id,
)
if not page:
raise HTTPException(status_code=404, detail="Content page not found")
return page.to_dict()
@@ -248,9 +221,4 @@ def delete_page(
db: Session = Depends(get_db),
):
"""Delete a content page."""
success = content_page_service.delete_page(db, page_id)
if not success:
raise HTTPException(status_code=404, detail="Content page not found")
return
content_page_service.delete_page_or_raise(db, page_id)

View File

@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.core.logging import reload_log_level
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
from app.services.admin_audit_service import admin_audit_service
from app.services.admin_settings_service import admin_settings_service
from app.services.log_service import log_service
@@ -26,8 +27,12 @@ from models.schema.admin import (
ApplicationLogFilters,
ApplicationLogListResponse,
FileLogResponse,
LogCleanupResponse,
LogDeleteResponse,
LogFileListResponse,
LogSettingsResponse,
LogSettingsUpdate,
LogSettingsUpdateResponse,
LogStatistics,
)
@@ -87,7 +92,7 @@ def get_log_statistics(
return log_service.get_log_statistics(db, days)
@router.delete("/database/cleanup")
@router.delete("/database/cleanup", response_model=LogCleanupResponse)
def cleanup_old_logs(
retention_days: int = Query(30, ge=1, le=365),
confirm: bool = Query(False, description="Must be true to confirm cleanup"),
@@ -99,13 +104,8 @@ def cleanup_old_logs(
Requires confirmation parameter.
"""
from fastapi import HTTPException
if not confirm:
raise HTTPException(
status_code=400,
detail="Cleanup requires confirmation parameter: confirm=true",
)
raise ConfirmationRequiredException(operation="cleanup_logs")
deleted_count = log_service.cleanup_old_logs(db, retention_days)
@@ -119,13 +119,13 @@ def cleanup_old_logs(
details={"retention_days": retention_days, "deleted_count": deleted_count},
)
return {
"message": f"Deleted {deleted_count} log entries older than {retention_days} days",
"deleted_count": deleted_count,
}
return LogCleanupResponse(
message=f"Deleted {deleted_count} log entries older than {retention_days} days",
deleted_count=deleted_count,
)
@router.delete("/database/{log_id}")
@router.delete("/database/{log_id}", response_model=LogDeleteResponse)
def delete_log(
log_id: int,
db: Session = Depends(get_db),
@@ -144,7 +144,7 @@ def delete_log(
details={},
)
return {"message": message}
return LogDeleteResponse(message=message)
# ============================================================================
@@ -152,7 +152,7 @@ def delete_log(
# ============================================================================
@router.get("/files")
@router.get("/files", response_model=LogFileListResponse)
def list_log_files(
current_admin: User = Depends(get_current_admin_api),
):
@@ -161,7 +161,7 @@ def list_log_files(
Returns list of log files with size and modification date.
"""
return {"files": log_service.list_log_files()}
return LogFileListResponse(files=log_service.list_log_files())
@router.get("/files/{filename}", response_model=FileLogResponse)
@@ -191,7 +191,6 @@ def download_log_file(
from pathlib import Path
from app.core.config import settings
from fastapi import HTTPException
from fastapi.responses import FileResponse
# Determine log file path
@@ -202,7 +201,7 @@ def download_log_file(
log_file = Path("logs") / filename
if not log_file.exists():
raise HTTPException(status_code=404, detail=f"Log file '{filename}' not found")
raise ResourceNotFoundException(resource_type="LogFile", identifier=filename)
# Log action
from app.core.database import get_db
@@ -267,7 +266,7 @@ def get_log_settings(
)
@router.put("/settings")
@router.put("/settings", response_model=LogSettingsUpdateResponse)
def update_log_settings(
settings_update: LogSettingsUpdate,
db: Session = Depends(get_db),
@@ -335,8 +334,8 @@ def update_log_settings(
details={"updated_fields": updated},
)
return {
"message": "Log settings updated successfully",
"updated_fields": updated,
"note": "Log level changes are applied immediately. File rotation settings require restart.",
}
return LogSettingsUpdateResponse(
message="Log settings updated successfully",
updated_fields=updated,
note="Log level changes are applied immediately. File rotation settings require restart.",
)

View File

@@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
from app.services.admin_audit_service import admin_audit_service
from app.services.admin_settings_service import admin_settings_service
from models.database.user import User
@@ -78,9 +79,9 @@ def get_setting(
setting = admin_settings_service.get_setting_by_key(db, key)
if not setting:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
raise ResourceNotFoundException(
resource_type="Setting", identifier=key
)
return AdminSettingResponse.model_validate(setting)
@@ -184,12 +185,10 @@ def delete_setting(
Requires confirmation parameter.
WARNING: Deleting settings may affect platform functionality.
"""
from fastapi import HTTPException
if not confirm:
raise HTTPException(
status_code=400,
detail="Deletion requires confirmation parameter: confirm=true",
raise ConfirmationRequiredException(
operation="delete_setting",
message="Deletion requires confirmation parameter: confirm=true",
)
message = admin_settings_service.delete_setting(

View File

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

View File

@@ -16,14 +16,20 @@ This prevents:
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.environment import should_use_secure_cookies
from app.exceptions import VendorNotFoundException
from app.services.customer_service import customer_service
from models.schema.auth import UserLogin
from models.schema.auth import (
LogoutResponse,
PasswordResetRequestResponse,
PasswordResetResponse,
UserLogin,
)
from models.schema.customer import CustomerRegister, CustomerResponse
router = APIRouter()
@@ -62,10 +68,7 @@ def register_customer(
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] register_customer for vendor {vendor.subdomain}",
@@ -122,10 +125,7 @@ def customer_login(
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] customer_login for vendor {vendor.subdomain}",
@@ -199,7 +199,7 @@ def customer_login(
)
@router.post("/auth/logout")
@router.post("/auth/logout", response_model=LogoutResponse)
def customer_logout(request: Request, response: Response):
"""
Customer logout for current vendor.
@@ -245,10 +245,10 @@ def customer_logout(request: Request, response: Response):
logger.debug(f"Deleted customer_token cookie (path={cookie_path})")
return {"message": "Logged out successfully"}
return LogoutResponse(message="Logged out successfully")
@router.post("/auth/forgot-password")
@router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse)
def forgot_password(request: Request, email: str, db: Session = Depends(get_db)):
"""
Request password reset for customer.
@@ -263,10 +263,7 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] forgot_password for vendor {vendor.subdomain}",
@@ -285,12 +282,12 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
logger.info(f"Password reset requested for {email} (vendor: {vendor.subdomain})")
return {
"message": "If an account exists with this email, a password reset link has been sent."
}
return PasswordResetRequestResponse(
message="If an account exists with this email, a password reset link has been sent."
)
@router.post("/auth/reset-password")
@router.post("/auth/reset-password", response_model=PasswordResetResponse)
def reset_password(
request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)
):
@@ -307,10 +304,7 @@ def reset_password(
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] reset_password for vendor {vendor.subdomain}",
@@ -329,6 +323,6 @@ def reset_password(
logger.info(f"Password reset completed (vendor: {vendor.subdomain})")
return {
"message": "Password reset successfully. You can now log in with your new password."
}
return PasswordResetResponse(
message="Password reset successfully. You can now log in with your new password."
)

View File

@@ -3,17 +3,21 @@
Shop Shopping Cart API (Public)
Public endpoints for managing shopping cart in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware).
Uses vendor from middleware context (VendorContextMiddleware).
No authentication required - uses session ID for cart tracking.
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
"""
import logging
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request
from fastapi import APIRouter, Body, Depends, Path
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.cart_service import cart_service
from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor
from models.schema.cart import (
AddToCartRequest,
CartOperationResponse,
@@ -31,30 +35,21 @@ logger = logging.getLogger(__name__)
# ============================================================================
@router.get("/cart/{session_id}", response_model=CartResponse)
@router.get("/cart/{session_id}", response_model=CartResponse) # public
def get_cart(
request: Request,
session_id: str = Path(..., description="Shopping session ID"),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CartResponse:
"""
Get shopping cart contents for current vendor.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID for cart tracking.
Path Parameters:
- session_id: Unique session identifier for the cart
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.info(
f"[SHOP_API] get_cart for session {session_id}, vendor {vendor.id}",
extra={
@@ -79,17 +74,17 @@ def get_cart(
return CartResponse.from_service_dict(cart)
@router.post("/cart/{session_id}/items", response_model=CartOperationResponse)
@router.post("/cart/{session_id}/items", response_model=CartOperationResponse) # public
def add_to_cart(
request: Request,
session_id: str = Path(..., description="Shopping session ID"),
cart_data: AddToCartRequest = Body(...),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CartOperationResponse:
"""
Add product to cart for current vendor.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
@@ -99,15 +94,6 @@ def add_to_cart(
- product_id: ID of product to add
- quantity: Quantity to add (default: 1)
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.info(
f"[SHOP_API] add_to_cart: product {cart_data.product_id}, qty {cart_data.quantity}, session {session_id}",
extra={
@@ -140,18 +126,18 @@ def add_to_cart(
@router.put(
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
)
) # public
def update_cart_item(
request: Request,
session_id: str = Path(..., description="Shopping session ID"),
product_id: int = Path(..., description="Product ID", gt=0),
cart_data: UpdateCartItemRequest = Body(...),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CartOperationResponse:
"""
Update cart item quantity for current vendor.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
@@ -161,15 +147,6 @@ def update_cart_item(
Request Body:
- quantity: New quantity (must be >= 1)
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug(
f"[SHOP_API] update_cart_item: product {product_id}, qty {cart_data.quantity}",
extra={
@@ -194,32 +171,23 @@ def update_cart_item(
@router.delete(
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
)
) # public
def remove_from_cart(
request: Request,
session_id: str = Path(..., description="Shopping session ID"),
product_id: int = Path(..., description="Product ID", gt=0),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CartOperationResponse:
"""
Remove item from cart for current vendor.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
- session_id: Unique session identifier for the cart
- product_id: ID of product to remove
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug(
f"[SHOP_API] remove_from_cart: product {product_id}",
extra={
@@ -237,30 +205,21 @@ def remove_from_cart(
return CartOperationResponse(**result)
@router.delete("/cart/{session_id}", response_model=ClearCartResponse)
@router.delete("/cart/{session_id}", response_model=ClearCartResponse) # public
def clear_cart(
request: Request,
session_id: str = Path(..., description="Shopping session ID"),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> ClearCartResponse:
"""
Clear all items from cart for current vendor.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
- session_id: Unique session identifier for the cart
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug(
f"[SHOP_API] clear_cart for session {session_id}",
extra={

View File

@@ -8,7 +8,7 @@ No authentication required.
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -90,16 +90,13 @@ def get_content_page(slug: str, request: Request, db: Session = Depends(get_db))
vendor = getattr(request.state, "vendor", None)
vendor_id = vendor.id if vendor else None
page = content_page_service.get_page_for_vendor(
page = content_page_service.get_page_for_vendor_or_raise(
db,
slug=slug,
vendor_id=vendor_id,
include_unpublished=False, # Only show published pages
)
if not page:
raise HTTPException(status_code=404, detail=f"Content page not found: {slug}")
return {
"slug": page.slug,
"title": page.title,

View File

@@ -3,17 +3,21 @@
Shop Product Catalog API (Public)
Public endpoints for browsing product catalog in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware).
Uses vendor from middleware context (VendorContextMiddleware).
No authentication required.
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.product_service import product_service
from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor
from models.schema.product import (
ProductDetailResponse,
ProductListResponse,
@@ -24,19 +28,19 @@ router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/products", response_model=ProductListResponse)
@router.get("/products", response_model=ProductListResponse) # public
def get_product_catalog(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: str | None = Query(None, description="Search products by name"),
is_featured: bool | None = Query(None, description="Filter by featured products"),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
):
"""
Get product catalog for current vendor.
Vendor is automatically determined from request context (domain/subdomain/path).
Vendor is automatically determined from request context (URL/subdomain/domain).
Only returns active products visible to customers.
No authentication required.
@@ -46,15 +50,6 @@ def get_product_catalog(
- search: Search query for product name/description
- is_featured: Filter by featured products only
"""
# Get vendor from middleware (injected by VendorContextMiddleware)
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug(
f"[SHOP_API] get_product_catalog for vendor: {vendor.subdomain}",
extra={
@@ -85,30 +80,21 @@ def get_product_catalog(
)
@router.get("/products/{product_id}", response_model=ProductDetailResponse)
@router.get("/products/{product_id}", response_model=ProductDetailResponse) # public
def get_product_details(
request: Request,
product_id: int = Path(..., description="Product ID", gt=0),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
):
"""
Get detailed product information for customers.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required.
Path Parameters:
- product_id: ID of the product to retrieve
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug(
f"[SHOP_API] get_product_details for product {product_id}",
extra={
@@ -131,19 +117,19 @@ def get_product_details(
return ProductDetailResponse.model_validate(product)
@router.get("/products/search", response_model=ProductListResponse)
@router.get("/products/search", response_model=ProductListResponse) # public
def search_products(
request: Request,
q: str = Query(..., min_length=1, description="Search query"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
):
"""
Search products in current vendor's catalog.
Searches in product names, descriptions, and SKUs.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required.
Query Parameters:
@@ -151,15 +137,6 @@ def search_products(
- skip: Number of results to skip (pagination)
- limit: Maximum number of results to return
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug(
f"[SHOP_API] search_products: '{q}'",
extra={

View File

@@ -1,6 +1,8 @@
# app/api/v1/vendor/analytics.py
"""
Vendor analytics and reporting endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
@@ -10,21 +12,27 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.stats_service import stats_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/analytics")
logger = logging.getLogger(__name__)
def _get_vendor_id_from_token(current_user: User) -> int:
"""Helper to get vendor_id from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return current_user.token_vendor_id
@router.get("")
def get_vendor_analytics(
period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get vendor analytics data for specified time period."""
return stats_service.get_vendor_analytics(db, vendor.id, period)
vendor_id = _get_vendor_id_from_token(current_user)
return stats_service.get_vendor_analytics(db, vendor_id, period)

View File

@@ -25,8 +25,8 @@ from app.exceptions import InvalidCredentialsException
from app.services.auth_service import auth_service
from middleware.vendor_context import get_current_vendor
from models.database.user import User
from models.database.vendor import Role, Vendor, VendorUser
from models.schema.auth import UserLogin
from models.database.vendor import Vendor
from models.schema.auth import LogoutResponse, UserLogin, VendorUserResponse
router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)
@@ -68,13 +68,7 @@ def vendor_login(
if not vendor and hasattr(user_credentials, "vendor_code"):
vendor_code = getattr(user_credentials, "vendor_code", None)
if vendor_code:
vendor = (
db.query(Vendor)
.filter(
Vendor.vendor_code == vendor_code.upper(), Vendor.is_active == True
)
.first()
)
vendor = auth_service.get_vendor_by_code(db, vendor_code)
# Authenticate user
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
@@ -91,51 +85,22 @@ def vendor_login(
vendor_role = "Member"
if vendor:
# Check if user is vendor owner (via company ownership)
is_owner = vendor.company and vendor.company.owner_user_id == user.id
# Check if user has access to this vendor
has_access, role = auth_service.get_user_vendor_role(db, user, vendor)
if is_owner:
vendor_role = "Owner"
if has_access:
vendor_role = role
else:
# Check if user is team member
vendor_user = (
db.query(VendorUser)
.join(Role)
.filter(
VendorUser.user_id == user.id,
VendorUser.vendor_id == vendor.id,
VendorUser.is_active == True,
)
.first()
logger.warning(
f"User {user.username} attempted login to vendor {vendor.vendor_code} "
f"but is not authorized"
)
raise InvalidCredentialsException(
"You do not have access to this vendor"
)
if vendor_user:
vendor_role = vendor_user.role.name
else:
logger.warning(
f"User {user.username} attempted login to vendor {vendor.vendor_code} "
f"but is not authorized"
)
raise InvalidCredentialsException(
"You do not have access to this vendor"
)
else:
# No vendor context - find which vendor this user belongs to
# Check owned vendors first (via company ownership)
for company in user.owned_companies:
if company.vendors:
vendor = company.vendors[0]
vendor_role = "Owner"
break
# Check vendor memberships if no owned vendor found
if not vendor and user.vendor_memberships:
active_membership = next(
(vm for vm in user.vendor_memberships if vm.is_active), None
)
if active_membership:
vendor = active_membership.vendor
vendor_role = active_membership.role.name
vendor, vendor_role = auth_service.find_user_vendor(user)
if not vendor:
raise InvalidCredentialsException("User is not associated with any vendor")
@@ -194,7 +159,7 @@ def vendor_login(
)
@router.post("/logout")
@router.post("/logout", response_model=LogoutResponse)
def vendor_logout(response: Response):
"""
Vendor team member logout.
@@ -212,10 +177,10 @@ def vendor_logout(response: Response):
logger.debug("Deleted vendor_token cookie")
return {"message": "Logged out successfully"}
return LogoutResponse(message="Logged out successfully")
@router.get("/me")
@router.get("/me", response_model=VendorUserResponse)
def get_current_vendor_user(
user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db)
):
@@ -225,10 +190,10 @@ def get_current_vendor_user(
This endpoint can be called to verify authentication and get user info.
Requires Authorization header (header-only authentication for API endpoints).
"""
return {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"is_active": user.is_active,
}
return VendorUserResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
)

View File

@@ -10,11 +10,12 @@ Vendors can:
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, get_db
from app.exceptions.content_page import VendorNotAssociatedException
from app.services.content_page_service import content_page_service
from models.database.user import User
@@ -106,9 +107,7 @@ def list_vendor_pages(
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
"""
if not current_user.vendor_id:
raise HTTPException(
status_code=403, detail="User is not associated with a vendor"
)
raise VendorNotAssociatedException()
pages = content_page_service.list_pages_for_vendor(
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
@@ -129,9 +128,7 @@ def list_vendor_overrides(
Shows what the vendor has customized.
"""
if not current_user.vendor_id:
raise HTTPException(
status_code=403, detail="User is not associated with a vendor"
)
raise VendorNotAssociatedException()
pages = content_page_service.list_all_vendor_pages(
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
@@ -153,20 +150,15 @@ def get_page(
Returns vendor override if exists, otherwise platform default.
"""
if not current_user.vendor_id:
raise HTTPException(
status_code=403, detail="User is not associated with a vendor"
)
raise VendorNotAssociatedException()
page = content_page_service.get_page_for_vendor(
page = content_page_service.get_page_for_vendor_or_raise(
db,
slug=slug,
vendor_id=current_user.vendor_id,
include_unpublished=include_unpublished,
)
if not page:
raise HTTPException(status_code=404, detail=f"Content page not found: {slug}")
return page.to_dict()
@@ -182,9 +174,7 @@ def create_vendor_page(
This will be shown instead of the platform default for this vendor.
"""
if not current_user.vendor_id:
raise HTTPException(
status_code=403, detail="User is not associated with a vendor"
)
raise VendorNotAssociatedException()
page = content_page_service.create_page(
db,
@@ -218,24 +208,13 @@ def update_vendor_page(
Can only update pages owned by this vendor.
"""
if not current_user.vendor_id:
raise HTTPException(
status_code=403, detail="User is not associated with a vendor"
)
raise VendorNotAssociatedException()
# Verify ownership
existing_page = content_page_service.get_page_by_id(db, page_id)
if not existing_page:
raise HTTPException(status_code=404, detail="Content page not found")
if existing_page.vendor_id != current_user.vendor_id:
raise HTTPException(
status_code=403, detail="Cannot edit pages from other vendors"
)
# Update
page = content_page_service.update_page(
# Update with ownership check in service layer
page = content_page_service.update_vendor_page(
db,
page_id=page_id,
vendor_id=current_user.vendor_id,
title=page_data.title,
content=page_data.content,
content_format=page_data.content_format,
@@ -264,21 +243,7 @@ def delete_vendor_page(
After deletion, platform default will be shown (if exists).
"""
if not current_user.vendor_id:
raise HTTPException(
status_code=403, detail="User is not associated with a vendor"
)
raise VendorNotAssociatedException()
# Verify ownership
existing_page = content_page_service.get_page_by_id(db, page_id)
if not existing_page:
raise HTTPException(status_code=404, detail="Content page not found")
if existing_page.vendor_id != current_user.vendor_id:
raise HTTPException(
status_code=403, detail="Cannot delete pages from other vendors"
)
# Delete
content_page_service.delete_page(db, page_id)
return
# Delete with ownership check in service layer
content_page_service.delete_vendor_page(db, page_id, current_user.vendor_id)

View File

@@ -2,6 +2,8 @@
# app/api/v1/vendor/customers.py
"""
Vendor customer management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
@@ -11,21 +13,27 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/customers")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("")
def get_vendor_customers(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: str | None = Query(None),
is_active: bool | None = Query(None),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -38,6 +46,7 @@ def get_vendor_customers(
- Support filtering by active status
- Return paginated results
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"customers": [],
"total": 0,
@@ -50,7 +59,6 @@ def get_vendor_customers(
@router.get("/{customer_id}")
def get_customer_details(
customer_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -63,13 +71,13 @@ def get_customer_details(
- Include order history
- Include total spent, etc.
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Customer details coming in Slice 4"}
@router.get("/{customer_id}/orders")
def get_customer_orders(
customer_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -81,6 +89,7 @@ def get_customer_orders(
- Filter by vendor_id
- Return order details
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"orders": [], "message": "Customer orders coming in Slice 5"}
@@ -88,7 +97,6 @@ def get_customer_orders(
def update_customer(
customer_id: int,
customer_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -100,13 +108,13 @@ def update_customer(
- Verify customer belongs to vendor
- Update customer preferences
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Customer update coming in Slice 4"}
@router.put("/{customer_id}/status")
def toggle_customer_status(
customer_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -118,13 +126,13 @@ def toggle_customer_status(
- Verify customer belongs to vendor
- Log the change
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Customer status toggle coming in Slice 4"}
@router.get("/{customer_id}/stats")
def get_customer_statistics(
customer_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -137,6 +145,7 @@ def get_customer_statistics(
- Average order value
- Last order date
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"total_orders": 0,
"total_spent": 0.0,

View File

@@ -10,7 +10,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException, VendorNotActiveException
from app.services.stats_service import stats_service
from app.services.vendor_service import vendor_service
from models.database.user import User
router = APIRouter(prefix="/dashboard")
@@ -35,23 +37,17 @@ def get_vendor_dashboard_stats(
Vendor is determined from the JWT token (vendor_id claim).
Requires Authorization header (API endpoint).
"""
from fastapi import HTTPException
# Get vendor ID from token (set by get_current_vendor_api)
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
# Get vendor object to include in response
from models.database.vendor import Vendor
# Get vendor object (raises VendorNotFoundException if not found)
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor or not vendor.is_active:
raise HTTPException(status_code=404, detail="Vendor not found or inactive")
if not vendor.is_active:
raise VendorNotActiveException(vendor.vendor_code)
# Get vendor-scoped statistics
stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor_id)

View File

@@ -1,4 +1,9 @@
# app/api/v1/vendor/inventory.py
"""
Vendor inventory management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
from fastapi import APIRouter, Depends, Query
@@ -6,10 +11,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.inventory_service import inventory_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User
from models.database.vendor import Vendor
from models.schema.inventory import (
InventoryAdjust,
InventoryCreate,
@@ -24,70 +28,77 @@ router = APIRouter()
logger = logging.getLogger(__name__)
def _get_vendor_id_from_token(current_user: User) -> int:
"""Helper to get vendor_id from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return current_user.token_vendor_id
@router.post("/inventory/set", response_model=InventoryResponse)
def set_inventory(
inventory: InventoryCreate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Set exact inventory quantity (replaces existing)."""
return inventory_service.set_inventory(db, vendor.id, inventory)
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.set_inventory(db, vendor_id, inventory)
@router.post("/inventory/adjust", response_model=InventoryResponse)
def adjust_inventory(
adjustment: InventoryAdjust,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Adjust inventory (positive to add, negative to remove)."""
return inventory_service.adjust_inventory(db, vendor.id, adjustment)
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.adjust_inventory(db, vendor_id, adjustment)
@router.post("/inventory/reserve", response_model=InventoryResponse)
def reserve_inventory(
reservation: InventoryReserve,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Reserve inventory for an order."""
return inventory_service.reserve_inventory(db, vendor.id, reservation)
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.reserve_inventory(db, vendor_id, reservation)
@router.post("/inventory/release", response_model=InventoryResponse)
def release_reservation(
reservation: InventoryReserve,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Release reserved inventory (cancel order)."""
return inventory_service.release_reservation(db, vendor.id, reservation)
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.release_reservation(db, vendor_id, reservation)
@router.post("/inventory/fulfill", response_model=InventoryResponse)
def fulfill_reservation(
reservation: InventoryReserve,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Fulfill reservation (complete order, remove from stock)."""
return inventory_service.fulfill_reservation(db, vendor.id, reservation)
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.fulfill_reservation(db, vendor_id, reservation)
@router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary)
def get_product_inventory(
product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get inventory summary for a product."""
return inventory_service.get_product_inventory(db, vendor.id, product_id)
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.get_product_inventory(db, vendor_id, product_id)
@router.get("/inventory", response_model=InventoryListResponse)
@@ -96,13 +107,13 @@ def get_vendor_inventory(
limit: int = Query(100, ge=1, le=1000),
location: str | None = Query(None),
low_stock: int | None = Query(None, ge=0),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get all inventory for vendor."""
vendor_id = _get_vendor_id_from_token(current_user)
inventories = inventory_service.get_vendor_inventory(
db, vendor.id, skip, limit, location, low_stock
db, vendor_id, skip, limit, location, low_stock
)
# Get total count
@@ -117,23 +128,23 @@ def get_vendor_inventory(
def update_inventory(
inventory_id: int,
inventory_update: InventoryUpdate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update inventory entry."""
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.update_inventory(
db, vendor.id, inventory_id, inventory_update
db, vendor_id, inventory_id, inventory_update
)
@router.delete("/inventory/{inventory_id}")
def delete_inventory(
inventory_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Delete inventory entry."""
inventory_service.delete_inventory(db, vendor.id, inventory_id)
vendor_id = _get_vendor_id_from_token(current_user)
inventory_service.delete_inventory(db, vendor_id, inventory_id)
return {"message": "Inventory deleted successfully"}

View File

@@ -1,7 +1,8 @@
# app/api/v1/vendor/marketplace.py # Note: Should be under /vendor/ route
# app/api/v1/vendor/marketplace.py
"""
Marketplace import endpoints for vendors.
Vendor context is automatically injected by middleware.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
@@ -11,37 +12,45 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException, UnauthorizedVendorAccessException
from app.services.marketplace_import_job_service import marketplace_import_job_service
from app.services.vendor_service import vendor_service
from app.tasks.background_tasks import process_marketplace_import
from middleware.decorators import rate_limit
from middleware.vendor_context import require_vendor_context # IMPORTANT
from models.database.user import User
from models.database.vendor import Vendor
from models.schema.marketplace_import_job import (
MarketplaceImportJobRequest,
MarketplaceImportJobResponse,
)
router = APIRouter()
router = APIRouter(prefix="/marketplace")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.post("/import", response_model=MarketplaceImportJobResponse)
@rate_limit(max_requests=10, window_seconds=3600)
async def import_products_from_marketplace(
request: MarketplaceImportJobRequest,
background_tasks: BackgroundTasks,
vendor: Vendor = Depends(require_vendor_context()), # ADDED: Vendor from middleware
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Import products from marketplace CSV with background processing (Protected)."""
vendor = _get_vendor_from_token(current_user, db)
logger.info(
f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} "
f"by user {current_user.username}"
)
# Create import job (vendor comes from middleware)
# Create import job (vendor comes from token)
import_job = marketplace_import_job_service.create_import_job(
db, request, vendor, current_user
)
@@ -50,9 +59,9 @@ async def import_products_from_marketplace(
background_tasks.add_task(
process_marketplace_import,
import_job.id,
request.source_url, # FIXED: was request.url
request.source_url,
request.marketplace,
vendor.id, # Pass vendor_id instead of vendor_code
vendor.id,
request.batch_size or 1000,
)
@@ -62,7 +71,7 @@ async def import_products_from_marketplace(
marketplace=request.marketplace,
vendor_id=import_job.vendor_id,
vendor_code=vendor.vendor_code,
vendor_name=vendor.name, # FIXED: from vendor object
vendor_name=vendor.name,
source_url=request.source_url,
message=f"Marketplace import started from {request.marketplace}. "
f"Check status with /import-status/{import_job.id}",
@@ -77,17 +86,16 @@ async def import_products_from_marketplace(
@router.get("/imports/{job_id}", response_model=MarketplaceImportJobResponse)
def get_marketplace_import_status(
job_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get status of marketplace import job (Protected)."""
vendor = _get_vendor_from_token(current_user, db)
job = marketplace_import_job_service.get_import_job_by_id(db, job_id, current_user)
# Verify job belongs to current vendor
if job.vendor_id != vendor.id:
from app.exceptions import UnauthorizedVendorAccessException
raise UnauthorizedVendorAccessException(vendor.vendor_code, current_user.id)
return marketplace_import_job_service.convert_to_response_model(job)
@@ -98,11 +106,12 @@ def get_marketplace_import_jobs(
marketplace: str | None = Query(None, description="Filter by marketplace"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get marketplace import jobs for current vendor (Protected)."""
vendor = _get_vendor_from_token(current_user, db)
jobs = marketplace_import_job_service.get_import_jobs(
db=db,
vendor=vendor,

View File

@@ -2,6 +2,8 @@
# app/api/v1/vendor/media.py
"""
Vendor media and file management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
@@ -11,21 +13,27 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/media")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("")
def get_media_library(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
media_type: str | None = Query(None, description="image, video, document"),
search: str | None = Query(None),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -39,6 +47,7 @@ def get_media_library(
- Support pagination
- Return file URLs, sizes, metadata
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"media": [],
"total": 0,
@@ -52,7 +61,6 @@ def get_media_library(
async def upload_media(
file: UploadFile = File(...),
folder: str | None = Query(None, description="products, general, etc."),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -67,6 +75,7 @@ async def upload_media(
- Save metadata to database
- Return file URL
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"file_url": None,
"thumbnail_url": None,
@@ -78,7 +87,6 @@ async def upload_media(
async def upload_multiple_media(
files: list[UploadFile] = File(...),
folder: str | None = Query(None),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -91,6 +99,7 @@ async def upload_multiple_media(
- Return list of uploaded file URLs
- Handle errors gracefully
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"uploaded_files": [],
"failed_files": [],
@@ -101,7 +110,6 @@ async def upload_multiple_media(
@router.get("/{media_id}")
def get_media_details(
media_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -113,6 +121,7 @@ def get_media_details(
- Return file URL
- Return usage information (which products use this file)
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media details coming in Slice 3"}
@@ -120,7 +129,6 @@ def get_media_details(
def update_media_metadata(
media_id: int,
metadata: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -133,13 +141,13 @@ def update_media_metadata(
- Update tags/categories
- Update description
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media update coming in Slice 3"}
@router.delete("/{media_id}")
def delete_media(
media_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -153,13 +161,13 @@ def delete_media(
- Delete database record
- Return success/error
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media deletion coming in Slice 3"}
@router.get("/{media_id}/usage")
def get_media_usage(
media_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -171,6 +179,7 @@ def get_media_usage(
- Check other entities using this media
- Return list of usage
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"products": [],
"other_usage": [],
@@ -181,7 +190,6 @@ def get_media_usage(
@router.post("/optimize/{media_id}")
def optimize_media(
media_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -194,4 +202,5 @@ def optimize_media(
- Keep original
- Update database with new versions
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media optimization coming in Slice 3"}

View File

@@ -2,6 +2,8 @@
# app/api/v1/vendor/notifications.py
"""
Vendor notification management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
@@ -11,20 +13,26 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/notifications")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("")
def get_notifications(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
unread_only: bool | None = Query(False),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -37,6 +45,7 @@ def get_notifications(
- Support pagination
- Return notification details
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"notifications": [],
"total": 0,
@@ -47,7 +56,6 @@ def get_notifications(
@router.get("/unread-count")
def get_unread_count(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -58,13 +66,13 @@ def get_unread_count(
- Count unread notifications for vendor
- Used for notification badge
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"unread_count": 0, "message": "Unread count coming in Slice 5"}
@router.put("/{notification_id}/read")
def mark_as_read(
notification_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -75,12 +83,12 @@ def mark_as_read(
- Mark single notification as read
- Update read timestamp
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Mark as read coming in Slice 5"}
@router.put("/mark-all-read")
def mark_all_as_read(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -91,13 +99,13 @@ def mark_all_as_read(
- Mark all vendor notifications as read
- Update timestamps
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Mark all as read coming in Slice 5"}
@router.delete("/{notification_id}")
def delete_notification(
notification_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -108,12 +116,12 @@ def delete_notification(
- Delete single notification
- Verify notification belongs to vendor
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Notification deletion coming in Slice 5"}
@router.get("/settings")
def get_notification_settings(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -125,6 +133,7 @@ def get_notification_settings(
- Get in-app notification settings
- Get notification types enabled/disabled
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"email_notifications": True,
"in_app_notifications": True,
@@ -136,7 +145,6 @@ def get_notification_settings(
@router.put("/settings")
def update_notification_settings(
settings: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -148,12 +156,12 @@ def update_notification_settings(
- Update in-app notification settings
- Enable/disable specific notification types
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Notification settings update coming in Slice 5"}
@router.get("/templates")
def get_notification_templates(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -165,6 +173,7 @@ def get_notification_templates(
- Include: order confirmation, shipping notification, etc.
- Return template details
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"templates": [], "message": "Notification templates coming in Slice 5"}
@@ -172,7 +181,6 @@ def get_notification_templates(
def update_notification_template(
template_id: int,
template_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -185,13 +193,13 @@ def update_notification_template(
- Validate template variables
- Preview template
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Template update coming in Slice 5"}
@router.post("/test")
def send_test_notification(
notification_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -203,4 +211,5 @@ def send_test_notification(
- Use specified template
- Send to current user's email
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Test notification coming in Slice 5"}

View File

@@ -2,6 +2,8 @@
# app/api/v1/vendor/payments.py
"""
Vendor payment configuration and processing endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
@@ -11,17 +13,23 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/payments")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("/config")
def get_payment_configuration(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -34,6 +42,7 @@ def get_payment_configuration(
- Get currency settings
- Return masked/secure information only
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"payment_gateway": None,
"accepted_methods": [],
@@ -46,7 +55,6 @@ def get_payment_configuration(
@router.put("/config")
def update_payment_configuration(
payment_config: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -59,13 +67,13 @@ def update_payment_configuration(
- Update accepted payment methods
- Validate configuration before saving
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Payment configuration update coming in Slice 5"}
@router.post("/stripe/connect")
def connect_stripe_account(
stripe_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -78,12 +86,12 @@ def connect_stripe_account(
- Verify Stripe account is active
- Enable payment processing
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Stripe connection coming in Slice 5"}
@router.delete("/stripe/disconnect")
def disconnect_stripe_account(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -95,12 +103,12 @@ def disconnect_stripe_account(
- Disable payment processing
- Warn about pending payments
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Stripe disconnection coming in Slice 5"}
@router.get("/methods")
def get_payment_methods(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -111,12 +119,12 @@ def get_payment_methods(
- Return list of enabled payment methods
- Include: credit card, PayPal, bank transfer, etc.
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"methods": [], "message": "Payment methods coming in Slice 5"}
@router.get("/transactions")
def get_payment_transactions(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -129,6 +137,7 @@ def get_payment_transactions(
- Include payment details
- Support pagination
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"transactions": [],
"total": 0,
@@ -138,7 +147,6 @@ def get_payment_transactions(
@router.get("/balance")
def get_payment_balance(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -151,6 +159,7 @@ def get_payment_balance(
- Get next payout date
- Get payout history
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"available_balance": 0.0,
"pending_balance": 0.0,
@@ -164,7 +173,6 @@ def get_payment_balance(
def refund_payment(
payment_id: int,
refund_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -177,4 +185,5 @@ def refund_payment(
- Update order status
- Send refund notification to customer
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Payment refund coming in Slice 5"}

View File

@@ -10,13 +10,16 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.product_service import product_service
from models.database.user import User
from models.schema.product import (
ProductCreate,
ProductDeleteResponse,
ProductDetailResponse,
ProductListResponse,
ProductResponse,
ProductToggleResponse,
ProductUpdate,
)
@@ -42,14 +45,9 @@ def get_vendor_products(
Vendor is determined from JWT token (vendor_id claim).
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -77,14 +75,9 @@ def get_product_details(
db: Session = Depends(get_db),
):
"""Get detailed product information including inventory."""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -106,14 +99,9 @@ def add_product_to_catalog(
This publishes a MarketplaceProduct to the vendor's public catalog.
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -137,14 +125,9 @@ def update_product(
db: Session = Depends(get_db),
):
"""Update product in vendor catalog."""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -160,21 +143,16 @@ def update_product(
return ProductResponse.model_validate(product)
@router.delete("/{product_id}")
@router.delete("/{product_id}", response_model=ProductDeleteResponse)
def remove_product_from_catalog(
product_id: int,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Remove product from vendor catalog."""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -185,7 +163,7 @@ def remove_product_from_catalog(
f"for vendor {current_user.token_vendor_code}"
)
return {"message": f"Product {product_id} removed from catalog"}
return ProductDeleteResponse(message=f"Product {product_id} removed from catalog")
@router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse)
@@ -199,14 +177,9 @@ def publish_from_marketplace(
Shortcut endpoint for publishing directly from marketplace import.
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -226,21 +199,16 @@ def publish_from_marketplace(
return ProductResponse.model_validate(product)
@router.put("/{product_id}/toggle-active")
@router.put("/{product_id}/toggle-active", response_model=ProductToggleResponse)
def toggle_product_active(
product_id: int,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Toggle product active status."""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -253,24 +221,19 @@ def toggle_product_active(
status = "activated" if product.is_active else "deactivated"
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
return {"message": f"Product {status}", "is_active": product.is_active}
return ProductToggleResponse(message=f"Product {status}", is_active=product.is_active)
@router.put("/{product_id}/toggle-featured")
@router.put("/{product_id}/toggle-featured", response_model=ProductToggleResponse)
def toggle_product_featured(
product_id: int,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Toggle product featured status."""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -283,4 +246,4 @@ def toggle_product_featured(
status = "featured" if product.is_featured else "unfeatured"
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
return {"message": f"Product {status}", "is_featured": product.is_featured}
return ProductToggleResponse(message=f"Product {status}", is_featured=product.is_featured)

View File

@@ -1,45 +1,54 @@
# app/api/v1/vendor/profile.py
"""
Vendor profile management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
from app.services.vendor_service import vendor_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User
from models.database.vendor import Vendor
from models.schema.vendor import VendorResponse, VendorUpdate
router = APIRouter(prefix="/profile")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("", response_model=VendorResponse)
def get_vendor_profile(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get current vendor profile information."""
vendor = _get_vendor_from_token(current_user, db)
return vendor
@router.put("", response_model=VendorResponse)
def update_vendor_profile(
vendor_update: VendorUpdate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update vendor profile information."""
vendor = _get_vendor_from_token(current_user, db)
# Verify user has permission to update vendor
if not vendor_service.can_update_vendor(vendor, current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions")
raise InsufficientPermissionsException(required_permission="vendor:profile:update")
return vendor_service.update_vendor(db, vendor.id, vendor_update)

View File

@@ -1,19 +1,20 @@
# app/api/v1/vendor/settings.py
"""
Vendor settings and configuration endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
from app.services.vendor_service import vendor_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/settings")
logger = logging.getLogger(__name__)
@@ -21,11 +22,16 @@ logger = logging.getLogger(__name__)
@router.get("")
def get_vendor_settings(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get vendor settings and configuration."""
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
return {
"vendor_code": vendor.vendor_code,
"subdomain": vendor.subdomain,
@@ -46,14 +52,21 @@ def get_vendor_settings(
@router.put("/marketplace")
def update_marketplace_settings(
marketplace_config: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update marketplace integration settings."""
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
# Verify permissions
if not vendor_service.can_update_vendor(vendor, current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions")
raise InsufficientPermissionsException(
required_permission="vendor:settings:update"
)
# Update Letzshop URLs
if "letzshop_csv_url_fr" in marketplace_config: