diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cbf106 --- /dev/null +++ b/.gitignore @@ -0,0 +1,69 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +pytest_cache/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE files +.idea/ +.vscode/ +*.swp +*.swo + +# Database +*.sqlite +*.db + +# Logs +logs/ +*.log + +# OS specific +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Local storage +/app/storage/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..caa35ab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy project files +COPY . . + +# Create necessary directories +RUN mkdir -p /app/storage/db /app/storage/logs /app/storage/product_images /app/storage/user_images + +# Set permissions +RUN chmod +x /app/scripts/lint.sh + +# Initialize the database +RUN python -m app.utils.db_init + +# Expose port +EXPOSE 8000 + +# Command to run the application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/README.md b/README.md index e8acfba..ee889fe 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,238 @@ -# FastAPI Application +# Comprehensive E-Commerce Platform -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A full-featured e-commerce API built with FastAPI and SQLite, designed to provide all the backend functionality needed for a modern online store. + +## Features + +- **User Management** + - User registration and authentication with JWT + - Role-based access control (Customer, Seller, Admin) + - User profiles with address information + - Password reset functionality + +- **Product Management** + - CRUD operations for products + - Multiple product images + - SKU and barcode support + - Inventory tracking + - Digital and physical product support + +- **Category & Tag Management** + - Hierarchical category structure + - Product tagging system + - SEO-friendly slugs + +- **Shopping Cart** + - Add, update, remove items + - Custom product attributes/options + - Persistent cart for registered users + +- **Order Processing** + - Order creation from cart + - Order status tracking + - Order history + - Address management for shipping/billing + +- **Payment Integration** + - Multiple payment method support + - Payment status tracking + - Refund handling + - Mock implementations for Stripe and PayPal + +- **Inventory Management** + - Stock level tracking + - Low stock alerts + - Stock update APIs + +- **Search & Filtering** + - Advanced product search + - Sorting and filtering options + - Relevance-based results + +- **Reviews & Ratings** + - User product reviews + - Rating system + - Verified purchase badges + +- **Admin Dashboard** + - Sales analytics + - Customer insights + - Product performance metrics + - Order management + +- **Security** + - Rate limiting + - Security headers + - Input validation + - Error handling + +## Tech Stack + +- **Framework**: FastAPI +- **Database**: SQLite with SQLAlchemy ORM +- **Migration**: Alembic +- **Authentication**: JWT with Python-JOSE +- **Validation**: Pydantic +- **Linting**: Ruff +- **Logging**: Python's built-in logging with rotation + +## Installation + +### Prerequisites + +- Python 3.10 or higher +- pip (Python package manager) + +### Setup + +1. Clone the repository: + ```bash + git clone https://github.com/yourusername/comprehensiveecommerceplatform.git + cd comprehensiveecommerceplatform + ``` + +2. Create a virtual environment: + ```bash + python -m venv venv + source venv/bin/activate # On Windows, use: venv\Scripts\activate + ``` + +3. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +4. Initialize the database: + ```bash + python -m app.utils.db_init + ``` + +## Running the Application + +Start the application with: + +```bash +uvicorn main:app --reload +``` + +The API will be available at http://localhost:8000. + +- Interactive API documentation: http://localhost:8000/docs +- Alternative API documentation: http://localhost:8000/redoc +- Health check endpoint: http://localhost:8000/health + +## API Documentation + +The API is fully documented with OpenAPI. You can explore the documentation using the interactive Swagger UI at `/docs` or ReDoc at `/redoc`. + +### Authentication + +Most endpoints require authentication using JSON Web Tokens (JWT). + +To authenticate: +1. Register a user using the `/api/auth/register` endpoint +2. Login using the `/api/auth/login` endpoint +3. Use the returned access token in the Authorization header: + `Authorization: Bearer {your_access_token}` + +### Default Admin Account + +A default admin account is created when you initialize the database: +- Email: admin@example.com +- Password: admin + +## Development + +### Project Structure + +``` +/ +├── app/ # Application package +│ ├── core/ # Core functionality +│ │ ├── config.py # Application settings +│ │ ├── database.py # Database connection +│ │ └── security.py # Security utilities +│ ├── dependencies/ # FastAPI dependencies +│ ├── middlewares/ # Custom middlewares +│ ├── models/ # SQLAlchemy models +│ ├── routers/ # API routes +│ ├── schemas/ # Pydantic schemas +│ ├── services/ # Business logic services +│ └── utils/ # Utility functions +├── migrations/ # Alembic migrations +├── scripts/ # Utility scripts +├── main.py # Application entry point +├── requirements.txt # Dependencies +├── alembic.ini # Alembic configuration +└── pyproject.toml # Project configuration +``` + +### Database Migrations + +The project uses Alembic for database migrations. The initial migration is already set up. + +To create a new migration after making changes to the models: + +```bash +alembic revision --autogenerate -m "Description of changes" +``` + +To apply migrations: + +```bash +alembic upgrade head +``` + +### Adding New Features + +When adding new features: + +1. Define models in `app/models/` +2. Create Pydantic schemas in `app/schemas/` +3. Implement business logic in `app/services/` +4. Create API endpoints in `app/routers/` +5. Register new routers in `main.py` +6. Generate and apply database migrations + +### Code Quality + +Run linting and formatting with: + +```bash +./scripts/lint.sh +``` + +## Deployment + +### Production Configuration + +For production deployment: + +1. Set up a proper database (e.g., PostgreSQL) by updating the connection settings in `app/core/config.py` +2. Configure proper authentication for production in `app/core/config.py` +3. Update CORS settings in `main.py` to restrict allowed origins +4. Set up a reverse proxy (e.g., Nginx) in front of the application +5. Configure HTTPS + +### Docker Deployment + +A Dockerfile is included for containerized deployment: + +```bash +docker build -t ecommerce-api . +docker run -p 8000:8000 ecommerce-api +``` + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..50ab011 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,109 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..fe13970 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# E-Commerce application package diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..0828787 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +# Core functionalities for the application diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..f614500 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,65 @@ +import secrets +from pathlib import Path + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # API Settings + API_V1_STR: str = "/api" + PROJECT_NAME: str = "E-Commerce API" + + # Security + SECRET_KEY: str = secrets.token_urlsafe(32) + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days + ALGORITHM: str = "HS256" + + # CORS + BACKEND_CORS_ORIGINS: list[str] = ["*"] + + # Database + DB_DIR: Path = Path("/app") / "storage" / "db" + SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite" + + # Storage paths + STORAGE_DIR: Path = Path("/app") / "storage" + PRODUCT_IMAGES_DIR: Path = STORAGE_DIR / "product_images" + USER_IMAGES_DIR: Path = STORAGE_DIR / "user_images" + LOGS_DIR: Path = STORAGE_DIR / "logs" + + # Payment settings (placeholders for real integration) + STRIPE_API_KEY: str | None = None + PAYPAL_CLIENT_ID: str | None = None + PAYPAL_CLIENT_SECRET: str | None = None + + # Email settings (placeholders for real integration) + SMTP_TLS: bool = True + SMTP_PORT: int | None = None + SMTP_HOST: str | None = None + SMTP_USER: str | None = None + SMTP_PASSWORD: str | None = None + EMAILS_FROM_EMAIL: str | None = None + EMAILS_FROM_NAME: str | None = None + + # Admin user + FIRST_SUPERUSER_EMAIL: str = "admin@example.com" + FIRST_SUPERUSER_PASSWORD: str = "admin" + + # Rate limiting + RATE_LIMIT_PER_MINUTE: int = 60 + + class Config: + env_file = ".env" + case_sensitive = True + + +settings = Settings() + +# Create necessary directories +for directory in [ + settings.DB_DIR, + settings.PRODUCT_IMAGES_DIR, + settings.USER_IMAGES_DIR, + settings.LOGS_DIR, +]: + directory.mkdir(parents=True, exist_ok=True) diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..6c4da85 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,32 @@ +from pathlib import Path + +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +# Create database directory +DB_DIR = Path("/app") / "storage" / "db" +DB_DIR.mkdir(parents=True, exist_ok=True) + +# Database URL +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite" + +# Create SQLAlchemy engine +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +# Create SessionLocal class +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Create Base class +Base = declarative_base() + +# Dependency to get DB session +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..b051b0a --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,34 @@ +from datetime import datetime, timedelta +from typing import Any + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import settings + +# Password hashing +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against a hash.""" + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + """Generate password hash.""" + return pwd_context.hash(password) + +def create_access_token(subject: str | Any, expires_delta: timedelta | None = None) -> str: + """Create a JWT access token.""" + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode( + to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM + ) + + return encoded_jwt diff --git a/app/dependencies/__init__.py b/app/dependencies/__init__.py new file mode 100644 index 0000000..742e1de --- /dev/null +++ b/app/dependencies/__init__.py @@ -0,0 +1 @@ +# FastAPI dependency functions diff --git a/app/dependencies/auth.py b/app/dependencies/auth.py new file mode 100644 index 0000000..0282936 --- /dev/null +++ b/app/dependencies/auth.py @@ -0,0 +1,95 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.database import get_db +from app.core.security import verify_password +from app.models.user import User, UserRole +from app.schemas.user import TokenPayload + +# OAuth2 password bearer flow for token authentication +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login") + +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(oauth2_scheme) +) -> User: + """ + Validate the access token and return the current user. + """ + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + token_data = TokenPayload(**payload) + except (JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = db.query(User).filter(User.id == token_data.sub).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" + ) + + return user + +def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + """ + Check if the user is active. + """ + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" + ) + return current_user + +def get_current_seller( + current_user: User = Depends(get_current_active_user), +) -> User: + """ + Check if the user is a seller. + """ + if current_user.role != UserRole.SELLER and current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Operation requires seller privileges" + ) + return current_user + +def get_current_admin( + current_user: User = Depends(get_current_active_user), +) -> User: + """ + Check if the user is an admin. + """ + if current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Operation requires admin privileges" + ) + return current_user + +def authenticate_user(db: Session, email: str, password: str) -> User: + """ + Authenticate a user by email and password. + """ + user = db.query(User).filter(User.email == email).first() + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + + return user diff --git a/app/middlewares/__init__.py b/app/middlewares/__init__.py new file mode 100644 index 0000000..9f516ab --- /dev/null +++ b/app/middlewares/__init__.py @@ -0,0 +1 @@ +# Custom middleware components diff --git a/app/middlewares/rate_limiter.py b/app/middlewares/rate_limiter.py new file mode 100644 index 0000000..86a3f4a --- /dev/null +++ b/app/middlewares/rate_limiter.py @@ -0,0 +1,155 @@ +import logging +import time +from collections.abc import Callable + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +logger = logging.getLogger(__name__) + +class RateLimiter: + """ + Simple in-memory rate limiter implementation. + For production use, consider using Redis or another distributed store. + """ + + def __init__(self, rate_limit_per_minute: int = 60): + self.rate_limit_per_minute = rate_limit_per_minute + self.requests: dict[str, dict[float, int]] = {} + self.window_size = 60 # 1 minute in seconds + + def is_rate_limited(self, client_id: str) -> tuple[bool, dict]: + """ + Check if a client is rate limited. + + Args: + client_id: Identifier for the client (usually IP address) + + Returns: + Tuple of (is_limited, rate_limit_info) + + """ + current_time = time.time() + + # Initialize client record if it doesn't exist + if client_id not in self.requests: + self.requests[client_id] = {} + + # Clean up old records + self._cleanup(client_id, current_time) + + # Count recent requests + recent_requests = sum(self.requests[client_id].values()) + + # Check if rate limit is exceeded + is_limited = recent_requests >= self.rate_limit_per_minute + + # Update request count if not limited + if not is_limited: + self.requests[client_id][current_time] = self.requests[client_id].get(current_time, 0) + 1 + + # Calculate rate limit info + remaining = max(0, self.rate_limit_per_minute - recent_requests) + reset_at = current_time + self.window_size + + return is_limited, { + "limit": self.rate_limit_per_minute, + "remaining": remaining, + "reset": int(reset_at), + } + + def _cleanup(self, client_id: str, current_time: float) -> None: + """ + Clean up old records for a client. + + Args: + client_id: Identifier for the client + current_time: Current timestamp + + """ + cutoff_time = current_time - self.window_size + timestamps_to_remove = [ts for ts in self.requests[client_id].keys() if ts < cutoff_time] + + for ts in timestamps_to_remove: + del self.requests[client_id][ts] + +class RateLimitMiddleware(BaseHTTPMiddleware): + """ + Middleware for rate limiting API requests. + """ + + def __init__( + self, + app: ASGIApp, + rate_limit_per_minute: int = 60, + whitelist_paths: list | None = None, + client_id_func: Callable[[Request], str] | None = None + ): + super().__init__(app) + self.rate_limiter = RateLimiter(rate_limit_per_minute) + self.whitelist_paths = whitelist_paths or ["/health", "/docs", "/redoc", "/openapi.json"] + self.client_id_func = client_id_func or self._default_client_id + + async def dispatch(self, request: Request, call_next) -> Response: + """ + Process the request through rate limiting. + + Args: + request: The incoming request + call_next: The next handler in the middleware chain + + Returns: + The response + + """ + # Skip rate limiting for whitelisted paths + path = request.url.path + if any(path.startswith(wl_path) for wl_path in self.whitelist_paths): + return await call_next(request) + + # Get client identifier + client_id = self.client_id_func(request) + + # Check if rate limited + is_limited, rate_limit_info = self.rate_limiter.is_rate_limited(client_id) + + # If rate limited, return 429 Too Many Requests + if is_limited: + logger.warning(f"Rate limit exceeded for client {client_id}") + response = Response( + content={"detail": "Rate limit exceeded"}, + status_code=429, + media_type="application/json" + ) + else: + # Process the request normally + response = await call_next(request) + + # Add rate limit headers to response + response.headers["X-RateLimit-Limit"] = str(rate_limit_info["limit"]) + response.headers["X-RateLimit-Remaining"] = str(rate_limit_info["remaining"]) + response.headers["X-RateLimit-Reset"] = str(rate_limit_info["reset"]) + + return response + + def _default_client_id(self, request: Request) -> str: + """ + Default function to extract client identifier from request. + Uses the client's IP address. + + Args: + request: The incoming request + + Returns: + Client identifier string + + """ + # Try to get real IP from forwarded header (for proxies) + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + # Get the first IP in the list (client IP) + return forwarded_for.split(",")[0].strip() + + # Fall back to the direct client address + return request.client.host if request.client else "unknown" diff --git a/app/middlewares/security.py b/app/middlewares/security.py new file mode 100644 index 0000000..c5d6db5 --- /dev/null +++ b/app/middlewares/security.py @@ -0,0 +1,47 @@ +import logging + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +logger = logging.getLogger(__name__) + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """ + Middleware to add security headers to responses. + """ + + def __init__( + self, + app: ASGIApp, + content_security_policy: str = None, + ): + super().__init__(app) + self.content_security_policy = content_security_policy or "default-src 'self'" + + async def dispatch(self, request: Request, call_next) -> Response: + """ + Process the request and add security headers to the response. + + Args: + request: The incoming request + call_next: The next handler in the middleware chain + + Returns: + The response with added security headers + + """ + # Process the request + response = await call_next(request) + + # Add security headers + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + response.headers["Content-Security-Policy"] = self.content_security_policy + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + + return response diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..d663944 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,9 @@ +# Import all models here for easier access from other parts of the application +from app.models.cart import CartItem +from app.models.category import Category +from app.models.order import Order, OrderItem +from app.models.payment import Payment +from app.models.product import Product, ProductImage +from app.models.review import Review +from app.models.tag import ProductTag, Tag +from app.models.user import User diff --git a/app/models/cart.py b/app/models/cart.py new file mode 100644 index 0000000..6d8e84a --- /dev/null +++ b/app/models/cart.py @@ -0,0 +1,32 @@ +from uuid import uuid4 + +from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.core.database import Base + + +class CartItem(Base): + __tablename__ = "cart_items" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid4())) + user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + product_id = Column(String(36), ForeignKey("products.id", ondelete="CASCADE"), nullable=False) + quantity = Column(Integer, default=1, nullable=False) + price_at_addition = Column(Float, nullable=False) # Price when added to cart + custom_properties = Column(Text, nullable=True) # JSON string for custom product properties + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + user = relationship("User", back_populates="cart_items") + product = relationship("Product", back_populates="cart_items") + + def __repr__(self): + return f"" + + @property + def subtotal(self): + """Calculate the subtotal for this cart item""" + return self.price_at_addition * self.quantity diff --git a/app/models/category.py b/app/models/category.py new file mode 100644 index 0000000..cd413b8 --- /dev/null +++ b/app/models/category.py @@ -0,0 +1,29 @@ +from uuid import uuid4 + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.core.database import Base + + +class Category(Base): + __tablename__ = "categories" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid4())) + name = Column(String(100), nullable=False, index=True) + slug = Column(String(120), nullable=False, unique=True) + description = Column(Text, nullable=True) + image = Column(String(255), nullable=True) + parent_id = Column(String(36), ForeignKey("categories.id"), nullable=True) + is_active = Column(Boolean, default=True) + display_order = Column(Integer, default=0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + parent = relationship("Category", remote_side=[id], backref="subcategories") + products = relationship("Product", back_populates="category") + + def __repr__(self): + return f"" diff --git a/app/models/order.py b/app/models/order.py new file mode 100644 index 0000000..98f6dbc --- /dev/null +++ b/app/models/order.py @@ -0,0 +1,75 @@ +import enum +from uuid import uuid4 + +from sqlalchemy import JSON, Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.core.database import Base + + +class OrderStatus(enum.Enum): + PENDING = "pending" + PROCESSING = "processing" + SHIPPED = "shipped" + DELIVERED = "delivered" + CANCELLED = "cancelled" + REFUNDED = "refunded" + +class ShippingMethod(enum.Enum): + STANDARD = "standard" + EXPRESS = "express" + OVERNIGHT = "overnight" + PICKUP = "pickup" + DIGITAL = "digital" + +class Order(Base): + __tablename__ = "orders" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid4())) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + order_number = Column(String(50), nullable=False, unique=True, index=True) + status = Column(Enum(OrderStatus), default=OrderStatus.PENDING) + total_amount = Column(Float, nullable=False) + subtotal = Column(Float, nullable=False) + tax_amount = Column(Float, nullable=False) + shipping_amount = Column(Float, nullable=False) + discount_amount = Column(Float, default=0.0) + shipping_method = Column(Enum(ShippingMethod), nullable=True) + tracking_number = Column(String(100), nullable=True) + notes = Column(Text, nullable=True) + shipping_address = Column(JSON, nullable=True) # JSON containing shipping address + billing_address = Column(JSON, nullable=True) # JSON containing billing address + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + user = relationship("User", back_populates="orders") + items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan") + payments = relationship("Payment", back_populates="order", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + +class OrderItem(Base): + __tablename__ = "order_items" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid4())) + order_id = Column(String(36), ForeignKey("orders.id", ondelete="CASCADE"), nullable=False) + product_id = Column(String(36), ForeignKey("products.id"), nullable=False) + quantity = Column(Integer, default=1, nullable=False) + unit_price = Column(Float, nullable=False) # Price at the time of purchase + subtotal = Column(Float, nullable=False) # unit_price * quantity + discount = Column(Float, default=0.0) + tax_amount = Column(Float, default=0.0) + product_name = Column(String(255), nullable=False) # Store name in case product is deleted + product_sku = Column(String(100), nullable=True) + product_options = Column(JSON, nullable=True) # JSON containing selected options + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + order = relationship("Order", back_populates="items") + product = relationship("Product", back_populates="order_items") + + def __repr__(self): + return f"" diff --git a/app/models/payment.py b/app/models/payment.py new file mode 100644 index 0000000..827cbfe --- /dev/null +++ b/app/models/payment.py @@ -0,0 +1,45 @@ +import enum +from uuid import uuid4 + +from sqlalchemy import JSON, Column, DateTime, Enum, Float, ForeignKey, String +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.core.database import Base + + +class PaymentStatus(enum.Enum): + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + REFUNDED = "refunded" + +class PaymentMethod(enum.Enum): + CREDIT_CARD = "credit_card" + PAYPAL = "paypal" + BANK_TRANSFER = "bank_transfer" + CASH_ON_DELIVERY = "cash_on_delivery" + STRIPE = "stripe" + APPLE_PAY = "apple_pay" + GOOGLE_PAY = "google_pay" + +class Payment(Base): + __tablename__ = "payments" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid4())) + order_id = Column(String(36), ForeignKey("orders.id"), nullable=False) + amount = Column(Float, nullable=False) + payment_method = Column(Enum(PaymentMethod), nullable=False) + status = Column(Enum(PaymentStatus), default=PaymentStatus.PENDING) + transaction_id = Column(String(255), nullable=True, unique=True) + payment_details = Column(JSON, nullable=True) # JSON with payment provider details + error_message = Column(String(512), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + order = relationship("Order", back_populates="payments") + + def __repr__(self): + return f"" diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..4baa030 --- /dev/null +++ b/app/models/product.py @@ -0,0 +1,87 @@ +import enum +from uuid import uuid4 + +from sqlalchemy import Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.core.database import Base + + +class ProductStatus(enum.Enum): + DRAFT = "draft" + PUBLISHED = "published" + OUT_OF_STOCK = "out_of_stock" + DISCONTINUED = "discontinued" + +class Product(Base): + __tablename__ = "products" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid4())) + name = Column(String(255), nullable=False, index=True) + description = Column(Text, nullable=True) + price = Column(Float, nullable=False) + sku = Column(String(100), unique=True, nullable=True) + barcode = Column(String(100), unique=True, nullable=True) + stock_quantity = Column(Integer, default=0) + weight = Column(Float, nullable=True) # In kg + dimensions = Column(String(100), nullable=True) # Format: LxWxH in cm + status = Column(Enum(ProductStatus), default=ProductStatus.DRAFT) + is_featured = Column(Boolean, default=False) + is_digital = Column(Boolean, default=False) + digital_download_link = Column(String(512), nullable=True) + slug = Column(String(255), nullable=False, unique=True) + tax_rate = Column(Float, default=0.0) # As a percentage + discount_price = Column(Float, nullable=True) + discount_start_date = Column(DateTime(timezone=True), nullable=True) + discount_end_date = Column(DateTime(timezone=True), nullable=True) + category_id = Column(String(36), ForeignKey("categories.id"), nullable=True) + seller_id = Column(String(36), ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + category = relationship("Category", back_populates="products") + seller = relationship("User", back_populates="products") + images = relationship("ProductImage", back_populates="product", cascade="all, delete-orphan") + reviews = relationship("Review", back_populates="product", cascade="all, delete-orphan") + order_items = relationship("OrderItem", back_populates="product") + cart_items = relationship("CartItem", back_populates="product") + + # Tags relationship + tags = relationship("Tag", secondary="product_tags") + + def __repr__(self): + return f"" + + @property + def average_rating(self): + if not self.reviews: + return None + return sum(review.rating for review in self.reviews) / len(self.reviews) + + @property + def current_price(self): + """Returns the current effective price (discount or regular)""" + now = func.now() + if (self.discount_price and self.discount_start_date and self.discount_end_date and + self.discount_start_date <= now and now <= self.discount_end_date): + return self.discount_price + return self.price + +class ProductImage(Base): + __tablename__ = "product_images" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid4())) + product_id = Column(String(36), ForeignKey("products.id", ondelete="CASCADE"), nullable=False) + image_url = Column(String(512), nullable=False) + alt_text = Column(String(255), nullable=True) + is_primary = Column(Boolean, default=False) + display_order = Column(Integer, default=0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationship + product = relationship("Product", back_populates="images") + + def __repr__(self): + return f"" diff --git a/app/models/review.py b/app/models/review.py new file mode 100644 index 0000000..de0bc9d --- /dev/null +++ b/app/models/review.py @@ -0,0 +1,29 @@ +from uuid import uuid4 + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.core.database import Base + + +class Review(Base): + __tablename__ = "reviews" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid4())) + product_id = Column(String(36), ForeignKey("products.id", ondelete="CASCADE"), nullable=False) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + rating = Column(Integer, nullable=False) # 1-5 rating + title = Column(String(255), nullable=True) + comment = Column(Text, nullable=True) + is_verified_purchase = Column(Boolean, default=False) + is_approved = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + product = relationship("Product", back_populates="reviews") + user = relationship("User", back_populates="reviews") + + def __repr__(self): + return f"" diff --git a/app/models/tag.py b/app/models/tag.py new file mode 100644 index 0000000..da0e980 --- /dev/null +++ b/app/models/tag.py @@ -0,0 +1,27 @@ +from uuid import uuid4 + +from sqlalchemy import Column, DateTime, ForeignKey, String +from sqlalchemy.sql import func + +from app.core.database import Base + + +# Association table for product-tag relationship +class ProductTag(Base): + __tablename__ = "product_tags" + + product_id = Column(String(36), ForeignKey("products.id", ondelete="CASCADE"), primary_key=True) + tag_id = Column(String(36), ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + +class Tag(Base): + __tablename__ = "tags" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid4())) + name = Column(String(50), nullable=False, unique=True, index=True) + slug = Column(String(60), nullable=False, unique=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + def __repr__(self): + return f"" diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..9e70808 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,55 @@ +import enum +from uuid import uuid4 + +from sqlalchemy import Boolean, Column, DateTime, Enum, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.core.database import Base + + +class UserRole(enum.Enum): + CUSTOMER = "customer" + SELLER = "seller" + ADMIN = "admin" + +class User(Base): + __tablename__ = "users" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid4())) + email = Column(String(255), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + first_name = Column(String(100), nullable=True) + last_name = Column(String(100), nullable=True) + is_active = Column(Boolean, default=True) + role = Column(Enum(UserRole), default=UserRole.CUSTOMER) + phone_number = Column(String(20), nullable=True) + profile_image = Column(String(255), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + orders = relationship("Order", back_populates="user") + reviews = relationship("Review", back_populates="user") + cart_items = relationship("CartItem", back_populates="user") + + # For sellers: products they are selling + products = relationship("Product", back_populates="seller") + + # Address information (simplified - in a real app you'd likely have a separate table) + address_line1 = Column(String(255), nullable=True) + address_line2 = Column(String(255), nullable=True) + city = Column(String(100), nullable=True) + state = Column(String(100), nullable=True) + postal_code = Column(String(20), nullable=True) + country = Column(String(100), nullable=True) + + # Additional fields + email_verified = Column(Boolean, default=False) + verification_token = Column(String(255), nullable=True) + reset_password_token = Column(String(255), nullable=True) + reset_token_expires_at = Column(DateTime(timezone=True), nullable=True) + bio = Column(Text, nullable=True) + + def __repr__(self): + return f"" diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..7abd34c --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +# API router modules diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000..c05eb3c --- /dev/null +++ b/app/routers/admin.py @@ -0,0 +1,98 @@ + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.dependencies.auth import get_current_admin +from app.models.user import User +from app.schemas.admin import ( + DashboardSummary, + OrdersPerStatus, + SalesOverTime, + SalesSummary, + TimePeriod, + TopCategorySales, + TopCustomerSales, + TopProductSales, +) +from app.services.admin import AdminDashboardService + +router = APIRouter() + +@router.get("/dashboard", response_model=DashboardSummary) +async def get_dashboard_summary( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Get a summary of key metrics for the admin dashboard. + """ + return AdminDashboardService.get_dashboard_summary(db) + +@router.get("/sales/summary", response_model=SalesSummary) +async def get_sales_summary( + period: TimePeriod = TimePeriod.LAST_30_DAYS, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Get sales summary for a specific time period. + """ + return AdminDashboardService.get_sales_summary(db, period) + +@router.get("/sales/over-time", response_model=SalesOverTime) +async def get_sales_over_time( + period: TimePeriod = TimePeriod.LAST_30_DAYS, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Get sales data over time for a specific period. + """ + return AdminDashboardService.get_sales_over_time(db, period) + +@router.get("/sales/top-categories", response_model=TopCategorySales) +async def get_top_categories( + period: TimePeriod = TimePeriod.LAST_30_DAYS, + limit: int = Query(5, ge=1, le=50), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Get top selling categories for a specific period. + """ + return AdminDashboardService.get_top_categories(db, period, limit) + +@router.get("/sales/top-products", response_model=TopProductSales) +async def get_top_products( + period: TimePeriod = TimePeriod.LAST_30_DAYS, + limit: int = Query(5, ge=1, le=50), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Get top selling products for a specific period. + """ + return AdminDashboardService.get_top_products(db, period, limit) + +@router.get("/sales/top-customers", response_model=TopCustomerSales) +async def get_top_customers( + period: TimePeriod = TimePeriod.LAST_30_DAYS, + limit: int = Query(5, ge=1, le=50), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Get top customers for a specific period. + """ + return AdminDashboardService.get_top_customers(db, period, limit) + +@router.get("/orders/by-status", response_model=list[OrdersPerStatus]) +async def get_orders_by_status( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Get order counts by status. + """ + return AdminDashboardService.get_orders_by_status(db) diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..273ad7d --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,118 @@ +import logging +from datetime import timedelta + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.database import get_db +from app.core.security import create_access_token, get_password_hash +from app.dependencies.auth import authenticate_user, get_current_active_user +from app.models.user import User, UserRole +from app.schemas.user import Token, UserCreate +from app.schemas.user import User as UserSchema + +router = APIRouter() +logger = logging.getLogger(__name__) + +@router.post("/login", response_model=Token) +async def login( + form_data: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(get_db) +): + """ + OAuth2 compatible token login, get an access token for future requests. + """ + user = authenticate_user(db, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + subject=user.id, expires_delta=access_token_expires + ) + + logger.info(f"User {user.email} logged in successfully") + return {"access_token": access_token, "token_type": "bearer"} + +@router.post("/register", response_model=UserSchema) +async def register( + user_in: UserCreate, + db: Session = Depends(get_db) +): + """ + Register a new user. + """ + # Check if user with this email already exists + user = db.query(User).filter(User.email == user_in.email).first() + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A user with this email already exists", + ) + + # Create new user + db_user = User( + email=user_in.email, + hashed_password=get_password_hash(user_in.password), + first_name=user_in.first_name, + last_name=user_in.last_name, + phone_number=user_in.phone_number, + role=UserRole.CUSTOMER, + is_active=True, + ) + + db.add(db_user) + db.commit() + db.refresh(db_user) + + logger.info(f"New user registered with email: {db_user.email}") + return db_user + +@router.post("/register/seller", response_model=UserSchema) +async def register_seller( + user_in: UserCreate, + db: Session = Depends(get_db) +): + """ + Register a new seller account. + """ + # Check if user with this email already exists + user = db.query(User).filter(User.email == user_in.email).first() + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A user with this email already exists", + ) + + # Create new seller user + db_user = User( + email=user_in.email, + hashed_password=get_password_hash(user_in.password), + first_name=user_in.first_name, + last_name=user_in.last_name, + phone_number=user_in.phone_number, + role=UserRole.SELLER, + is_active=True, + ) + + db.add(db_user) + db.commit() + db.refresh(db_user) + + logger.info(f"New seller registered with email: {db_user.email}") + return db_user + +@router.get("/me", response_model=UserSchema) +async def read_users_me( + current_user: User = Depends(get_current_active_user) +): + """ + Get current user information. + """ + return current_user diff --git a/app/routers/cart.py b/app/routers/cart.py new file mode 100644 index 0000000..42f8b15 --- /dev/null +++ b/app/routers/cart.py @@ -0,0 +1,293 @@ +import json +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.dependencies.auth import get_current_active_user +from app.models.cart import CartItem +from app.models.product import Product, ProductStatus +from app.models.user import User +from app.schemas.cart import ( + CartItem as CartItemSchema, +) +from app.schemas.cart import ( + CartItemCreate, + CartItemUpdate, + CartSummary, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + +@router.get("/", response_model=CartSummary) +async def get_cart( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Get the current user's shopping cart. + """ + cart_items = db.query(CartItem).filter(CartItem.user_id == current_user.id).all() + + # Enhance cart items with product information + items = [] + total_items = 0 + subtotal = 0 + total_weight = 0 + + for item in cart_items: + # Skip items with deleted products + if not item.product: + continue + + # Skip items with unavailable products + if item.product.status != ProductStatus.PUBLISHED and item.product.status != ProductStatus.OUT_OF_STOCK: + continue + + # Get primary image if available + product_image = None + primary_image = next((img for img in item.product.images if img.is_primary), None) + if primary_image: + product_image = primary_image.image_url + elif item.product.images: + product_image = item.product.images[0].image_url + + # Calculate current price and subtotal + current_price = item.product.current_price + item_subtotal = current_price * item.quantity + + # Parse custom properties + custom_properties = None + if item.custom_properties: + try: + custom_properties = json.loads(item.custom_properties) + except: + custom_properties = None + + # Create enhanced cart item + cart_item = { + "id": item.id, + "user_id": item.user_id, + "product_id": item.product_id, + "quantity": item.quantity, + "price_at_addition": item.price_at_addition, + "custom_properties": custom_properties, + "created_at": item.created_at, + "updated_at": item.updated_at, + "product_name": item.product.name, + "product_image": product_image, + "current_price": current_price, + "subtotal": item_subtotal + } + + items.append(cart_item) + total_items += item.quantity + subtotal += item_subtotal + + # Add weight if available + if item.product.weight: + total_weight += item.product.weight * item.quantity + + return { + "items": items, + "total_items": total_items, + "subtotal": subtotal, + "total_weight": total_weight if total_weight > 0 else None + } + +@router.post("/items", response_model=CartItemSchema) +async def add_cart_item( + item_in: CartItemCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Add an item to the shopping cart. + """ + # Check if product exists and is available + product = db.query(Product).filter(Product.id == item_in.product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + if product.status != ProductStatus.PUBLISHED: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Product is not available for purchase" + ) + + if product.stock_quantity < item_in.quantity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Not enough stock available. Only {product.stock_quantity} items left." + ) + + # Check if the item is already in the cart + existing_item = db.query(CartItem).filter( + CartItem.user_id == current_user.id, + CartItem.product_id == item_in.product_id + ).first() + + if existing_item: + # Update quantity if item already exists + new_quantity = existing_item.quantity + item_in.quantity + + # Check stock for the new quantity + if product.stock_quantity < new_quantity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Not enough stock available. Only {product.stock_quantity} items left." + ) + + existing_item.quantity = new_quantity + + # Update custom properties if provided + if item_in.custom_properties: + existing_item.custom_properties = json.dumps(item_in.custom_properties) + + db.commit() + db.refresh(existing_item) + cart_item = existing_item + else: + # Serialize custom properties if provided + custom_properties_json = None + if item_in.custom_properties: + custom_properties_json = json.dumps(item_in.custom_properties) + + # Create new cart item + cart_item = CartItem( + user_id=current_user.id, + product_id=item_in.product_id, + quantity=item_in.quantity, + price_at_addition=product.current_price, + custom_properties=custom_properties_json + ) + + db.add(cart_item) + db.commit() + db.refresh(cart_item) + + # Enhance cart item with product information for response + product_image = None + primary_image = next((img for img in product.images if img.is_primary), None) + if primary_image: + product_image = primary_image.image_url + elif product.images: + product_image = product.images[0].image_url + + cart_item.product_name = product.name + cart_item.product_image = product_image + cart_item.current_price = product.current_price + cart_item.subtotal = product.current_price * cart_item.quantity + + logger.info(f"Item added to cart: Product {product.name} (ID: {product.id}) for user {current_user.email}") + return cart_item + +@router.put("/items/{item_id}", response_model=CartItemSchema) +async def update_cart_item( + item_id: str, + item_in: CartItemUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Update a cart item. + """ + # Find the cart item + cart_item = db.query(CartItem).filter( + CartItem.id == item_id, + CartItem.user_id == current_user.id + ).first() + + if not cart_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cart item not found" + ) + + # Check if product exists + product = db.query(Product).filter(Product.id == cart_item.product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + # Update quantity if provided + if item_in.quantity is not None: + # Check stock + if product.stock_quantity < item_in.quantity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Not enough stock available. Only {product.stock_quantity} items left." + ) + + cart_item.quantity = item_in.quantity + + # Update custom properties if provided + if item_in.custom_properties is not None: + cart_item.custom_properties = json.dumps(item_in.custom_properties) + + db.commit() + db.refresh(cart_item) + + # Enhance cart item with product information for response + product_image = None + primary_image = next((img for img in product.images if img.is_primary), None) + if primary_image: + product_image = primary_image.image_url + elif product.images: + product_image = product.images[0].image_url + + cart_item.product_name = product.name + cart_item.product_image = product_image + cart_item.current_price = product.current_price + cart_item.subtotal = product.current_price * cart_item.quantity + + logger.info(f"Cart item updated: ID {item_id} for user {current_user.email}") + return cart_item + +@router.delete("/items/{item_id}", response_model=dict) +async def remove_cart_item( + item_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Remove an item from the shopping cart. + """ + cart_item = db.query(CartItem).filter( + CartItem.id == item_id, + CartItem.user_id == current_user.id + ).first() + + if not cart_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cart item not found" + ) + + # Delete the cart item + db.delete(cart_item) + db.commit() + + logger.info(f"Cart item removed: ID {item_id} for user {current_user.email}") + return {"message": "Item removed from cart successfully"} + +@router.delete("/", response_model=dict) +async def clear_cart( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Clear the current user's shopping cart. + """ + db.query(CartItem).filter(CartItem.user_id == current_user.id).delete() + db.commit() + + logger.info(f"Cart cleared for user {current_user.email}") + return {"message": "Cart cleared successfully"} diff --git a/app/routers/categories.py b/app/routers/categories.py new file mode 100644 index 0000000..bd6157c --- /dev/null +++ b/app/routers/categories.py @@ -0,0 +1,313 @@ +import logging +import os +import re +import uuid +from datetime import datetime + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.database import get_db +from app.dependencies.auth import get_current_admin +from app.models.category import Category +from app.models.user import User +from app.schemas.category import ( + Category as CategorySchema, +) +from app.schemas.category import ( + CategoryCreate, + CategoryUpdate, + CategoryWithChildren, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + +def slugify(text): + """Convert a string to a URL-friendly slug.""" + # Remove non-alphanumeric characters + text = re.sub(r'[^\w\s-]', '', text.lower()) + # Replace spaces with hyphens + text = re.sub(r'[\s]+', '-', text) + # Remove consecutive hyphens + text = re.sub(r'[-]+', '-', text) + # Add timestamp to ensure uniqueness + timestamp = int(datetime.now().timestamp()) + return f"{text}-{timestamp}" + +def count_products_in_category(category, include_subcategories=True): + """Count products in a category and optionally in its subcategories.""" + product_count = len(category.products) + + if include_subcategories: + for subcategory in category.subcategories: + product_count += count_products_in_category(subcategory) + + return product_count + +def build_category_tree(categories, parent_id=None): + """Recursively build a category tree structure.""" + tree = [] + for category in categories: + if category.parent_id == parent_id: + # Convert to CategoryWithChildren schema + category_dict = { + "id": category.id, + "name": category.name, + "slug": category.slug, + "description": category.description, + "image": category.image, + "parent_id": category.parent_id, + "is_active": category.is_active, + "display_order": category.display_order, + "created_at": category.created_at, + "updated_at": category.updated_at, + "subcategories": build_category_tree(categories, category.id), + "product_count": count_products_in_category(category) + } + tree.append(category_dict) + + # Sort by display_order + tree.sort(key=lambda x: x["display_order"]) + return tree + +@router.get("/", response_model=list[CategorySchema]) +async def get_categories( + skip: int = 0, + limit: int = 100, + active_only: bool = True, + db: Session = Depends(get_db) +): + """ + Get all categories. + """ + query = db.query(Category) + + if active_only: + query = query.filter(Category.is_active == True) + + categories = query.order_by(Category.display_order).offset(skip).limit(limit).all() + return categories + +@router.get("/tree", response_model=list[CategoryWithChildren]) +async def get_category_tree( + active_only: bool = True, + db: Session = Depends(get_db) +): + """ + Get categories in a hierarchical tree structure. + """ + query = db.query(Category) + + if active_only: + query = query.filter(Category.is_active == True) + + categories = query.all() + tree = build_category_tree(categories) + return tree + +@router.get("/{category_id}", response_model=CategorySchema) +async def get_category( + category_id: str, + db: Session = Depends(get_db) +): + """ + Get a specific category by ID. + """ + category = db.query(Category).filter(Category.id == category_id).first() + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found" + ) + + return category + +@router.post("/", response_model=CategorySchema) +async def create_category( + category_in: CategoryCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Create a new category (admin only). + """ + # Check if slug already exists + existing_category = db.query(Category).filter(Category.slug == category_in.slug).first() + if existing_category: + # If slug exists, create a unique one + category_in.slug = slugify(category_in.name) + + # Check if parent category exists (if a parent is specified) + if category_in.parent_id: + parent_category = db.query(Category).filter(Category.id == category_in.parent_id).first() + if not parent_category: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Parent category not found" + ) + + # Create new category + db_category = Category( + name=category_in.name, + slug=category_in.slug, + description=category_in.description, + image=category_in.image, + parent_id=category_in.parent_id, + is_active=category_in.is_active, + display_order=category_in.display_order + ) + + db.add(db_category) + db.commit() + db.refresh(db_category) + + logger.info(f"Category created: {db_category.name} (ID: {db_category.id})") + return db_category + +@router.put("/{category_id}", response_model=CategorySchema) +async def update_category( + category_id: str, + category_in: CategoryUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Update a category (admin only). + """ + category = db.query(Category).filter(Category.id == category_id).first() + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found" + ) + + # Check if slug already exists (if updating slug) + if category_in.slug and category_in.slug != category.slug: + existing_category = db.query(Category).filter(Category.slug == category_in.slug).first() + if existing_category: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Slug already exists" + ) + + # Check if parent category exists (if updating parent) + if category_in.parent_id and category_in.parent_id != category.parent_id: + # Prevent setting a category as its own parent + if category_in.parent_id == category_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Category cannot be its own parent" + ) + + parent_category = db.query(Category).filter(Category.id == category_in.parent_id).first() + if not parent_category: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Parent category not found" + ) + + # Prevent circular references + current_parent = parent_category + while current_parent: + if current_parent.id == category_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Circular reference in category hierarchy" + ) + current_parent = db.query(Category).filter(Category.id == current_parent.parent_id).first() + + # Update category attributes + for key, value in category_in.dict(exclude_unset=True).items(): + setattr(category, key, value) + + db.commit() + db.refresh(category) + + logger.info(f"Category updated: {category.name} (ID: {category.id})") + return category + +@router.delete("/{category_id}", response_model=dict) +async def delete_category( + category_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Delete a category (admin only). + """ + category = db.query(Category).filter(Category.id == category_id).first() + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found" + ) + + # Check if category has products + if category.products: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete category with products. Remove products first or reassign them." + ) + + # Check if category has subcategories + if category.subcategories: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete category with subcategories. Delete subcategories first." + ) + + # Delete the category + db.delete(category) + db.commit() + + logger.info(f"Category deleted: {category.name} (ID: {category.id})") + return {"message": "Category successfully deleted"} + +@router.post("/{category_id}/image", response_model=CategorySchema) +async def upload_category_image( + category_id: str, + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Upload an image for a category (admin only). + """ + category = db.query(Category).filter(Category.id == category_id).first() + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found" + ) + + # Validate file + content_type = file.content_type + if not content_type.startswith("image/"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be an image" + ) + + # Create category images directory + category_images_dir = settings.STORAGE_DIR / "category_images" + category_images_dir.mkdir(parents=True, exist_ok=True) + + # Generate unique filename + file_extension = os.path.splitext(file.filename)[1] + unique_filename = f"{uuid.uuid4()}{file_extension}" + file_path = category_images_dir / unique_filename + + # Save the file + with open(file_path, "wb") as buffer: + buffer.write(await file.read()) + + # Update the category's image in the database + relative_path = f"/storage/category_images/{unique_filename}" + category.image = relative_path + db.commit() + db.refresh(category) + + logger.info(f"Image uploaded for category ID {category_id}: {unique_filename}") + return category diff --git a/app/routers/health.py b/app/routers/health.py new file mode 100644 index 0000000..a2e4d0b --- /dev/null +++ b/app/routers/health.py @@ -0,0 +1,27 @@ +import time + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db + +router = APIRouter() + +@router.get("/health") +async def health_check(db: Session = Depends(get_db)): + """ + Health check endpoint that verifies the API is running + and can connect to the database. + """ + try: + # Check database connection by executing a simple query + db.execute("SELECT 1") + db_status = "healthy" + except Exception as e: + db_status = f"unhealthy: {str(e)}" + + return { + "status": "online", + "timestamp": time.time(), + "database": db_status, + } diff --git a/app/routers/inventory.py b/app/routers/inventory.py new file mode 100644 index 0000000..fcb12b9 --- /dev/null +++ b/app/routers/inventory.py @@ -0,0 +1,147 @@ +from typing import Any + +from fastapi import APIRouter, Body, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.dependencies.auth import get_current_admin, get_current_seller +from app.models.product import Product +from app.models.user import User +from app.schemas.product import Product as ProductSchema +from app.services.inventory import InventoryService + +router = APIRouter() + +@router.put("/products/{product_id}/stock", response_model=ProductSchema) +async def update_product_stock( + product_id: str, + quantity: int = Body(..., ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_seller) +): + """ + Update the stock quantity of a product. + Seller can only update their own products. + Admin can update any product. + """ + # Check if product exists and belongs to the current user + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + # Check ownership + if current_user.id != product.seller_id and not any(role.name == "admin" for role in current_user.roles): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to update this product" + ) + + # Get current stock + current_stock = product.stock_quantity + + # Calculate change + if quantity > current_stock: + # Add stock + quantity_change = quantity - current_stock + product = InventoryService.update_stock(db, product_id, quantity_change, "add") + elif quantity < current_stock: + # Remove stock + quantity_change = current_stock - quantity + product = InventoryService.update_stock(db, product_id, quantity_change, "subtract") + else: + # No change + return product + + return product + +@router.put("/products/{product_id}/stock/add", response_model=ProductSchema) +async def add_stock( + product_id: str, + quantity: int = Body(..., gt=0), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_seller) +): + """ + Add stock to a product. + """ + # Check if product exists and belongs to the current user + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + # Check ownership + if current_user.id != product.seller_id and not any(role.name == "admin" for role in current_user.roles): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to update this product" + ) + + product = InventoryService.update_stock(db, product_id, quantity, "add") + return product + +@router.put("/products/{product_id}/stock/subtract", response_model=ProductSchema) +async def subtract_stock( + product_id: str, + quantity: int = Body(..., gt=0), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_seller) +): + """ + Subtract stock from a product. + """ + # Check if product exists and belongs to the current user + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + # Check ownership + if current_user.id != product.seller_id and not any(role.name == "admin" for role in current_user.roles): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to update this product" + ) + + product = InventoryService.update_stock(db, product_id, quantity, "subtract") + return product + +@router.get("/low-stock", response_model=list[ProductSchema]) +async def get_low_stock_products( + threshold: int = 5, + category_id: str | None = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_seller) +): + """ + Get products with low stock. + Seller can only see their own products. + Admin can see all products. + """ + # For sellers, always filter by seller_id + seller_id = None if any(role.name == "admin" for role in current_user.roles) else current_user.id + + products = InventoryService.get_low_stock_products( + db, threshold, category_id, seller_id + ) + + return products + +@router.put("/bulk-update", response_model=list[ProductSchema]) +async def bulk_update_stock( + updates: list[dict[str, Any]] = Body(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Update stock for multiple products at once (admin only). + """ + products = InventoryService.bulk_update_stock(db, updates) + return products diff --git a/app/routers/orders.py b/app/routers/orders.py new file mode 100644 index 0000000..1b68a03 --- /dev/null +++ b/app/routers/orders.py @@ -0,0 +1,323 @@ +import json +import logging +import random +import string +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import desc +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.dependencies.auth import get_current_active_user, get_current_admin +from app.models.cart import CartItem +from app.models.order import Order, OrderItem, OrderStatus +from app.models.product import Product, ProductStatus +from app.models.user import User, UserRole +from app.schemas.order import ( + Order as OrderSchema, +) +from app.schemas.order import ( + OrderCreate, + OrderSummary, + OrderUpdate, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + +def generate_order_number(): + """Generate a unique order number.""" + timestamp = datetime.now().strftime("%Y%m%d") + random_chars = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) + return f"ORD-{timestamp}-{random_chars}" + +@router.get("/", response_model=list[OrderSummary]) +async def get_orders( + skip: int = 0, + limit: int = 100, + status: OrderStatus | None = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Get all orders for the current user. + Admins can filter by user ID. + """ + query = db.query(Order) + + # Filter by user ID (regular users can only see their own orders) + if current_user.role != UserRole.ADMIN: + query = query.filter(Order.user_id == current_user.id) + + # Filter by status if provided + if status: + query = query.filter(Order.status == status) + + # Apply pagination and order by newest first + orders = query.order_by(desc(Order.created_at)).offset(skip).limit(limit).all() + + # Add item count to each order + for order in orders: + order.item_count = sum(item.quantity for item in order.items) + + return orders + +@router.get("/{order_id}", response_model=OrderSchema) +async def get_order( + order_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Get a specific order by ID. + Regular users can only get their own orders. + Admins can get any order. + """ + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found" + ) + + # Check permissions + if current_user.role != UserRole.ADMIN and order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this order" + ) + + return order + +@router.post("/", response_model=OrderSchema) +async def create_order( + order_in: OrderCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Create a new order from the shopping cart. + """ + # Validate cart items + if not order_in.cart_items: + # If no cart items are specified, use all items from the user's cart + cart_items = db.query(CartItem).filter(CartItem.user_id == current_user.id).all() + if not cart_items: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Shopping cart is empty" + ) + cart_item_ids = [item.id for item in cart_items] + else: + # If specific cart items are specified, validate them + cart_items = db.query(CartItem).filter( + CartItem.id.in_(order_in.cart_items), + CartItem.user_id == current_user.id + ).all() + + if len(cart_items) != len(order_in.cart_items): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="One or more cart items not found" + ) + + cart_item_ids = order_in.cart_items + + # Calculate totals + subtotal = 0 + tax_amount = 0 + shipping_amount = 10.0 # Default shipping cost (should be calculated based on shipping method, weight, etc.) + discount_amount = 0 + + # Use default user addresses if requested + if order_in.use_default_addresses: + shipping_address = { + "first_name": current_user.first_name or "", + "last_name": current_user.last_name or "", + "address_line1": current_user.address_line1 or "", + "address_line2": current_user.address_line2, + "city": current_user.city or "", + "state": current_user.state or "", + "postal_code": current_user.postal_code or "", + "country": current_user.country or "", + "phone_number": current_user.phone_number, + "email": current_user.email + } + + billing_address = shipping_address + else: + shipping_address = order_in.shipping_address.dict() + billing_address = order_in.billing_address.dict() if order_in.billing_address else shipping_address + + # Create order items and calculate totals + order_items_data = [] + + for cart_item in cart_items: + # Validate product availability and stock + product = db.query(Product).filter(Product.id == cart_item.product_id).first() + + if not product: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Product not found for cart item {cart_item.id}" + ) + + if product.status != ProductStatus.PUBLISHED: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Product '{product.name}' is not available for purchase" + ) + + if product.stock_quantity < cart_item.quantity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Not enough stock for product '{product.name}'. Available: {product.stock_quantity}" + ) + + # Calculate item subtotal and tax + item_unit_price = product.current_price + item_subtotal = item_unit_price * cart_item.quantity + item_tax = item_subtotal * (product.tax_rate / 100) + + # Create order item + order_item_data = { + "product_id": product.id, + "quantity": cart_item.quantity, + "unit_price": item_unit_price, + "subtotal": item_subtotal, + "tax_amount": item_tax, + "product_name": product.name, + "product_sku": product.sku, + "product_options": cart_item.custom_properties + } + + order_items_data.append(order_item_data) + + # Update totals + subtotal += item_subtotal + tax_amount += item_tax + + # Update product stock + product.stock_quantity -= cart_item.quantity + + # Check if product is now out of stock + if product.stock_quantity == 0: + product.status = ProductStatus.OUT_OF_STOCK + + # Calculate final total + total_amount = subtotal + tax_amount + shipping_amount - discount_amount + + # Create the order + db_order = Order( + user_id=current_user.id, + order_number=generate_order_number(), + status=OrderStatus.PENDING, + total_amount=total_amount, + subtotal=subtotal, + tax_amount=tax_amount, + shipping_amount=shipping_amount, + discount_amount=discount_amount, + shipping_method=order_in.shipping_method, + shipping_address=json.dumps(shipping_address), + billing_address=json.dumps(billing_address), + notes=order_in.notes + ) + + db.add(db_order) + db.commit() + db.refresh(db_order) + + # Create order items + for item_data in order_items_data: + db_order_item = OrderItem( + order_id=db_order.id, + **item_data + ) + db.add(db_order_item) + + # Remove cart items + for cart_item_id in cart_item_ids: + db.query(CartItem).filter(CartItem.id == cart_item_id).delete() + + db.commit() + db.refresh(db_order) + + logger.info(f"Order created: {db_order.order_number} (ID: {db_order.id}) for user {current_user.email}") + return db_order + +@router.put("/{order_id}", response_model=OrderSchema) +async def update_order( + order_id: str, + order_in: OrderUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Update an order. + Regular users can only update their own orders and with limited fields. + Admins can update any order with all fields. + """ + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found" + ) + + # Check permissions + if current_user.role != UserRole.ADMIN and order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to update this order" + ) + + # Regular users can only cancel pending orders + if current_user.role != UserRole.ADMIN: + if order.status != OrderStatus.PENDING: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only pending orders can be updated" + ) + + if order_in.status and order_in.status != OrderStatus.CANCELLED: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You can only cancel an order" + ) + + # Update order attributes + for key, value in order_in.dict(exclude_unset=True).items(): + setattr(order, key, value) + + db.commit() + db.refresh(order) + + logger.info(f"Order updated: {order.order_number} (ID: {order.id})") + return order + +@router.delete("/{order_id}", response_model=dict) +async def delete_order( + order_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Delete an order (admin only). + """ + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found" + ) + + # Delete order items first (should happen automatically with cascade) + db.query(OrderItem).filter(OrderItem.order_id == order_id).delete() + + # Delete the order + db.delete(order) + db.commit() + + logger.info(f"Order deleted: {order.order_number} (ID: {order.id})") + return {"message": "Order successfully deleted"} diff --git a/app/routers/payments.py b/app/routers/payments.py new file mode 100644 index 0000000..af70404 --- /dev/null +++ b/app/routers/payments.py @@ -0,0 +1,244 @@ +import json +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.dependencies.auth import get_current_active_user, get_current_admin +from app.models.order import Order, OrderStatus +from app.models.payment import Payment, PaymentMethod, PaymentStatus +from app.models.user import User, UserRole +from app.schemas.payment import ( + Payment as PaymentSchema, +) +from app.schemas.payment import ( + PaymentCreate, + PaymentResponse, + PaymentUpdate, +) +from app.services.payment import PaymentService + +router = APIRouter() +logger = logging.getLogger(__name__) + +@router.get("/", response_model=list[PaymentSchema]) +async def get_payments( + skip: int = 0, + limit: int = 100, + order_id: str | None = None, + status: PaymentStatus | None = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Get payments. + Regular users can only see their own payments. + Admins can see all payments and filter by user ID. + """ + query = db.query(Payment) + + # Join with orders to filter by user + if current_user.role != UserRole.ADMIN: + query = query.join(Order).filter(Order.user_id == current_user.id) + + # Filter by order ID if provided + if order_id: + query = query.filter(Payment.order_id == order_id) + + # Filter by status if provided + if status: + query = query.filter(Payment.status == status) + + # Apply pagination + payments = query.offset(skip).limit(limit).all() + + # Ensure payment details are parsed as JSON + for payment in payments: + if payment.payment_details and isinstance(payment.payment_details, str): + try: + payment.payment_details = json.loads(payment.payment_details) + except: + payment.payment_details = {} + + return payments + +@router.get("/{payment_id}", response_model=PaymentSchema) +async def get_payment( + payment_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Get a specific payment by ID. + Regular users can only get their own payments. + Admins can get any payment. + """ + payment = db.query(Payment).filter(Payment.id == payment_id).first() + if not payment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Payment not found" + ) + + # Check if the payment belongs to the user (if not admin) + if current_user.role != UserRole.ADMIN: + order = db.query(Order).filter(Order.id == payment.order_id).first() + if not order or order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this payment" + ) + + # Parse payment details as JSON + if payment.payment_details and isinstance(payment.payment_details, str): + try: + payment.payment_details = json.loads(payment.payment_details) + except: + payment.payment_details = {} + + return payment + +@router.post("/process", response_model=PaymentResponse) +async def process_payment( + payment_in: PaymentCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Process a payment for an order. + """ + # Check if order exists + order = db.query(Order).filter(Order.id == payment_in.order_id).first() + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found" + ) + + # Check if the order belongs to the user (if not admin) + if current_user.role != UserRole.ADMIN and order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to process payment for this order" + ) + + # Check if order is in a state that can be paid + if order.status != OrderStatus.PENDING and order.status != OrderStatus.FAILED: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot process payment for order with status {order.status.value}" + ) + + # Process payment based on payment method + try: + if payment_in.payment_method == PaymentMethod.STRIPE: + result = await PaymentService.process_stripe_payment( + db, order, payment_in.payment_details + ) + elif payment_in.payment_method == PaymentMethod.PAYPAL: + result = await PaymentService.process_paypal_payment( + db, order, payment_in.payment_details + ) + else: + # Process generic payment + result = await PaymentService.process_payment( + db, order, payment_in.payment_method, payment_in.payment_details + ) + + logger.info(f"Payment processed for order {order.id}: {result}") + return result + + except Exception as e: + logger.error(f"Error processing payment: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing payment: {str(e)}" + ) + +@router.post("/verify/{payment_id}", response_model=PaymentResponse) +async def verify_payment( + payment_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Verify a payment status. + """ + payment = db.query(Payment).filter(Payment.id == payment_id).first() + if not payment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Payment not found" + ) + + # Check if the payment belongs to the user (if not admin) + if current_user.role != UserRole.ADMIN: + order = db.query(Order).filter(Order.id == payment.order_id).first() + if not order or order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to verify this payment" + ) + + # Verify payment + updated_payment = await PaymentService.verify_payment(db, payment_id) + + return { + "success": updated_payment.status == PaymentStatus.COMPLETED, + "payment_id": updated_payment.id, + "status": updated_payment.status, + "transaction_id": updated_payment.transaction_id, + "error_message": updated_payment.error_message, + } + +@router.put("/{payment_id}", response_model=PaymentSchema) +async def update_payment( + payment_id: str, + payment_in: PaymentUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Update a payment (admin only). + """ + payment = db.query(Payment).filter(Payment.id == payment_id).first() + if not payment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Payment not found" + ) + + # Update payment attributes + update_data = payment_in.dict(exclude_unset=True) + + # Convert payment_details to JSON string if provided + if "payment_details" in update_data and update_data["payment_details"] is not None: + update_data["payment_details"] = json.dumps(update_data["payment_details"]) + + for key, value in update_data.items(): + setattr(payment, key, value) + + # If payment status is changing, update order status as well + if payment_in.status and payment_in.status != payment.status: + order = db.query(Order).filter(Order.id == payment.order_id).first() + if order: + if payment_in.status == PaymentStatus.COMPLETED: + order.status = OrderStatus.PROCESSING + elif payment_in.status == PaymentStatus.FAILED: + order.status = OrderStatus.PENDING + elif payment_in.status == PaymentStatus.REFUNDED: + order.status = OrderStatus.REFUNDED + + db.commit() + db.refresh(payment) + + # Parse payment details for response + if payment.payment_details and isinstance(payment.payment_details, str): + try: + payment.payment_details = json.loads(payment.payment_details) + except: + payment.payment_details = {} + + logger.info(f"Payment updated: ID {payment_id}") + return payment diff --git a/app/routers/products.py b/app/routers/products.py new file mode 100644 index 0000000..f4d89c4 --- /dev/null +++ b/app/routers/products.py @@ -0,0 +1,441 @@ +import logging +import os +import re +import uuid +from datetime import datetime + +from fastapi import APIRouter, Depends, File, HTTPException, Path, UploadFile, status +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.database import get_db +from app.dependencies.auth import get_current_active_user, get_current_seller +from app.models.category import Category +from app.models.product import Product, ProductImage, ProductStatus +from app.models.tag import Tag +from app.models.user import User, UserRole +from app.schemas.product import ( + Product as ProductSchema, +) +from app.schemas.product import ( + ProductCreate, + ProductDetails, + ProductUpdate, +) +from app.schemas.product import ( + ProductImage as ProductImageSchema, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + +def slugify(text): + """Convert a string to a URL-friendly slug.""" + # Remove non-alphanumeric characters + text = re.sub(r'[^\w\s-]', '', text.lower()) + # Replace spaces with hyphens + text = re.sub(r'[\s]+', '-', text) + # Remove consecutive hyphens + text = re.sub(r'[-]+', '-', text) + # Add timestamp to ensure uniqueness + timestamp = int(datetime.now().timestamp()) + return f"{text}-{timestamp}" + +@router.get("/", response_model=list[ProductSchema]) +async def get_products( + skip: int = 0, + limit: int = 100, + category_id: str | None = None, + status: ProductStatus | None = None, + search: str | None = None, + min_price: float | None = None, + max_price: float | None = None, + featured: bool | None = None, + db: Session = Depends(get_db) +): + """ + Get all products with optional filtering. + """ + query = db.query(Product) + + # Apply filters + if category_id: + query = query.filter(Product.category_id == category_id) + + if status: + query = query.filter(Product.status == status) + else: + # By default, only show published products + query = query.filter(Product.status == ProductStatus.PUBLISHED) + + if search: + search_term = f"%{search}%" + query = query.filter( + or_( + Product.name.ilike(search_term), + Product.description.ilike(search_term), + Product.sku.ilike(search_term) + ) + ) + + if min_price is not None: + query = query.filter(Product.price >= min_price) + + if max_price is not None: + query = query.filter(Product.price <= max_price) + + if featured is not None: + query = query.filter(Product.is_featured == featured) + + # Apply pagination + products = query.offset(skip).limit(limit).all() + + # Enhance products with category name and tags + for product in products: + if product.category: + product.category_name = product.category.name + + product.tags = [tag.name for tag in product.tags] + + return products + +@router.get("/{product_id}", response_model=ProductDetails) +async def get_product( + product_id: str = Path(..., title="The ID of the product to get"), + db: Session = Depends(get_db) +): + """ + Get a specific product by ID. + """ + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + # Add category name if available + if product.category: + product.category_name = product.category.name + + # Add tags + product.tags = [tag.name for tag in product.tags] + + # Calculate in_stock + product.in_stock = product.stock_quantity > 0 + + # For seller/admin, add sales data + product.total_sales = 0 + product.total_revenue = 0 + + for order_item in product.order_items: + product.total_sales += order_item.quantity + product.total_revenue += order_item.subtotal + + return product + +@router.post("/", response_model=ProductSchema) +async def create_product( + product_in: ProductCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_seller) +): + """ + Create a new product (seller or admin only). + """ + # Check if slug already exists + existing_product = db.query(Product).filter(Product.slug == product_in.slug).first() + if existing_product: + # If slug exists, create a unique one + product_in.slug = slugify(product_in.name) + + # Check if category exists + if product_in.category_id: + category = db.query(Category).filter(Category.id == product_in.category_id).first() + if not category: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Category not found" + ) + + # Create new product + db_product = Product( + name=product_in.name, + description=product_in.description, + price=product_in.price, + sku=product_in.sku, + barcode=product_in.barcode, + stock_quantity=product_in.stock_quantity, + weight=product_in.weight, + dimensions=product_in.dimensions, + status=product_in.status, + is_featured=product_in.is_featured, + is_digital=product_in.is_digital, + digital_download_link=product_in.digital_download_link, + slug=product_in.slug, + tax_rate=product_in.tax_rate, + discount_price=product_in.discount_price, + discount_start_date=product_in.discount_start_date, + discount_end_date=product_in.discount_end_date, + category_id=product_in.category_id, + seller_id=current_user.id, + ) + + db.add(db_product) + db.commit() + db.refresh(db_product) + + # Add images if provided + if product_in.images: + for image_data in product_in.images: + db_image = ProductImage( + product_id=db_product.id, + image_url=image_data.image_url, + alt_text=image_data.alt_text, + is_primary=image_data.is_primary, + display_order=image_data.display_order + ) + db.add(db_image) + + db.commit() + + # Add tags if provided + if product_in.tag_ids: + for tag_id in product_in.tag_ids: + tag = db.query(Tag).filter(Tag.id == tag_id).first() + if tag: + db_product.tags.append(tag) + + db.commit() + db.refresh(db_product) + + logger.info(f"Product created: {db_product.name} (ID: {db_product.id})") + return db_product + +@router.put("/{product_id}", response_model=ProductSchema) +async def update_product( + product_id: str, + product_in: ProductUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Update a product. + Sellers can only update their own products. + Admins can update any product. + """ + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + # Check permissions + if current_user.role != UserRole.ADMIN and current_user.id != product.seller_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to update this product" + ) + + # Check if slug already exists (if updating slug) + if product_in.slug and product_in.slug != product.slug: + existing_product = db.query(Product).filter(Product.slug == product_in.slug).first() + if existing_product: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Slug already exists" + ) + + # Check if category exists (if updating category) + if product_in.category_id: + category = db.query(Category).filter(Category.id == product_in.category_id).first() + if not category: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Category not found" + ) + + # Update product attributes + for key, value in product_in.dict(exclude_unset=True).items(): + if key != "tag_ids": # Handle tags separately + setattr(product, key, value) + + # Update tags if provided + if product_in.tag_ids is not None: + # Clear existing tags + product.tags = [] + + # Add new tags + for tag_id in product_in.tag_ids: + tag = db.query(Tag).filter(Tag.id == tag_id).first() + if tag: + product.tags.append(tag) + + db.commit() + db.refresh(product) + + logger.info(f"Product updated: {product.name} (ID: {product.id})") + return product + +@router.delete("/{product_id}", response_model=dict) +async def delete_product( + product_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Delete a product. + Sellers can only delete their own products. + Admins can delete any product. + """ + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + # Check permissions + if current_user.role != UserRole.ADMIN and current_user.id != product.seller_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to delete this product" + ) + + # Delete the product + db.delete(product) + db.commit() + + logger.info(f"Product deleted: {product.name} (ID: {product.id})") + return {"message": "Product successfully deleted"} + +@router.post("/{product_id}/images", response_model=ProductImageSchema) +async def upload_product_image( + product_id: str, + file: UploadFile = File(...), + is_primary: bool = False, + alt_text: str = None, + display_order: int = 0, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Upload an image for a product. + Sellers can only upload images for their own products. + Admins can upload images for any product. + """ + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + # Check permissions + if current_user.role != UserRole.ADMIN and current_user.id != product.seller_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to update this product" + ) + + # Validate file + content_type = file.content_type + if not content_type.startswith("image/"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be an image" + ) + + # Create product images directory if it doesn't exist + product_images_dir = settings.PRODUCT_IMAGES_DIR + product_images_dir.mkdir(parents=True, exist_ok=True) + + # Generate unique filename + file_extension = os.path.splitext(file.filename)[1] + unique_filename = f"{uuid.uuid4()}{file_extension}" + file_path = product_images_dir / unique_filename + + # Save the file + with open(file_path, "wb") as buffer: + buffer.write(await file.read()) + + # If this is the primary image, update other images + if is_primary: + db.query(ProductImage).filter( + ProductImage.product_id == product_id, + ProductImage.is_primary == True + ).update({"is_primary": False}) + + # Create the image record + relative_path = f"/storage/product_images/{unique_filename}" + db_image = ProductImage( + product_id=product_id, + image_url=relative_path, + alt_text=alt_text, + is_primary=is_primary, + display_order=display_order + ) + + db.add(db_image) + db.commit() + db.refresh(db_image) + + logger.info(f"Image uploaded for product ID {product_id}: {unique_filename}") + return db_image + +@router.delete("/{product_id}/images/{image_id}", response_model=dict) +async def delete_product_image( + product_id: str, + image_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Delete a product image. + Sellers can only delete images for their own products. + Admins can delete images for any product. + """ + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + # Check permissions + if current_user.role != UserRole.ADMIN and current_user.id != product.seller_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to update this product" + ) + + # Find the image + image = db.query(ProductImage).filter( + ProductImage.id == image_id, + ProductImage.product_id == product_id + ).first() + + if not image: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Image not found" + ) + + # Delete the image from the database + db.delete(image) + db.commit() + + # Try to delete the physical file (if it exists) + try: + file_name = os.path.basename(image.image_url) + file_path = settings.PRODUCT_IMAGES_DIR / file_name + if os.path.exists(file_path): + os.remove(file_path) + except Exception as e: + # Log the error but don't fail the request + logger.error(f"Error deleting image file: {str(e)}") + + logger.info(f"Image deleted for product ID {product_id}: {image_id}") + return {"message": "Product image successfully deleted"} diff --git a/app/routers/reviews.py b/app/routers/reviews.py new file mode 100644 index 0000000..2de38bc --- /dev/null +++ b/app/routers/reviews.py @@ -0,0 +1,270 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import desc +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.dependencies.auth import get_current_active_user +from app.models.order import Order, OrderStatus +from app.models.product import Product +from app.models.review import Review +from app.models.user import User, UserRole +from app.schemas.review import ( + Review as ReviewSchema, +) +from app.schemas.review import ( + ReviewCreate, + ReviewUpdate, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + +@router.get("/", response_model=list[ReviewSchema]) +async def get_reviews( + product_id: str | None = None, + user_id: str | None = None, + approved_only: bool = True, + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), +): + """ + Get reviews. + Filter by product_id or user_id. + Regular users can only see approved reviews for products. + """ + query = db.query(Review) + + # Filter by product ID if provided + if product_id: + query = query.filter(Review.product_id == product_id) + + # Filter by user ID if provided + if user_id: + query = query.filter(Review.user_id == user_id) + + # Filter by approved status + if approved_only: + query = query.filter(Review.is_approved == True) + + # Apply pagination and order by newest first + reviews = query.order_by(desc(Review.created_at)).offset(skip).limit(limit).all() + + # Enhance reviews with user and product names + for review in reviews: + # Add user name if available + if review.user and review.user.first_name: + if review.user.last_name: + review.user_name = f"{review.user.first_name} {review.user.last_name}" + else: + review.user_name = review.user.first_name + else: + review.user_name = "Anonymous" + + # Add product name if available + if review.product: + review.product_name = review.product.name + + return reviews + +@router.get("/{review_id}", response_model=ReviewSchema) +async def get_review( + review_id: str, + db: Session = Depends(get_db), +): + """ + Get a specific review by ID. + """ + review = db.query(Review).filter(Review.id == review_id).first() + if not review: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Review not found" + ) + + # Add user name if available + if review.user and review.user.first_name: + if review.user.last_name: + review.user_name = f"{review.user.first_name} {review.user.last_name}" + else: + review.user_name = review.user.first_name + else: + review.user_name = "Anonymous" + + # Add product name if available + if review.product: + review.product_name = review.product.name + + return review + +@router.post("/", response_model=ReviewSchema) +async def create_review( + review_in: ReviewCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Create a new review for a product. + User must have purchased the product to leave a verified review. + """ + # Check if product exists + product = db.query(Product).filter(Product.id == review_in.product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + # Check if user has already reviewed this product + existing_review = db.query(Review).filter( + Review.product_id == review_in.product_id, + Review.user_id == current_user.id + ).first() + + if existing_review: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You have already reviewed this product" + ) + + # Check if the user has purchased the product for verified review status + is_verified_purchase = False + + # Query completed orders + completed_orders = db.query(Order).filter( + Order.user_id == current_user.id, + Order.status.in_([OrderStatus.DELIVERED, OrderStatus.COMPLETED]) + ).all() + + # Check if any of these orders contain the product + for order in completed_orders: + for item in order.items: + if item.product_id == review_in.product_id: + is_verified_purchase = True + break + if is_verified_purchase: + break + + # Admin reviews are always approved, regular user reviews may need approval + is_approved = True if current_user.role == UserRole.ADMIN else True # Auto-approve for now + + # Create the review + db_review = Review( + product_id=review_in.product_id, + user_id=current_user.id, + rating=review_in.rating, + title=review_in.title, + comment=review_in.comment, + is_verified_purchase=is_verified_purchase, + is_approved=is_approved + ) + + db.add(db_review) + db.commit() + db.refresh(db_review) + + # Add user name and product name for the response + if current_user.first_name: + if current_user.last_name: + db_review.user_name = f"{current_user.first_name} {current_user.last_name}" + else: + db_review.user_name = current_user.first_name + else: + db_review.user_name = "Anonymous" + + db_review.product_name = product.name + + logger.info(f"Review created: ID {db_review.id} for product {product.name}") + return db_review + +@router.put("/{review_id}", response_model=ReviewSchema) +async def update_review( + review_id: str, + review_in: ReviewUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Update a review. + Users can only update their own reviews. + Admins can update any review. + """ + review = db.query(Review).filter(Review.id == review_id).first() + if not review: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Review not found" + ) + + # Check permissions + is_admin = current_user.role == UserRole.ADMIN + is_owner = review.user_id == current_user.id + + if not is_admin and not is_owner: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to update this review" + ) + + # Regular users cannot update approval status + update_data = review_in.dict(exclude_unset=True) + if not is_admin and "is_approved" in update_data: + del update_data["is_approved"] + + # Update review attributes + for key, value in update_data.items(): + setattr(review, key, value) + + db.commit() + db.refresh(review) + + # Add user name and product name for the response + if review.user and review.user.first_name: + if review.user.last_name: + review.user_name = f"{review.user.first_name} {review.user.last_name}" + else: + review.user_name = review.user.first_name + else: + review.user_name = "Anonymous" + + if review.product: + review.product_name = review.product.name + + logger.info(f"Review updated: ID {review.id}") + return review + +@router.delete("/{review_id}", response_model=dict) +async def delete_review( + review_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Delete a review. + Users can only delete their own reviews. + Admins can delete any review. + """ + review = db.query(Review).filter(Review.id == review_id).first() + if not review: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Review not found" + ) + + # Check permissions + is_admin = current_user.role == UserRole.ADMIN + is_owner = review.user_id == current_user.id + + if not is_admin and not is_owner: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to delete this review" + ) + + db.delete(review) + db.commit() + + logger.info(f"Review deleted: ID {review.id}") + return {"message": "Review successfully deleted"} diff --git a/app/routers/search.py b/app/routers/search.py new file mode 100644 index 0000000..41ae8bb --- /dev/null +++ b/app/routers/search.py @@ -0,0 +1,68 @@ + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.models.product import ProductStatus +from app.schemas.product import Product as ProductSchema +from app.services.search import SearchService + +router = APIRouter() + +@router.get("/products", response_model=dict) +async def search_products( + query: str | None = None, + category_id: str | None = None, + tag_ids: list[str] | None = Query(None), + min_price: float | None = None, + max_price: float | None = None, + min_rating: int | None = Query(None, ge=1, le=5), + status: ProductStatus | None = ProductStatus.PUBLISHED, + sort_by: str = "relevance", + sort_order: str = "desc", + is_featured: bool | None = None, + seller_id: str | None = None, + offset: int = 0, + limit: int = 100, + db: Session = Depends(get_db) +): + """ + Search and filter products with advanced options. + + - **query**: Text to search in product name and description + - **category_id**: Filter by category ID + - **tag_ids**: Filter by tag IDs (products must have ALL specified tags) + - **min_price**: Minimum price filter + - **max_price**: Maximum price filter + - **min_rating**: Minimum average rating filter (1-5) + - **status**: Filter by product status (admin only sees non-published) + - **sort_by**: Field to sort by (name, price, created_at, rating, relevance) + - **sort_order**: Sort order (asc or desc) + - **is_featured**: Filter by featured status + - **seller_id**: Filter by seller ID + - **offset**: Pagination offset + - **limit**: Pagination limit + """ + search_results = SearchService.search_products( + db=db, + search_query=query, + category_id=category_id, + tag_ids=tag_ids, + min_price=min_price, + max_price=max_price, + min_rating=min_rating, + status=status, + sort_by=sort_by, + sort_order=sort_order, + is_featured=is_featured, + seller_id=seller_id, + offset=offset, + limit=limit + ) + + # Convert SQLAlchemy objects to Pydantic models + search_results["products"] = [ + ProductSchema.from_orm(product) for product in search_results["products"] + ] + + return search_results diff --git a/app/routers/tags.py b/app/routers/tags.py new file mode 100644 index 0000000..f697d3a --- /dev/null +++ b/app/routers/tags.py @@ -0,0 +1,185 @@ +import logging +import re +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.dependencies.auth import get_current_admin +from app.models.tag import ProductTag, Tag +from app.models.user import User +from app.schemas.tag import ( + Tag as TagSchema, +) +from app.schemas.tag import ( + TagCreate, + TagUpdate, + TagWithProductCount, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + +def slugify(text): + """Convert a string to a URL-friendly slug.""" + # Remove non-alphanumeric characters + text = re.sub(r'[^\w\s-]', '', text.lower()) + # Replace spaces with hyphens + text = re.sub(r'[\s]+', '-', text) + # Remove consecutive hyphens + text = re.sub(r'[-]+', '-', text) + # Add timestamp to ensure uniqueness + timestamp = int(datetime.now().timestamp()) + return f"{text}-{timestamp}" + +@router.get("/", response_model=list[TagSchema]) +async def get_tags( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db) +): + """ + Get all tags. + """ + tags = db.query(Tag).offset(skip).limit(limit).all() + return tags + +@router.get("/with-count", response_model=list[TagWithProductCount]) +async def get_tags_with_product_count( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db) +): + """ + Get all tags with product counts. + """ + tags = db.query(Tag).offset(skip).limit(limit).all() + + # Add product count to each tag + for tag in tags: + tag.product_count = db.query(ProductTag).filter(ProductTag.tag_id == tag.id).count() + + return tags + +@router.get("/{tag_id}", response_model=TagSchema) +async def get_tag( + tag_id: str, + db: Session = Depends(get_db) +): + """ + Get a specific tag by ID. + """ + tag = db.query(Tag).filter(Tag.id == tag_id).first() + if not tag: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tag not found" + ) + + return tag + +@router.post("/", response_model=TagSchema) +async def create_tag( + tag_in: TagCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Create a new tag (admin only). + """ + # Check if name already exists + existing_tag_name = db.query(Tag).filter(func.lower(Tag.name) == tag_in.name.lower()).first() + if existing_tag_name: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A tag with this name already exists" + ) + + # Check if slug already exists + existing_tag_slug = db.query(Tag).filter(Tag.slug == tag_in.slug).first() + if existing_tag_slug: + # If slug exists, create a unique one + tag_in.slug = slugify(tag_in.name) + + # Create new tag + db_tag = Tag( + name=tag_in.name, + slug=tag_in.slug + ) + + db.add(db_tag) + db.commit() + db.refresh(db_tag) + + logger.info(f"Tag created: {db_tag.name} (ID: {db_tag.id})") + return db_tag + +@router.put("/{tag_id}", response_model=TagSchema) +async def update_tag( + tag_id: str, + tag_in: TagUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Update a tag (admin only). + """ + tag = db.query(Tag).filter(Tag.id == tag_id).first() + if not tag: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tag not found" + ) + + # Check if name already exists (if updating name) + if tag_in.name and tag_in.name.lower() != tag.name.lower(): + existing_tag = db.query(Tag).filter(func.lower(Tag.name) == tag_in.name.lower()).first() + if existing_tag: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A tag with this name already exists" + ) + + # Check if slug already exists (if updating slug) + if tag_in.slug and tag_in.slug != tag.slug: + existing_tag = db.query(Tag).filter(Tag.slug == tag_in.slug).first() + if existing_tag: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Slug already exists" + ) + + # Update tag attributes + for key, value in tag_in.dict(exclude_unset=True).items(): + setattr(tag, key, value) + + db.commit() + db.refresh(tag) + + logger.info(f"Tag updated: {tag.name} (ID: {tag.id})") + return tag + +@router.delete("/{tag_id}", response_model=dict) +async def delete_tag( + tag_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Delete a tag (admin only). + """ + tag = db.query(Tag).filter(Tag.id == tag_id).first() + if not tag: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tag not found" + ) + + # Delete the tag + db.delete(tag) + db.commit() + + logger.info(f"Tag deleted: {tag.name} (ID: {tag.id})") + return {"message": "Tag successfully deleted"} diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..6869ee0 --- /dev/null +++ b/app/routers/users.py @@ -0,0 +1,172 @@ +import logging +import os +import uuid + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.database import get_db +from app.core.security import get_password_hash, verify_password +from app.dependencies.auth import get_current_active_user, get_current_admin +from app.models.user import User, UserRole +from app.schemas.user import ( + User as UserSchema, +) +from app.schemas.user import ( + UserPasswordChange, + UserUpdate, + UserWithAddress, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + +@router.get("/", response_model=list[UserSchema]) +async def get_users( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin) +): + """ + Get all users (admin only). + """ + users = db.query(User).offset(skip).limit(limit).all() + return users + +@router.get("/{user_id}", response_model=UserWithAddress) +async def get_user( + user_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Get a specific user by ID. + Regular users can only get their own user details. + Admin users can get any user details. + """ + if current_user.id != user_id and current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this user's data" + ) + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + return user + +@router.put("/me", response_model=UserWithAddress) +async def update_user_me( + user_update: UserUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Update current user information. + """ + for key, value in user_update.dict(exclude_unset=True).items(): + setattr(current_user, key, value) + + db.commit() + db.refresh(current_user) + logger.info(f"User {current_user.email} updated their profile") + return current_user + +@router.post("/me/change-password", response_model=dict) +async def change_password( + password_data: UserPasswordChange, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Change current user's password. + """ + if not verify_password(password_data.current_password, current_user.hashed_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect current password" + ) + + current_user.hashed_password = get_password_hash(password_data.new_password) + db.commit() + logger.info(f"User {current_user.email} changed their password") + return {"message": "Password updated successfully"} + +@router.post("/me/profile-image", response_model=dict) +async def upload_profile_image( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Upload a profile image for the current user. + """ + # Validate the file + content_type = file.content_type + if not content_type.startswith("image/"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be an image" + ) + + # Create user images directory if it doesn't exist + user_images_dir = settings.USER_IMAGES_DIR + user_images_dir.mkdir(parents=True, exist_ok=True) + + # Generate a unique filename + file_extension = os.path.splitext(file.filename)[1] + unique_filename = f"{uuid.uuid4()}{file_extension}" + file_path = user_images_dir / unique_filename + + # Save the file + with open(file_path, "wb") as buffer: + buffer.write(await file.read()) + + # Update the user's profile image in the database + relative_path = f"/storage/user_images/{unique_filename}" + current_user.profile_image = relative_path + db.commit() + + logger.info(f"User {current_user.email} uploaded a new profile image") + return {"filename": unique_filename, "path": relative_path} + +@router.delete("/{user_id}", response_model=dict) +async def delete_user( + user_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Delete a user. + Regular users can only delete their own account. + Admin users can delete any user. + """ + if current_user.id != user_id and current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to delete this user" + ) + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Soft delete the user by setting is_active to False + # In a real-world application, you might want to consider: + # 1. Hard deleting the user data for GDPR compliance + # 2. Anonymizing the user data instead of deleting + # 3. Setting up a scheduled task to actually delete inactive users after a certain period + user.is_active = False + db.commit() + + logger.info(f"User {user.email} was marked as inactive (deleted)") + return {"message": "User successfully deleted"} diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..2c2925d --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +# Pydantic schemas/models for request and response handling diff --git a/app/schemas/admin.py b/app/schemas/admin.py new file mode 100644 index 0000000..b9c4452 --- /dev/null +++ b/app/schemas/admin.py @@ -0,0 +1,79 @@ +from datetime import date +from enum import Enum + +from pydantic import BaseModel + + +class TimePeriod(str, Enum): + TODAY = "today" + YESTERDAY = "yesterday" + LAST_7_DAYS = "last_7_days" + LAST_30_DAYS = "last_30_days" + THIS_MONTH = "this_month" + LAST_MONTH = "last_month" + THIS_YEAR = "this_year" + ALL_TIME = "all_time" + +class SalesSummary(BaseModel): + period: TimePeriod + total_sales: float + total_orders: int + average_order_value: float + refunded_amount: float + +class DateSales(BaseModel): + date: date + total_sales: float + order_count: int + +class SalesOverTime(BaseModel): + period: TimePeriod + data: list[DateSales] + total_sales: float + total_orders: int + +class CategorySales(BaseModel): + category_id: str + category_name: str + total_sales: float + percentage: float + +class TopCategorySales(BaseModel): + period: TimePeriod + categories: list[CategorySales] + total_sales: float + +class ProductSales(BaseModel): + product_id: str + product_name: str + quantity_sold: int + total_sales: float + +class TopProductSales(BaseModel): + period: TimePeriod + products: list[ProductSales] + total_sales: float + +class CustomerSales(BaseModel): + user_id: str + user_name: str + order_count: int + total_spent: float + +class TopCustomerSales(BaseModel): + period: TimePeriod + customers: list[CustomerSales] + total_sales: float + +class DashboardSummary(BaseModel): + sales_summary: SalesSummary + pending_orders: int + low_stock_products: int + new_customers: int + total_products: int + total_customers: int + +class OrdersPerStatus(BaseModel): + status: str + count: int + percentage: float diff --git a/app/schemas/cart.py b/app/schemas/cart.py new file mode 100644 index 0000000..46be4c1 --- /dev/null +++ b/app/schemas/cart.py @@ -0,0 +1,49 @@ +import json +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field, validator + + +class CartItemBase(BaseModel): + product_id: str + quantity: int = Field(..., gt=0) + custom_properties: dict[str, Any] | None = None + +class CartItemCreate(CartItemBase): + pass + +class CartItemUpdate(BaseModel): + quantity: int | None = Field(None, gt=0) + custom_properties: dict[str, Any] | None = None + +class CartItemInDBBase(CartItemBase): + id: str + user_id: str + price_at_addition: float + created_at: datetime + updated_at: datetime | None = None + + class Config: + orm_mode = True + + @validator('custom_properties', pre=True) + def parse_custom_properties(cls, v): + if isinstance(v, str): + try: + return json.loads(v) + except: + return None + return v + +class CartItem(CartItemInDBBase): + product_name: str + product_image: str | None = None + current_price: float + subtotal: float + +class CartSummary(BaseModel): + items: list[CartItem] + total_items: int + subtotal: float + total_weight: float | None = None diff --git a/app/schemas/category.py b/app/schemas/category.py new file mode 100644 index 0000000..8bd8394 --- /dev/null +++ b/app/schemas/category.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class CategoryBase(BaseModel): + name: str + slug: str + description: str | None = None + image: str | None = None + parent_id: str | None = None + is_active: bool | None = True + display_order: int | None = 0 + +class CategoryCreate(CategoryBase): + pass + +class CategoryUpdate(BaseModel): + name: str | None = None + slug: str | None = None + description: str | None = None + image: str | None = None + parent_id: str | None = None + is_active: bool | None = None + display_order: int | None = None + +class CategoryInDBBase(CategoryBase): + id: str + created_at: datetime + updated_at: datetime | None = None + + class Config: + orm_mode = True + +class Category(CategoryInDBBase): + pass + +class CategoryWithChildren(Category): + subcategories: list['CategoryWithChildren'] = [] + product_count: int = 0 diff --git a/app/schemas/order.py b/app/schemas/order.py new file mode 100644 index 0000000..6a14c45 --- /dev/null +++ b/app/schemas/order.py @@ -0,0 +1,107 @@ +import json +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field, validator + +from app.models.order import OrderStatus, ShippingMethod + + +class AddressSchema(BaseModel): + first_name: str + last_name: str + address_line1: str + address_line2: str | None = None + city: str + state: str + postal_code: str + country: str + phone_number: str | None = None + email: str | None = None + +class OrderItemBase(BaseModel): + product_id: str + quantity: int = Field(..., gt=0) + unit_price: float + product_options: dict[str, Any] | None = None + +class OrderItemCreate(OrderItemBase): + pass + +class OrderItemInDB(OrderItemBase): + id: str + order_id: str + subtotal: float + discount: float = 0.0 + tax_amount: float = 0.0 + product_name: str + product_sku: str | None = None + created_at: datetime + + class Config: + orm_mode = True + + @validator('product_options', pre=True) + def parse_product_options(cls, v): + if isinstance(v, str): + try: + return json.loads(v) + except: + return None + return v + +class OrderBase(BaseModel): + shipping_method: ShippingMethod + shipping_address: AddressSchema + billing_address: AddressSchema | None = None + notes: str | None = None + +class OrderCreate(OrderBase): + cart_items: list[str] = [] # List of cart item IDs + coupon_code: str | None = None + use_default_addresses: bool = False + +class OrderUpdate(BaseModel): + status: OrderStatus | None = None + tracking_number: str | None = None + notes: str | None = None + +class OrderInDBBase(OrderBase): + id: str + user_id: str + order_number: str + status: OrderStatus + total_amount: float + subtotal: float + tax_amount: float + shipping_amount: float + discount_amount: float = 0.0 + tracking_number: str | None = None + created_at: datetime + updated_at: datetime | None = None + + class Config: + orm_mode = True + + @validator('shipping_address', 'billing_address', pre=True) + def parse_address(cls, v): + if isinstance(v, str): + try: + return json.loads(v) + except: + return {} + return v + +class Order(OrderInDBBase): + items: list[OrderItemInDB] = [] + +class OrderSummary(BaseModel): + id: str + order_number: str + status: OrderStatus + total_amount: float + created_at: datetime + item_count: int + + class Config: + orm_mode = True diff --git a/app/schemas/payment.py b/app/schemas/payment.py new file mode 100644 index 0000000..ca32e24 --- /dev/null +++ b/app/schemas/payment.py @@ -0,0 +1,57 @@ +import json +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + +from app.models.payment import PaymentMethod, PaymentStatus + + +class PaymentBase(BaseModel): + order_id: str + amount: float = Field(..., gt=0) + payment_method: PaymentMethod + +class PaymentCreate(PaymentBase): + payment_details: dict[str, Any] = {} + +class PaymentUpdate(BaseModel): + status: PaymentStatus | None = None + transaction_id: str | None = None + payment_details: dict[str, Any] | None = None + error_message: str | None = None + +class PaymentInDBBase(PaymentBase): + id: str + status: PaymentStatus + transaction_id: str | None = None + error_message: str | None = None + created_at: datetime + updated_at: datetime | None = None + + class Config: + orm_mode = True + + @property + def payment_details_parsed(self) -> dict[str, Any]: + if hasattr(self, 'payment_details') and self.payment_details: + if isinstance(self.payment_details, str): + try: + return json.loads(self.payment_details) + except: + return {} + return self.payment_details + return {} + +class Payment(PaymentInDBBase): + payment_details: dict[str, Any] | None = None + +class PaymentResponse(BaseModel): + """Response model for payment processing endpoints""" + + success: bool + payment_id: str | None = None + status: PaymentStatus | None = None + transaction_id: str | None = None + redirect_url: str | None = None + error_message: str | None = None diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..0e8c0d3 --- /dev/null +++ b/app/schemas/product.py @@ -0,0 +1,114 @@ +from datetime import datetime + +from pydantic import BaseModel, Field, validator + +from app.models.product import ProductStatus + + +class ProductImageBase(BaseModel): + image_url: str + alt_text: str | None = None + is_primary: bool | None = False + display_order: int | None = 0 + +class ProductImageCreate(ProductImageBase): + pass + +class ProductImageUpdate(ProductImageBase): + image_url: str | None = None + +class ProductImage(ProductImageBase): + id: str + product_id: str + created_at: datetime + + class Config: + orm_mode = True + +class ProductBase(BaseModel): + name: str + description: str | None = None + price: float = Field(..., gt=0) + sku: str | None = None + barcode: str | None = None + stock_quantity: int = Field(0, ge=0) + weight: float | None = None + dimensions: str | None = None + status: ProductStatus = ProductStatus.DRAFT + is_featured: bool | None = False + is_digital: bool | None = False + digital_download_link: str | None = None + slug: str + tax_rate: float | None = 0.0 + discount_price: float | None = None + discount_start_date: datetime | None = None + discount_end_date: datetime | None = None + category_id: str | None = None + + @validator('discount_price') + def discount_price_must_be_less_than_price(cls, v, values): + if v is not None and 'price' in values and v >= values['price']: + raise ValueError('Discount price must be less than regular price') + return v + + @validator('discount_end_date') + def end_date_must_be_after_start_date(cls, v, values): + if (v is not None and 'discount_start_date' in values + and values['discount_start_date'] is not None + and v <= values['discount_start_date']): + raise ValueError('Discount end date must be after start date') + return v + +class ProductCreate(ProductBase): + images: list[ProductImageCreate] | None = None + tag_ids: list[str] | None = [] + +class ProductUpdate(BaseModel): + name: str | None = None + description: str | None = None + price: float | None = Field(None, gt=0) + sku: str | None = None + barcode: str | None = None + stock_quantity: int | None = Field(None, ge=0) + weight: float | None = None + dimensions: str | None = None + status: ProductStatus | None = None + is_featured: bool | None = None + is_digital: bool | None = None + digital_download_link: str | None = None + slug: str | None = None + tax_rate: float | None = None + discount_price: float | None = None + discount_start_date: datetime | None = None + discount_end_date: datetime | None = None + category_id: str | None = None + tag_ids: list[str] | None = None + + @validator('discount_price') + def discount_price_validation(cls, v, values): + if v is not None and 'price' in values and values['price'] is not None and v >= values['price']: + raise ValueError('Discount price must be less than regular price') + return v + +class ProductInDBBase(ProductBase): + id: str + seller_id: str | None + created_at: datetime + updated_at: datetime | None + + class Config: + orm_mode = True + +class Product(ProductInDBBase): + images: list[ProductImage] = [] + average_rating: float | None = None + current_price: float + category_name: str | None = None + tags: list[str] = [] + +class ProductDetails(Product): + """Extended product details including inventory and sales data""" + + total_sales: int | None = None + total_revenue: float | None = None + in_stock: bool diff --git a/app/schemas/review.py b/app/schemas/review.py new file mode 100644 index 0000000..bf789f1 --- /dev/null +++ b/app/schemas/review.py @@ -0,0 +1,44 @@ +from datetime import datetime + +from pydantic import BaseModel, Field, validator + + +class ReviewBase(BaseModel): + product_id: str + rating: int = Field(..., ge=1, le=5) + title: str | None = None + comment: str | None = None + +class ReviewCreate(ReviewBase): + @validator('rating') + def rating_must_be_valid(cls, v): + if v < 1 or v > 5: + raise ValueError('Rating must be between 1 and 5') + return v + +class ReviewUpdate(BaseModel): + rating: int | None = Field(None, ge=1, le=5) + title: str | None = None + comment: str | None = None + is_approved: bool | None = None + + @validator('rating') + def rating_must_be_valid(cls, v): + if v is not None and (v < 1 or v > 5): + raise ValueError('Rating must be between 1 and 5') + return v + +class ReviewInDBBase(ReviewBase): + id: str + user_id: str + is_verified_purchase: bool + is_approved: bool + created_at: datetime + updated_at: datetime | None = None + + class Config: + orm_mode = True + +class Review(ReviewInDBBase): + user_name: str | None = None + product_name: str | None = None diff --git a/app/schemas/tag.py b/app/schemas/tag.py new file mode 100644 index 0000000..96a8f70 --- /dev/null +++ b/app/schemas/tag.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class TagBase(BaseModel): + name: str + slug: str + +class TagCreate(TagBase): + pass + +class TagUpdate(BaseModel): + name: str | None = None + slug: str | None = None + +class TagInDBBase(TagBase): + id: str + created_at: datetime + updated_at: datetime | None = None + + class Config: + orm_mode = True + +class Tag(TagInDBBase): + pass + +class TagWithProductCount(Tag): + product_count: int = 0 diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..905bd52 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,88 @@ +from datetime import datetime + +from pydantic import BaseModel, EmailStr, Field, validator + +from app.models.user import UserRole + + +class UserBase(BaseModel): + email: EmailStr + first_name: str | None = None + last_name: str | None = None + phone_number: str | None = None + +class UserCreate(UserBase): + password: str = Field(..., min_length=8, max_length=100) + confirm_password: str + + @validator('confirm_password') + def passwords_match(cls, v, values, **kwargs): + if 'password' in values and v != values['password']: + raise ValueError('Passwords do not match') + return v + +class UserUpdate(BaseModel): + first_name: str | None = None + last_name: str | None = None + phone_number: str | None = None + email: EmailStr | None = None + profile_image: str | None = None + address_line1: str | None = None + address_line2: str | None = None + city: str | None = None + state: str | None = None + postal_code: str | None = None + country: str | None = None + bio: str | None = None + +class UserPasswordChange(BaseModel): + current_password: str + new_password: str = Field(..., min_length=8, max_length=100) + confirm_password: str + + @validator('confirm_password') + def passwords_match(cls, v, values, **kwargs): + if 'new_password' in values and v != values['new_password']: + raise ValueError('Passwords do not match') + return v + +class UserInDBBase(UserBase): + id: str + is_active: bool + role: UserRole + created_at: datetime + updated_at: datetime | None = None + email_verified: bool + profile_image: str | None = None + + class Config: + orm_mode = True + +class User(UserInDBBase): + """User model returned to clients""" + + pass + +class UserWithAddress(User): + """User model with address information""" + + address_line1: str | None = None + address_line2: str | None = None + city: str | None = None + state: str | None = None + postal_code: str | None = None + country: str | None = None + bio: str | None = None + +class UserInDB(UserInDBBase): + """User model stored in DB (includes hashed password)""" + + hashed_password: str + +class Token(BaseModel): + access_token: str + token_type: str + +class TokenPayload(BaseModel): + sub: str + exp: datetime diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..601d061 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# Service modules for business logic diff --git a/app/services/admin.py b/app/services/admin.py new file mode 100644 index 0000000..1b7adea --- /dev/null +++ b/app/services/admin.py @@ -0,0 +1,437 @@ +from datetime import datetime, timedelta +from typing import Any + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.models.category import Category +from app.models.order import Order, OrderStatus +from app.models.product import Product, ProductStatus +from app.models.user import User, UserRole +from app.schemas.admin import TimePeriod + + +class AdminDashboardService: + """ + Service for admin dashboard analytics. + """ + + @staticmethod + def get_date_range(period: TimePeriod) -> tuple: + """ + Get date range for a given time period. + + Args: + period: Time period to get range for + + Returns: + Tuple of (start_date, end_date) + + """ + now = datetime.now() + end_date = now + + if period == TimePeriod.TODAY: + start_date = datetime(now.year, now.month, now.day, 0, 0, 0) + + elif period == TimePeriod.YESTERDAY: + yesterday = now - timedelta(days=1) + start_date = datetime(yesterday.year, yesterday.month, yesterday.day, 0, 0, 0) + end_date = datetime(yesterday.year, yesterday.month, yesterday.day, 23, 59, 59) + + elif period == TimePeriod.LAST_7_DAYS: + start_date = now - timedelta(days=7) + + elif period == TimePeriod.LAST_30_DAYS: + start_date = now - timedelta(days=30) + + elif period == TimePeriod.THIS_MONTH: + start_date = datetime(now.year, now.month, 1) + + elif period == TimePeriod.LAST_MONTH: + if now.month == 1: + start_date = datetime(now.year - 1, 12, 1) + end_date = datetime(now.year, now.month, 1) - timedelta(days=1) + else: + start_date = datetime(now.year, now.month - 1, 1) + end_date = datetime(now.year, now.month, 1) - timedelta(days=1) + + elif period == TimePeriod.THIS_YEAR: + start_date = datetime(now.year, 1, 1) + + else: # ALL_TIME + start_date = datetime(1900, 1, 1) # A long time ago + + return start_date, end_date + + @staticmethod + def get_sales_summary(db: Session, period: TimePeriod) -> dict[str, Any]: + """ + Get sales summary for a given time period. + + Args: + db: Database session + period: Time period to get summary for + + Returns: + Dictionary with sales summary data + + """ + start_date, end_date = AdminDashboardService.get_date_range(period) + + # Get completed orders in the date range + completed_statuses = [OrderStatus.DELIVERED, OrderStatus.COMPLETED] + orders = db.query(Order).filter( + Order.created_at.between(start_date, end_date), + Order.status.in_(completed_statuses) + ).all() + + # Get refunded orders in the date range + refunded_orders = db.query(Order).filter( + Order.created_at.between(start_date, end_date), + Order.status == OrderStatus.REFUNDED + ).all() + + # Calculate totals + total_sales = sum(order.total_amount for order in orders) + total_orders = len(orders) + average_order_value = total_sales / total_orders if total_orders > 0 else 0 + refunded_amount = sum(order.total_amount for order in refunded_orders) + + return { + "period": period, + "total_sales": total_sales, + "total_orders": total_orders, + "average_order_value": average_order_value, + "refunded_amount": refunded_amount + } + + @staticmethod + def get_sales_over_time(db: Session, period: TimePeriod) -> dict[str, Any]: + """ + Get sales data over time for the given period. + + Args: + db: Database session + period: Time period to get data for + + Returns: + Dictionary with sales over time data + + """ + start_date, end_date = AdminDashboardService.get_date_range(period) + + # Determine the date grouping and format based on the period + date_format = "%Y-%m-%d" # Default daily format + delta = timedelta(days=1) # Default daily increment + + if period in [TimePeriod.THIS_YEAR, TimePeriod.ALL_TIME]: + date_format = "%Y-%m" # Monthly format + + # Generate all dates in the range + date_range = [] + current_date = start_date + while current_date <= end_date: + date_range.append(current_date.strftime(date_format)) + current_date += delta + + # Get completed orders in the date range + completed_statuses = [OrderStatus.DELIVERED, OrderStatus.COMPLETED] + orders = db.query(Order).filter( + Order.created_at.between(start_date, end_date), + Order.status.in_(completed_statuses) + ).all() + + # Group orders by date + date_sales = {} + for date_str in date_range: + date_sales[date_str] = {"date": date_str, "total_sales": 0, "order_count": 0} + + for order in orders: + date_str = order.created_at.strftime(date_format) + if date_str in date_sales: + date_sales[date_str]["total_sales"] += order.total_amount + date_sales[date_str]["order_count"] += 1 + + # Convert to list and sort by date + data = list(date_sales.values()) + data.sort(key=lambda x: x["date"]) + + total_sales = sum(item["total_sales"] for item in data) + total_orders = sum(item["order_count"] for item in data) + + return { + "period": period, + "data": data, + "total_sales": total_sales, + "total_orders": total_orders + } + + @staticmethod + def get_top_categories(db: Session, period: TimePeriod, limit: int = 5) -> dict[str, Any]: + """ + Get top selling categories for the given period. + + Args: + db: Database session + period: Time period to get data for + limit: Number of categories to return + + Returns: + Dictionary with top category sales data + + """ + start_date, end_date = AdminDashboardService.get_date_range(period) + + # Get completed orders in the date range + completed_statuses = [OrderStatus.DELIVERED, OrderStatus.COMPLETED] + + # This is a complex query that would involve joining multiple tables + # For simplicity, we'll use a more direct approach + + # Get all order items from completed orders + orders = db.query(Order).filter( + Order.created_at.between(start_date, end_date), + Order.status.in_(completed_statuses) + ).all() + + # Collect category sales data + category_sales = {} + total_sales = 0 + + for order in orders: + for item in order.items: + product = db.query(Product).filter(Product.id == item.product_id).first() + if product and product.category_id: + category_id = product.category_id + category = db.query(Category).filter(Category.id == category_id).first() + + if category: + if category_id not in category_sales: + category_sales[category_id] = { + "category_id": category_id, + "category_name": category.name, + "total_sales": 0 + } + + item_total = item.unit_price * item.quantity - item.discount + category_sales[category_id]["total_sales"] += item_total + total_sales += item_total + + # Convert to list and sort by total sales + categories = list(category_sales.values()) + categories.sort(key=lambda x: x["total_sales"], reverse=True) + + # Calculate percentages and limit results + for category in categories: + category["percentage"] = (category["total_sales"] / total_sales * 100) if total_sales > 0 else 0 + + categories = categories[:limit] + + return { + "period": period, + "categories": categories, + "total_sales": total_sales + } + + @staticmethod + def get_top_products(db: Session, period: TimePeriod, limit: int = 5) -> dict[str, Any]: + """ + Get top selling products for the given period. + + Args: + db: Database session + period: Time period to get data for + limit: Number of products to return + + Returns: + Dictionary with top product sales data + + """ + start_date, end_date = AdminDashboardService.get_date_range(period) + + # Get completed orders in the date range + completed_statuses = [OrderStatus.DELIVERED, OrderStatus.COMPLETED] + + # Get all order items from completed orders + orders = db.query(Order).filter( + Order.created_at.between(start_date, end_date), + Order.status.in_(completed_statuses) + ).all() + + # Collect product sales data + product_sales = {} + total_sales = 0 + + for order in orders: + for item in order.items: + product_id = item.product_id + + if product_id not in product_sales: + product = db.query(Product).filter(Product.id == product_id).first() + product_name = item.product_name if item.product_name else (product.name if product else "Unknown Product") + + product_sales[product_id] = { + "product_id": product_id, + "product_name": product_name, + "quantity_sold": 0, + "total_sales": 0 + } + + product_sales[product_id]["quantity_sold"] += item.quantity + item_total = item.unit_price * item.quantity - item.discount + product_sales[product_id]["total_sales"] += item_total + total_sales += item_total + + # Convert to list and sort by total sales + products = list(product_sales.values()) + products.sort(key=lambda x: x["total_sales"], reverse=True) + + # Limit results + products = products[:limit] + + return { + "period": period, + "products": products, + "total_sales": total_sales + } + + @staticmethod + def get_top_customers(db: Session, period: TimePeriod, limit: int = 5) -> dict[str, Any]: + """ + Get top customers for the given period. + + Args: + db: Database session + period: Time period to get data for + limit: Number of customers to return + + Returns: + Dictionary with top customer data + + """ + start_date, end_date = AdminDashboardService.get_date_range(period) + + # Get completed orders in the date range + completed_statuses = [OrderStatus.DELIVERED, OrderStatus.COMPLETED] + + # Get all orders from the period + orders = db.query(Order).filter( + Order.created_at.between(start_date, end_date), + Order.status.in_(completed_statuses) + ).all() + + # Collect customer data + customer_data = {} + total_sales = 0 + + for order in orders: + user_id = order.user_id + + if user_id not in customer_data: + user = db.query(User).filter(User.id == user_id).first() + user_name = f"{user.first_name} {user.last_name}" if user and user.first_name else f"User {user_id}" + + customer_data[user_id] = { + "user_id": user_id, + "user_name": user_name, + "order_count": 0, + "total_spent": 0 + } + + customer_data[user_id]["order_count"] += 1 + customer_data[user_id]["total_spent"] += order.total_amount + total_sales += order.total_amount + + # Convert to list and sort by total spent + customers = list(customer_data.values()) + customers.sort(key=lambda x: x["total_spent"], reverse=True) + + # Limit results + customers = customers[:limit] + + return { + "period": period, + "customers": customers, + "total_sales": total_sales + } + + @staticmethod + def get_dashboard_summary(db: Session) -> dict[str, Any]: + """ + Get a summary of key metrics for the dashboard. + + Args: + db: Database session + + Returns: + Dictionary with dashboard summary data + + """ + # Get sales summary for last 30 days + sales_summary = AdminDashboardService.get_sales_summary(db, TimePeriod.LAST_30_DAYS) + + # Count pending orders + pending_orders = db.query(Order).filter(Order.status == OrderStatus.PENDING).count() + + # Count low stock products (less than 5 items) + low_stock_products = db.query(Product).filter( + Product.stock_quantity <= 5, + Product.status != ProductStatus.DISCONTINUED + ).count() + + # Count new customers in the last 30 days + last_30_days = datetime.now() - timedelta(days=30) + new_customers = db.query(User).filter( + User.created_at >= last_30_days, + User.role == UserRole.CUSTOMER + ).count() + + # Count total products and customers + total_products = db.query(Product).count() + total_customers = db.query(User).filter(User.role == UserRole.CUSTOMER).count() + + return { + "sales_summary": sales_summary, + "pending_orders": pending_orders, + "low_stock_products": low_stock_products, + "new_customers": new_customers, + "total_products": total_products, + "total_customers": total_customers + } + + @staticmethod + def get_orders_by_status(db: Session) -> list[dict[str, Any]]: + """ + Get order counts by status. + + Args: + db: Database session + + Returns: + List of dictionaries with order status counts + + """ + # Count orders by status + status_counts = db.query( + Order.status, + func.count(Order.id).label('count') + ).group_by(Order.status).all() + + # Calculate total orders + total_orders = sum(count for _, count in status_counts) + + # Format results + result = [] + for status, count in status_counts: + percentage = (count / total_orders * 100) if total_orders > 0 else 0 + result.append({ + "status": status.value, + "count": count, + "percentage": percentage + }) + + # Sort by count (descending) + result.sort(key=lambda x: x["count"], reverse=True) + + return result diff --git a/app/services/inventory.py b/app/services/inventory.py new file mode 100644 index 0000000..d6688be --- /dev/null +++ b/app/services/inventory.py @@ -0,0 +1,136 @@ +import logging +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.product import Product, ProductStatus + +logger = logging.getLogger(__name__) + +class InventoryService: + """ + Service for managing product inventory. + """ + + @staticmethod + def update_stock( + db: Session, + product_id: str, + quantity_change: int, + operation: str = "add" + ) -> Product: + """ + Update the stock quantity of a product. + + Args: + db: Database session + product_id: Product ID + quantity_change: Amount to change stock by (positive or negative) + operation: "add" to increase stock, "subtract" to decrease + + Returns: + Updated product object + + """ + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found" + ) + + if operation == "add": + product.stock_quantity += quantity_change + + # If stock was 0 and now it's not, update status + if product.stock_quantity > 0 and product.status == ProductStatus.OUT_OF_STOCK: + product.status = ProductStatus.PUBLISHED + + elif operation == "subtract": + if product.stock_quantity < quantity_change: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot subtract {quantity_change} units. Only {product.stock_quantity} in stock." + ) + + product.stock_quantity -= quantity_change + + # If stock is now 0, update status + if product.stock_quantity == 0 and product.status == ProductStatus.PUBLISHED: + product.status = ProductStatus.OUT_OF_STOCK + else: + raise ValueError(f"Invalid operation: {operation}. Must be 'add' or 'subtract'.") + + db.commit() + db.refresh(product) + + logger.info(f"Updated stock for product {product.id}. New quantity: {product.stock_quantity}") + return product + + @staticmethod + def get_low_stock_products( + db: Session, + threshold: int = 5, + category_id: str | None = None, + seller_id: str | None = None + ) -> list[Product]: + """ + Get products with low stock. + + Args: + db: Database session + threshold: Stock threshold to consider "low" + category_id: Optional category ID to filter by + seller_id: Optional seller ID to filter by + + Returns: + List of products with low stock + + """ + query = db.query(Product).filter(Product.stock_quantity <= threshold) + + if category_id: + query = query.filter(Product.category_id == category_id) + + if seller_id: + query = query.filter(Product.seller_id == seller_id) + + return query.all() + + @staticmethod + def bulk_update_stock( + db: Session, + updates: list[dict[str, Any]] + ) -> list[Product]: + """ + Update stock for multiple products at once. + + Args: + db: Database session + updates: List of dicts with product_id, quantity_change, and operation + + Returns: + List of updated products + + """ + updated_products = [] + + for update in updates: + product_id = update.get('product_id') + quantity_change = update.get('quantity_change', 0) + operation = update.get('operation', 'add') + + if not product_id or not isinstance(quantity_change, int): + logger.warning(f"Skipping invalid update: {update}") + continue + + try: + product = InventoryService.update_stock( + db, product_id, quantity_change, operation + ) + updated_products.append(product) + except Exception as e: + logger.error(f"Error updating stock for product {product_id}: {str(e)}") + + return updated_products diff --git a/app/services/payment.py b/app/services/payment.py new file mode 100644 index 0000000..7e155a9 --- /dev/null +++ b/app/services/payment.py @@ -0,0 +1,188 @@ +import json +import logging +import uuid +from typing import Any + +from app.models.order import Order, OrderStatus +from app.models.payment import Payment, PaymentMethod, PaymentStatus + +logger = logging.getLogger(__name__) + +class PaymentService: + """ + Payment service for processing payments. + + This is a mock implementation that simulates payment processing for demonstration purposes. + In a real-world application, this would integrate with actual payment providers. + """ + + @staticmethod + async def process_payment( + db_session, + order: Order, + payment_method: PaymentMethod, + payment_details: dict[str, Any] + ) -> dict[str, Any]: + """ + Process a payment for an order. + + Args: + db_session: Database session + order: Order to be paid + payment_method: Payment method to use + payment_details: Additional payment details + + Returns: + Dictionary with payment result details + + """ + # Mock payment processing + # In a real application, this would call the payment provider's API + + # Create a mock transaction ID + transaction_id = f"TRANS-{uuid.uuid4()}" + + # Simulate payment processing + success = True + error_message = None + status = PaymentStatus.COMPLETED + + # For demonstration purposes, let's fail some payments randomly based on order ID + if int(order.id.replace("-", ""), 16) % 10 == 0: + success = False + error_message = "Payment declined by the provider" + status = PaymentStatus.FAILED + + # Create payment record + payment = Payment( + id=str(uuid.uuid4()), + order_id=order.id, + amount=order.total_amount, + payment_method=payment_method, + status=status, + transaction_id=transaction_id if success else None, + payment_details=json.dumps(payment_details), + error_message=error_message, + ) + + db_session.add(payment) + + # Update order status based on payment result + if success: + order.status = OrderStatus.PROCESSING + + db_session.commit() + + # Return payment result + return { + "success": success, + "payment_id": payment.id, + "status": status, + "transaction_id": transaction_id if success else None, + "error_message": error_message, + "redirect_url": None, # In real payment flows, this might be a URL to redirect the user to + } + + @staticmethod + async def process_stripe_payment( + db_session, + order: Order, + payment_details: dict[str, Any] + ) -> dict[str, Any]: + """ + Process a payment using Stripe. + + Args: + db_session: Database session + order: Order to be paid + payment_details: Stripe payment details including token + + Returns: + Dictionary with payment result details + + """ + logger.info(f"Processing Stripe payment for order {order.id}") + + # In a real application, this would use the Stripe API + # Example code (not actually executed): + # import stripe + # stripe.api_key = settings.STRIPE_API_KEY + # try: + # charge = stripe.Charge.create( + # amount=int(order.total_amount * 100), # Amount in cents + # currency="usd", + # source=payment_details.get("token"), + # description=f"Payment for order {order.order_number}", + # metadata={"order_id": order.id} + # ) + # transaction_id = charge.id + # success = True + # error_message = None + # except stripe.error.CardError as e: + # transaction_id = None + # success = False + # error_message = e.error.message + + # Mock implementation + return await PaymentService.process_payment( + db_session, order, PaymentMethod.STRIPE, payment_details + ) + + @staticmethod + async def process_paypal_payment( + db_session, + order: Order, + payment_details: dict[str, Any] + ) -> dict[str, Any]: + """ + Process a payment using PayPal. + + Args: + db_session: Database session + order: Order to be paid + payment_details: PayPal payment details + + Returns: + Dictionary with payment result details and potentially a redirect URL + + """ + logger.info(f"Processing PayPal payment for order {order.id}") + + # In a real application, this would use the PayPal API + # Mock implementation + result = await PaymentService.process_payment( + db_session, order, PaymentMethod.PAYPAL, payment_details + ) + + # PayPal often requires redirect to complete payment + if result["success"]: + result["redirect_url"] = f"https://www.paypal.com/checkout/mock-redirect?order_id={order.id}" + + return result + + @staticmethod + async def verify_payment( + db_session, + payment_id: str + ) -> Payment | None: + """ + Verify the status of a payment. + + Args: + db_session: Database session + payment_id: ID of the payment to verify + + Returns: + Updated payment object or None if not found + + """ + # Get the payment + payment = db_session.query(Payment).filter(Payment.id == payment_id).first() + if not payment: + return None + + # In a real application, this would check with the payment provider's API + # to get the current status of the payment + + # For mock implementation, we'll just return the payment as is + return payment diff --git a/app/services/search.py b/app/services/search.py new file mode 100644 index 0000000..64637d5 --- /dev/null +++ b/app/services/search.py @@ -0,0 +1,194 @@ +from typing import Any + +from sqlalchemy import and_, asc, desc, func, or_ +from sqlalchemy.orm import Session + +from app.models.category import Category +from app.models.product import Product, ProductStatus +from app.models.review import Review +from app.models.tag import ProductTag + + +class SearchService: + """ + Service for advanced search and filtering of products. + """ + + @staticmethod + def search_products( + db: Session, + search_query: str | None = None, + category_id: str | None = None, + tag_ids: list[str] | None = None, + min_price: float | None = None, + max_price: float | None = None, + min_rating: int | None = None, + status: ProductStatus | None = ProductStatus.PUBLISHED, + sort_by: str = "relevance", + sort_order: str = "desc", + is_featured: bool | None = None, + seller_id: str | None = None, + offset: int = 0, + limit: int = 100 + ) -> dict[str, Any]: + """ + Search and filter products with advanced options. + + Args: + db: Database session + search_query: Text to search in product name and description + category_id: Filter by category ID + tag_ids: Filter by tag IDs (products must have ALL specified tags) + min_price: Minimum price filter + max_price: Maximum price filter + min_rating: Minimum average rating filter + status: Filter by product status + sort_by: Field to sort by (name, price, created_at, rating, relevance) + sort_order: Sort order (asc or desc) + is_featured: Filter by featured status + seller_id: Filter by seller ID + offset: Pagination offset + limit: Pagination limit + + Returns: + Dict with products and total count + + """ + # Start with base query + query = db.query(Product).outerjoin(Review, Product.id == Review.product_id) + + # Apply filters + filters = [] + + # Status filter + if status: + filters.append(Product.status == status) + + # Search query filter + if search_query: + search_term = f"%{search_query}%" + filters.append( + or_( + Product.name.ilike(search_term), + Product.description.ilike(search_term), + Product.sku.ilike(search_term) + ) + ) + + # Category filter + if category_id: + # Get all subcategory IDs recursively + subcategory_ids = [category_id] + + def get_subcategories(parent_id): + subcategories = db.query(Category).filter(Category.parent_id == parent_id).all() + for subcategory in subcategories: + subcategory_ids.append(subcategory.id) + get_subcategories(subcategory.id) + + get_subcategories(category_id) + + filters.append(Product.category_id.in_(subcategory_ids)) + + # Tag filters + if tag_ids: + # This creates a subquery where products must have ALL the specified tags + for tag_id in tag_ids: + # For each tag, we add a separate exists condition to ensure all tags are present + tag_subquery = db.query(ProductTag).filter( + ProductTag.product_id == Product.id, + ProductTag.tag_id == tag_id + ).exists() + filters.append(tag_subquery) + + # Price filters + if min_price is not None: + filters.append(Product.price >= min_price) + + if max_price is not None: + filters.append(Product.price <= max_price) + + # Featured filter + if is_featured is not None: + filters.append(Product.is_featured == is_featured) + + # Seller filter + if seller_id: + filters.append(Product.seller_id == seller_id) + + # Apply all filters + if filters: + query = query.filter(and_(*filters)) + + # Rating filter (applied separately as it's an aggregation) + if min_rating is not None: + # Group by product ID and filter by average rating + query = query.group_by(Product.id).having( + func.avg(Review.rating) >= min_rating + ) + + # Count total results before pagination + total_count = query.count() + + # Apply sorting + if sort_by == "name": + order_func = asc if sort_order == "asc" else desc + query = query.order_by(order_func(Product.name)) + elif sort_by == "price": + order_func = asc if sort_order == "asc" else desc + query = query.order_by(order_func(Product.price)) + elif sort_by == "created_at": + order_func = asc if sort_order == "asc" else desc + query = query.order_by(order_func(Product.created_at)) + elif sort_by == "rating": + order_func = asc if sort_order == "asc" else desc + query = query.group_by(Product.id).order_by(order_func(func.avg(Review.rating))) + else: # Default to relevance (works best with search_query) + # For relevance, if there's a search query, we first sort by search match quality + if search_query: + # This prioritizes exact name matches, then description matches + search_term = f"%{search_query}%" + + # Custom ordering function for relevance + relevance_score = ( + # Exact match in name (highest priority) + func.case( + [(Product.name.ilike(search_query), 100)], + else_=0 + ) + + # Name starts with the query + func.case( + [(Product.name.ilike(f"{search_query}%"), 50)], + else_=0 + ) + + # Name contains the query + func.case( + [(Product.name.ilike(search_term), 25)], + else_=0 + ) + + # Description contains the query + func.case( + [(Product.description.ilike(search_term), 10)], + else_=0 + ) + ) + + order_func = desc if sort_order == "desc" else asc + query = query.order_by(order_func(relevance_score), desc(Product.is_featured)) + else: + # If no search query, sort by featured status and most recent + query = query.order_by(desc(Product.is_featured), desc(Product.created_at)) + + # Apply pagination + query = query.offset(offset).limit(limit) + + # Execute query + products = query.all() + + # Prepare response data + return { + "total": total_count, + "products": products, + "offset": offset, + "limit": limit, + } diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..ce48e4b --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +# Utility functions and helpers diff --git a/app/utils/db_init.py b/app/utils/db_init.py new file mode 100644 index 0000000..9881713 --- /dev/null +++ b/app/utils/db_init.py @@ -0,0 +1,60 @@ +import logging +import sys +from pathlib import Path + +# Add the project root to the Python path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) + +from sqlalchemy import create_engine + +from app.core.config import settings +from app.core.database import SessionLocal +from app.core.security import get_password_hash +from app.models.user import User, UserRole + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def init_db(): + """Initialize the database with required tables and initial admin user.""" + try: + # Create database directory if it doesn't exist + settings.DB_DIR.mkdir(parents=True, exist_ok=True) + + # Create database engine + engine = create_engine(settings.SQLALCHEMY_DATABASE_URL) + + # Create an admin user + db = SessionLocal() + + # Check if admin user already exists + existing_admin = db.query(User).filter(User.email == settings.FIRST_SUPERUSER_EMAIL).first() + + if not existing_admin: + logger.info("Creating initial admin user...") + admin_user = User( + email=settings.FIRST_SUPERUSER_EMAIL, + hashed_password=get_password_hash(settings.FIRST_SUPERUSER_PASSWORD), + is_active=True, + role=UserRole.ADMIN, + first_name="Admin", + last_name="User", + email_verified=True + ) + db.add(admin_user) + db.commit() + logger.info(f"Admin user created with email: {settings.FIRST_SUPERUSER_EMAIL}") + else: + logger.info("Admin user already exists") + + db.close() + logger.info("Database initialization completed successfully") + + except Exception as e: + logger.error(f"Error initializing database: {e}") + raise + +if __name__ == "__main__": + logger.info("Creating initial database tables and admin user") + init_db() + logger.info("Initial data created") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d4472b0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + api: + build: . + container_name: ecommerce_api + ports: + - "8000:8000" + volumes: + - ./storage:/app/storage + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e77b3d5 --- /dev/null +++ b/main.py @@ -0,0 +1,193 @@ +import logging +import sys +import time +from logging.handlers import RotatingFileHandler +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app.core.config import settings +from app.middlewares.rate_limiter import RateLimitMiddleware +from app.middlewares.security import SecurityHeadersMiddleware + +# Import routers +from app.routers import ( + admin, + auth, + cart, + categories, + health, + inventory, + orders, + payments, + products, + reviews, + search, + tags, + users, +) + +# Configure logging +log_dir = Path("/app/storage/logs") +log_dir.mkdir(parents=True, exist_ok=True) +log_file = log_dir / "app.log" + +# Set up rotating file handler +file_handler = RotatingFileHandler(log_file, maxBytes=10485760, backupCount=5) +file_handler.setFormatter(logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +)) + +# Set up console handler +console_handler = logging.StreamHandler(sys.stdout) +console_handler.setFormatter(logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +)) + +# Configure root logger +logging.basicConfig( + level=logging.INFO, + handlers=[file_handler, console_handler] +) + +# Create FastAPI app instance +app = FastAPI( + title="Comprehensive E-Commerce API", + description=""" + # E-Commerce API + + A full-featured e-commerce API built with FastAPI and SQLite. + + ## Features + + * **User Management**: Registration, authentication, profiles + * **Product Management**: CRUD operations, image uploads + * **Category & Tag Management**: Hierarchical categories, product tagging + * **Shopping Cart**: Add, update, remove items + * **Order Processing**: Create orders, track status + * **Payment Integration**: Process payments with multiple methods + * **Inventory Management**: Track stock levels + * **Search & Filtering**: Advanced product search and filtering + * **Reviews & Ratings**: User reviews and product ratings + * **Admin Dashboard**: Sales analytics and reporting + + ## Authentication + + Most endpoints require authentication using JSON Web Tokens (JWT). + + To authenticate: + 1. Register a user or use the default admin credentials + 2. Login using the /api/auth/login endpoint + 3. Use the returned access token in the Authorization header for subsequent requests: + `Authorization: Bearer {your_access_token}` + + ## Getting Started + + 1. Create a user account using the /api/auth/register endpoint + 2. Browse products using the /api/products endpoints + 3. Add items to your cart using the /api/cart endpoints + 4. Create an order using the /api/orders endpoints + 5. Process payment using the /api/payments endpoints + + ## API Status Codes + + * 200: Success + * 201: Created + * 204: No Content + * 400: Bad Request + * 401: Unauthorized + * 403: Forbidden + * 404: Not Found + * 422: Validation Error + * 429: Too Many Requests + * 500: Internal Server Error + """, + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", + openapi_tags=[ + {"name": "Health", "description": "Health check endpoint"}, + {"name": "Authentication", "description": "User registration and authentication"}, + {"name": "Users", "description": "User profile management"}, + {"name": "Products", "description": "Product management and information"}, + {"name": "Categories", "description": "Product category management"}, + {"name": "Tags", "description": "Product tag management"}, + {"name": "Shopping Cart", "description": "Shopping cart operations"}, + {"name": "Orders", "description": "Order creation and management"}, + {"name": "Payments", "description": "Payment processing and management"}, + {"name": "Reviews", "description": "Product reviews and ratings"}, + {"name": "Search", "description": "Product search and filtering"}, + {"name": "Inventory", "description": "Inventory management"}, + {"name": "Admin", "description": "Admin dashboard and analytics"}, + ], + license_info={ + "name": "MIT", + "url": "https://opensource.org/licenses/MIT", + }, + contact={ + "name": "E-Commerce API Support", + "email": "support@example.com", + "url": "https://example.com/support", + }, +) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, replace this with specific origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Add security headers middleware +app.add_middleware( + SecurityHeadersMiddleware, + content_security_policy="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'" +) + +# Add rate limiting middleware +app.add_middleware( + RateLimitMiddleware, + rate_limit_per_minute=settings.RATE_LIMIT_PER_MINUTE, + whitelist_paths=["/health", "/docs", "/redoc", "/openapi.json"] +) + +# Add request timing middleware +@app.middleware("http") +async def add_process_time_header(request: Request, call_next): + start_time = time.time() + response = await call_next(request) + process_time = time.time() - start_time + response.headers["X-Process-Time"] = f"{process_time:.4f} sec" + return response + +# Add global exception handler +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + logging.error(f"Unhandled exception: {str(exc)}") + return JSONResponse( + status_code=500, + content={"detail": "An unexpected error occurred. Please try again later."} + ) + +# Include routers +app.include_router(health.router, tags=["Health"]) +app.include_router(users.router, prefix="/api/users", tags=["Users"]) +app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) +app.include_router(products.router, prefix="/api/products", tags=["Products"]) +app.include_router(categories.router, prefix="/api/categories", tags=["Categories"]) +app.include_router(tags.router, prefix="/api/tags", tags=["Tags"]) +app.include_router(cart.router, prefix="/api/cart", tags=["Shopping Cart"]) +app.include_router(orders.router, prefix="/api/orders", tags=["Orders"]) +app.include_router(payments.router, prefix="/api/payments", tags=["Payments"]) +app.include_router(reviews.router, prefix="/api/reviews", tags=["Reviews"]) +app.include_router(search.router, prefix="/api/search", tags=["Search"]) +app.include_router(inventory.router, prefix="/api/inventory", tags=["Inventory"]) +app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..b0037a0 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,82 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.database import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """ + Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, # For SQLite + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """ + Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + is_sqlite = connection.dialect.name == "sqlite" + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=is_sqlite, # Enable batch mode for SQLite + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..3cf5352 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/001_initial_database_setup.py b/migrations/versions/001_initial_database_setup.py new file mode 100644 index 0000000..e97609b --- /dev/null +++ b/migrations/versions/001_initial_database_setup.py @@ -0,0 +1,231 @@ +""" +Initial database setup + +Revision ID: 001 +Revises: +Create Date: 2023-07-25 00:00:00.000000 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import sqlite + +# revision identifiers, used by Alembic. +revision: str = '001' +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Create enum types for SQLite + user_role_type = sa.Enum('customer', 'seller', 'admin', name='userroletype') + product_status_type = sa.Enum('draft', 'published', 'out_of_stock', 'discontinued', name='productstatustype') + order_status_type = sa.Enum('pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded', name='orderstatustype') + shipping_method_type = sa.Enum('standard', 'express', 'overnight', 'pickup', 'digital', name='shippingmethodtype') + payment_status_type = sa.Enum('pending', 'processing', 'completed', 'failed', 'refunded', name='paymentstatustype') + payment_method_type = sa.Enum('credit_card', 'paypal', 'bank_transfer', 'cash_on_delivery', 'stripe', 'apple_pay', 'google_pay', name='paymentmethodtype') + + # Users table + op.create_table( + 'users', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('email', sa.String(255), nullable=False, unique=True, index=True), + sa.Column('hashed_password', sa.String(255), nullable=False), + sa.Column('first_name', sa.String(100), nullable=True), + sa.Column('last_name', sa.String(100), nullable=True), + sa.Column('is_active', sa.Boolean(), default=True), + sa.Column('role', user_role_type, default='customer'), + sa.Column('phone_number', sa.String(20), nullable=True), + sa.Column('profile_image', sa.String(255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()), + sa.Column('address_line1', sa.String(255), nullable=True), + sa.Column('address_line2', sa.String(255), nullable=True), + sa.Column('city', sa.String(100), nullable=True), + sa.Column('state', sa.String(100), nullable=True), + sa.Column('postal_code', sa.String(20), nullable=True), + sa.Column('country', sa.String(100), nullable=True), + sa.Column('email_verified', sa.Boolean(), default=False), + sa.Column('verification_token', sa.String(255), nullable=True), + sa.Column('reset_password_token', sa.String(255), nullable=True), + sa.Column('reset_token_expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('bio', sa.Text(), nullable=True), + ) + + # Categories table + op.create_table( + 'categories', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('name', sa.String(100), nullable=False, index=True), + sa.Column('slug', sa.String(120), nullable=False, unique=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('image', sa.String(255), nullable=True), + sa.Column('parent_id', sa.String(36), sa.ForeignKey('categories.id'), nullable=True), + sa.Column('is_active', sa.Boolean(), default=True), + sa.Column('display_order', sa.Integer(), default=0), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()), + ) + + # Tags table + op.create_table( + 'tags', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('name', sa.String(50), nullable=False, unique=True, index=True), + sa.Column('slug', sa.String(60), nullable=False, unique=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()), + ) + + # Products table + op.create_table( + 'products', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('name', sa.String(255), nullable=False, index=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('sku', sa.String(100), unique=True, nullable=True), + sa.Column('barcode', sa.String(100), unique=True, nullable=True), + sa.Column('stock_quantity', sa.Integer(), default=0), + sa.Column('weight', sa.Float(), nullable=True), + sa.Column('dimensions', sa.String(100), nullable=True), + sa.Column('status', product_status_type, default='draft'), + sa.Column('is_featured', sa.Boolean(), default=False), + sa.Column('is_digital', sa.Boolean(), default=False), + sa.Column('digital_download_link', sa.String(512), nullable=True), + sa.Column('slug', sa.String(255), nullable=False, unique=True), + sa.Column('tax_rate', sa.Float(), default=0.0), + sa.Column('discount_price', sa.Float(), nullable=True), + sa.Column('discount_start_date', sa.DateTime(timezone=True), nullable=True), + sa.Column('discount_end_date', sa.DateTime(timezone=True), nullable=True), + sa.Column('category_id', sa.String(36), sa.ForeignKey('categories.id'), nullable=True), + sa.Column('seller_id', sa.String(36), sa.ForeignKey('users.id'), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()), + ) + + # Product Images table + op.create_table( + 'product_images', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('product_id', sa.String(36), sa.ForeignKey('products.id', ondelete='CASCADE'), nullable=False), + sa.Column('image_url', sa.String(512), nullable=False), + sa.Column('alt_text', sa.String(255), nullable=True), + sa.Column('is_primary', sa.Boolean(), default=False), + sa.Column('display_order', sa.Integer(), default=0), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Product-Tag association table + op.create_table( + 'product_tags', + sa.Column('product_id', sa.String(36), sa.ForeignKey('products.id', ondelete='CASCADE'), primary_key=True), + sa.Column('tag_id', sa.String(36), sa.ForeignKey('tags.id', ondelete='CASCADE'), primary_key=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Cart Items table + op.create_table( + 'cart_items', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('product_id', sa.String(36), sa.ForeignKey('products.id', ondelete='CASCADE'), nullable=False), + sa.Column('quantity', sa.Integer(), default=1, nullable=False), + sa.Column('price_at_addition', sa.Float(), nullable=False), + sa.Column('custom_properties', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()), + ) + + # Orders table + op.create_table( + 'orders', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id'), nullable=False), + sa.Column('order_number', sa.String(50), nullable=False, unique=True, index=True), + sa.Column('status', order_status_type, default='pending'), + sa.Column('total_amount', sa.Float(), nullable=False), + sa.Column('subtotal', sa.Float(), nullable=False), + sa.Column('tax_amount', sa.Float(), nullable=False), + sa.Column('shipping_amount', sa.Float(), nullable=False), + sa.Column('discount_amount', sa.Float(), default=0.0), + sa.Column('shipping_method', shipping_method_type, nullable=True), + sa.Column('tracking_number', sa.String(100), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('shipping_address', sqlite.JSON(), nullable=True), + sa.Column('billing_address', sqlite.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()), + ) + + # Order Items table + op.create_table( + 'order_items', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('order_id', sa.String(36), sa.ForeignKey('orders.id', ondelete='CASCADE'), nullable=False), + sa.Column('product_id', sa.String(36), sa.ForeignKey('products.id'), nullable=False), + sa.Column('quantity', sa.Integer(), default=1, nullable=False), + sa.Column('unit_price', sa.Float(), nullable=False), + sa.Column('subtotal', sa.Float(), nullable=False), + sa.Column('discount', sa.Float(), default=0.0), + sa.Column('tax_amount', sa.Float(), default=0.0), + sa.Column('product_name', sa.String(255), nullable=False), + sa.Column('product_sku', sa.String(100), nullable=True), + sa.Column('product_options', sqlite.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Payments table + op.create_table( + 'payments', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('order_id', sa.String(36), sa.ForeignKey('orders.id'), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('payment_method', payment_method_type, nullable=False), + sa.Column('status', payment_status_type, default='pending'), + sa.Column('transaction_id', sa.String(255), nullable=True, unique=True), + sa.Column('payment_details', sqlite.JSON(), nullable=True), + sa.Column('error_message', sa.String(512), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()), + ) + + # Reviews table + op.create_table( + 'reviews', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('product_id', sa.String(36), sa.ForeignKey('products.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id'), nullable=False), + sa.Column('rating', sa.Integer(), nullable=False), + sa.Column('title', sa.String(255), nullable=True), + sa.Column('comment', sa.Text(), nullable=True), + sa.Column('is_verified_purchase', sa.Boolean(), default=False), + sa.Column('is_approved', sa.Boolean(), default=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()), + ) + + # Create indexes + op.create_index('ix_users_email', 'users', ['email'], unique=True) + op.create_index('ix_categories_name', 'categories', ['name']) + op.create_index('ix_products_name', 'products', ['name']) + op.create_index('ix_tags_name', 'tags', ['name'], unique=True) + op.create_index('ix_orders_order_number', 'orders', ['order_number'], unique=True) + + +def downgrade() -> None: + # Drop tables in reverse order of creation + op.drop_table('reviews') + op.drop_table('payments') + op.drop_table('order_items') + op.drop_table('orders') + op.drop_table('cart_items') + op.drop_table('product_tags') + op.drop_table('product_images') + op.drop_table('products') + op.drop_table('tags') + op.drop_table('categories') + op.drop_table('users') diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1247bdd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[tool.ruff] +# Enable Pyflakes (`F`), McCabe complexity (`C90`), pycodestyle (`E`), +# isort (`I`), PEP8 naming (`N`), and more +line-length = 100 +target-version = "py310" + +# Exclude a variety of commonly ignored directories +exclude = [ + ".git", + ".ruff_cache", + "__pycache__", + "venv", + ".env", + ".venv", + "env", + "dist", + "build", +] + +# Format code with black formatting style +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "auto" +docstring-code-format = true + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "D", "UP", "C90", "B", "C4", "SIM", "RET"] + +# Ignore specific rules +ignore = [ + "E203", # Whitespace before ':' (conflicts with black) + "E501", # Line too long (handled by formatter) + "D100", # Missing module docstring (for now) + "D101", # Missing class docstring (for now) + "D103", # Missing function docstring (for now) + "D104", # Missing package docstring (for now) + "D203", # One blank line required before class docstring + "D212", # Multi-line docstring summary should start at the first line +] + +# Allow autofix for all enabled rules (when `--fix`) is provided +fixable = ["ALL"] +unfixable = [] + +[tool.ruff.lint.isort] +known-first-party = ["app"] + +# Per-file-ignores to clean code based on functionality +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["D104"] +"migrations/*.py" = ["D100", "D103", "D400", "D415"] +"app/models/*.py" = ["D101"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..efdd73a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +fastapi>=0.100.0 +uvicorn>=0.22.0 +sqlalchemy>=2.0.0 +alembic>=1.11.0 +pydantic>=2.0.0 +pydantic-settings>=2.0.0 +pydantic[email]>=2.0.0 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +python-multipart>=0.0.6 +email-validator>=2.0.0 +httpx>=0.24.0 +pytest>=7.3.1 +ruff>=0.0.272 +python-dotenv>=1.0.0 +Pillow>=10.0.0 +redis>=4.6.0 +tenacity>=8.2.2 +ulid-py>=1.1.0 +aiosqlite>=0.19.0 \ No newline at end of file diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..b70c7a8 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Run Ruff linter in check mode +echo "Running Ruff linter..." +ruff check . + +# Check if linting failed +if [ $? -ne 0 ]; then + echo "Linting failed. Attempting to fix automatically..." + ruff check --fix . + + # Check if auto-fixing resolved all issues + if [ $? -ne 0 ]; then + echo "Some issues could not be fixed automatically. Please fix them manually." + exit 1 + else + echo "Auto-fixing succeeded!" + fi +fi + +# Run Ruff formatter +echo "Running Ruff formatter..." +ruff format . + +echo "Linting and formatting completed successfully!" \ No newline at end of file