From fda42cf81e1d3bc0d2131910a3e1067bbf3d44db Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 29 Aug 2025 19:09:42 +0000 Subject: [PATCH 01/11] Remove email and order management endpoints, schemas, and related utilities - Deleted email confirmation endpoint and its associated request schema. - Removed order management endpoint and its related data. - Eliminated email-related schemas and utilities, including email sending functions and configuration. - Cleaned up user-related schemas for login and registration. - Removed email confirmation HTML template. - Deleted utility functions for security, string manipulation, and hashing. - Cleared out related test cases for authentication, email, and utility functions. --- app/models/user.py | 63 --- app/routes/v1/__init__.py | 6 - app/routes/v1/endpoints/authentication.py | 418 ------------------ app/routes/v1/endpoints/email.py | 65 --- app/routes/v1/endpoints/orders.py | 51 --- app/routes/v1/schemas/email/__init__.py | 0 app/routes/v1/schemas/email/request.py | 19 - app/routes/v1/schemas/user/__init__.py | 0 app/routes/v1/schemas/user/login.py | 34 -- app/routes/v1/schemas/user/register.py | 25 -- app/templates/email_confirmation.html | 107 ----- app/utility/email/__init__.py | 0 app/utility/email/config.py | 27 -- app/utility/email/schemas.py | 51 --- app/utility/email/sender.py | 36 -- app/utility/security.py | 231 ---------- app/utility/string_utils.py | 15 - tests/test_routes/v1/test_authentication.py | 278 ------------ tests/test_routes/v1/test_email.py | 116 ----- tests/test_routes/v1/test_orders.py | 29 -- .../test_create_verification_token.py | 35 -- tests/test_utility/test_encrypt_field.py | 52 --- tests/test_utility/test_hash_field.py | 44 -- tests/test_utility/test_sanitize_username.py | 24 - tests/test_utility/test_verify_otp.py | 59 --- tests/test_utility/test_verify_password.py | 57 --- 26 files changed, 1842 deletions(-) delete mode 100644 app/models/user.py delete mode 100644 app/routes/v1/endpoints/authentication.py delete mode 100644 app/routes/v1/endpoints/email.py delete mode 100644 app/routes/v1/endpoints/orders.py delete mode 100644 app/routes/v1/schemas/email/__init__.py delete mode 100644 app/routes/v1/schemas/email/request.py delete mode 100644 app/routes/v1/schemas/user/__init__.py delete mode 100644 app/routes/v1/schemas/user/login.py delete mode 100644 app/routes/v1/schemas/user/register.py delete mode 100644 app/templates/email_confirmation.html delete mode 100644 app/utility/email/__init__.py delete mode 100644 app/utility/email/config.py delete mode 100644 app/utility/email/schemas.py delete mode 100644 app/utility/email/sender.py delete mode 100644 app/utility/security.py delete mode 100644 app/utility/string_utils.py delete mode 100644 tests/test_routes/v1/test_authentication.py delete mode 100644 tests/test_routes/v1/test_email.py delete mode 100644 tests/test_routes/v1/test_orders.py delete mode 100644 tests/test_utility/test_create_verification_token.py delete mode 100644 tests/test_utility/test_encrypt_field.py delete mode 100644 tests/test_utility/test_hash_field.py delete mode 100644 tests/test_utility/test_sanitize_username.py delete mode 100644 tests/test_utility/test_verify_otp.py delete mode 100644 tests/test_utility/test_verify_password.py diff --git a/app/models/user.py b/app/models/user.py deleted file mode 100644 index 18a79db..0000000 --- a/app/models/user.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -This module defines the SQLAlchemy ORM model for the 'users' table. - -It provides the User class, which stores user authentication and profile information, -including encrypted and hashed email/phone fields, password hash, language preference, -and timestamps for creation, updates, login, and deletion. -""" - -import uuid -from datetime import datetime - -from sqlalchemy import Boolean, Column, DateTime, Integer, Text -from sqlalchemy.dialects.postgresql import UUID - -from app.models import Base - - -class User(Base): - """ - SQLAlchemy ORM model for the 'users' table. - - Stores user authentication and profile information, including encrypted and hashed - email/phone fields, password hash, language preference, and timestamps for - creation, updates, login, and deletion. - - Attributes: - user_id (UUID): Primary key, unique user identifier. - username (str): Unique username for the user. - email_encrypted (str): AES-encrypted email address. - email_hash (str): SHA-256 hash of the normalized email. - is_email_verified (bool): Whether the user's email is verified. - password_hash (str): Argon2 hash of the user's password. - phone_encrypted (str): AES-encrypted phone number. - phone_hash (str): SHA-256 hash of the normalized phone number. - language_id (int): Preferred language ID. - created_at (datetime): Timestamp of user creation. - updated_at (datetime): Timestamp of last update. - last_login_at (datetime): Timestamp of last login. - deleted_at (datetime): Timestamp of deletion (soft delete). - """ - - __tablename__ = "users" - __table_args__ = {"extend_existing": True} - - user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - - username = Column(Text, unique=True, nullable=False) - - email_encrypted = Column(Text, nullable=False) - email_hash = Column(Text, unique=True, nullable=False) - is_email_verified = Column(Boolean, default=False) - - password_hash = Column(Text, nullable=False) - - phone_encrypted = Column(Text) - phone_hash = Column(Text, unique=True) - - language_id = Column(Integer, nullable=True) - - created_at = Column(DateTime(timezone=True), default=datetime.utcnow) - updated_at = Column(DateTime(timezone=True), default=datetime.utcnow) - last_login_at = Column(DateTime(timezone=True)) - deleted_at = Column(DateTime(timezone=True)) diff --git a/app/routes/v1/__init__.py b/app/routes/v1/__init__.py index d11db38..ff1787e 100644 --- a/app/routes/v1/__init__.py +++ b/app/routes/v1/__init__.py @@ -4,16 +4,10 @@ from fastapi import FastAPI -from .endpoints.authentication import router as auth_router -from .endpoints.email import router as email_router -from .endpoints.orders import router as order_router from .endpoints.products import router as product_router __version__ = "1.2.0" api = FastAPI(title="ChocoMax Shop API", version=__version__) -api.include_router(auth_router, prefix="/auth", tags=["Authentication"]) -api.include_router(email_router, prefix="/email", tags=["Email"]) api.include_router(product_router, prefix="/products", tags=["Products"]) -api.include_router(order_router, prefix="/orders", tags=["Orders"]) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py deleted file mode 100644 index e82b4a0..0000000 --- a/app/routes/v1/endpoints/authentication.py +++ /dev/null @@ -1,418 +0,0 @@ -""" -Authentication endpoints and utilities for user login, registration, and 2FA. - -This module provides FastAPI endpoints for user authentication, including login, -two-factor authentication (2FA), and registration. It also includes utility -functions for interacting with the database and handling authentication logic. -""" - -import json -import random -import secrets -import time - -from fastapi import APIRouter, Depends, HTTPException, Request -from pyotp import random_base32 as generate_otp_secret -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession -from user_agents import parse as parse_user_agent - -from app.routes.v1.schemas.user.login import UserLogin, UserLogin2FA -from app.routes.v1.schemas.user.register import UserRegister -from app.utility.database import get_db -from app.utility.security import hash_email, hash_password, verify_otp, verify_password -from app.utility.string_utils import sanitize_username - -router = APIRouter() -_2fa_sessions = ( - {} -) # Temporary in-memory store for 2FA sessions TODO: (replace with Redis or DB in production) - - -# --- Common utility functions --- - - -async def get_password_hash_by_email_hash( - db: AsyncSession, email_hash: str -) -> str | None: - """ - Retrieve the password hash for a user by their email hash. - - Args: - db (AsyncSession): The database session. - email_hash (str): The hashed email address. - - Returns: - str | None: The password hash if found, otherwise None. - """ - result = await db.execute( - text("SELECT get_password_hash_by_email_hash(:email_hash)"), - {"email_hash": email_hash}, - ) - return result.scalar() - - -async def get_2fa_secret(db: AsyncSession, email_hash: str, method: str = "TOTP"): - """ - Retrieve the 2FA secret for a user by their email hash and authentication method. - - Args: - db (AsyncSession): The database session. - email_hash (str): The hashed email address. - method (str): The authentication method (default: "TOTP"). - - Returns: - Row or None: The database row containing the 2FA secret, or None if not found. - """ - result = await db.execute( - text( - "SELECT * FROM get_user_2fa_secret_by_email_hash(:email_hash, :auth_method)" - ), - {"email_hash": email_hash, "auth_method": method}, - ) - return result.fetchone() - - -async def get_user_info(db: AsyncSession, email_hash: str): - """ - Retrieve user information by their email hash. - - Args: - db (AsyncSession): The database session. - email_hash (str): The hashed email address. - - Returns: - Row or None: The database row containing user information, or None if not found. - """ - result = await db.execute( - text("SELECT * FROM get_user_info_by_email_hash(:email_hash)"), - {"email_hash": email_hash}, - ) - return result.fetchone() - - -async def save_session_token( - db: AsyncSession, - user_id: int, - session_token: str, - device_info: str, - ip_address: str, - user_agent: str, -): - """ - Save a session token for a user with device and IP info. - - Args: - db (AsyncSession): The database session. - user_id (int): The user's ID. - session_token (str): The session token to save. - device_info (str): Information about the user's device. - ip_address (str): The user's IP address. - """ - await db.execute( - text( - """ - CALL create_user_session_token( - :p_user_id, - :p_session_token, - :p_device_info, - :p_ip_address, - :p_user_agent - ) - """ - ), - { - "p_user_id": user_id, - "p_session_token": session_token, - "p_device_info": device_info, - "p_ip_address": ip_address, - "p_user_agent": user_agent, - }, - ) - await db.commit() - - -async def save_refresh_token( - db: AsyncSession, - user_id: int, - session_token: str, - device_info: str, - ip_address: str, - user_agent: str, -): - """ - Save a session token for a user with device and IP info. - - Args: - db (AsyncSession): The database session. - user_id (int): The user's ID. - session_token (str): The session token to save. - device_info (str): Information about the user's device. - ip_address (str): The user's IP address. - """ - await db.execute( - text( - """ - CALL create_user_refresh_token( - :p_user_id, - :p_session_token, - :p_device_info, - :p_ip_address, - :p_user_agent - ) - """ - ), - { - "p_user_id": user_id, - "p_session_token": session_token, - "p_device_info": device_info, - "p_ip_address": ip_address, - "p_user_agent": user_agent, - }, - ) - await db.commit() - - -def get_device_info_and_ip(request: Request): - """Extract device info and IP address from the request.""" - user_agent_str = request.headers.get("User-Agent", "") - ua = parse_user_agent(user_agent_str) - device_info = json.dumps( - { - "family": ua.device.family, - "brand": ua.device.brand, - "model": ua.device.model, - "is_mobile": ua.is_mobile, - "is_tablet": ua.is_tablet, - "is_pc": ua.is_pc, - "is_bot": ua.is_bot, - } - ) - ip_address = request.headers.get("X-Real-IP") or request.client.host - return device_info, ip_address, user_agent_str - - -def filter_user_fields(user_dict, fields): - return {k: user_dict[k] for k in fields if k in user_dict} - - -async def create_and_return_session( - db, user_info, device_info, ip_address, user_agent_str -): - """Create session and refresh tokens, save them, and return selected user info with tokens.""" - session_token = secrets.token_urlsafe(32) - refresh_token = secrets.token_urlsafe(32) - - await save_session_token( - db, user_info.user_id, session_token, device_info, ip_address, user_agent_str - ) - await save_refresh_token( - db, user_info.user_id, refresh_token, device_info, ip_address, user_agent_str - ) - - user_dict = dict(user_info._mapping) - selected_fields = [ - "username", - "discriminator", - "language_id", - "display_role", - "created_at", - ] - - return { - **filter_user_fields(user_dict, selected_fields), - "session_token": session_token, - "refresh_token": refresh_token, - } - - -# --- Endpoints --- - - -@router.post("/login") -async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(get_db)): - """ - Step 1: Verify email and password, check if 2FA is required. - - Args: - data (UserLogin): The login request payload. - db (AsyncSession): The database session. - - Returns: - dict: If 2FA is required, returns a dict with 2FA info and a temporary token. - Otherwise, returns user info/session. - Raises: - HTTPException: If credentials are invalid. - """ - email_hash = hash_email(data.email) - password = data.password - - # Parse user agent for device info - device_info, ip_address, user_agent_str = get_device_info_and_ip(request) - - # Verify password - password_hash = await get_password_hash_by_email_hash(db, email_hash) - if not password_hash or not verify_password(password, password_hash): - raise HTTPException(401, "Invalid credentials") - - # Check if 2FA is enabled before fetching user info - row = await get_2fa_secret(db, email_hash) - user_2fa_secret = row.authentication_secret if row else None - - if user_2fa_secret: - temp_token = secrets.token_urlsafe(32) - - # Store mapping with expiry (5 minutes) - _2fa_sessions[temp_token] = { - "email_hash": email_hash, - "expires_at": time.time() + 300, - } - - result = await db.execute( - text("SELECT * FROM get_user_2fa_methods_by_email_hash(:email_hash)"), - {"email_hash": email_hash}, - ) - methods_rows = await result.fetchall() - methods = [row.authentication_method for row in methods_rows] - preferred_method = next( - (row.authentication_method for row in methods_rows if row.is_preferred), - None, - ) - - if methods: - return { - "2fa_required": True, - "token": temp_token, - "methods": methods, - "preferred_method": preferred_method, - } - - user_info = await get_user_info(db, email_hash) - return await create_and_return_session( - db, user_info, device_info, ip_address, user_agent_str - ) - - -@router.post("/login/otp") -async def login_otp( - data: UserLogin2FA, request: Request, db: AsyncSession = Depends(get_db) -): - """ - Step 2: Verify OTP code and return user info/session. - - Args: - data (UserLogin2FA): The 2FA login request payload. - db (AsyncSession): The database session. - - Returns: - dict: User info/session if OTP is valid. - Raises: - HTTPException: If the session token is invalid/expired, 2FA is not enabled, or OTP is invalid. - """ - device_info, ip_address, user_agent_str = get_device_info_and_ip(request) - - session = _2fa_sessions.get(data.token) - if not session or session["expires_at"] < time.time(): - raise HTTPException(401, "Invalid or expired 2FA session token") - - email_hash = session["email_hash"] - - row = await get_2fa_secret(db, email_hash) - secret = row.authentication_secret if row else None - - if not secret: - raise HTTPException(400, "2FA is not enabled for this user") - - if not verify_otp(secret, data.otp_code): - raise HTTPException(401, "Invalid 2FA code") - - user_info = await get_user_info(db, email_hash) - return await create_and_return_session( - db, user_info, device_info, ip_address, user_agent_str - ) - - -@router.post("/register") -async def register(data: UserRegister, db: AsyncSession = Depends(get_db)): - """ - Endpoint for user registration. - - Args: - data (UserRegister): The registration request payload. - db (AsyncSession): The database session. - - Returns: - dict: Registration result with username and discriminator. - Raises: - HTTPException: If the token is missing/invalid, all discriminators are taken, or email exists. - """ - token = data.token - username = sanitize_username(data.username) - password_hash = hash_password(data.password) - language_id = data.language_id - otp_secret = generate_otp_secret() - - if not token: - raise HTTPException(400, "Token is required for registration") - - # Check if the token exists and is valid using the new function - result = await db.execute( - text("SELECT is_verification_token_valid(:token)"), - {"token": token}, - ) - is_valid = result.scalar() - - if not is_valid: - raise HTTPException(400, "Invalid or expired verification token") - - # Retrieve the list of discriminators for the username - result = await db.execute( - text("SELECT get_used_discriminators(:username) AS discriminator"), - {"username": username}, - ) - used_discriminators = [row.discriminator for row in result.fetchall()] - available_discriminators = set(range(0, 10000)) - set(used_discriminators) - - if not available_discriminators: - raise HTTPException(409, "All discriminators taken for this username") - - # Choose a random discriminator from the available ones - discriminator = random.choice(list(available_discriminators)) - - # Check if email is available - result = await db.execute( - text("SELECT is_email_available(:token) AS available"), {"token": token} - ) - available = result.scalar() - - if not available: - raise HTTPException(409, "Email already exists") - - await db.execute( - text( - """ - CALL register_user( - :token, - :username, - :discriminator, - :password_hash, - :preferred_language_id, - :otp_secret - ) - """ - ), - { - "token": token, - "username": username, - "discriminator": discriminator, - "password_hash": password_hash, - "preferred_language_id": language_id, - "otp_secret": otp_secret, - }, - ) - await db.commit() - return { - "message": "User registered successfully", - "username": username, - "discriminator": discriminator, - } diff --git a/app/routes/v1/endpoints/email.py b/app/routes/v1/endpoints/email.py deleted file mode 100644 index aa8bc82..0000000 --- a/app/routes/v1/endpoints/email.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -Email endpoints for user confirmation. - -This module provides FastAPI endpoints for sending confirmation emails to users. -It handles the creation of verification tokens, email encryption, and interaction -with the database to register pending users. -""" - -from fastapi import APIRouter, BackgroundTasks, Depends -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.routes.v1.schemas.email.request import EmailRequest -from app.utility.database import get_db -from app.utility.email.schemas import RegistrationEmailSchema -from app.utility.email.sender import send_email_background -from app.utility.security import create_verification_token, encrypt_email, hash_email - -router = APIRouter() - - -@router.post("/confirmation") -async def send_confirmation_email( - data: EmailRequest, - background_tasks: BackgroundTasks, - db: AsyncSession = Depends(get_db), -): - """ - Endpoint to send a confirmation email to the user. - - This endpoint accepts a POST request with the user's email in the body, - generates a verification token, encrypts and hashes the email, sends a - confirmation email asynchronously, and stores the pending user in the database. - - Args: - data (EmailRequest): The request payload containing the user's email. - background_tasks (BackgroundTasks): FastAPI background task manager. - db (AsyncSession): The database session. - - Returns: - dict: A dictionary with a detail message and the confirmation token. - """ - email = data.email - token = create_verification_token() - email_encrypted = encrypt_email(email) - email_hash = hash_email(email) - - email_schema = RegistrationEmailSchema( - email=[email], - body={ - "title": "Welcome to ChocoMax", - "confirmation_url": f"http://?token={token}", - }, - ) - - send_email_background(background_tasks, email_schema) - - # Send the token to the Database - await db.execute( - text("CALL create_pending_user(:email_encrypted, :email_hash, :token)"), - {"email_encrypted": email_encrypted, "email_hash": email_hash, "token": token}, - ) - await db.commit() - - return {"detail": "Confirmation email sent"} diff --git a/app/routes/v1/endpoints/orders.py b/app/routes/v1/endpoints/orders.py deleted file mode 100644 index dab208d..0000000 --- a/app/routes/v1/endpoints/orders.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -This module defines the API routes for order management. -""" - -from fastapi import APIRouter - -router = APIRouter() - -orders = [ - { - "customer_name": "John Doe", - "date": "2025-04-06", - "order_id": 13, - "order_status": "Shipped", - "order": [ - {"product_id": 1, "product_name": "Pure Chocolate", "quantity": 2}, - {"product_id": 2, "product_name": "Hazelnut Chocolate", "quantity": 1}, - ], - "total_price": 37.0, - "transaction_status": "Completed", - }, - { - "customer_name": "Jane Smith", - "date": "2025-04-28", - "order_id": 42, - "order_status": "Processing", - "order": [ - {"product_id": 3, "product_name": "Pecan Nut Chocolate", "quantity": 3} - ], - "total_price": 60.0, - "transaction_status": "Pending", - }, - { - "customer_name": "John Doe", - "date": "2025-04-28", - "order_id": 216, - "order_status": "Delivered", - "order": [ - {"product_id": 1, "product_name": "Pure Chocolate", "quantity": 1}, - {"product_id": 3, "product_name": "Pecan Nut Chocolate", "quantity": 2}, - ], - "total_price": 51.0, - "transaction_status": "Completed", - }, -] - - -@router.get("/") -def get_orders(): - """Retrieve all orders.""" - return orders diff --git a/app/routes/v1/schemas/email/__init__.py b/app/routes/v1/schemas/email/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/routes/v1/schemas/email/request.py b/app/routes/v1/schemas/email/request.py deleted file mode 100644 index a546123..0000000 --- a/app/routes/v1/schemas/email/request.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Schemas for email-related API requests. - -This module defines the Pydantic model used for validating and serializing -email request payloads in the API v1 endpoints. -""" - -from pydantic import BaseModel, EmailStr - - -class EmailRequest(BaseModel): - """ - Schema for email-related API requests. - - Attributes: - email (EmailStr): The user's email address. - """ - - email: EmailStr diff --git a/app/routes/v1/schemas/user/__init__.py b/app/routes/v1/schemas/user/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/routes/v1/schemas/user/login.py b/app/routes/v1/schemas/user/login.py deleted file mode 100644 index 76222ac..0000000 --- a/app/routes/v1/schemas/user/login.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -Schemas for user login and two-factor authentication (2FA) requests. - -This module defines Pydantic models used for validating and serializing -user login and 2FA payloads in the authentication endpoints. -""" - -from pydantic import BaseModel - - -class UserLogin(BaseModel): - """ - Schema for user login request. - - Attributes: - email (str): The user's email address. - password (str): The user's password. - """ - - email: str - password: str - - -class UserLogin2FA(BaseModel): - """ - Schema for user two-factor authentication (2FA) request. - - Attributes: - otp_code (int): The one-time password code for 2FA. - token (str): The temporary token issued after initial login. - """ - - otp_code: int - token: str diff --git a/app/routes/v1/schemas/user/register.py b/app/routes/v1/schemas/user/register.py deleted file mode 100644 index e21b779..0000000 --- a/app/routes/v1/schemas/user/register.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Schemas for user registration requests. - -This module defines the Pydantic model used for validating and serializing -user registration payloads in the authentication endpoints. -""" - -from pydantic import BaseModel - - -class UserRegister(BaseModel): - """ - Schema for user registration request. - - Attributes: - token (str): The registration or invitation token. - username (str): The desired username for the new user. - password (str): The user's password. - language_id (int | None): Optional language preference identifier. - """ - - token: str - username: str - password: str - language_id: int | None = None diff --git a/app/templates/email_confirmation.html b/app/templates/email_confirmation.html deleted file mode 100644 index 645dce7..0000000 --- a/app/templates/email_confirmation.html +++ /dev/null @@ -1,107 +0,0 @@ - - - - Email Confirmation - - - - - -
-

