From 9aee314837c5324f75a1d3fd21de69fe866b9b0b Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 19 Oct 2025 16:03:25 +0200 Subject: [PATCH] vendor features for admin and vendor admin area --- app/api/v1/admin/vendors.py | 229 ++++++++++++++++--- app/api/v1/public/vendors/vendors.py | 128 +++++++++++ app/api/v1/vendor/analytics.py | 29 +++ app/api/v1/vendor/auth.py | 114 +++++++-- app/api/v1/vendor/customers.py | 157 ++++++++++++- app/api/v1/vendor/media.py | 207 ++++++++++++++++- app/api/v1/vendor/notifications.py | 223 ++++++++++++++++++ app/api/v1/vendor/payments.py | 189 +++++++++++++++ app/api/v1/vendor/profile.py | 44 ++++ app/api/v1/vendor/settings.py | 96 +++++++- app/api/v1/vendor/teams.py | 74 +++++- app/api/v1/vendor/vendor.py | 330 --------------------------- app/routes/frontend.py | 105 +++++---- models/schema/auth.py | 1 + models/schema/vendor.py | 238 +++++++++++++++---- 15 files changed, 1693 insertions(+), 471 deletions(-) create mode 100644 app/api/v1/public/vendors/vendors.py create mode 100644 app/api/v1/vendor/analytics.py create mode 100644 app/api/v1/vendor/profile.py delete mode 100644 app/api/v1/vendor/vendor.py diff --git a/app/api/v1/admin/vendors.py b/app/api/v1/admin/vendors.py index ae034ebe..452a4584 100644 --- a/app/api/v1/admin/vendors.py +++ b/app/api/v1/admin/vendors.py @@ -12,55 +12,89 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_user from app.core.database import get_db from app.services.admin_service import admin_service -from models.schema.vendor import VendorListResponse, VendorResponse, VendorCreate +from models.schema.vendor import ( + VendorListResponse, + VendorResponse, + VendorDetailResponse, + VendorCreate, + VendorCreateResponse, + VendorUpdate, + VendorTransferOwnership, + VendorTransferOwnershipResponse, +) from models.database.user import User router = APIRouter(prefix="/vendors") logger = logging.getLogger(__name__) -@router.post("", response_model=VendorResponse) +@router.post("", response_model=VendorCreateResponse) def create_vendor_with_owner( - vendor_data: VendorCreate, - db: Session = Depends(get_db), - current_admin: User = Depends(get_current_admin_user), + vendor_data: VendorCreate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), ): """ Create a new vendor with owner user account (Admin only). This endpoint: - 1. Creates a new vendor - 2. Creates an owner user account for the vendor - 3. Sets up default roles (Owner, Manager, Editor, Viewer) - 4. Sends welcome email to vendor owner (if email service configured) + 1. Creates a new vendor record + 2. Creates an owner user account with owner_email + 3. Sets contact_email (defaults to owner_email if not provided) + 4. Sets up default roles (Owner, Manager, Editor, Viewer) + 5. Returns credentials (temporary password shown ONCE) - Returns the created vendor with owner information. + **Email Fields:** + - `owner_email`: Used for owner's login/authentication (stored in users.email) + - `contact_email`: Public business contact (stored in vendors.contact_email) + + Returns vendor details with owner credentials. """ vendor, owner_user, temp_password = admin_service.create_vendor_with_owner( db=db, vendor_data=vendor_data ) - return { - **VendorResponse.model_validate(vendor).model_dump(), - "owner_email": owner_user.email, - "owner_username": owner_user.username, - "temporary_password": temp_password, # Only shown once! - "login_url": f"{vendor.subdomain}.platform.com/vendor/login" if vendor.subdomain else None - } + return VendorCreateResponse( + # Vendor fields + id=vendor.id, + vendor_code=vendor.vendor_code, + subdomain=vendor.subdomain, + name=vendor.name, + description=vendor.description, + owner_user_id=vendor.owner_user_id, + contact_email=vendor.contact_email, + contact_phone=vendor.contact_phone, + website=vendor.website, + business_address=vendor.business_address, + tax_number=vendor.tax_number, + letzshop_csv_url_fr=vendor.letzshop_csv_url_fr, + letzshop_csv_url_en=vendor.letzshop_csv_url_en, + letzshop_csv_url_de=vendor.letzshop_csv_url_de, + theme_config=vendor.theme_config or {}, + is_active=vendor.is_active, + is_verified=vendor.is_verified, + created_at=vendor.created_at, + updated_at=vendor.updated_at, + # Owner credentials + owner_email=owner_user.email, + owner_username=owner_user.username, + temporary_password=temp_password, + login_url=f"http://localhost:8000/vendor/{vendor.subdomain}/login" + ) @router.get("", response_model=VendorListResponse) def get_all_vendors_admin( - skip: int = Query(0, ge=0), - limit: int = Query(100, ge=1, le=1000), - search: Optional[str] = Query(None, description="Search by name or vendor code"), - is_active: Optional[bool] = Query(None), - is_verified: Optional[bool] = Query(None), - db: Session = Depends(get_db), - current_admin: User = Depends(get_current_admin_user), + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + search: Optional[str] = Query(None, description="Search by name or vendor code"), + is_active: Optional[bool] = Query(None), + is_verified: Optional[bool] = Query(None), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), ): - """Get all vendors with admin view (Admin only).""" + """Get all vendors with filtering (Admin only).""" vendors, total = admin_service.get_all_vendors( db=db, skip=skip, @@ -72,15 +106,144 @@ def get_all_vendors_admin( return VendorListResponse(vendors=vendors, total=total, skip=skip, limit=limit) -@router.get("/{vendor_id}", response_model=VendorResponse) +@router.get("/{vendor_id}", response_model=VendorDetailResponse) def get_vendor_details( - vendor_id: int, - db: Session = Depends(get_db), - current_admin: User = Depends(get_current_admin_user), + vendor_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), ): - """Get detailed vendor information (Admin only).""" + """ + Get detailed vendor information including owner details (Admin only). + + Returns both: + - `contact_email` (business contact) + - `owner_email` (owner's authentication email) + """ vendor = admin_service.get_vendor_by_id(db, vendor_id) - return VendorResponse.model_validate(vendor) + + return VendorDetailResponse( + # Vendor fields + id=vendor.id, + vendor_code=vendor.vendor_code, + subdomain=vendor.subdomain, + name=vendor.name, + description=vendor.description, + owner_user_id=vendor.owner_user_id, + contact_email=vendor.contact_email, + contact_phone=vendor.contact_phone, + website=vendor.website, + business_address=vendor.business_address, + tax_number=vendor.tax_number, + letzshop_csv_url_fr=vendor.letzshop_csv_url_fr, + letzshop_csv_url_en=vendor.letzshop_csv_url_en, + letzshop_csv_url_de=vendor.letzshop_csv_url_de, + theme_config=vendor.theme_config or {}, + is_active=vendor.is_active, + is_verified=vendor.is_verified, + created_at=vendor.created_at, + updated_at=vendor.updated_at, + # Owner details + owner_email=vendor.owner.email, + owner_username=vendor.owner.username, + ) + + +@router.put("/{vendor_id}", response_model=VendorDetailResponse) +def update_vendor( + vendor_id: int, + vendor_update: VendorUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + """ + Update vendor information (Admin only). + + **Can update:** + - Basic info: name, description, subdomain + - Business contact: contact_email, contact_phone, website + - Business details: business_address, tax_number + - Marketplace URLs + - Status: is_active, is_verified + + **Cannot update:** + - `owner_email` (use POST /vendors/{id}/transfer-ownership) + - `vendor_code` (immutable) + - `owner_user_id` (use POST /vendors/{id}/transfer-ownership) + """ + vendor = admin_service.update_vendor(db, vendor_id, vendor_update) + + return VendorDetailResponse( + id=vendor.id, + vendor_code=vendor.vendor_code, + subdomain=vendor.subdomain, + name=vendor.name, + description=vendor.description, + owner_user_id=vendor.owner_user_id, + contact_email=vendor.contact_email, + contact_phone=vendor.contact_phone, + website=vendor.website, + business_address=vendor.business_address, + tax_number=vendor.tax_number, + letzshop_csv_url_fr=vendor.letzshop_csv_url_fr, + letzshop_csv_url_en=vendor.letzshop_csv_url_en, + letzshop_csv_url_de=vendor.letzshop_csv_url_de, + theme_config=vendor.theme_config or {}, + is_active=vendor.is_active, + is_verified=vendor.is_verified, + created_at=vendor.created_at, + updated_at=vendor.updated_at, + owner_email=vendor.owner.email, + owner_username=vendor.owner.username, + ) + + +@router.post("/{vendor_id}/transfer-ownership", response_model=VendorTransferOwnershipResponse) +def transfer_vendor_ownership( + vendor_id: int, + transfer_data: VendorTransferOwnership, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + """ + Transfer vendor ownership to another user (Admin only). + + **This is a critical operation that:** + - Changes the owner_user_id + - Assigns new owner to "Owner" role + - Demotes old owner to "Manager" role (or removes them) + - Creates audit trail + + ⚠️ **This action is logged and should be used carefully.** + + **Requires:** + - `new_owner_user_id`: ID of user who will become owner + - `confirm_transfer`: Must be true + - `transfer_reason`: Optional reason for audit trail + """ + from datetime import datetime, timezone + + vendor, old_owner, new_owner = admin_service.transfer_vendor_ownership( + db, vendor_id, transfer_data + ) + + return VendorTransferOwnershipResponse( + message="Ownership transferred successfully", + vendor_id=vendor.id, + vendor_code=vendor.vendor_code, + vendor_name=vendor.name, + old_owner={ + "id": old_owner.id, + "username": old_owner.username, + "email": old_owner.email, + }, + new_owner={ + "id": new_owner.id, + "username": new_owner.username, + "email": new_owner.email, + }, + transferred_at=datetime.now(timezone.utc), + transfer_reason=transfer_data.transfer_reason, + ) @router.put("/{vendor_id}/verify") @@ -115,14 +278,14 @@ def delete_vendor( """ Delete vendor and all associated data (Admin only). - WARNING: This is destructive and will delete: + ⚠️ **WARNING: This is destructive and will delete:** - Vendor account - All products - All orders - All customers - All team members - Requires confirmation parameter. + Requires confirmation parameter: `confirm=true` """ if not confirm: raise HTTPException( diff --git a/app/api/v1/public/vendors/vendors.py b/app/api/v1/public/vendors/vendors.py new file mode 100644 index 00000000..88242760 --- /dev/null +++ b/app/api/v1/public/vendors/vendors.py @@ -0,0 +1,128 @@ +# app/api/v1/public/vendors/vendors.py +""" +Public vendor information endpoints. + +Provides public-facing vendor lookup and information retrieval. +""" + +import logging +from fastapi import APIRouter, Depends, HTTPException, Path +from sqlalchemy.orm import Session + +from app.core.database import get_db +from models.database.vendor import Vendor + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.get("/by-code/{vendor_code}") +def get_vendor_by_code( + vendor_code: str = Path(..., description="Vendor code (e.g., TECHSTORE233)"), + db: Session = Depends(get_db) +): + """ + Get public vendor information by vendor code. + + This endpoint is used by: + - Frontend vendor login page to validate vendor existence + - Customer shop to display vendor information + + Returns basic vendor information (no sensitive data). + """ + vendor = db.query(Vendor).filter( + Vendor.vendor_code == vendor_code.upper(), + Vendor.is_active == True + ).first() + + if not vendor: + logger.warning(f"Vendor lookup failed for code: {vendor_code}") + raise HTTPException( + status_code=404, + detail=f"Vendor '{vendor_code}' not found or inactive" + ) + + logger.info(f"Vendor lookup successful: {vendor.vendor_code}") + + # Return public vendor information (no sensitive data) + return { + "id": vendor.id, + "vendor_code": vendor.vendor_code, + "subdomain": vendor.subdomain, + "name": vendor.name, + "description": vendor.description, + "website": vendor.website, + "is_active": vendor.is_active, + "is_verified": vendor.is_verified + } + + +@router.get("/by-subdomain/{subdomain}") +def get_vendor_by_subdomain( + subdomain: str = Path(..., description="Vendor subdomain (e.g., techstore233)"), + db: Session = Depends(get_db) +): + """ + Get public vendor information by subdomain. + + Used for subdomain-based vendor detection in production environments. + Example: techstore233.platform.com -> subdomain is "techstore233" + """ + vendor = db.query(Vendor).filter( + Vendor.subdomain == subdomain.lower(), + Vendor.is_active == True + ).first() + + if not vendor: + logger.warning(f"Vendor lookup failed for subdomain: {subdomain}") + raise HTTPException( + status_code=404, + detail=f"Vendor with subdomain '{subdomain}' not found or inactive" + ) + + logger.info(f"Vendor lookup by subdomain successful: {vendor.vendor_code}") + + return { + "id": vendor.id, + "vendor_code": vendor.vendor_code, + "subdomain": vendor.subdomain, + "name": vendor.name, + "description": vendor.description, + "website": vendor.website, + "is_active": vendor.is_active, + "is_verified": vendor.is_verified + } + + +@router.get("/{vendor_id}/info") +def get_vendor_info( + vendor_id: int = Path(..., description="Vendor ID"), + db: Session = Depends(get_db) +): + """ + Get public vendor information by ID. + + Used when vendor_id is already known (e.g., from URL parameters). + """ + vendor = db.query(Vendor).filter( + Vendor.id == vendor_id, + Vendor.is_active == True + ).first() + + if not vendor: + logger.warning(f"Vendor lookup failed for ID: {vendor_id}") + raise HTTPException( + status_code=404, + detail=f"Vendor with ID {vendor_id} not found or inactive" + ) + + return { + "id": vendor.id, + "vendor_code": vendor.vendor_code, + "subdomain": vendor.subdomain, + "name": vendor.name, + "description": vendor.description, + "website": vendor.website, + "is_active": vendor.is_active, + "is_verified": vendor.is_verified + } diff --git a/app/api/v1/vendor/analytics.py b/app/api/v1/vendor/analytics.py new file mode 100644 index 00000000..69266e8a --- /dev/null +++ b/app/api/v1/vendor/analytics.py @@ -0,0 +1,29 @@ +# app/api/v1/vendor/analytics.py +""" +Vendor analytics and reporting endpoints. +""" + +import logging +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.core.database import get_db +from middleware.vendor_context import require_vendor_context +from app.services.stats_service import stats_service +from models.database.user import User +from models.database.vendor import Vendor + +router = APIRouter(prefix="/analytics") +logger = logging.getLogger(__name__) + + +@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_user), + db: Session = Depends(get_db), +): + """Get vendor analytics data for specified time period.""" + return stats_service.get_vendor_analytics(db, vendor.id, period) diff --git a/app/api/v1/vendor/auth.py b/app/api/v1/vendor/auth.py index 91b32ecc..df4c1fac 100644 --- a/app/api/v1/vendor/auth.py +++ b/app/api/v1/vendor/auth.py @@ -9,21 +9,32 @@ This module provides: """ import logging -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request, HTTPException from sqlalchemy.orm import Session from app.core.database import get_db from app.services.auth_service import auth_service from app.exceptions import InvalidCredentialsException from middleware.vendor_context import get_current_vendor -from models.schema.auth import LoginResponse, UserLogin -from models.database.vendor import Vendor +from models.schema.auth import UserLogin +from models.database.vendor import Vendor, VendorUser, Role +from pydantic import BaseModel -router = APIRouter() +router = APIRouter(prefix="/auth") logger = logging.getLogger(__name__) -@router.post("/login", response_model=LoginResponse) +# Response model for vendor login +class VendorLoginResponse(BaseModel): + access_token: str + token_type: str + expires_in: int + user: dict + vendor: dict + vendor_role: str + + +@router.post("/login", response_model=VendorLoginResponse) def vendor_login( user_credentials: UserLogin, request: Request, @@ -35,6 +46,18 @@ def vendor_login( Authenticates users who are part of a vendor team. Validates against vendor context if available. """ + # Try to get vendor from middleware first + vendor = get_current_vendor(request) + + # If no vendor from middleware, try to get from request body + 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() + # Authenticate user login_result = auth_service.login_user(db=db, user_credentials=user_credentials) user = login_result["user"] @@ -44,32 +67,79 @@ def vendor_login( logger.warning(f"Admin user attempted vendor login: {user.username}") raise InvalidCredentialsException("Please use admin portal to login") - # Optional: Validate user belongs to current vendor context - vendor = get_current_vendor(request) + # Determine vendor and role + vendor_role = "Member" + if vendor: - # Check if user is vendor owner or team member - is_owner = any(v.id == vendor.id for v in user.owned_vendors) - is_team_member = any( - vm.vendor_id == vendor.id and vm.is_active - for vm in user.vendor_memberships - ) + # Check if user is vendor owner + is_owner = vendor.owner_user_id == user.id - if not (is_owner or is_team_member): - logger.warning( - f"User {user.username} attempted login to vendor {vendor.vendor_code} " - f"but is not authorized" + if is_owner: + vendor_role = "Owner" + 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() + + 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 + if user.owned_vendors: + vendor = user.owned_vendors[0] + vendor_role = "Owner" + # Check vendor memberships + elif 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 + + if not vendor: raise InvalidCredentialsException( - "You do not have access to this vendor" + "User is not associated with any vendor" ) - logger.info(f"Vendor team login successful: {user.username}") + logger.info( + f"Vendor team login successful: {user.username} " + f"for vendor {vendor.vendor_code} as {vendor_role}" + ) - return LoginResponse( + return VendorLoginResponse( access_token=login_result["token_data"]["access_token"], token_type=login_result["token_data"]["token_type"], expires_in=login_result["token_data"]["expires_in"], - user=login_result["user"], + user={ + "id": user.id, + "username": user.username, + "email": user.email, + "role": user.role, + "is_active": user.is_active + }, + vendor={ + "id": vendor.id, + "vendor_code": vendor.vendor_code, + "subdomain": vendor.subdomain, + "name": vendor.name, + "is_active": vendor.is_active, + "is_verified": vendor.is_verified + }, + vendor_role=vendor_role ) @@ -80,4 +150,4 @@ def vendor_logout(): Client should remove token from storage. """ - return {"message": "Logged out successfully"} + return {"message": "Logged out successfully"} \ No newline at end of file diff --git a/app/api/v1/vendor/customers.py b/app/api/v1/vendor/customers.py index e0c6d1ea..1156d0c3 100644 --- a/app/api/v1/vendor/customers.py +++ b/app/api/v1/vendor/customers.py @@ -1 +1,156 @@ -# Vendor customer management +# Vendor customer management +# app/api/v1/vendor/customers.py +""" +Vendor customer management endpoints. +""" + +import logging +from typing import Optional +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.core.database import get_db +from middleware.vendor_context import require_vendor_context +from models.database.user import User +from models.database.vendor import Vendor + +router = APIRouter(prefix="/customers") +logger = logging.getLogger(__name__) + + +@router.get("") +def get_vendor_customers( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + search: Optional[str] = Query(None), + is_active: Optional[bool] = Query(None), + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Get all customers for this vendor. + + TODO: Implement in Slice 4 + - Query customers filtered by vendor_id + - Support search by name/email + - Support filtering by active status + - Return paginated results + """ + return { + "customers": [], + "total": 0, + "skip": skip, + "limit": limit, + "message": "Customer management coming in Slice 4" + } + + +@router.get("/{customer_id}") +def get_customer_details( + customer_id: int, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Get detailed customer information. + + TODO: Implement in Slice 4 + - Get customer by ID + - Verify customer belongs to vendor + - Include order history + - Include total spent, etc. + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Get order history for a specific customer. + + TODO: Implement in Slice 5 + - Get all orders for customer + - Filter by vendor_id + - Return order details + """ + return { + "orders": [], + "message": "Customer orders coming in Slice 5" + } + + +@router.put("/{customer_id}") +def update_customer( + customer_id: int, + customer_data: dict, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Update customer information. + + TODO: Implement in Slice 4 + - Update customer details + - Verify customer belongs to vendor + - Update customer preferences + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Activate/deactivate customer account. + + TODO: Implement in Slice 4 + - Toggle customer is_active status + - Verify customer belongs to vendor + - Log the change + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Get customer statistics and metrics. + + TODO: Implement in Slice 4 + - Total orders + - Total spent + - Average order value + - Last order date + """ + return { + "total_orders": 0, + "total_spent": 0.0, + "average_order_value": 0.0, + "last_order_date": None, + "message": "Customer statistics coming in Slice 4" + } + diff --git a/app/api/v1/vendor/media.py b/app/api/v1/vendor/media.py index e6751751..e3ccf272 100644 --- a/app/api/v1/vendor/media.py +++ b/app/api/v1/vendor/media.py @@ -1 +1,206 @@ -# File and media management +# File and media management +# app/api/v1/vendor/media.py +""" +Vendor media and file management endpoints. +""" + +import logging +from typing import Optional +from fastapi import APIRouter, Depends, Query, UploadFile, File +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.core.database import get_db +from middleware.vendor_context import require_vendor_context +from models.database.user import User +from models.database.vendor import Vendor + +router = APIRouter(prefix="/media") +logger = logging.getLogger(__name__) + + +@router.get("") +def get_media_library( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + media_type: Optional[str] = Query(None, description="image, video, document"), + search: Optional[str] = Query(None), + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Get vendor media library. + + TODO: Implement in Slice 3 + - Get all media files for vendor + - Filter by type (image, video, document) + - Search by filename + - Support pagination + - Return file URLs, sizes, metadata + """ + return { + "media": [], + "total": 0, + "skip": skip, + "limit": limit, + "message": "Media library coming in Slice 3" + } + + +@router.post("/upload") +async def upload_media( + file: UploadFile = File(...), + folder: Optional[str] = Query(None, description="products, general, etc."), + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Upload media file. + + TODO: Implement in Slice 3 + - Accept file upload + - Validate file type and size + - Store file (local or cloud storage) + - Generate thumbnails for images + - Save metadata to database + - Return file URL + """ + return { + "file_url": None, + "thumbnail_url": None, + "message": "Media upload coming in Slice 3" + } + + +@router.post("/upload/multiple") +async def upload_multiple_media( + files: list[UploadFile] = File(...), + folder: Optional[str] = Query(None), + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Upload multiple media files at once. + + TODO: Implement in Slice 3 + - Accept multiple files + - Process each file + - Return list of uploaded file URLs + - Handle errors gracefully + """ + return { + "uploaded_files": [], + "failed_files": [], + "message": "Multiple upload coming in Slice 3" + } + + +@router.get("/{media_id}") +def get_media_details( + media_id: int, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Get media file details. + + TODO: Implement in Slice 3 + - Get file metadata + - Return file URL + - Return usage information (which products use this file) + """ + return { + "message": "Media details coming in Slice 3" + } + + +@router.put("/{media_id}") +def update_media_metadata( + media_id: int, + metadata: dict, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Update media file metadata. + + TODO: Implement in Slice 3 + - Update filename + - Update alt text + - Update tags/categories + - Update description + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Delete media file. + + TODO: Implement in Slice 3 + - Verify file belongs to vendor + - Check if file is in use by products + - Delete file from storage + - Delete database record + - Return success/error + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Get where this media file is being used. + + TODO: Implement in Slice 3 + - Check products using this media + - Check other entities using this media + - Return list of usage + """ + return { + "products": [], + "other_usage": [], + "message": "Media usage tracking coming in Slice 3" + } + + +@router.post("/optimize/{media_id}") +def optimize_media( + media_id: int, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Optimize media file (compress, resize, etc.). + + TODO: Implement in Slice 3 + - Optimize image (compress, resize) + - Generate multiple sizes + - Keep original + - Update database with new versions + """ + return { + "message": "Media optimization coming in Slice 3" + } + diff --git a/app/api/v1/vendor/notifications.py b/app/api/v1/vendor/notifications.py index 2154ca57..835bd536 100644 --- a/app/api/v1/vendor/notifications.py +++ b/app/api/v1/vendor/notifications.py @@ -1 +1,224 @@ # Notification management +# app/api/v1/vendor/notifications.py +""" +Vendor notification management endpoints. +""" + +import logging +from typing import Optional +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.core.database import get_db +from middleware.vendor_context import require_vendor_context +from models.database.user import User +from models.database.vendor import Vendor + +router = APIRouter(prefix="/notifications") +logger = logging.getLogger(__name__) + + +@router.get("") +def get_notifications( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + unread_only: Optional[bool] = Query(False), + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Get vendor notifications. + + TODO: Implement in Slice 5 + - Get all notifications for vendor + - Filter by read/unread status + - Support pagination + - Return notification details + """ + return { + "notifications": [], + "total": 0, + "unread_count": 0, + "message": "Notifications coming in Slice 5" + } + + +@router.get("/unread-count") +def get_unread_count( + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Get count of unread notifications. + + TODO: Implement in Slice 5 + - Count unread notifications for vendor + - Used for notification badge + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Mark notification as read. + + TODO: Implement in Slice 5 + - Mark single notification as read + - Update read timestamp + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Mark all notifications as read. + + TODO: Implement in Slice 5 + - Mark all vendor notifications as read + - Update timestamps + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Delete notification. + + TODO: Implement in Slice 5 + - Delete single notification + - Verify notification belongs to vendor + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Get notification preferences. + + TODO: Implement in Slice 5 + - Get email notification settings + - Get in-app notification settings + - Get notification types enabled/disabled + """ + return { + "email_notifications": True, + "in_app_notifications": True, + "notification_types": {}, + "message": "Notification settings coming in Slice 5" + } + + +@router.put("/settings") +def update_notification_settings( + settings: dict, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Update notification preferences. + + TODO: Implement in Slice 5 + - Update email notification settings + - Update in-app notification settings + - Enable/disable specific notification types + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Get notification email templates. + + TODO: Implement in Slice 5 + - Get all notification templates + - Include: order confirmation, shipping notification, etc. + - Return template details + """ + return { + "templates": [], + "message": "Notification templates coming in Slice 5" + } + + +@router.put("/templates/{template_id}") +def update_notification_template( + template_id: int, + template_data: dict, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Update notification email template. + + TODO: Implement in Slice 5 + - Update template subject + - Update template body (HTML/text) + - Validate template variables + - Preview template + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Send test notification. + + TODO: Implement in Slice 5 + - Send test email notification + - Use specified template + - Send to current user's email + """ + return { + "message": "Test notification coming in Slice 5" + } diff --git a/app/api/v1/vendor/payments.py b/app/api/v1/vendor/payments.py index 2136b212..c8b154cf 100644 --- a/app/api/v1/vendor/payments.py +++ b/app/api/v1/vendor/payments.py @@ -1 +1,190 @@ # Payment configuration and processing +# app/api/v1/vendor/payments.py +""" +Vendor payment configuration and processing endpoints. +""" + +import logging +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.core.database import get_db +from middleware.vendor_context import require_vendor_context +from models.database.user import User +from models.database.vendor import Vendor + +router = APIRouter(prefix="/payments") +logger = logging.getLogger(__name__) + + +@router.get("/config") +def get_payment_configuration( + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Get vendor payment configuration. + + TODO: Implement in Slice 5 + - Get payment gateway settings (Stripe, PayPal, etc.) + - Get accepted payment methods + - Get currency settings + - Return masked/secure information only + """ + return { + "payment_gateway": None, + "accepted_methods": [], + "currency": "EUR", + "stripe_connected": False, + "message": "Payment configuration coming in Slice 5" + } + + +@router.put("/config") +def update_payment_configuration( + payment_config: dict, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Update vendor payment configuration. + + TODO: Implement in Slice 5 + - Update payment gateway settings + - Connect/disconnect Stripe account + - Update accepted payment methods + - Validate configuration before saving + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Connect Stripe account for payment processing. + + TODO: Implement in Slice 5 + - Handle Stripe OAuth flow + - Store Stripe account ID securely + - Verify Stripe account is active + - Enable payment processing + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Disconnect Stripe account. + + TODO: Implement in Slice 5 + - Remove Stripe account connection + - Disable payment processing + - Warn about pending payments + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Get accepted payment methods for vendor. + + TODO: Implement in Slice 5 + - Return list of enabled payment methods + - Include: credit card, PayPal, bank transfer, etc. + """ + 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_user), + db: Session = Depends(get_db), +): + """ + Get payment transaction history. + + TODO: Implement in Slice 5 + - Get all payment transactions for vendor + - Filter by date range, status, etc. + - Include payment details + - Support pagination + """ + return { + "transactions": [], + "total": 0, + "message": "Payment transactions coming in Slice 5" + } + + +@router.get("/balance") +def get_payment_balance( + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Get vendor payment balance and payout information. + + TODO: Implement in Slice 5 + - Get available balance + - Get pending balance + - Get next payout date + - Get payout history + """ + return { + "available_balance": 0.0, + "pending_balance": 0.0, + "currency": "EUR", + "next_payout_date": None, + "message": "Payment balance coming in Slice 5" + } + + +@router.post("/refund/{payment_id}") +def refund_payment( + payment_id: int, + refund_data: dict, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Process payment refund. + + TODO: Implement in Slice 5 + - Verify payment belongs to vendor + - Process refund through payment gateway + - Update order status + - Send refund notification to customer + """ + return { + "message": "Payment refund coming in Slice 5" + } diff --git a/app/api/v1/vendor/profile.py b/app/api/v1/vendor/profile.py new file mode 100644 index 00000000..32df0155 --- /dev/null +++ b/app/api/v1/vendor/profile.py @@ -0,0 +1,44 @@ +# app/api/v1/vendor/profile.py +""" +Vendor profile management endpoints. +""" + +import logging +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.core.database import get_db +from middleware.vendor_context import require_vendor_context +from app.services.vendor_service import vendor_service +from models.schema.vendor import VendorUpdate, VendorResponse +from models.database.user import User +from models.database.vendor import Vendor + +router = APIRouter(prefix="/profile") +logger = logging.getLogger(__name__) + + +@router.get("", response_model=VendorResponse) +def get_vendor_profile( + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get current vendor profile information.""" + 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_user), + db: Session = Depends(get_db), +): + """Update vendor profile information.""" + # 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") + + return vendor_service.update_vendor(db, vendor.id, vendor_update) diff --git a/app/api/v1/vendor/settings.py b/app/api/v1/vendor/settings.py index 9d16efa5..3e45ff58 100644 --- a/app/api/v1/vendor/settings.py +++ b/app/api/v1/vendor/settings.py @@ -1 +1,95 @@ -# Vendor settings and configuration +# app/api/v1/vendor/settings.py +""" +Vendor settings and configuration endpoints. +""" + +import logging +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.core.database import get_db +from middleware.vendor_context import require_vendor_context +from app.services.vendor_service import vendor_service +from models.database.user import User +from models.database.vendor import Vendor + +router = APIRouter(prefix="/settings") +logger = logging.getLogger(__name__) + + +@router.get("") +def get_vendor_settings( + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get vendor settings and configuration.""" + return { + "vendor_code": vendor.vendor_code, + "subdomain": vendor.subdomain, + "name": vendor.name, + "contact_email": vendor.contact_email, + "contact_phone": vendor.contact_phone, + "website": vendor.website, + "business_address": vendor.business_address, + "tax_number": vendor.tax_number, + "letzshop_csv_url_fr": vendor.letzshop_csv_url_fr, + "letzshop_csv_url_en": vendor.letzshop_csv_url_en, + "letzshop_csv_url_de": vendor.letzshop_csv_url_de, + "theme_config": vendor.theme_config, + "is_active": vendor.is_active, + "is_verified": vendor.is_verified, + } + + +@router.put("/marketplace") +def update_marketplace_settings( + marketplace_config: dict, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Update marketplace integration settings.""" + # Verify permissions + if not vendor_service.can_update_vendor(vendor, current_user): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + # Update Letzshop URLs + if "letzshop_csv_url_fr" in marketplace_config: + vendor.letzshop_csv_url_fr = marketplace_config["letzshop_csv_url_fr"] + if "letzshop_csv_url_en" in marketplace_config: + vendor.letzshop_csv_url_en = marketplace_config["letzshop_csv_url_en"] + if "letzshop_csv_url_de" in marketplace_config: + vendor.letzshop_csv_url_de = marketplace_config["letzshop_csv_url_de"] + + db.commit() + db.refresh(vendor) + + return { + "message": "Marketplace settings updated successfully", + "letzshop_csv_url_fr": vendor.letzshop_csv_url_fr, + "letzshop_csv_url_en": vendor.letzshop_csv_url_en, + "letzshop_csv_url_de": vendor.letzshop_csv_url_de, + } + + +@router.put("/theme") +def update_theme_settings( + theme_config: dict, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Update vendor theme configuration.""" + if not vendor_service.can_update_vendor(vendor, current_user): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + vendor.theme_config = theme_config + db.commit() + db.refresh(vendor) + + return { + "message": "Theme settings updated successfully", + "theme_config": vendor.theme_config, + } diff --git a/app/api/v1/vendor/teams.py b/app/api/v1/vendor/teams.py index b8dcf518..819999e2 100644 --- a/app/api/v1/vendor/teams.py +++ b/app/api/v1/vendor/teams.py @@ -1 +1,73 @@ -# Team member management +# app/api/v1/vendor/teams.py +""" +Vendor team member management endpoints. +""" + +import logging +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.core.database import get_db +from middleware.vendor_context import require_vendor_context +from app.services.team_service import team_service +from models.database.user import User +from models.database.vendor import Vendor + +router = APIRouter(prefix="/teams") +logger = logging.getLogger(__name__) + + +@router.get("/members") +def get_team_members( + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get all team members for vendor.""" + return team_service.get_team_members(db, vendor.id, current_user) + + +@router.post("/invite") +def invite_team_member( + invitation_data: dict, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Invite a new team member.""" + return team_service.invite_team_member(db, vendor.id, invitation_data, current_user) + + +@router.put("/members/{user_id}") +def update_team_member( + user_id: int, + update_data: dict, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Update team member role or status.""" + return team_service.update_team_member(db, vendor.id, user_id, update_data, current_user) + + +@router.delete("/members/{user_id}") +def remove_team_member( + user_id: int, + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Remove team member from vendor.""" + team_service.remove_team_member(db, vendor.id, user_id, current_user) + return {"message": "Team member removed successfully"} + + +@router.get("/roles") +def get_team_roles( + vendor: Vendor = Depends(require_vendor_context()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get available roles for vendor team.""" + return team_service.get_vendor_roles(db, vendor.id) diff --git a/app/api/v1/vendor/vendor.py b/app/api/v1/vendor/vendor.py deleted file mode 100644 index 7942a0b0..00000000 --- a/app/api/v1/vendor/vendor.py +++ /dev/null @@ -1,330 +0,0 @@ -# app/api/v1/vendor/vendor.py -""" -Vendor management endpoints for vendor-scoped operations. - -This module provides: -- Vendor profile management -- Vendor settings configuration -- Vendor team member management -- Vendor dashboard statistics -""" - -import logging -from typing import List, Optional - -from fastapi import APIRouter, Depends, Query -from sqlalchemy.orm import Session - -from app.api.deps import get_current_user -from app.core.database import get_db -from middleware.vendor_context import require_vendor_context -from app.services.vendor_service import vendor_service -from app.services.team_service import team_service -from models.schema.vendor import VendorUpdate, VendorResponse -from models.schema.product import ProductResponse, ProductListResponse -from models.database.user import User -from models.database.vendor import Vendor - -router = APIRouter() -logger = logging.getLogger(__name__) - - -# ============================================================================ -# VENDOR PROFILE ENDPOINTS -# ============================================================================ - -@router.get("/profile", response_model=VendorResponse) -def get_vendor_profile( - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Get current vendor profile information.""" - return vendor - - -@router.put("/profile", response_model=VendorResponse) -def update_vendor_profile( - vendor_update: VendorUpdate, - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Update vendor profile information.""" - # Verify user has permission to update vendor - if not vendor_service.can_update_vendor(vendor, current_user): - from fastapi import HTTPException - raise HTTPException(status_code=403, detail="Insufficient permissions") - - return vendor_service.update_vendor(db, vendor.id, vendor_update) - - -# ============================================================================ -# VENDOR SETTINGS ENDPOINTS -# ============================================================================ - -@router.get("/settings") -def get_vendor_settings( - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Get vendor settings and configuration.""" - return { - "vendor_code": vendor.vendor_code, - "subdomain": vendor.subdomain, - "name": vendor.name, - "contact_email": vendor.contact_email, - "contact_phone": vendor.contact_phone, - "website": vendor.website, - "business_address": vendor.business_address, - "tax_number": vendor.tax_number, - "letzshop_csv_url_fr": vendor.letzshop_csv_url_fr, - "letzshop_csv_url_en": vendor.letzshop_csv_url_en, - "letzshop_csv_url_de": vendor.letzshop_csv_url_de, - "theme_config": vendor.theme_config, - "is_active": vendor.is_active, - "is_verified": vendor.is_verified, - } - - -@router.put("/settings/marketplace") -def update_marketplace_settings( - marketplace_config: dict, - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Update marketplace integration settings.""" - # Verify permissions - if not vendor_service.can_update_vendor(vendor, current_user): - from fastapi import HTTPException - raise HTTPException(status_code=403, detail="Insufficient permissions") - - # Update Letzshop URLs - if "letzshop_csv_url_fr" in marketplace_config: - vendor.letzshop_csv_url_fr = marketplace_config["letzshop_csv_url_fr"] - if "letzshop_csv_url_en" in marketplace_config: - vendor.letzshop_csv_url_en = marketplace_config["letzshop_csv_url_en"] - if "letzshop_csv_url_de" in marketplace_config: - vendor.letzshop_csv_url_de = marketplace_config["letzshop_csv_url_de"] - - db.commit() - db.refresh(vendor) - - return { - "message": "Marketplace settings updated successfully", - "letzshop_csv_url_fr": vendor.letzshop_csv_url_fr, - "letzshop_csv_url_en": vendor.letzshop_csv_url_en, - "letzshop_csv_url_de": vendor.letzshop_csv_url_de, - } - - -@router.put("/settings/theme") -def update_theme_settings( - theme_config: dict, - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Update vendor theme configuration.""" - if not vendor_service.can_update_vendor(vendor, current_user): - from fastapi import HTTPException - raise HTTPException(status_code=403, detail="Insufficient permissions") - - vendor.theme_config = theme_config - db.commit() - db.refresh(vendor) - - return { - "message": "Theme settings updated successfully", - "theme_config": vendor.theme_config, - } - - -# ============================================================================ -# VENDOR CATALOG ENDPOINTS -# ============================================================================ - -@router.get("/products", response_model=ProductListResponse) -def get_vendor_products( - skip: int = Query(0, ge=0), - limit: int = Query(100, ge=1, le=1000), - is_active: Optional[bool] = Query(None), - is_featured: Optional[bool] = Query(None), - search: Optional[str] = Query(None), - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Get all products in vendor catalog.""" - products, total = vendor_service.get_products( - db=db, - vendor=vendor, - current_user=current_user, - skip=skip, - limit=limit, - active_only=is_active, - featured_only=is_featured, - ) - - return ProductListResponse( - products=products, - total=total, - skip=skip, - limit=limit - ) - - -@router.post("/products", response_model=ProductResponse) -def add_product_to_catalog( - product_data: dict, - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Add a product from marketplace to vendor catalog.""" - from models.schema.product import ProductCreate - - product_create = ProductCreate(**product_data) - return vendor_service.add_product_to_catalog(db, vendor, product_create) - - -@router.get("/products/{product_id}", response_model=ProductResponse) -def get_vendor_product( - product_id: int, - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Get a specific product from vendor catalog.""" - from app.services.product_service import product_service - return product_service.get_product(db, vendor.id, product_id) - - -@router.put("/products/{product_id}", response_model=ProductResponse) -def update_vendor_product( - product_id: int, - product_update: dict, - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Update product in vendor catalog.""" - from app.services.product_service import product_service - from models.schema.product import ProductUpdate - - product_update_schema = ProductUpdate(**product_update) - return product_service.update_product(db, vendor.id, product_id, product_update_schema) - - -@router.delete("/products/{product_id}") -def remove_product_from_catalog( - product_id: int, - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Remove product from vendor catalog.""" - from app.services.product_service import product_service - product_service.delete_product(db, vendor.id, product_id) - return {"message": "Product removed from catalog successfully"} - - -# ============================================================================ -# VENDOR TEAM ENDPOINTS -# ============================================================================ - -@router.get("/team/members") -def get_team_members( - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Get all team members for vendor.""" - return team_service.get_team_members(db, vendor.id, current_user) - - -@router.post("/team/invite") -def invite_team_member( - invitation_data: dict, - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Invite a new team member.""" - return team_service.invite_team_member(db, vendor.id, invitation_data, current_user) - - -@router.put("/team/members/{user_id}") -def update_team_member( - user_id: int, - update_data: dict, - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Update team member role or status.""" - return team_service.update_team_member(db, vendor.id, user_id, update_data, current_user) - - -@router.delete("/team/members/{user_id}") -def remove_team_member( - user_id: int, - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Remove team member from vendor.""" - team_service.remove_team_member(db, vendor.id, user_id, current_user) - return {"message": "Team member removed successfully"} - - -@router.get("/team/roles") -def get_team_roles( - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Get available roles for vendor team.""" - return team_service.get_vendor_roles(db, vendor.id) - - -# ============================================================================ -# VENDOR DASHBOARD & STATISTICS -# ============================================================================ - -@router.get("/dashboard") -def get_vendor_dashboard( - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Get vendor dashboard statistics.""" - from app.services.stats_service import stats_service - - return { - "vendor": { - "code": vendor.vendor_code, - "name": vendor.name, - "subdomain": vendor.subdomain, - "is_verified": vendor.is_verified, - }, - "stats": stats_service.get_vendor_stats(db, vendor.id), - "recent_imports": [], # TODO: Implement - "recent_orders": [], # TODO: Implement - "low_stock_products": [], # TODO: Implement - } - - -@router.get("/analytics") -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_user), - db: Session = Depends(get_db), -): - """Get vendor analytics data.""" - from app.services.stats_service import stats_service - - return stats_service.get_vendor_analytics(db, vendor.id, period) diff --git a/app/routes/frontend.py b/app/routes/frontend.py index ba36571f..c3930fce 100644 --- a/app/routes/frontend.py +++ b/app/routes/frontend.py @@ -3,9 +3,10 @@ Frontend HTML route handlers. Serves static HTML files for admin, vendor, and customer interfaces. +Supports both path-based (/vendor/{vendor_code}/) and query-based access. """ -from fastapi import APIRouter +from fastapi import APIRouter, Path from fastapi.responses import FileResponse router = APIRouter(include_in_schema=False) @@ -33,60 +34,84 @@ async def admin_vendors(): """Serve admin vendors management page""" return FileResponse("static/admin/vendors.html") +@router.get("/admin/vendor-edit") +async def admin_vendor_edit(): + """Serve admin vendor edit page""" + return FileResponse("static/admin/vendor-edit.html") # ============================================================================ -# VENDOR ROUTES +# VENDOR ROUTES (with vendor code in path) # ============================================================================ +@router.get("/vendor/{vendor_code}/") +@router.get("/vendor/{vendor_code}/login") +async def vendor_login_with_code(vendor_code: str = Path(...)): + """Serve vendor login page with vendor code in path""" + return FileResponse("static/vendor/login.html") + + +@router.get("/vendor/{vendor_code}/dashboard") +async def vendor_dashboard_with_code(vendor_code: str = Path(...)): + """Serve vendor dashboard page with vendor code in path""" + return FileResponse("static/vendor/dashboard.html") + + +@router.get("/vendor/{vendor_code}/products") +@router.get("/vendor/{vendor_code}/admin/products") +async def vendor_products_with_code(vendor_code: str = Path(...)): + """Serve vendor products management page""" + return FileResponse("static/vendor/admin/products.html") + + +@router.get("/vendor/{vendor_code}/orders") +@router.get("/vendor/{vendor_code}/admin/orders") +async def vendor_orders_with_code(vendor_code: str = Path(...)): + """Serve vendor orders management page""" + return FileResponse("static/vendor/admin/orders.html") + + +@router.get("/vendor/{vendor_code}/marketplace") +@router.get("/vendor/{vendor_code}/admin/marketplace") +async def vendor_marketplace_with_code(vendor_code: str = Path(...)): + """Serve vendor marketplace import page""" + return FileResponse("static/vendor/admin/marketplace.html") + + +@router.get("/vendor/{vendor_code}/customers") +@router.get("/vendor/{vendor_code}/admin/customers") +async def vendor_customers_with_code(vendor_code: str = Path(...)): + """Serve vendor customers management page""" + return FileResponse("static/vendor/admin/customers.html") + + +@router.get("/vendor/{vendor_code}/inventory") +@router.get("/vendor/{vendor_code}/admin/inventory") +async def vendor_inventory_with_code(vendor_code: str = Path(...)): + """Serve vendor inventory management page""" + return FileResponse("static/vendor/admin/inventory.html") + + +@router.get("/vendor/{vendor_code}/team") +@router.get("/vendor/{vendor_code}/admin/team") +async def vendor_team_with_code(vendor_code: str = Path(...)): + """Serve vendor team management page""" + return FileResponse("static/vendor/admin/team.html") + + +# Fallback vendor routes (without vendor code - for query parameter access) @router.get("/vendor/") @router.get("/vendor/login") async def vendor_login(): - """Serve vendor login page""" + """Serve vendor login page (query parameter based)""" return FileResponse("static/vendor/login.html") @router.get("/vendor/dashboard") async def vendor_dashboard(): - """Serve vendor dashboard page""" + """Serve vendor dashboard page (query parameter based)""" return FileResponse("static/vendor/dashboard.html") -@router.get("/vendor/admin/products") -async def vendor_products(): - """Serve vendor products management page""" - return FileResponse("static/vendor/admin/products.html") - - -@router.get("/vendor/admin/orders") -async def vendor_orders(): - """Serve vendor orders management page""" - return FileResponse("static/vendor/admin/orders.html") - - -@router.get("/vendor/admin/marketplace") -async def vendor_marketplace(): - """Serve vendor marketplace import page""" - return FileResponse("static/vendor/admin/marketplace.html") - - -@router.get("/vendor/admin/customers") -async def vendor_customers(): - """Serve vendor customers management page""" - return FileResponse("static/vendor/admin/customers.html") - - -@router.get("/vendor/admin/inventory") -async def vendor_inventory(): - """Serve vendor inventory management page""" - return FileResponse("static/vendor/admin/inventory.html") - - -@router.get("/vendor/admin/team") -async def vendor_team(): - """Serve vendor team management page""" - return FileResponse("static/vendor/admin/team.html") - - # ============================================================================ # CUSTOMER/SHOP ROUTES # ============================================================================ diff --git a/models/schema/auth.py b/models/schema/auth.py index 6c02d880..5fa9f9e1 100644 --- a/models/schema/auth.py +++ b/models/schema/auth.py @@ -31,6 +31,7 @@ class UserRegister(BaseModel): class UserLogin(BaseModel): username: str = Field(..., description="Username") password: str = Field(..., description="Password") + vendor_code: Optional[str] = Field(None, description="Optional vendor code for context") @field_validator("username") @classmethod diff --git a/models/schema/vendor.py b/models/schema/vendor.py index 1ec75182..c6b17fb9 100644 --- a/models/schema/vendor.py +++ b/models/schema/vendor.py @@ -1,47 +1,86 @@ # models/schema/vendor.py +""" +Pydantic schemas for Vendor-related operations. + +Schemas include: +- VendorCreate: For creating vendors with owner accounts +- VendorUpdate: For updating vendor information (Admin only) +- VendorResponse: Standard vendor response +- VendorDetailResponse: Vendor response with owner details +- VendorCreateResponse: Response after vendor creation (includes credentials) +- VendorListResponse: Paginated vendor list +- VendorSummary: Lightweight vendor info +- VendorTransferOwnership: For transferring vendor ownership +""" + import re from datetime import datetime -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Any from pydantic import BaseModel, ConfigDict, Field, field_validator class VendorCreate(BaseModel): - """Schema for creating a new vendor.""" - vendor_code: str = Field(..., description="Unique vendor identifier (e.g., TECHSTORE)") - subdomain: str = Field(..., description="Unique subdomain for the vendor") - name: str = Field(..., description="Display name of the vendor") - description: Optional[str] = None + """Schema for creating a new vendor with owner account.""" - # Owner information - REQUIRED for admin creation - owner_email: str = Field(..., description="Email for the vendor owner account") + # Basic Information + vendor_code: str = Field( + ..., + description="Unique vendor identifier (e.g., TECHSTORE)", + min_length=2, + max_length=50 + ) + subdomain: str = Field( + ..., + description="Unique subdomain for the vendor", + min_length=2, + max_length=100 + ) + name: str = Field( + ..., + description="Display name of the vendor", + min_length=2, + max_length=255 + ) + description: Optional[str] = Field(None, description="Vendor description") - # Contact information - contact_phone: Optional[str] = None - website: Optional[str] = None + # Owner Information (Creates User Account) + owner_email: str = Field( + ..., + description="Email for the vendor owner (used for login and authentication)" + ) - # Business information - business_address: Optional[str] = None - tax_number: Optional[str] = None + # Business Contact Information (Vendor Fields) + contact_email: Optional[str] = Field( + None, + description="Public business contact email (defaults to owner_email if not provided)" + ) + contact_phone: Optional[str] = Field(None, description="Contact phone number") + website: Optional[str] = Field(None, description="Website URL") - # Letzshop CSV URLs (multi-language support) - letzshop_csv_url_fr: Optional[str] = None - letzshop_csv_url_en: Optional[str] = None - letzshop_csv_url_de: Optional[str] = None + # Business Details + business_address: Optional[str] = Field(None, description="Business address") + tax_number: Optional[str] = Field(None, description="Tax/VAT number") - # Theme configuration - theme_config: Optional[Dict] = Field(default_factory=dict) + # Marketplace URLs (multi-language support) + letzshop_csv_url_fr: Optional[str] = Field(None, description="French CSV URL") + letzshop_csv_url_en: Optional[str] = Field(None, description="English CSV URL") + letzshop_csv_url_de: Optional[str] = Field(None, description="German CSV URL") - @field_validator("owner_email") + # Theme Configuration + theme_config: Optional[Dict] = Field(default_factory=dict, description="Theme settings") + + @field_validator("owner_email", "contact_email") @classmethod - def validate_owner_email(cls, v): - if not v or "@" not in v or "." not in v: - raise ValueError("Valid email address required for vendor owner") - return v.lower() + def validate_emails(cls, v): + """Validate email format and normalize to lowercase.""" + if v and ("@" not in v or "." not in v): + raise ValueError("Invalid email format") + return v.lower() if v else v @field_validator("subdomain") @classmethod def validate_subdomain(cls, v): - # Basic subdomain validation: lowercase alphanumeric with hyphens + """Validate subdomain format: lowercase alphanumeric with hyphens.""" if v and not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', v): raise ValueError("Subdomain must contain only lowercase letters, numbers, and hyphens") return v.lower() if v else v @@ -49,35 +88,66 @@ class VendorCreate(BaseModel): @field_validator("vendor_code") @classmethod def validate_vendor_code(cls, v): - # Ensure vendor code is uppercase for consistency + """Ensure vendor code is uppercase for consistency.""" return v.upper() if v else v class VendorUpdate(BaseModel): - """Schema for updating vendor information.""" - name: Optional[str] = None + """ + Schema for updating vendor information (Admin only). + + Note: owner_email is NOT included here. To change the owner, + use the transfer-ownership endpoint instead. + """ + + # Basic Information + name: Optional[str] = Field(None, min_length=2, max_length=255) description: Optional[str] = None - contact_email: Optional[str] = None + subdomain: Optional[str] = Field(None, min_length=2, max_length=100) + + # Business Contact Information (Vendor Fields) + contact_email: Optional[str] = Field( + None, + description="Public business contact email" + ) contact_phone: Optional[str] = None website: Optional[str] = None + + # Business Details business_address: Optional[str] = None tax_number: Optional[str] = None + + # Marketplace URLs letzshop_csv_url_fr: Optional[str] = None letzshop_csv_url_en: Optional[str] = None letzshop_csv_url_de: Optional[str] = None + + # Theme Configuration theme_config: Optional[Dict] = None + + # Status (Admin only) is_active: Optional[bool] = None + is_verified: Optional[bool] = None + + @field_validator("subdomain") + @classmethod + def subdomain_lowercase(cls, v): + """Normalize subdomain to lowercase.""" + return v.lower().strip() if v else v @field_validator("contact_email") @classmethod def validate_contact_email(cls, v): + """Validate contact email format.""" if v and ("@" not in v or "." not in v): raise ValueError("Invalid email format") return v.lower() if v else v + model_config = ConfigDict(from_attributes=True) + class VendorResponse(BaseModel): - """Schema for vendor response data.""" + """Standard schema for vendor response data.""" model_config = ConfigDict(from_attributes=True) id: int @@ -87,24 +157,24 @@ class VendorResponse(BaseModel): description: Optional[str] owner_user_id: int - # Contact information + # Contact Information (Business) contact_email: Optional[str] contact_phone: Optional[str] website: Optional[str] - # Business information + # Business Information business_address: Optional[str] tax_number: Optional[str] - # Letzshop URLs + # Marketplace URLs letzshop_csv_url_fr: Optional[str] letzshop_csv_url_en: Optional[str] letzshop_csv_url_de: Optional[str] - # Theme configuration + # Theme Configuration theme_config: Dict - # Status flags + # Status Flags is_active: bool is_verified: bool @@ -113,6 +183,42 @@ class VendorResponse(BaseModel): updated_at: datetime +class VendorDetailResponse(VendorResponse): + """ + Extended vendor response including owner information. + + Includes both: + - contact_email (business contact) + - owner_email (owner's authentication email) + """ + + owner_email: str = Field( + ..., + description="Email of the vendor owner (for login/authentication)" + ) + owner_username: str = Field( + ..., + description="Username of the vendor owner" + ) + + +class VendorCreateResponse(VendorDetailResponse): + """ + Response after creating vendor - includes generated credentials. + + IMPORTANT: temporary_password is shown only once! + """ + + temporary_password: str = Field( + ..., + description="Temporary password for owner (SHOWN ONLY ONCE)" + ) + login_url: Optional[str] = Field( + None, + description="URL for vendor owner to login" + ) + + class VendorListResponse(BaseModel): """Schema for paginated vendor list.""" vendors: List[VendorResponse] @@ -132,9 +238,57 @@ class VendorSummary(BaseModel): is_active: bool -class VendorCreateResponse(VendorResponse): - """Extended response for vendor creation with owner credentials.""" - owner_email: str - owner_username: str - temporary_password: str - login_url: Optional[str] = None +class VendorTransferOwnership(BaseModel): + """ + Schema for transferring vendor ownership to another user. + + This is a critical operation that requires: + - Confirmation flag + - Reason for audit trail (optional) + """ + + new_owner_user_id: int = Field( + ..., + description="ID of the user who will become the new owner", + gt=0 + ) + + confirm_transfer: bool = Field( + ..., + description="Must be true to confirm ownership transfer" + ) + + transfer_reason: Optional[str] = Field( + None, + max_length=500, + description="Reason for ownership transfer (for audit logs)" + ) + + @field_validator("confirm_transfer") + @classmethod + def validate_confirmation(cls, v): + """Ensure confirmation is explicitly true.""" + if not v: + raise ValueError("Ownership transfer requires explicit confirmation") + return v + + +class VendorTransferOwnershipResponse(BaseModel): + """Response after successful ownership transfer.""" + + message: str + vendor_id: int + vendor_code: str + vendor_name: str + + old_owner: Dict[str, Any] = Field( + ..., + description="Information about the previous owner" + ) + new_owner: Dict[str, Any] = Field( + ..., + description="Information about the new owner" + ) + + transferred_at: datetime + transfer_reason: Optional[str]