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..7bfeb89 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -19,9 +19,10 @@ jobs: runs-on: ${{ matrix.os }} strategy: + fail-fast: false 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/.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 1511dea..c0c6b8a 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/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..ee7a4c2 --- /dev/null +++ b/app/api/schemas/v1/product.py @@ -0,0 +1,204 @@ +from datetime import datetime +from decimal import Decimal +from enum import Enum +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +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") + + +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/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..a4d7459 --- /dev/null +++ b/app/api/services/product_service.py @@ -0,0 +1,175 @@ +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, + ) + + @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/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..9a68ac4 --- /dev/null +++ b/app/api/v1/endpoints/products.py @@ -0,0 +1,152 @@ +from datetime import datetime +from typing import Annotated, Optional + +from fastapi import APIRouter, Body, HTTPException, Query, status +from sqlalchemy.exc import IntegrityError + +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 + +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: + 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: + raise HTTPException( + 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.", + ) 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..f4b3a20 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,61 @@ +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, + "env_map": { + "DEBUG": "DEBUG", + }, + } + + # 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, description="Enable debug mode") + + # 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..28c1dd5 --- /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(pattern=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..a9b2ad7 --- /dev/null +++ b/app/database/functions/products/get_paginated.py @@ -0,0 +1,129 @@ +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=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/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..7f7c083 100644 --- a/app/main.py +++ b/app/main.py @@ -1,19 +1,55 @@ -""" -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 sqlalchemy import text -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 +from .core.database import engine -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...") -# 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) + # 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...") + + +# 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/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 = [ 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"