{{ title }}

-
-

Hello!

-

- Thank you for signing up! Please confirm your email address - to complete your account creation. -

- - Confirm Your Email - - - -
-
- - diff --git a/app/utility/email/__init__.py b/app/utility/email/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/utility/email/config.py b/app/utility/email/config.py deleted file mode 100644 index 6e8ca41..0000000 --- a/app/utility/email/config.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Email configuration module. - -This module loads environment variables and sets up the email connection -configuration for sending emails through the application. -""" - -import os -from pathlib import Path - -from dotenv import load_dotenv -from fastapi_mail import ConnectionConfig - -load_dotenv(".env") - -conf = ConnectionConfig( - MAIL_USERNAME=os.getenv("MAIL_USERNAME", "user@example.com"), - MAIL_PASSWORD=os.getenv("MAIL_PASSWORD", "password"), - MAIL_FROM=os.getenv("MAIL_FROM", "noreply@chocomax.com"), - MAIL_PORT=os.getenv("MAIL_PORT", "587"), - MAIL_SERVER=os.getenv("MAIL_SERVER", "smtp.example.com"), - MAIL_FROM_NAME=os.getenv("MAIL_FROM_NAME", "ChocoMax"), - MAIL_STARTTLS=True, - MAIL_SSL_TLS=False, - USE_CREDENTIALS=True, - TEMPLATE_FOLDER=Path(__file__).parent.parent.parent / "templates", -) diff --git a/app/utility/email/schemas.py b/app/utility/email/schemas.py deleted file mode 100644 index 66ff4e6..0000000 --- a/app/utility/email/schemas.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Schemas for email-related payloads. - -This module defines Pydantic models for validating and serializing -email payloads, such as registration and password reset emails, -used by the application's email utility functions. -""" - -from typing import Any, Dict, List - -from pydantic import BaseModel, EmailStr - - -class BaseEmailSchema(BaseModel): - """ - Base schema for email payloads. - - Attributes: - email (List[EmailStr]): List of recipient email addresses. - subject (str): Subject line of the email. - template_name (str): Name of the template to use for the email body. - body (Dict[str, Any]): Data to render within the email template. - """ - - email: List[EmailStr] - subject: str - template_name: str - body: Dict[str, Any] - - -class RegistrationEmailSchema(BaseEmailSchema): - """ - Schema for registration confirmation emails. - - Attributes: - subject (str): Default subject for registration emails. - template_name (str): Default template for registration emails. - """ - - subject: str = "ChocoMax - Email Confirmation" - template_name: str = "email_confirmation.html" - - -class PasswordResetEmailSchema(BaseEmailSchema): - """ - Schema for password reset emails. - - Inherits all fields from BaseEmailSchema. - """ - - pass # TODO diff --git a/app/utility/email/sender.py b/app/utility/email/sender.py deleted file mode 100644 index 1b69148..0000000 --- a/app/utility/email/sender.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Email sending utilities. - -This module provides functions for sending emails using FastAPI background tasks -and the FastMail library. It is used to send templated emails asynchronously -throughout the application. -""" - -from fastapi import BackgroundTasks -from fastapi_mail import FastMail, MessageSchema, MessageType - -from .config import conf -from .schemas import BaseEmailSchema - - -def send_email_background(background_tasks: BackgroundTasks, email: BaseEmailSchema): - """ - Send an email in the background using FastAPI's BackgroundTasks. - - Args: - background_tasks (BackgroundTasks): The FastAPI background task manager. - email (BaseEmailSchema): The email payload containing recipients, subject, template, and body. - - This function creates a message from the provided schema and schedules it to be sent - asynchronously using FastMail and the specified template. - """ - message = MessageSchema( - subject=email.subject, - recipients=email.email, - template_body=email.body, - subtype=MessageType.html, - ) - fm = FastMail(conf) - background_tasks.add_task( - fm.send_message, message, template_name=email.template_name - ) diff --git a/app/utility/security.py b/app/utility/security.py deleted file mode 100644 index c13bee6..0000000 --- a/app/utility/security.py +++ /dev/null @@ -1,231 +0,0 @@ -""" -Security utility module for encryption, hashing, and authentication. - -This module provides functions and constants for handling password hashing, -field encryption/decryption, OTP verification, and normalization of sensitive -fields such as email and phone numbers. It is used throughout the application -to ensure secure handling of user credentials and sensitive data. -""" - -import base64 -import hashlib -import os -import secrets - -import pyotp -from argon2 import PasswordHasher -from cryptography.hazmat.primitives.ciphers.aead import AESGCM - -ph = PasswordHasher() - -AES_KEY = bytes.fromhex(os.getenv("AES_SECRET_KEY", os.urandom(32).hex())) -PEPPER = os.getenv("PEPPER", "SuperSecretPepper").encode("utf-8") - - -def create_token(length: int) -> str: - """ - Generate a secure random token for session management. - Returns: - str: A URL-safe, random token string. - """ - return secrets.token_urlsafe(length) - - -def create_verification_token() -> str: - """ - Generate a secure random token for email verification. - - Returns: - str: A URL-safe, random token string. - """ - return create_token(32) - - -def create_access_token() -> str: - """ - Generate a secure random token for access control. - - Returns: - str: A URL-safe, random token string. - """ - return create_token(32) - - -def create_refresh_token() -> str: - """ - Generate a secure random token for refresh operations. - - Returns: - str: A URL-safe, random token string. - """ - return create_token(64) - - -def encrypt_field(value: str) -> str: - """ - Encrypt a value using AES-256-GCM. - - Args: - value (str): The value to encrypt. - - Returns: - str: Base64-encoded string of IV + ciphertext + tag. - """ - aesgcm = AESGCM(AES_KEY) - iv = os.urandom(12) # 96-bit IV recommended for AES-GCM - ciphertext = aesgcm.encrypt(iv, value.encode("utf-8"), associated_data=None) - return base64.b64encode(iv + ciphertext).decode("utf-8") - - -def decrypt_field(encrypted_base64: str) -> str: - """ - Decrypt a value encrypted with AES-256-GCM. - - Args: - encrypted_base64 (str): The base64-encoded encrypted value. - - Returns: - str: The decrypted string. - """ - encrypted_data = base64.b64decode(encrypted_base64) - iv, ciphertext = encrypted_data[:12], encrypted_data[12:] - aesgcm = AESGCM(AES_KEY) - decrypted = aesgcm.decrypt(iv, ciphertext, associated_data=None) - return decrypted.decode("utf-8") - - -def hash_field(value: str) -> str: - """ - Generate a SHA-256 hash of a field (used for fast lookup). - - Args: - value (str): The value to hash. - - Returns: - str: The SHA-256 hash as a hexadecimal string. - """ - return hashlib.sha256(value.encode("utf-8")).hexdigest() - - -def hash_password(password: str) -> str: - """ - Hash a password using Argon2 and a pepper. - - Args: - password (str): The plaintext password. - - Returns: - str: The Argon2 hash of the peppered password. - """ - peppered_password = password.encode("utf-8") + PEPPER - return ph.hash(peppered_password) - - -def verify_otp( - secret: str, otp_code: str, otp_method: str = "TOTP", counter: int = 0 -) -> bool: - """ - Verify a one-time password (OTP) against a secret using TOTP or HOTP. - - Args: - secret (str): The OTP secret. - otp_code (str): The OTP code to verify. - otp_method (str): The OTP method ("TOTP" or "HOTP"). - counter (int): The HOTP counter (required for HOTP). - - Returns: - bool: True if the OTP is valid, False otherwise. - """ - try: - match otp_method: - case "TOTP": - return pyotp.TOTP(secret).verify(otp_code) - case "HOTP": - return pyotp.HOTP(secret).verify(otp_code, counter) - case _: - raise ValueError("Unsupported OTP method") - except Exception: - return False - - -def verify_password(password: str, hashed_password: str) -> bool: - """ - Verify a password against a hashed password. - - Args: - hashed_password (str): The Argon2 hashed password. - password (str): The plaintext password to verify. - - Returns: - bool: True if the password matches, False otherwise. - """ - peppered_password = password.encode("utf-8") + PEPPER - try: - return ph.verify(hashed_password, peppered_password) - except Exception: - return False - - -def hash_email(email: str) -> str: - """ - Generate a SHA-256 hash of the email (used for fast lookup). - - Args: - email (str): The email address. - - Returns: - str: The SHA-256 hash of the email. - """ - return hash_field(email) - - -def hash_phone(phone: str) -> str: - """ - Generate a SHA-256 hash of the phone number (used for fast lookup). - - Args: - phone (str): The phone number. - - Returns: - str: The SHA-256 hash of the phone number. - """ - return hash_field(phone) - - -def hash_token(token: str) -> str: - """ - Generate a SHA-256 hash of the token (used for fast lookup). - - Args: - token (str): The token to hash. - - Returns: - str: The SHA-256 hash of the token. - """ - return hash_field(token) - - -def encrypt_email(email: str) -> str: - """ - Encrypt the email address. - - Args: - email (str): The email address to encrypt. - - Returns: - str: The encrypted email. - """ - return encrypt_field(email) - - -def encrypt_phone(phone: str) -> str: - """ - Encrypt the normalized phone number. - - Args: - phone (str): The phone number to encrypt. - - Returns: - str: The encrypted phone number. - """ - return encrypt_field(phone) diff --git a/app/utility/string_utils.py b/app/utility/string_utils.py deleted file mode 100644 index 8ecd937..0000000 --- a/app/utility/string_utils.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -string_utils.py - -Utility functions for string manipulation and sanitization used throughout the API application. -""" - -import re - - -def sanitize_username(username: str) -> str: - """ - Replace all characters not allowed by the database username constraint - (^[a-zA-Z0-9_]+$) with underscores. - """ - return re.sub(r"[^a-zA-Z0-9_]", "_", username) diff --git a/tests/test_routes/v1/test_authentication.py b/tests/test_routes/v1/test_authentication.py deleted file mode 100644 index 93169d1..0000000 --- a/tests/test_routes/v1/test_authentication.py +++ /dev/null @@ -1,278 +0,0 @@ -import sys -from unittest.mock import AsyncMock, patch - -import pytest -from fastapi.testclient import TestClient - -from app.routes.v1.endpoints import authentication as auth_module - -AUTH_PATH = "app.routes.v1.endpoints.authentication" - - -@pytest.fixture -def client(): - """ - Returns a FastAPI TestClient with the authentication router included. - """ - from fastapi import FastAPI - - app = FastAPI() - app.include_router(auth_module.router, prefix="/v1/auth") - return TestClient(app) - - -@pytest.fixture(autouse=True) -def patch_auth_dependencies(): - """ - Automatically patches authentication dependencies for all tests. - Provides default mock return values for password hash, password verification, - 2FA secret, user info, and email hashing. - """ - with ( - patch( - f"{AUTH_PATH}.get_password_hash_by_email_hash", new_callable=AsyncMock - ) as get_pw_hash_mock, - patch(f"{AUTH_PATH}.verify_password") as verify_pw_mock, - patch( - f"{AUTH_PATH}.get_2fa_secret", new_callable=AsyncMock - ) as get_2fa_secret_mock, - patch( - f"{AUTH_PATH}.get_user_info", new_callable=AsyncMock - ) as get_user_info_mock, - patch(f"{AUTH_PATH}.hash_email") as hash_email_mock, - ): - get_pw_hash_mock.return_value = "hashed-password" - verify_pw_mock.return_value = True - get_2fa_secret_mock.return_value = None - get_user_info_mock.return_value = AsyncMock(_mapping={"username": "testuser"}) - hash_email_mock.return_value = "dummy-email-hash" - yield { - "get_pw_hash": get_pw_hash_mock, - "verify_pw": verify_pw_mock, - "get_2fa_secret": get_2fa_secret_mock, - "get_user_info": get_user_info_mock, - "hash_email": hash_email_mock, - } - - -@pytest.fixture -def mock_db_and_override(client): - """ - Provides a mock database session and overrides the get_db dependency. - """ - mock_db = AsyncMock() - - async def override_get_db(): - yield mock_db - - client.app.dependency_overrides[auth_module.get_db] = override_get_db - return mock_db - - -@pytest.fixture -def login_payload(): - """Returns a function to generate login payloads.""" - - def _payload(email="testuser", password="password123"): - return { - "email": email, - "password": password, - } - - return _payload - - -@pytest.fixture -def otp_payload(): - """Returns a function to generate OTP login payloads.""" - - def _payload(token="validtoken", otp_code=123456): - return { - "token": token, - "otp_code": otp_code, - } - - return _payload - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "username, password", - [ - ("testuser", "password123"), - ("admin", "adminpass"), - ("user123", "userpass"), - ("test.user", "testpass"), - ("first.last", "flpass"), - ], -) -async def test_login_returns_session_tokens( - client, - mock_db_and_override, - patch_auth_dependencies, - username, - password, - login_payload, -): - """ - Test successful login returns opaque session and refresh tokens when 2FA is not required. - """ - # Patch user info to include user_id for token generation simulation - patch_auth_dependencies["get_user_info"].return_value = AsyncMock( - _mapping={"username": "testuser"} - ) - - response = client.post("/v1/auth/login", json=login_payload()) - assert response.status_code == 200 - data = response.json() - # Expect opaque tokens in response - assert "session_token" in data - assert "refresh_token" in data - assert data["username"] == "testuser" - - -@pytest.mark.asyncio -async def test_login_2fa_required_returns_2fa_token( - client, mock_db_and_override, patch_auth_dependencies, login_payload -): - """ - Test login returns a 2FA-required response with a temporary token if 2FA is enabled. - """ - - # Simulate 2FA enabled - class Dummy2FASecret: - authentication_secret = "dummysecret" - - patch_auth_dependencies["get_2fa_secret"].return_value = Dummy2FASecret() - - # Simulate available 2FA methods - with ( - patch(f"{AUTH_PATH}.text"), - patch(f"{AUTH_PATH}.time"), - ): - # Patch DB call for 2FA methods - mock_methods = [ - type("Row", (), {"authentication_method": "TOTP", "is_preferred": True})(), - type("Row", (), {"authentication_method": "SMS", "is_preferred": False})(), - ] - mock_db = mock_db_and_override - mock_execute = AsyncMock() - mock_execute.fetchall.return_value = mock_methods - mock_db.execute.return_value = mock_execute - - response = client.post("/v1/auth/login", json=login_payload()) - - assert response.status_code == 200 - data = response.json() - assert data["2fa_required"] is True - assert "token" in data - assert set(data["methods"]) == {"TOTP", "SMS"} - assert data["preferred_method"] == "TOTP" - - -@pytest.mark.asyncio -async def test_login_invalid_credentials( - client, mock_db_and_override, patch_auth_dependencies, login_payload -): - """ - Test login with invalid credentials returns 401 and no tokens. - """ - patch_auth_dependencies["verify_pw"].return_value = False - - response = client.post( - "/v1/auth/login", - json=login_payload(email="invaliduser", password="wrongpassword"), - ) - - assert response.status_code == 401 - data = response.json() - assert data["detail"] == "Invalid credentials" - - -@pytest.mark.asyncio -async def test_login_missing_fields(client, mock_db_and_override): - """ - Test login with missing fields returns 422 and no tokens. - """ - response = client.post("/v1/auth/login", json={}) - assert response.status_code == 422 - data = response.json() - assert "detail" in data - - -@pytest.mark.asyncio -async def test_login_otp_success_returns_tokens( - client, mock_db_and_override, patch_auth_dependencies, otp_payload -): - """ - Test /login/otp returns session and refresh tokens on successful OTP verification. - """ - # Simulate valid 2FA session and OTP - patch_auth_dependencies["get_2fa_secret"].return_value = AsyncMock( - authentication_secret="dummysecret" - ) - with patch(f"{AUTH_PATH}.verify_otp") as verify_otp_mock: - verify_otp_mock.return_value = True - patch_auth_dependencies["get_user_info"].return_value = AsyncMock( - _mapping={"username": "testuser"} - ) - # Simulate valid token in _2fa_sessions - with patch.object( - auth_module, - "_2fa_sessions", - { - "validtoken": { - "email_hash": "dummy-email-hash", - "expires_at": 9999999999, - } - }, - ): - response = client.post("/v1/auth/login/otp", json=otp_payload()) - - assert response.status_code == 200 - data = response.json() - assert "session_token" in data - assert "refresh_token" in data - assert data["username"] == "testuser" - - -@pytest.mark.asyncio -async def test_login_otp_invalid_token(client, otp_payload): - """ - Test /login/otp with an invalid or expired token returns 401. - """ - with patch.object(auth_module, "_2fa_sessions", {}): - response = client.post( - "/v1/auth/login/otp", json=otp_payload(token="invalidtoken") - ) - - assert response.status_code == 401 - data = response.json() - assert "2FA session token" in data["detail"] - - -@pytest.mark.asyncio -async def test_login_otp_invalid_otp(client, patch_auth_dependencies, otp_payload): - """ - Test /login/otp with an invalid OTP code returns 401. - """ - patch_auth_dependencies["get_2fa_secret"].return_value = AsyncMock( - authentication_secret="dummysecret" - ) - with patch(f"{AUTH_PATH}.verify_otp") as verify_otp_mock: - verify_otp_mock.return_value = False - with patch.object( - auth_module, - "_2fa_sessions", - { - "validtoken": { - "email_hash": "dummy-email-hash", - "expires_at": sys.maxsize, - } - }, - ): - response = client.post("/v1/auth/login/otp", json=otp_payload()) - - assert response.status_code == 401 - data = response.json() - assert "Invalid 2FA code" in data["detail"] diff --git a/tests/test_routes/v1/test_email.py b/tests/test_routes/v1/test_email.py deleted file mode 100644 index d87cb64..0000000 --- a/tests/test_routes/v1/test_email.py +++ /dev/null @@ -1,116 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest -from fastapi.testclient import TestClient - -from app.routes.v1.endpoints import email as email_module - -EMAIL_PATH = "app.routes.v1.endpoints.email" - - -@pytest.fixture -def client(): - from fastapi import FastAPI - - app = FastAPI() - app.include_router(email_module.router, prefix="/v1/email") - return TestClient(app) - - -@pytest.fixture(autouse=True) -def patch_email_dependencies(): - with ( - patch(f"{EMAIL_PATH}.send_email_background") as send_mock, - patch(f"{EMAIL_PATH}.hash_email", return_value="hashed-email") as hash_mock, - patch( - f"{EMAIL_PATH}.encrypt_email", return_value="encrypted-email" - ) as enc_mock, - patch( - f"{EMAIL_PATH}.create_verification_token", return_value="test-token" - ) as token_mock, - ): - yield { - "send": send_mock, - "hash": hash_mock, - "enc": enc_mock, - "token": token_mock, - } - - -@pytest.fixture -def mock_db_and_override(client): - mock_db = AsyncMock() - - async def override_get_db(): - yield mock_db - - client.app.dependency_overrides[email_module.get_db] = override_get_db - return mock_db - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "email", - [ - "user@example.com", - "USER@domain.io", - "test.user+alias@domain.co.uk", - "first.last@sub.domain.com", - "user123@domain.io", - "user_name@domain.org", - ], -) -async def test_send_confirmation_email_success( - client: TestClient, - patch_email_dependencies: dict, - mock_db_and_override: AsyncMock, - email: str, -): - mock_db = mock_db_and_override - - response = client.post("/v1/email/confirmation", json={"email": email}) - - assert response.status_code == 200 - data = response.json() - assert data["detail"] == "Confirmation email sent" - - patch_email_dependencies["token"].assert_called_once() - patch_email_dependencies["enc"].assert_called_once_with(email) - patch_email_dependencies["hash"].assert_called_once_with(email) - patch_email_dependencies["send"].assert_called_once() - mock_db.execute.assert_awaited_once() - mock_db.commit.assert_awaited_once() - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "email", - [ - "", - "not-an-email", - "user@.com", - "user@domain", - "userdomain.com", - "@domain.com", - "user@domain..com", - "user@domain,com", - "user@domain.?com", - ], -) -async def test_send_confirmation_email_failure( - client: TestClient, - patch_email_dependencies: dict, - mock_db_and_override: AsyncMock, - email: str, -): - mock_db = mock_db_and_override - - response = client.post("/v1/email/confirmation", json={"email": email}) - assert response.status_code == 422 # Unprocessable Entity - - patch_email_dependencies["token"].assert_not_called() - patch_email_dependencies["enc"].assert_not_called() - patch_email_dependencies["hash"].assert_not_called() - patch_email_dependencies["send"].assert_not_called() - mock_db.execute.assert_not_awaited() - mock_db.commit.assert_not_awaited() diff --git a/tests/test_routes/v1/test_orders.py b/tests/test_routes/v1/test_orders.py deleted file mode 100644 index bc90801..0000000 --- a/tests/test_routes/v1/test_orders.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Test suite for the v1 `/orders` endpoint of the ChocoMax API. - -This module uses the `v1_get` utility to avoid repeating the API version path. -""" - -import pytest -from fastapi.testclient import TestClient - -from app.routes.v1.endpoints import orders as orders_module - - -@pytest.fixture -def client(): - from fastapi import FastAPI - - app = FastAPI() - app.include_router(orders_module.router, prefix="/v1/orders") - return TestClient(app) - - -def test_orders(client: TestClient): - """ - Test that the `/api/v1/orders` endpoint returns a 200 status - and responds with a JSON list. - """ - response = client.get("/v1/orders") - assert response.status_code == 200 - assert isinstance(response.json(), list) diff --git a/tests/test_utility/test_create_verification_token.py b/tests/test_utility/test_create_verification_token.py deleted file mode 100644 index bda3804..0000000 --- a/tests/test_utility/test_create_verification_token.py +++ /dev/null @@ -1,35 +0,0 @@ -import re - -import pytest - -from app.utility.security import create_verification_token - - -@pytest.fixture -def verification_token(): - """Fixture to provide a verification token for testing.""" - return create_verification_token() - - -def test_create_verification_token_type(verification_token): - """ - Test the `create_verification_token` function to ensure it generates a token - with the expected structure and content. - """ - assert isinstance(verification_token, str) - - -def test_create_verification_token_length(verification_token): - """ - Test the length of the token generated by `create_verification_token`. - The token should be URL-safe and typically 43 characters long. - """ - assert len(verification_token) == 43 - - -def test_create_verification_token_format(verification_token): - """ - Test the format of the token generated by `create_verification_token`. - The token should be URL-safe, containing alphanumeric characters and hyphens. - """ - assert re.match(r"^[A-Za-z0-9_-]+$", verification_token) diff --git a/tests/test_utility/test_encrypt_field.py b/tests/test_utility/test_encrypt_field.py deleted file mode 100644 index 93fe647..0000000 --- a/tests/test_utility/test_encrypt_field.py +++ /dev/null @@ -1,52 +0,0 @@ -import re - -import pytest - -from app.utility.security import decrypt_field, encrypt_email, encrypt_phone - - -@pytest.fixture -def encrypted_email(sample_email): - """Fixture to provide an encrypted email for testing.""" - return encrypt_email(sample_email) - - -@pytest.fixture -def encrypted_phone(sample_phone): - """Fixture to provide an encrypted phone number for testing.""" - return encrypt_phone(sample_phone) - - -def test_encrypt_field_type(encrypted_email, encrypted_phone): - """ - Test that the encrypted fields are of type str. - This ensures that the encryption function returns a string. - """ - assert encrypted_email.isascii() - assert encrypted_phone.isascii() - - -def test_encrypt_field_length(encrypted_email, encrypted_phone): - """ - Test that the encrypted fields are not empty. - This ensures that the encryption does not produce empty strings. - """ - assert len(encrypted_email) > 0 - assert len(encrypted_phone) > 0 - - -def test_encrypt_field_format(encrypted_email, encrypted_phone): - """ - Test that the encrypted fields are in the expected format. - Encrypted fields should be base64 encoded strings. - """ - assert re.match(r"^[A-Za-z0-9+/=]+$", encrypted_email) - assert re.match(r"^[A-Za-z0-9+/=]+$", encrypted_phone) - - -def test_decrypt_email(sample_email, encrypted_email): - assert decrypt_field(encrypted_email) == sample_email - - -def test_decrypt_phone(sample_phone, encrypted_phone): - assert decrypt_field(encrypted_phone) == sample_phone diff --git a/tests/test_utility/test_hash_field.py b/tests/test_utility/test_hash_field.py deleted file mode 100644 index a5fe4a3..0000000 --- a/tests/test_utility/test_hash_field.py +++ /dev/null @@ -1,44 +0,0 @@ -import re - -import pytest - -from app.utility.security import hash_email, hash_phone - - -@pytest.fixture -def hashed_email(sample_email): - """Fixture to provide a hashed email for testing.""" - return hash_email(sample_email) - - -@pytest.fixture -def hashed_phone(sample_phone): - """Fixture to provide a hashed phone number for testing.""" - return hash_phone(sample_phone) - - -def test_hash_field_type(hashed_email, hashed_phone): - """ - Test that the hashed fields are of type str. - This ensures that the hash function returns a string. - """ - assert isinstance(hashed_email, str) - assert isinstance(hashed_phone, str) - - -def test_hash_field_length(hashed_email, hashed_phone): - """ - Test that the hashed fields are precisely 64 characters long. - This is the expected length for SHA-256 hashes. - """ - assert len(hashed_email) == 64 - assert len(hashed_phone) == 64 - - -def test_hash_field_format(hashed_email, hashed_phone): - """ - Test that the hashed fields are in the expected format. - Hashed fields should be hexadecimal strings. - """ - assert re.match(r"^[0-9a-f]{64}$", hashed_email) - assert re.match(r"^[0-9a-f]{64}$", hashed_phone) diff --git a/tests/test_utility/test_sanitize_username.py b/tests/test_utility/test_sanitize_username.py deleted file mode 100644 index 9306a74..0000000 --- a/tests/test_utility/test_sanitize_username.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest - -from app.utility.string_utils import sanitize_username - - -@pytest.mark.parametrize( - "input_username,expected", - [ - ("validUser_123", "validUser_123"), - ("user.name", "user_name"), - ("user-name", "user_name"), - ("user name", "user_name"), - ("user@domain.com", "user_domain_com"), - ("user!$%^&*()", "user________"), - ("", ""), - ("___", "___"), - ("user__name", "user__name"), - ("user\nname", "user_name"), - ("user\tname", "user_name"), - ("user/\\name", "user__name"), - ], -) -def test_sanitize_username(input_username, expected): - assert sanitize_username(input_username) == expected diff --git a/tests/test_utility/test_verify_otp.py b/tests/test_utility/test_verify_otp.py deleted file mode 100644 index ef73562..0000000 --- a/tests/test_utility/test_verify_otp.py +++ /dev/null @@ -1,59 +0,0 @@ -import pyotp -import pytest - -from app.utility.security import verify_otp - - -@pytest.fixture -def totp_secret(): - return pyotp.random_base32() - - -@pytest.fixture -def hotp_secret(): - return pyotp.random_base32() - - -@pytest.fixture -def unsupported_secret(): - return pyotp.random_base32() - - -def test_verify_otp_valid_totp(totp_secret): - totp = pyotp.TOTP(totp_secret) - otp_code = totp.now() - assert verify_otp(totp_secret, otp_code, "TOTP") is True - - -def test_verify_otp_invalid_totp(totp_secret): - otp_code = "000000" - assert verify_otp(totp_secret, otp_code, "TOTP") is False - - -def test_verify_otp_valid_hotp(hotp_secret): - hotp = pyotp.HOTP(hotp_secret) - counter = 0 - otp_code = hotp.at(counter) - assert verify_otp(hotp_secret, otp_code, "HOTP", counter) is True - - -def test_verify_otp_invalid_hotp(hotp_secret): - counter = 0 - otp_code = "000000" - assert verify_otp(hotp_secret, otp_code, "HOTP", counter) is False - - -def test_verify_otp_unsupported_method(unsupported_secret): - otp_code = "123456" - assert verify_otp(unsupported_secret, otp_code, "SMS") is False - - -def test_verify_otp_invalid_secret(): - secret = "not_a_valid_secret" - otp_code = "123456" - assert verify_otp(secret, otp_code, "TOTP") is False - - -def test_verify_otp_invalid_code_type(totp_secret): - otp_code = None - assert verify_otp(totp_secret, otp_code, "TOTP") is False diff --git a/tests/test_utility/test_verify_password.py b/tests/test_utility/test_verify_password.py deleted file mode 100644 index 81e97f8..0000000 --- a/tests/test_utility/test_verify_password.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest -from argon2 import PasswordHasher - -from app.utility.security import hash_password, verify_password - -ph = PasswordHasher() - - -@pytest.fixture -def sample_password(): - """Fixture to provide a sample password for testing.""" - return "TestPass123!" - - -@pytest.fixture -def hashed_password(sample_password): - """Fixture to provide a hashed password for testing.""" - return hash_password(sample_password) - - -@pytest.fixture -def wrong_password(): - """Fixture to provide a wrong password for testing.""" - return "WrongPass123!" - - -def test_verify_password(sample_password, hashed_password): - """ - Test that the password verification works correctly. - This ensures that the password can be hashed and then verified successfully. - """ - assert verify_password(sample_password, hashed_password) is True - - -def test_verify_wrong_password(hashed_password, wrong_password): - """ - Test that the password verification fails for a wrong password. - This ensures that the verification function does not falsely accept incorrect passwords. - """ - assert verify_password(wrong_password, hashed_password) is False - - -def test_verify_empty_password(hashed_password): - """ - Test that the password verification fails for an empty password. - This ensures that the verification function does not accept empty strings as valid passwords. - """ - assert verify_password("", hashed_password) is False - - -def test_verify_password_tampered(sample_password, hashed_password): - """ - Test that the password verification fails if the hash is tampered with. - This ensures that the verification function detects modifications to the hash. - """ - tampered_hash = hashed_password[:-5] + "xyz" # Modify the hash slightly - assert verify_password(sample_password, tampered_hash) is False From 06e5126c1d47e1170f73e7d30711a26c28fcbb8f Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Tue, 2 Sep 2025 00:02:44 +0000 Subject: [PATCH 02/11] Refactor API structure and remove deprecated routes - I have no idea if anything works - Deleted unused model and route files from app/models and app/routes. - Removed home route and associated test cases. - Cleaned up version 1 and version 2 API routes, including endpoints for products and categories. - Introduced new service layer for product operations with pagination support. - Updated database utility functions for asynchronous SQLAlchemy sessions. - Enhanced configuration management with Pydantic settings. - Added new schemas for product listing and pagination. - Implemented error handling in product retrieval endpoints. --- app/{routes => api}/__init__.py | 0 .../v1/endpoints => api/models}/__init__.py | 0 .../__init__.py => api/models/attribute.py} | 0 .../__init__.py => api/models/base.py} | 0 app/api/models/category.py | 0 app/api/models/contact.py | 0 app/api/models/customization.py | 0 app/api/models/language.py | 0 app/api/models/product.py | 0 app/api/models/tag.py | 0 app/api/models/translation.py | 0 app/api/schemas/__init__.py | 0 app/api/schemas/base.py | 19 +++ app/api/schemas/v1/__init__.py | 0 app/api/schemas/v1/attribute.py | 0 app/api/schemas/v1/category.py | 0 app/api/schemas/v1/common.py | 0 app/api/schemas/v1/customization.py | 0 app/api/schemas/v1/pagination.py | 0 app/api/schemas/v1/pricing.py | 0 app/api/schemas/v1/product.py | 87 ++++++++++++ app/api/schemas/v1/search.py | 0 app/api/schemas/v1/tag.py | 0 app/api/schemas/v1/translation.py | 0 app/api/schemas/v1/variant.py | 0 app/api/schemas/v2/__init__.py | 0 app/api/services/__init__.py | 0 app/api/services/base.py | 0 app/api/services/cache_service.py | 0 app/api/services/category_service.py | 0 app/api/services/customization_service.py | 0 app/api/services/image_service.py | 0 app/api/services/pricing_service.py | 0 app/api/services/product_service.py | 48 +++++++ app/api/services/search_service.py | 0 app/api/services/translation_service.py | 0 app/api/utils/__init__.py | 0 app/api/utils/constants.py | 0 app/api/utils/exceptions.py | 0 app/api/utils/formatters.py | 0 app/api/utils/helpers.py | 0 app/api/utils/validators.py | 0 app/api/v1/__init__.py | 0 app/api/v1/dependencies.py | 0 app/api/v1/endpoints/__init__.py | 0 app/api/v1/endpoints/admin.py | 0 app/api/v1/endpoints/attributes.py | 0 app/api/v1/endpoints/categories.py | 0 app/api/v1/endpoints/customization.py | 0 app/api/v1/endpoints/health.py | 0 app/api/v1/endpoints/pricing.py | 0 app/api/v1/endpoints/products.py | 79 +++++++++++ app/api/v1/endpoints/search.py | 0 app/api/v1/endpoints/tags.py | 0 app/api/v1/endpoints/translations.py | 0 app/api/v1/endpoints/variants.py | 0 app/api/v1/router.py | 8 ++ app/api/v2/__init__.py | 0 app/api/v2/endpoints/__init__.py | 0 app/api/v2/router.py | 0 app/core/__init__.py | 0 app/core/config.py | 58 ++++++++ app/core/database.py | 33 +++++ app/core/dependencies.py | 32 +++++ app/core/exceptions.py | 0 app/core/security.py | 0 app/database/__init__.py | 0 app/database/functions/__init__.py | 0 .../functions/customizations/__init__.py | 0 .../functions/customizations/get_options.py | 0 .../functions/customizations/get_values.py | 0 app/database/functions/pagination/__init__.py | 0 app/database/functions/pagination/get_info.py | 0 app/database/functions/products/__init__.py | 0 .../functions/products/calculate_price.py | 0 .../functions/products/get_details.py | 0 .../functions/products/get_paginated.py | 131 ++++++++++++++++++ app/database/functions/products/search.py | 0 app/main.py | 51 +++++-- app/models/__init__.py | 9 -- app/routes/home.py | 14 -- app/routes/v1/__init__.py | 13 -- app/routes/v1/endpoints/products.py | 70 ---------- app/routes/v2/__init__.py | 9 -- app/utility/database.py | 30 ---- app/version.py | 7 - requirements.txt | 18 +-- tests/test_home.py | 24 ---- tests/test_routes/v1/test_products.py | 29 ---- tests/test_utility/conftest.py | 13 -- 90 files changed, 538 insertions(+), 244 deletions(-) rename app/{routes => api}/__init__.py (100%) rename app/{routes/v1/endpoints => api/models}/__init__.py (100%) rename app/{routes/v1/schemas/__init__.py => api/models/attribute.py} (100%) rename app/{utility/__init__.py => api/models/base.py} (100%) create mode 100644 app/api/models/category.py create mode 100644 app/api/models/contact.py create mode 100644 app/api/models/customization.py create mode 100644 app/api/models/language.py create mode 100644 app/api/models/product.py create mode 100644 app/api/models/tag.py create mode 100644 app/api/models/translation.py create mode 100644 app/api/schemas/__init__.py create mode 100644 app/api/schemas/base.py create mode 100644 app/api/schemas/v1/__init__.py create mode 100644 app/api/schemas/v1/attribute.py create mode 100644 app/api/schemas/v1/category.py create mode 100644 app/api/schemas/v1/common.py create mode 100644 app/api/schemas/v1/customization.py create mode 100644 app/api/schemas/v1/pagination.py create mode 100644 app/api/schemas/v1/pricing.py create mode 100644 app/api/schemas/v1/product.py create mode 100644 app/api/schemas/v1/search.py create mode 100644 app/api/schemas/v1/tag.py create mode 100644 app/api/schemas/v1/translation.py create mode 100644 app/api/schemas/v1/variant.py create mode 100644 app/api/schemas/v2/__init__.py create mode 100644 app/api/services/__init__.py create mode 100644 app/api/services/base.py create mode 100644 app/api/services/cache_service.py create mode 100644 app/api/services/category_service.py create mode 100644 app/api/services/customization_service.py create mode 100644 app/api/services/image_service.py create mode 100644 app/api/services/pricing_service.py create mode 100644 app/api/services/product_service.py create mode 100644 app/api/services/search_service.py create mode 100644 app/api/services/translation_service.py create mode 100644 app/api/utils/__init__.py create mode 100644 app/api/utils/constants.py create mode 100644 app/api/utils/exceptions.py create mode 100644 app/api/utils/formatters.py create mode 100644 app/api/utils/helpers.py create mode 100644 app/api/utils/validators.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/dependencies.py create mode 100644 app/api/v1/endpoints/__init__.py create mode 100644 app/api/v1/endpoints/admin.py create mode 100644 app/api/v1/endpoints/attributes.py create mode 100644 app/api/v1/endpoints/categories.py create mode 100644 app/api/v1/endpoints/customization.py create mode 100644 app/api/v1/endpoints/health.py create mode 100644 app/api/v1/endpoints/pricing.py create mode 100644 app/api/v1/endpoints/products.py create mode 100644 app/api/v1/endpoints/search.py create mode 100644 app/api/v1/endpoints/tags.py create mode 100644 app/api/v1/endpoints/translations.py create mode 100644 app/api/v1/endpoints/variants.py create mode 100644 app/api/v1/router.py create mode 100644 app/api/v2/__init__.py create mode 100644 app/api/v2/endpoints/__init__.py create mode 100644 app/api/v2/router.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/database.py create mode 100644 app/core/dependencies.py create mode 100644 app/core/exceptions.py create mode 100644 app/core/security.py create mode 100644 app/database/__init__.py create mode 100644 app/database/functions/__init__.py create mode 100644 app/database/functions/customizations/__init__.py create mode 100644 app/database/functions/customizations/get_options.py create mode 100644 app/database/functions/customizations/get_values.py create mode 100644 app/database/functions/pagination/__init__.py create mode 100644 app/database/functions/pagination/get_info.py create mode 100644 app/database/functions/products/__init__.py create mode 100644 app/database/functions/products/calculate_price.py create mode 100644 app/database/functions/products/get_details.py create mode 100644 app/database/functions/products/get_paginated.py create mode 100644 app/database/functions/products/search.py delete mode 100644 app/models/__init__.py delete mode 100644 app/routes/home.py delete mode 100644 app/routes/v1/__init__.py delete mode 100644 app/routes/v1/endpoints/products.py delete mode 100644 app/routes/v2/__init__.py delete mode 100644 app/utility/database.py delete mode 100644 app/version.py delete mode 100644 tests/test_home.py delete mode 100644 tests/test_routes/v1/test_products.py delete mode 100644 tests/test_utility/conftest.py diff --git a/app/routes/__init__.py b/app/api/__init__.py similarity index 100% rename from app/routes/__init__.py rename to app/api/__init__.py diff --git a/app/routes/v1/endpoints/__init__.py b/app/api/models/__init__.py similarity index 100% rename from app/routes/v1/endpoints/__init__.py rename to app/api/models/__init__.py diff --git a/app/routes/v1/schemas/__init__.py b/app/api/models/attribute.py similarity index 100% rename from app/routes/v1/schemas/__init__.py rename to app/api/models/attribute.py diff --git a/app/utility/__init__.py b/app/api/models/base.py similarity index 100% rename from app/utility/__init__.py rename to app/api/models/base.py diff --git a/app/api/models/category.py b/app/api/models/category.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/models/contact.py b/app/api/models/contact.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/models/customization.py b/app/api/models/customization.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/models/language.py b/app/api/models/language.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/models/product.py b/app/api/models/product.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/models/tag.py b/app/api/models/tag.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/models/translation.py b/app/api/models/translation.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/__init__.py b/app/api/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/base.py b/app/api/schemas/base.py new file mode 100644 index 0000000..00cbd18 --- /dev/null +++ b/app/api/schemas/base.py @@ -0,0 +1,19 @@ +from typing import Generic, TypeVar + +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class PaginationInfo(BaseModel): + current_page: int = Field(..., description="Current page number") + page_size: int = Field(..., description="Number of items per page") + total_items: int = Field(..., description="Total number of items") + total_pages: int = Field(..., description="Total number of pages") + has_next: bool = Field(..., description="Whether there is a next page") + has_previous: bool = Field(..., description="Whether there is a previous page") + + +class PaginatedResponse(BaseModel, Generic[T]): + data: list[T] = Field(..., description="List of items") + pagination: PaginationInfo = Field(..., description="Pagination information") diff --git a/app/api/schemas/v1/__init__.py b/app/api/schemas/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/v1/attribute.py b/app/api/schemas/v1/attribute.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/v1/category.py b/app/api/schemas/v1/category.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/v1/common.py b/app/api/schemas/v1/common.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/v1/customization.py b/app/api/schemas/v1/customization.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/v1/pagination.py b/app/api/schemas/v1/pagination.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/v1/pricing.py b/app/api/schemas/v1/pricing.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/v1/product.py b/app/api/schemas/v1/product.py new file mode 100644 index 0000000..fc30032 --- /dev/null +++ b/app/api/schemas/v1/product.py @@ -0,0 +1,87 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + +from ....api.schemas.base import PaginationInfo + + +class ProductType(str, Enum): + STANDARD = "standard" + CONFIGURABLE = "configurable" + VARIANT_BASED = "variant_based" + + +class CategoryResponse(BaseModel): + category_id: int + category_name: str + category_color: str + category_description: Optional[str] = None + + +class ProductListItem(BaseModel): + """Product item for listing views (homepage, category pages)""" + + model_config = ConfigDict(from_attributes=True) + + # Core product data + product_id: int + product_name: str + product_description: str + product_type: ProductType + price: Optional[float] = Field( + None, description="Price for standard/variant products" + ) + base_price: Optional[float] = Field( + None, description="Base price for configurable products" + ) + image_url: Optional[str] = None + preparation_time_hours: int + min_order_hours: int + serving_info: Optional[str] = None + is_customizable: bool + created_at: datetime + + # Category information + category: CategoryResponse + + # Variant indicators + has_variants: bool = Field(..., description="Whether product has variants") + default_variant_id: Optional[int] = Field( + None, description="Default variant ID if applicable" + ) + variant_count: int = Field(0, description="Number of variants") + + # Related data counts + attribute_count: int = Field(0, description="Number of attributes") + tag_count: int = Field(0, description="Number of tags assigned") + image_count: int = Field(0, description="Number of additional images") + + +class ProductListResponse(BaseModel): + """Response for product listing endpoints""" + + products: list[ProductListItem] + pagination: PaginationInfo + + +# Query parameter schemas +class ProductSortBy(str, Enum): + CREATED_AT = "created_at" + PRICE = "price" + NAME = "name" + + +class SortOrder(str, Enum): + ASC = "ASC" + DESC = "DESC" + + +class ProductListFilters(BaseModel): + """Query parameters for filtering product lists""" + + category_id: Optional[int] = Field(None, description="Filter by category") + tag_ids: Optional[list[int]] = Field(None, description="Filter by tag IDs") + sort_by: ProductSortBy = Field(ProductSortBy.CREATED_AT, description="Sort field") + sort_order: SortOrder = Field(SortOrder.DESC, description="Sort order") diff --git a/app/api/schemas/v1/search.py b/app/api/schemas/v1/search.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/v1/tag.py b/app/api/schemas/v1/tag.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/v1/translation.py b/app/api/schemas/v1/translation.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/v1/variant.py b/app/api/schemas/v1/variant.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/v2/__init__.py b/app/api/schemas/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/services/__init__.py b/app/api/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/services/base.py b/app/api/services/base.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/services/cache_service.py b/app/api/services/cache_service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/services/category_service.py b/app/api/services/category_service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/services/customization_service.py b/app/api/services/customization_service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/services/image_service.py b/app/api/services/image_service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/services/pricing_service.py b/app/api/services/pricing_service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/services/product_service.py b/app/api/services/product_service.py new file mode 100644 index 0000000..160d442 --- /dev/null +++ b/app/api/services/product_service.py @@ -0,0 +1,48 @@ +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from ...database.functions.products.get_paginated import get_products_paginated_db +from ..schemas.v1.product import ProductListResponse, ProductSortBy, SortOrder + + +class ProductService: + """Service layer for product operations""" + + @staticmethod + async def get_products_list( + db: AsyncSession, + page: int = 1, + size: int = 12, + language_iso: str = "en", + sort_by: ProductSortBy = ProductSortBy.CREATED_AT, + sort_order: SortOrder = SortOrder.DESC, + category_id: Optional[int] = None, + tag_ids: Optional[list[int]] = None, + ) -> ProductListResponse: + """ + Get paginated list of products for homepage/category pages. + + Args: + db: Database session + page: Page number (1-based) + size: Items per page (max 100) + language_iso: Language for translations + sort_by: Field to sort by + sort_order: Sort direction + category_id: Optional category filter + tag_ids: Optional tag filters + + Returns: + ProductListResponse with products and pagination + """ + return await get_products_paginated_db( + db=db, + page=page, + size=size, + language_iso=language_iso, + sort_by=sort_by.value, + sort_order=sort_order.value, + category_filter=category_id, + tag_filter=tag_ids, + ) diff --git a/app/api/services/search_service.py b/app/api/services/search_service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/services/translation_service.py b/app/api/services/translation_service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/utils/__init__.py b/app/api/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/utils/constants.py b/app/api/utils/constants.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/utils/exceptions.py b/app/api/utils/exceptions.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/utils/formatters.py b/app/api/utils/formatters.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/utils/helpers.py b/app/api/utils/helpers.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/utils/validators.py b/app/api/utils/validators.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/dependencies.py b/app/api/v1/dependencies.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/admin.py b/app/api/v1/endpoints/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/attributes.py b/app/api/v1/endpoints/attributes.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/categories.py b/app/api/v1/endpoints/categories.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/customization.py b/app/api/v1/endpoints/customization.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/health.py b/app/api/v1/endpoints/health.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/pricing.py b/app/api/v1/endpoints/pricing.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/products.py b/app/api/v1/endpoints/products.py new file mode 100644 index 0000000..8e18c22 --- /dev/null +++ b/app/api/v1/endpoints/products.py @@ -0,0 +1,79 @@ +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from ....api.schemas.v1.product import ProductListResponse, ProductSortBy, SortOrder +from ....api.services.product_service import ProductService +from ....core.dependencies import DatabaseDep, LanguageDep, PaginationDep + +router = APIRouter() + + +@router.get( + "/", + response_model=ProductListResponse, + summary="Get paginated product list", + description="Retrieve a paginated list of products for homepage or category browsing", +) +async def get_products( + db: DatabaseDep, + pagination: PaginationDep, + language: LanguageDep, + category_id: Annotated[ + Optional[int], Query(description="Filter by category ID", ge=1) + ] = None, + tag_ids: Annotated[ + Optional[list[int]], Query(description="Filter by tag IDs") + ] = None, + sort_by: Annotated[ + ProductSortBy, Query(description="Field to sort by") + ] = ProductSortBy.CREATED_AT, + sort_order: Annotated[ + SortOrder, Query(description="Sort direction") + ] = SortOrder.DESC, +) -> ProductListResponse: + """ + Get a paginated list of products. + + This endpoint is optimized for homepage and category listing views. + It returns essential product information along with category details, + variant indicators, and related data counts. + + **Query Parameters:** + - `page`: Page number (starting from 1) + - `size`: Number of items per page (1-100) + - `lang`: Language code for translations (en, fr, es) + - `category_id`: Filter products by category + - `tag_ids`: Filter products by multiple tag IDs + - `sort_by`: Sort field (created_at, price, name) + - `sort_order`: Sort direction (ASC, DESC) + + **Response includes:** + - Product list with category information + - Pagination metadata + - Variant and customization indicators + - Related data counts (attributes, tags, images) + """ + try: + page, size = pagination + + result = await ProductService.get_products_list( + db=db, + page=page, + size=size, + language_iso=language, + sort_by=sort_by, + sort_order=sort_order, + category_id=category_id, + tag_ids=tag_ids, + ) + + return result + + except Exception as e: + # Log the error in production + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve products. Please try again.", + ) from e diff --git a/app/api/v1/endpoints/search.py b/app/api/v1/endpoints/search.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/tags.py b/app/api/v1/endpoints/tags.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/translations.py b/app/api/v1/endpoints/translations.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/variants.py b/app/api/v1/endpoints/variants.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/router.py b/app/api/v1/router.py new file mode 100644 index 0000000..7f428b2 --- /dev/null +++ b/app/api/v1/router.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from .endpoints import products + +api_router = APIRouter() + +# Include all endpoint routers +api_router.include_router(products.router, prefix="/products", tags=["products"]) diff --git a/app/api/v2/__init__.py b/app/api/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v2/endpoints/__init__.py b/app/api/v2/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v2/router.py b/app/api/v2/router.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..e3d4438 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,58 @@ +from typing import Optional +from urllib.parse import quote_plus + +from pydantic import Field, computed_field +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + + model_config = { + "env_file": ".env", + "case_sensitive": True, + "extra": "ignore", + "validate_assignment": True, + } + + # API Configuration + API_V1_STR: str = "/api/v1" + PROJECT_NAME: str = "Maxi'Mousse API" + API_HOST: str = Field(default="0.0.0.0", description="API host address") + API_PORT: int = Field(default=8000, ge=1, le=65535, description="API port number") + API_DEBUG: bool = Field(default=False, description="Enable debug mode") + + # Environment + ENVIRONMENT: str = Field( + default="development", description="Application environment" + ) + DEBUG: bool = Field(default=False, env="DEBUG") + + # Database + DB_HOST: str = Field(default="localhost", description="Database host") + DB_PORT: int = Field(default=5432, ge=1, le=65535, description="Database port") + DB_NAME: str = Field(default="chocomax", description="Database name") + DB_USER: str = Field(default="postgres", description="Database username") + DB_PASSWORD: str = Field( + default="your_secure_password_here", description="Database password" + ) + DB_SCHEMA: str = Field(default="public", description="Database schema") + DATABASE_URL: Optional[str] = Field( + default=None, + description="Complete database URL (overrides individual DB settings)", + ) + + @computed_field + @property + def database_url(self) -> str: + if self.DATABASE_URL: + return self.DATABASE_URL + + encoded_password = quote_plus(self.DB_PASSWORD) + base_url = ( + f"postgresql+asyncpg://{self.DB_USER}:{encoded_password}" + f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + ) + return base_url + + +settings = Settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..6f8bcb5 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,33 @@ +from typing import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from .config import settings + + +class Base(DeclarativeBase): + pass + + +engine = create_async_engine( + settings.database_url, + echo=settings.DEBUG, + future=True, + pool_pre_ping=True, + pool_recycle=300, +) + +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() diff --git a/app/core/dependencies.py b/app/core/dependencies.py new file mode 100644 index 0000000..64cc483 --- /dev/null +++ b/app/core/dependencies.py @@ -0,0 +1,32 @@ +from typing import Annotated + +from fastapi import Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from .database import get_db + +# Type aliases for common dependencies +DatabaseDep = Annotated[AsyncSession, Depends(get_db)] + + +# Pagination dependency with validation +def get_pagination_params( + page: Annotated[int, Query(ge=1, description="Page number (1-based)")] = 1, + size: Annotated[int, Query(ge=1, le=100, description="Items per page")] = 12, +) -> tuple[int, int]: + return page, size + + +PaginationDep = Annotated[tuple[int, int], Depends(get_pagination_params)] + + +# Language dependency for i18n +def get_language( + lang: Annotated[ + str, Query(regex=r"^[a-z]{2}$", description="Language ISO code") + ] = "en", +) -> str: + return lang + + +LanguageDep = Annotated[str, Depends(get_language)] diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database/__init__.py b/app/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database/functions/__init__.py b/app/database/functions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database/functions/customizations/__init__.py b/app/database/functions/customizations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database/functions/customizations/get_options.py b/app/database/functions/customizations/get_options.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database/functions/customizations/get_values.py b/app/database/functions/customizations/get_values.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database/functions/pagination/__init__.py b/app/database/functions/pagination/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database/functions/pagination/get_info.py b/app/database/functions/pagination/get_info.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database/functions/products/__init__.py b/app/database/functions/products/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database/functions/products/calculate_price.py b/app/database/functions/products/calculate_price.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database/functions/products/get_details.py b/app/database/functions/products/get_details.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database/functions/products/get_paginated.py b/app/database/functions/products/get_paginated.py new file mode 100644 index 0000000..6c8393c --- /dev/null +++ b/app/database/functions/products/get_paginated.py @@ -0,0 +1,131 @@ +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from ....api.schemas.v1.product import ( + PaginationInfo, + ProductListItem, + ProductListResponse, +) + + +async def get_products_paginated_db( + db: AsyncSession, + page: int = 1, + size: int = 12, + language_iso: str = "en", + sort_by: str = "created_at", + sort_order: str = "DESC", + category_filter: int | None = None, + tag_filter: list[int] | None = None, +) -> ProductListResponse: + """ + Python wrapper for the get_products_paginated PostgreSQL function. + + Args: + db: Database session + page: Page number (1-based) + size: Items per page + language_iso: Language code for translations + sort_by: Field to sort by + sort_order: Sort direction + category_filter: Optional category ID filter + tag_filter: Optional list of tag IDs to filter by + + Returns: + ProductListResponse with products and pagination info + """ + + # Prepare parameters for the PostgreSQL function + params = { + "p_page": page, + "p_size": size, + "p_language_iso": language_iso, + "p_sort_by": sort_by, + "p_sort_order": sort_order, + "p_category_filter": category_filter, + "p_tag_filter": tag_filter, + } + + # Call the PostgreSQL function + query = text( + """ + SELECT * FROM get_products_paginated( + p_page := :p_page, + p_size := :p_size, + p_language_iso := :p_language_iso, + p_sort_by := :p_sort_by, + p_sort_order := :p_sort_order, + p_category_filter := :p_category_filter, + p_tag_filter := :p_tag_filter + ) + """ + ) + + result = await db.execute(query, params) + rows = result.fetchall() + + if not rows: + return ProductListResponse( + products=[], + pagination=PaginationInfo( + current_page=page, + page_size=size, + total_items=0, + total_pages=0, + has_next=False, + has_previous=False, + ), + ) + + # Extract pagination info from first row (all rows have same pagination data) + first_row = rows[0] + pagination_data = first_row.pagination + + # Convert rows to ProductListItem objects + products = [] + for row in rows: + # Build category object + category = { + "category_id": row.category_id, + "category_name": row.category_name, + "category_color": row.category_color, + "category_description": row.category_description, + } + + # Build product object + product = ProductListItem( + product_id=row.product_id, + product_name=row.product_name, + product_description=row.product_description, + product_type=row.product_type, + price=row.price, + base_price=row.base_price, + image_url=row.image_url, + preparation_time_hours=row.preparation_time_hours, + min_order_hours=row.min_order_hours, + serving_info=row.serving_info, + is_customizable=row.is_customizable, + created_at=row.created_at, + category=category, + has_variants=row.has_variants, + default_variant_id=row.default_variant_id, + variant_count=row.variant_count, + attribute_count=row.attribute_count, + tag_count=row.tag_count, + image_count=row.image_count, + ) + products.append(product) + + # Convert PostgreSQL pagination_info to Pydantic model + pagination = PaginationInfo( + current_page=pagination_data.current_page, + page_size=pagination_data.page_size, + total_items=pagination_data.total_items, + total_pages=pagination_data.total_pages, + has_next=pagination_data.has_next, + has_previous=pagination_data.has_previous, + ) + + return ProductListResponse(products=products, pagination=pagination) diff --git a/app/database/functions/products/search.py b/app/database/functions/products/search.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py index 082c3df..b2b593b 100644 --- a/app/main.py +++ b/app/main.py @@ -1,19 +1,44 @@ -""" -This is the main entry point for the API application. -It sets up the FastAPI application, includes the home router, and mounts versioned APIs. -""" +from contextlib import asynccontextmanager from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware -from app.routes.home import router as home_router -from app.routes.v1 import api as v1 -from app.routes.v2 import api as v2 +from .api.v1.router import api_router as api_v1_router +from .core.config import settings -app = FastAPI(title="ChocoMax Shop API") -# Home - non-versioned because it is the main entry point -app.include_router(home_router) +@asynccontextmanager +async def lifespan(app: FastAPI): + print("Starting up Maxi'Mousse...") + yield + print("Shutting down Maxi'Mousse...") -# API versions to make sure applications using the API won't break when the API changes -app.mount("/api/v1", v1) -app.mount("/api/v2", v2) + +# Create FastAPI app with modern lifespan handler +app = FastAPI( + title=settings.PROJECT_NAME, + version="1.0.0", + description="E-commerce API for Maxi'Mousse products with multilingual support", + lifespan=lifespan, +) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://localhost:5173"], # Frontend URLs + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"], + allow_headers=["*"], +) + +# Include API routers +app.include_router( + api_v1_router, + prefix=settings.API_V1_STR, +) + + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy", "version": "1.0.0"} diff --git a/app/models/__init__.py b/app/models/__init__.py deleted file mode 100644 index 9949ae5..0000000 --- a/app/models/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -This module initializes the SQLAlchemy declarative base for all ORM models. - -All model classes should inherit from `Base` to ensure proper table mapping. -""" - -from sqlalchemy.orm import declarative_base - -Base = declarative_base() diff --git a/app/routes/home.py b/app/routes/home.py deleted file mode 100644 index 05ac404..0000000 --- a/app/routes/home.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -This is the main entry point for the API. -It sets up the home router and defines the root endpoint. -""" - -from fastapi import APIRouter - -router = APIRouter() - - -@router.get("/", tags=["Home"]) -def read_root(): - """Root endpoint that returns a welcome message.""" - return {"message": "Welcome to the ChocoMax Shop API!"} diff --git a/app/routes/v1/__init__.py b/app/routes/v1/__init__.py deleted file mode 100644 index ff1787e..0000000 --- a/app/routes/v1/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Base module for the API version 1 routes. -""" - -from fastapi import FastAPI - -from .endpoints.products import router as product_router - -__version__ = "1.2.0" - -api = FastAPI(title="ChocoMax Shop API", version=__version__) - -api.include_router(product_router, prefix="/products", tags=["Products"]) diff --git a/app/routes/v1/endpoints/products.py b/app/routes/v1/endpoints/products.py deleted file mode 100644 index f323509..0000000 --- a/app/routes/v1/endpoints/products.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -This module defines the API routes for managing products. -""" - -from fastapi import APIRouter - -router = APIRouter() - -products = [ - { - "product_id": 1, - "product_name": "Pure Chocolate", - "price": 11.0, - "status": "Available", - }, - { - "product_id": 2, - "product_name": "Hazelnut Chocolate", - "price": 15.0, - "status": "Available", - }, - { - "product_id": 3, - "product_name": "Pecan Nut Chocolate", - "price": 20.0, - "status": "Available", - }, - { - "product_id": 4, - "product_name": "Almond Chocolate", - "price": 12.0, - "status": "Available", - }, - { - "product_id": 5, - "product_name": "Macadamia Chocolate", - "price": 18.0, - "status": "Draft", - }, - { - "product_id": 6, - "product_name": "Cashew Chocolate", - "price": 14.0, - "status": "Available", - }, - { - "product_id": 7, - "product_name": "Pistachio Chocolate", - "price": 22.0, - "status": "Available", - }, - { - "product_id": 8, - "product_name": "Walnut Chocolate", - "price": 16.0, - "status": "Discontinued", - }, - { - "product_id": 9, - "product_name": "Coconut Chocolate", - "price": 19.0, - "status": "Available", - }, -] - - -@router.get("/") -def get_products(): - """Retrieve a list of all products.""" - return products diff --git a/app/routes/v2/__init__.py b/app/routes/v2/__init__.py deleted file mode 100644 index ba29719..0000000 --- a/app/routes/v2/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Base module for the API version 2 routes. -""" - -from fastapi import FastAPI - -__version__ = "2.0.0" - -api = FastAPI(title="ChocoMax Shop API", version=__version__) diff --git a/app/utility/database.py b/app/utility/database.py deleted file mode 100644 index 50b5fcb..0000000 --- a/app/utility/database.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Database utility module for asynchronous SQLAlchemy sessions. - -This module sets up the asynchronous SQLAlchemy engine and sessionmaker for -database interactions. It provides a dependency function for obtaining a -database session in FastAPI endpoints. -""" - -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine -from sqlalchemy.orm import sessionmaker - -DATABASE_URL = ( - "postgresql+asyncpg://postgres:S3cur3Str0ngP%40ss@172.17.0.1:5432/chocomax" -) -engine = create_async_engine(DATABASE_URL, echo=False, pool_pre_ping=True) -SessionLocal = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) - - -async def get_db(): - """ - Dependency that provides a SQLAlchemy asynchronous database session. - - Yields: - AsyncSession: An active SQLAlchemy async session for database operations. - - Usage: - Use as a dependency in FastAPI endpoints to access the database. - """ - async with SessionLocal() as session: - yield session diff --git a/app/version.py b/app/version.py deleted file mode 100644 index 0e4c943..0000000 --- a/app/version.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Version information for the application. -This module defines the version of the application. -It is used to track changes and updates to the codebase. -""" - -__version__ = "0.3.0" diff --git a/requirements.txt b/requirements.txt index d3c332e..e2cbd22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,6 @@ -argon2-cffi>=25.1.0,<26 asyncpg>=0.30.0,<1 -cryptography>=45.0.3,<46 -dotenv>=0.9.9,<1 -fastapi>=0.115.12,<1 -fastapi_mail>=1.5.0,<2 -greenlet>=3.2.3,<4 -httpx>=0.28.1,<1 -pydantic[email]>=2.11.6,<3 -pyotp>=2.9.0,<3 -requests>=2.32.4,<3 -sqlalchemy>=2.0.41,<3 -user-agents>=2.2.0,<3 -uvicorn>=0.34.2,<1 +fastapi>=0.116.1,<1 +pydantic==2.11.7,<3 +pydantic-settings==2.10.1,<3 +sqlalchemy[asyncio]==2.0.43,<3 +uvicorn[standard]==0.35.0,<1 diff --git a/tests/test_home.py b/tests/test_home.py deleted file mode 100644 index 5390b36..0000000 --- a/tests/test_home.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -Test the home endpoint of the API. -""" - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from app.routes.home import router as home_router - - -@pytest.fixture -def client(): - """Fixture to create a test client for the FastAPI app with only the home router.""" - app = FastAPI() - app.include_router(home_router) - return TestClient(app) - - -def test_home(client): - """Test the home endpoint.""" - response = client.get("/") - assert response.status_code == 200 - assert response.json() == {"message": "Welcome to the ChocoMax Shop API!"} diff --git a/tests/test_routes/v1/test_products.py b/tests/test_routes/v1/test_products.py deleted file mode 100644 index 50d400d..0000000 --- a/tests/test_routes/v1/test_products.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Test suite for the v1 `/products` endpoint of the ChocoMax API. - -This module uses the `v1_get` utility to avoid repeating the API version path. -""" - -import pytest -from fastapi.testclient import TestClient - -from app.routes.v1.endpoints import products as products_module - - -@pytest.fixture -def client(): - from fastapi import FastAPI - - app = FastAPI() - app.include_router(products_module.router, prefix="/v1/products") - return TestClient(app) - - -def test_products(client: TestClient): - """ - Test that the `/api/v1/products` endpoint returns a 200 status - and responds with a JSON list. - """ - response = client.get("/v1/products") - assert response.status_code == 200 - assert isinstance(response.json(), list) diff --git a/tests/test_utility/conftest.py b/tests/test_utility/conftest.py deleted file mode 100644 index 53eae16..0000000 --- a/tests/test_utility/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest - - -@pytest.fixture -def sample_email(): - """Fixture to provide a sample email for testing.""" - return "user@example.com" - - -@pytest.fixture -def sample_phone(): - """Fixture to provide a sample phone number for testing.""" - return "+1234567890" From 9037aa32e67bed79ffc699909749a0c3a33c3ce6 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Wed, 3 Sep 2025 00:57:25 +0000 Subject: [PATCH 03/11] Refactor product endpoint imports for clarity --- .vscode/settings.json | 21 ++++++++++++++++----- app/api/v1/endpoints/products.py | 3 +-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 1511dea..602111b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,16 +11,27 @@ "**/*.egg-info": true }, "[python]": { - "editor.rulers": [80], + "editor.rulers": [ + 80 + ], "editor.defaultFormatter": "ms-python.black-formatter", "editor.codeActionsOnSave": { - "source.organizeImports": "always" + "source.organizeImports": "always", + "source.unusedImports": "always" } }, - "isort.args": ["--profile", "black"], + "isort.args": [ + "--profile", + "black" + ], "triggerTaskOnSave.tasks": { - "Run file tests": ["tests/**/test_*.py"], - "Run all tests": ["app/**/*.py", "!tests/**"] + "Run file tests": [ + "tests/**/test_*.py" + ], + "Run all tests": [ + "app/**/*.py", + "!tests/**" + ] }, "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true diff --git a/app/api/v1/endpoints/products.py b/app/api/v1/endpoints/products.py index 8e18c22..c51190e 100644 --- a/app/api/v1/endpoints/products.py +++ b/app/api/v1/endpoints/products.py @@ -1,7 +1,6 @@ from typing import Annotated, Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, HTTPException, Query, status from ....api.schemas.v1.product import ProductListResponse, ProductSortBy, SortOrder from ....api.services.product_service import ProductService From fbc45b9d0e7810c9f0a380174aea51a6d4ba7cdb Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Wed, 3 Sep 2025 15:07:59 +0000 Subject: [PATCH 04/11] Refactor product endpoint for improved debugging and error handling; add launch configuration for FastAPI --- .vscode/launch.json | 19 ++++++++ .vscode/settings.json | 14 +++--- app/api/v1/endpoints/products.py | 46 +++++++++---------- .../functions/products/get_paginated.py | 14 +++--- app/main.py | 11 +++++ 5 files changed, 66 insertions(+), 38 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7a9ec73 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: FastAPI", + "type": "debugpy", + "request": "launch", + "module": "uvicorn", + "args": [ + "app.main:app", + "--reload" + ], + "jinja": true + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 602111b..c0c6b8a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,13 +25,13 @@ "black" ], "triggerTaskOnSave.tasks": { - "Run file tests": [ - "tests/**/test_*.py" - ], - "Run all tests": [ - "app/**/*.py", - "!tests/**" - ] + // "Run file tests": [ + // "tests/**/test_*.py" + // ], + // "Run all tests": [ + // "app/**/*.py", + // "!tests/**" + // ] }, "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true diff --git a/app/api/v1/endpoints/products.py b/app/api/v1/endpoints/products.py index c51190e..abcf579 100644 --- a/app/api/v1/endpoints/products.py +++ b/app/api/v1/endpoints/products.py @@ -32,30 +32,22 @@ async def get_products( SortOrder, Query(description="Sort direction") ] = SortOrder.DESC, ) -> ProductListResponse: - """ - Get a paginated list of products. + import traceback - This endpoint is optimized for homepage and category listing views. - It returns essential product information along with category details, - variant indicators, and related data counts. - - **Query Parameters:** - - `page`: Page number (starting from 1) - - `size`: Number of items per page (1-100) - - `lang`: Language code for translations (en, fr, es) - - `category_id`: Filter products by category - - `tag_ids`: Filter products by multiple tag IDs - - `sort_by`: Sort field (created_at, price, name) - - `sort_order`: Sort direction (ASC, DESC) - - **Response includes:** - - Product list with category information - - Pagination metadata - - Variant and customization indicators - - Related data counts (attributes, tags, images) - """ try: + print("=== PRODUCTS ENDPOINT DEBUG ===") + print(f"Database session: {db}") + print(f"Pagination: {pagination}") + print(f"Language: {language}") + print(f"Category ID: {category_id}") + print(f"Tag IDs: {tag_ids}") + print(f"Sort by: {sort_by}") + print(f"Sort order: {sort_order}") + page, size = pagination + print(f"Extracted page: {page}, size: {size}") + + print("About to call ProductService.get_products_list...") result = await ProductService.get_products_list( db=db, @@ -68,11 +60,19 @@ async def get_products( tag_ids=tag_ids, ) + print(f"ProductService returned: {type(result)}") + print("=== PRODUCTS ENDPOINT SUCCESS ===") return result except Exception as e: - # Log the error in production + print(f"=== PRODUCTS ENDPOINT ERROR ===") + print(f"Error: {e}") + print(f"Error type: {type(e)}") + print(f"Full traceback:\n{traceback.format_exc()}") + print("=== END ERROR ===") + + # Re-raise with original error for debugging raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to retrieve products. Please try again.", + detail=f"Debug: {str(e)}", ) from e diff --git a/app/database/functions/products/get_paginated.py b/app/database/functions/products/get_paginated.py index 6c8393c..a9b2ad7 100644 --- a/app/database/functions/products/get_paginated.py +++ b/app/database/functions/products/get_paginated.py @@ -1,5 +1,3 @@ -from typing import Any - from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession @@ -120,12 +118,12 @@ async def get_products_paginated_db( # Convert PostgreSQL pagination_info to Pydantic model pagination = PaginationInfo( - current_page=pagination_data.current_page, - page_size=pagination_data.page_size, - total_items=pagination_data.total_items, - total_pages=pagination_data.total_pages, - has_next=pagination_data.has_next, - has_previous=pagination_data.has_previous, + current_page=getattr(pagination_data, "current_page", page), + page_size=getattr(pagination_data, "page_size", size), + total_items=getattr(pagination_data, "total_items", 0), + total_pages=getattr(pagination_data, "total_pages", 0), + has_next=getattr(pagination_data, "has_next", False), + has_previous=getattr(pagination_data, "has_previous", False), ) return ProductListResponse(products=products, pagination=pagination) diff --git a/app/main.py b/app/main.py index b2b593b..7f7c083 100644 --- a/app/main.py +++ b/app/main.py @@ -2,14 +2,25 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text from .api.v1.router import api_router as api_v1_router from .core.config import settings +from .core.database import engine @asynccontextmanager async def lifespan(app: FastAPI): print("Starting up Maxi'Mousse...") + + # Test database connection on startup + try: + async with engine.begin() as conn: + result = await conn.execute(text("SELECT 1")) + print(f"Database connection successful: {result.scalar()}") + except Exception as e: + print(f"Database connection failed: {e}") + yield print("Shutting down Maxi'Mousse...") From 1763eacf365cba43bc5cf11119706008098fc4ba Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Wed, 3 Sep 2025 15:35:43 +0000 Subject: [PATCH 05/11] Bump Python version to 3.12.11 in workflows and pyproject.toml --- .github/workflows/package-python-app.yml | 2 +- .github/workflows/run-unit-tests.yml | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/package-python-app.yml b/.github/workflows/package-python-app.yml index 00c2bdd..09e35a9 100644 --- a/.github/workflows/package-python-app.yml +++ b/.github/workflows/package-python-app.yml @@ -56,7 +56,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12.10' + python-version: '3.12.11' - name: Install build dependencies run: pip install --user build diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index ffc53b7..5f47d42 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -21,7 +21,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.12.10', '3.x'] + python-version: ['3.12.11', '3.x'] steps: - name: Checkout code diff --git a/pyproject.toml b/pyproject.toml index 2183d47..bc7cb52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "ChocoMax API" readme = "README.md" license = "MIT" authors = [{ name = "Vianpyro" }] -requires-python = ">=3.12.10" +requires-python = ">=3.12.11" dependencies = [] # This will be populated dynamically below urls = { Homepage = "https://github.com/TheChocoMax/API" } classifiers = [ From 34949d97483f1fa95a920f78b59b0da7af292db7 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Wed, 3 Sep 2025 15:36:07 +0000 Subject: [PATCH 06/11] Fix regex parameter in language dependency for i18n --- app/core/dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/dependencies.py b/app/core/dependencies.py index 64cc483..28c1dd5 100644 --- a/app/core/dependencies.py +++ b/app/core/dependencies.py @@ -23,7 +23,7 @@ def get_pagination_params( # Language dependency for i18n def get_language( lang: Annotated[ - str, Query(regex=r"^[a-z]{2}$", description="Language ISO code") + str, Query(pattern=r"^[a-z]{2}$", description="Language ISO code") ] = "en", ) -> str: return lang From 13afbea6688163fcf9867185bf60d03855925db0 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Wed, 3 Sep 2025 15:47:47 +0000 Subject: [PATCH 07/11] Update workflow to ensure fail-fast strategy is disabled for unit tests --- .github/workflows/run-unit-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5f47d42..f5b0a51 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -17,8 +17,10 @@ jobs: test: name: Pytest on ${{ matrix.os }} with Python ${{ matrix.python-version }} runs-on: ${{ matrix.os }} + continue-on-error: true strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ['3.12.11', '3.x'] From 215222af2c2de98b2d5e8f627324028ec472784e Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Wed, 3 Sep 2025 15:53:33 +0000 Subject: [PATCH 08/11] Refactor unit test workflow to disable fail-fast strategy; update config for DEBUG environment variable handling --- .github/workflows/run-unit-tests.yml | 1 - app/core/config.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index f5b0a51..97583c9 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,6 @@ jobs: continue-on-error: true strategy: - fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ['3.12.11', '3.x'] diff --git a/app/core/config.py b/app/core/config.py index e3d4438..f4b3a20 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -12,6 +12,9 @@ class Settings(BaseSettings): "case_sensitive": True, "extra": "ignore", "validate_assignment": True, + "env_map": { + "DEBUG": "DEBUG", + }, } # API Configuration @@ -25,7 +28,7 @@ class Settings(BaseSettings): ENVIRONMENT: str = Field( default="development", description="Application environment" ) - DEBUG: bool = Field(default=False, env="DEBUG") + DEBUG: bool = Field(default=False, description="Enable debug mode") # Database DB_HOST: str = Field(default="localhost", description="Database host") From dfbca16a9ef8ecd4a0265525177f8bcba372c78c Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Wed, 3 Sep 2025 15:55:57 +0000 Subject: [PATCH 09/11] Update unit test workflow to ensure fail-fast strategy is disabled --- .github/workflows/run-unit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 97583c9..7bfeb89 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -17,9 +17,9 @@ jobs: test: name: Pytest on ${{ matrix.os }} with Python ${{ matrix.python-version }} runs-on: ${{ matrix.os }} - continue-on-error: true strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ['3.12.11', '3.x'] From 1a29616d078c5362a3fd29b8830a4ecff67d29b1 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Sat, 6 Sep 2025 17:19:39 +0000 Subject: [PATCH 10/11] Refactor get_products endpoint to remove debug print statements and improve error handling --- app/api/v1/endpoints/products.py | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/app/api/v1/endpoints/products.py b/app/api/v1/endpoints/products.py index abcf579..569f456 100644 --- a/app/api/v1/endpoints/products.py +++ b/app/api/v1/endpoints/products.py @@ -10,7 +10,7 @@ @router.get( - "/", + "", response_model=ProductListResponse, summary="Get paginated product list", description="Retrieve a paginated list of products for homepage or category browsing", @@ -32,22 +32,8 @@ async def get_products( SortOrder, Query(description="Sort direction") ] = SortOrder.DESC, ) -> ProductListResponse: - import traceback - try: - print("=== PRODUCTS ENDPOINT DEBUG ===") - print(f"Database session: {db}") - print(f"Pagination: {pagination}") - print(f"Language: {language}") - print(f"Category ID: {category_id}") - print(f"Tag IDs: {tag_ids}") - print(f"Sort by: {sort_by}") - print(f"Sort order: {sort_order}") - page, size = pagination - print(f"Extracted page: {page}, size: {size}") - - print("About to call ProductService.get_products_list...") result = await ProductService.get_products_list( db=db, @@ -60,19 +46,10 @@ async def get_products( tag_ids=tag_ids, ) - print(f"ProductService returned: {type(result)}") - print("=== PRODUCTS ENDPOINT SUCCESS ===") return result except Exception as e: - print(f"=== PRODUCTS ENDPOINT ERROR ===") - print(f"Error: {e}") - print(f"Error type: {type(e)}") - print(f"Full traceback:\n{traceback.format_exc()}") - print("=== END ERROR ===") - - # Re-raise with original error for debugging raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Debug: {str(e)}", + detail="Failed to retrieve products. Please try again.", ) from e From 22114d2f574ab78f8e5350bad3461c1534ede061 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Sat, 6 Sep 2025 17:58:32 +0000 Subject: [PATCH 11/11] Implement create_product endpoint with validation and error handling --- app/api/schemas/v1/product.py | 121 +++++++++++++++++++++++++- app/api/services/product_service.py | 127 ++++++++++++++++++++++++++++ app/api/v1/endpoints/products.py | 101 +++++++++++++++++++++- 3 files changed, 345 insertions(+), 4 deletions(-) diff --git a/app/api/schemas/v1/product.py b/app/api/schemas/v1/product.py index fc30032..ee7a4c2 100644 --- a/app/api/schemas/v1/product.py +++ b/app/api/schemas/v1/product.py @@ -1,8 +1,9 @@ from datetime import datetime +from decimal import Decimal from enum import Enum -from typing import Optional +from typing import List, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from ....api.schemas.base import PaginationInfo @@ -85,3 +86,119 @@ class ProductListFilters(BaseModel): tag_ids: Optional[list[int]] = Field(None, description="Filter by tag IDs") sort_by: ProductSortBy = Field(ProductSortBy.CREATED_AT, description="Sort field") sort_order: SortOrder = Field(SortOrder.DESC, description="Sort order") + + +class ProductAttributeInput(BaseModel): + """Schema for product attribute input""" + + name: str = Field( + ..., + min_length=1, + max_length=100, + description="Attribute name (e.g., 'allergen')", + ) + value: str = Field( + ..., + min_length=1, + max_length=200, + description="Attribute value (e.g., 'gluten')", + ) + color: Optional[str] = Field( + None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color (e.g., '#FF6B6B')" + ) + + +class ProductTranslationInput(BaseModel): + """Schema for product translation input""" + + language_iso: str = Field( + ..., pattern=r"^[a-z]{2}$", description="Language ISO code" + ) + name: str = Field( + ..., min_length=3, max_length=200, description="Translated product name" + ) + description: Optional[str] = Field( + None, max_length=2000, description="Translated description" + ) + + +class CreateProductRequest(BaseModel): + """Request schema for creating a new product""" + + model_config = ConfigDict(str_strip_whitespace=True) + + # Required fields + product_name: str = Field( + ..., + min_length=3, + max_length=200, + description="Product name", + pattern=r"^[^<>\'\"]+$", # Prevent XSS characters + ) + product_description: str = Field( + ..., min_length=1, max_length=2000, description="Product description" + ) + product_type: ProductType = Field(..., description="Type of product") + category_id: int = Field(..., ge=1, description="Category ID") + + # Pricing (conditional based on product_type) + price: Optional[Decimal] = Field( + None, ge=0, le=999999.99, description="Price for standard/variant products" + ) + base_price: Optional[Decimal] = Field( + None, ge=0, le=999999.99, description="Base price for configurable products" + ) + + # Optional fields + image_url: Optional[str] = Field( + None, + pattern=r"^https?://[^\s]+\.(jpg|jpeg|png|webp)(\?[^\s]*)?$", + description="Product image URL", + ) + preparation_time_hours: int = Field( + 48, ge=0, le=24 * 365, description="Preparation time in hours" + ) + min_order_hours: int = Field( + 48, ge=0, le=24 * 365, description="Minimum order advance time in hours" + ) + serving_info: Optional[str] = Field( + None, max_length=200, description="Serving information (e.g., '4-6 persons')" + ) + is_customizable: bool = Field(False, description="Whether product is customizable") + + # Related data + tag_ids: Optional[List[int]] = Field(None, description="List of tag IDs to assign") + attributes: Optional[List[ProductAttributeInput]] = Field( + None, description="Product attributes" + ) + translations: Optional[List[ProductTranslationInput]] = Field( + None, description="Product translations" + ) + + @model_validator(mode="after") + def validate_pricing(self) -> "CreateProductRequest": + """Validate pricing based on product type""" + if self.product_type == ProductType.CONFIGURABLE: + if self.base_price is None: + raise ValueError("Configurable products require a base_price") + if self.price is not None: + # Clear price for configurable products + self.price = None + else: + if self.price is None: + raise ValueError("Standard and variant-based products require a price") + if self.base_price is not None: + # Clear base_price for non-configurable products + self.base_price = None + return self + + +class CreateProductResponse(BaseModel): + """Response schema for product creation""" + + model_config = ConfigDict(from_attributes=True) + + product_id: int = Field(..., description="Created product ID") + product_name: str = Field(..., description="Product name") + message: str = Field(..., description="Success message") + created_at: datetime = Field(..., description="Timestamp of creation") diff --git a/app/api/services/product_service.py b/app/api/services/product_service.py index 160d442..a4d7459 100644 --- a/app/api/services/product_service.py +++ b/app/api/services/product_service.py @@ -46,3 +46,130 @@ async def get_products_list( category_filter=category_id, tag_filter=tag_ids, ) + + @staticmethod + async def create_product(db: AsyncSession, product_data: dict) -> dict: + """ + Create a new product in the database. + + Args: + db: Database session + product_data: Product data dictionary + + Returns: + Dictionary with product_id and success message + + Raises: + Exception: If product creation fails + """ + import json + + from sqlalchemy import text + + # Prepare attributes as JSONB + attributes_json = None + if product_data.get("attributes"): + attributes_json = json.dumps( + [ + { + "name": attr["name"], + "value": attr["value"], + "color": attr.get("color", "#32cd32"), + } + for attr in product_data["attributes"] + ] + ) + + # Prepare translations as JSONB + translations_json = None + if product_data.get("translations"): + translations_json = json.dumps( + [ + { + "language_iso": trans["language_iso"], + "name": trans["name"], + "description": trans.get("description", ""), + } + for trans in product_data["translations"] + ] + ) + + # Prepare parameters for the stored procedure + params = { + "p_product_name": product_data["product_name"], + "p_product_description": product_data["product_description"], + "p_product_type": product_data["product_type"], + "p_category_id": product_data["category_id"], + "p_price": product_data.get("price"), + "p_base_price": product_data.get("base_price"), + "p_image_url": product_data.get("image_url"), + "p_preparation_time_hours": product_data.get("preparation_time_hours", 48), + "p_min_order_hours": product_data.get("min_order_hours", 48), + "p_serving_info": product_data.get("serving_info"), + "p_is_customizable": product_data.get("is_customizable", False), + "p_tag_ids": product_data.get("tag_ids"), + "p_attributes": attributes_json, + "p_translations": translations_json, + "p_created_by": product_data.get("created_by", "api"), + } + + # Call the PostgreSQL function + query = text( + """ + SELECT add_new_product( + :p_product_name, + :p_product_description, + :p_product_type, + :p_category_id, + :p_price, + :p_base_price, + :p_image_url, + :p_preparation_time_hours, + :p_min_order_hours, + :p_serving_info, + :p_is_customizable, + :p_tag_ids, + :p_attributes, + :p_translations, + :p_created_by + ) + """ + ) + + try: + result = await db.execute(query, params) + product_id = result.scalar() + + # Commit the transaction + await db.commit() + + return { + "product_id": product_id, + "product_name": product_data["product_name"], + "message": f"Product '{product_data['product_name']}' created successfully with ID {product_id}", + } + + except Exception as e: + await db.rollback() + error_message = str(e) + + if "P0001" in error_message: + raise ValueError("Product name cannot be empty") + elif "P0002" in error_message: + raise ValueError("Product description cannot be empty") + elif "P0003" in error_message: + raise ValueError("Product name must be at least 3 characters long") + elif "P0004" in error_message: + raise ValueError("Product name cannot exceed 200 characters") + elif "P0005" in error_message: + raise ValueError("Product name contains invalid characters") + elif "P0011" in error_message: + raise ValueError("Invalid or disabled category ID") + elif "P0012" in error_message: + raise ValueError("Invalid image URL format") + elif "P0013" in error_message: + raise ValueError("A product with this name already exists") + elif "P0014" in error_message: + raise ValueError("Invalid or disabled tag ID") + else: + raise Exception(f"Failed to create product: {error_message}") diff --git a/app/api/v1/endpoints/products.py b/app/api/v1/endpoints/products.py index 569f456..9a68ac4 100644 --- a/app/api/v1/endpoints/products.py +++ b/app/api/v1/endpoints/products.py @@ -1,8 +1,16 @@ +from datetime import datetime from typing import Annotated, Optional -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, Body, HTTPException, Query, status +from sqlalchemy.exc import IntegrityError -from ....api.schemas.v1.product import ProductListResponse, ProductSortBy, SortOrder +from ....api.schemas.v1.product import ( + CreateProductRequest, + CreateProductResponse, + ProductListResponse, + ProductSortBy, + SortOrder, +) from ....api.services.product_service import ProductService from ....core.dependencies import DatabaseDep, LanguageDep, PaginationDep @@ -53,3 +61,92 @@ async def get_products( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve products. Please try again.", ) from e + + +@router.post( + "", + response_model=CreateProductResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a new product", + description="Create a new product/recipe in the database with optional translations and attributes", +) +async def create_product( + db: DatabaseDep, + product: Annotated[ + CreateProductRequest, + Body( + ..., + example={ + "product_name": "Chocolate Truffle Cake", + "product_description": "Rich chocolate cake with Belgian chocolate ganache", + "product_type": "standard", + "category_id": 1, + "price": 45.99, + "image_url": "https://example.com/images/chocolate-cake.jpg", + "preparation_time_hours": 24, + "min_order_hours": 24, + "serving_info": "8-10 persons", + "is_customizable": False, + "tag_ids": [1, 2], + "attributes": [ + {"name": "allergen", "value": "gluten", "color": "#FF6B6B"}, + {"name": "allergen", "value": "dairy", "color": "#FF6B6B"}, + {"name": "flavor", "value": "chocolate", "color": "#8B4513"}, + ], + "translations": [ + { + "language_iso": "fr", + "name": "Gâteau aux Truffes au Chocolat", + "description": "Gâteau au chocolat riche avec ganache au chocolat belge", + } + ], + }, + ), + ], +) -> CreateProductResponse: + """ + Create a new product with the following features: + + - **Validation**: Comprehensive input validation including XSS prevention + - **Product Types**: Support for standard, configurable, and variant-based products + - **Pricing**: Automatic price/base_price validation based on product type + - **Internationalization**: Support for multiple language translations + - **Attributes**: Add allergens, dietary info, flavors, etc. + - **Tags**: Assign marketing tags like "seasonal", "bestseller" + - **Security**: Input sanitization and SQL injection prevention + + Returns the created product ID and confirmation message. + """ + try: + # Convert Pydantic model to dict for service layer + product_data = product.model_dump(exclude_none=True) + + # Call service to create product + result = await ProductService.create_product(db, product_data) + + # Return response with created product info + return CreateProductResponse( + product_id=result["product_id"], + product_name=result["product_name"], + message=result["message"], + created_at=datetime.now(), + ) + + except ValueError as e: + # Handle validation errors from the database function + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except IntegrityError as e: + # Handle database integrity errors + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Product creation failed due to data conflict. Please check if the product name already exists.", + ) + except Exception as e: + # Log the error (in production, you'd use proper logging) + print(f"Error creating product: {str(e)}") + + # Return generic error to client + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create product. Please try again or contact support if the issue persists.", + )