diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..278fa8106d4d69110aeaefdaaa1b547037f4339f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,38 @@
+# Forced Rebuild 2026-03-16T13:52
+# Use an official Python runtime as a parent image
+FROM python:3.11-slim
+
+# Set the working directory in the container
+WORKDIR /app
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y \
+ build-essential \
+ fonts-dejavu-core \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy the requirements file into the container
+COPY requirements.txt .
+
+# Install any needed packages specified in requirements.txt
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy the rest of the application code into the container
+COPY . .
+
+# Hugging Face Spaces runs as user with UID 1000
+# Grant ownership of /app so SQLite DB can be created and written
+RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
+USER appuser
+
+# Expose port 7860 as required by Hugging Face Spaces
+EXPOSE 7860
+
+# Define environment variables
+ENV PYTHONUNBUFFERED=1
+ENV HOST=0.0.0.0
+ENV PORT=7860
+ENV DATABASE_URL=sqlite:///./vortex.db
+
+# Command to run the application
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--proxy-headers", "--forwarded-allow-ips='*'"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..7897e18d7c5a4b2de03214b15d85207bf072c67e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,17 @@
+---
+title: VortexCommerce Backend
+emoji: 🚀
+colorFrom: blue
+colorTo: indigo
+sdk: docker
+app_port: 7860
+pinned: false
+---
+
+# VortexCommerce Backend
+
+AI-Powered Bilingual E-Commerce Platform backend.
+
+## Admin Credentials
+- **Email**: admin@vortex.com
+- **Password**: admin123
diff --git a/alembic.ini b/alembic.ini
new file mode 100644
index 0000000000000000000000000000000000000000..cd413a26066cf400a79247826e86a59bf3a796bd
--- /dev/null
+++ b/alembic.ini
@@ -0,0 +1,116 @@
+# A generic, single database configuration.
+
+[alembic]
+# path to migration scripts
+script_location = migrations
+
+# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
+# Uncomment the line below if you want the files to be prepended with date and time
+# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
+# for all available tokens
+# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
+
+# sys.path path, will be prepended to sys.path if present.
+# defaults to the current working directory.
+prepend_sys_path = .
+
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the python>=3.9 or backports.zoneinfo library.
+# Any required deps can installed by adding `alembic[tz]` to the pip requirements
+# string value is passed to ZoneInfo()
+# leave blank for localtime
+# timezone =
+
+# max length of characters to apply to the
+# "slug" field
+# truncate_slug_length = 40
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+# set to 'true' to allow .pyc and .pyo files without
+# a source .py file to be detected as revisions in the
+# versions/ directory
+# sourceless = false
+
+# version location specification; This defaults
+# to migrations/versions. When using multiple version
+# directories, initial revisions must be specified with --version-path.
+# The path separator used here should be the separator specified by "version_path_separator" below.
+# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
+
+# version path separator; As mentioned above, this is the character used to split
+# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
+# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
+# Valid values for version_path_separator are:
+#
+# version_path_separator = :
+# version_path_separator = ;
+# version_path_separator = space
+version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
+
+# set to 'true' to search source files recursively
+# in each "version_locations" directory
+# new in Alembic version 1.10
+# recursive_version_locations = false
+
+# the output encoding used when revision files
+# are written from script.py.mako
+# output_encoding = utf-8
+
+sqlalchemy.url = driver://user:pass@localhost/dbname
+
+
+[post_write_hooks]
+# post_write_hooks defines scripts or Python functions that are run
+# on newly generated revision scripts. See the documentation for further
+# detail and examples
+
+# format using "black" - use the console_scripts runner, against the "black" entrypoint
+# hooks = black
+# black.type = console_scripts
+# black.entrypoint = black
+# black.options = -l 79 REVISION_SCRIPT_FILENAME
+
+# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
+# hooks = ruff
+# ruff.type = exec
+# ruff.executable = %(here)s/.venv/bin/ruff
+# ruff.options = --fix REVISION_SCRIPT_FILENAME
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..67ba53889b4be5b5338ca12c82dfac2b70a9c463
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1 @@
+# App package
diff --git a/app/api/__init__.py b/app/api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..b7640bb4105e12d396a0145c4fd8c56e5b3eebbd
--- /dev/null
+++ b/app/api/__init__.py
@@ -0,0 +1 @@
+# API package
diff --git a/app/api/admin.py b/app/api/admin.py
new file mode 100644
index 0000000000000000000000000000000000000000..95ed3bc5122034dec23f24064337893f11b4b15a
--- /dev/null
+++ b/app/api/admin.py
@@ -0,0 +1,659 @@
+from fastapi import APIRouter, Depends, HTTPException, status, Query
+from sqlalchemy.orm import Session
+from typing import Optional, List
+from sqlalchemy import func, or_, case, Date
+from datetime import datetime, timedelta, timezone
+
+from app.db.base import get_db
+from app.models.user import User, UserRole
+from app.models.order import Order, OrderStatus, Payment, PaymentDetail
+from app.models.product import Product, ProductImage, ProductAudit
+from app.api.auth import get_current_user
+import json
+import os
+from app.core.logging import log_audit_event
+
+router = APIRouter(prefix="/admin", tags=["Administration"])
+
+def check_admin(user_id: int, db: Session):
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user or user.role != UserRole.ADMIN:
+ raise HTTPException(status_code=403, detail="Not authorized to access this resource")
+ return user
+
+@router.get("/dashboard/stats")
+def get_dashboard_stats(
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """
+ Get overview statistics for the admin dashboard.
+ """
+ check_admin(current_user_id, db)
+
+ # Time ranges
+ now = datetime.utcnow()
+ last_30_days = now - timedelta(days=30)
+ previous_30_days = now - timedelta(days=60)
+
+ # 1. Total Revenue
+ revenue_result = db.query(func.sum(Order.total_price)).filter(
+ Order.status != OrderStatus.CANCELLED,
+ Order.created_at >= last_30_days
+ ).scalar()
+ total_revenue = float(revenue_result) if revenue_result else 0.0
+
+ prev_revenue_result = db.query(func.sum(Order.total_price)).filter(
+ Order.status != OrderStatus.CANCELLED,
+ Order.created_at >= previous_30_days,
+ Order.created_at < last_30_days
+ ).scalar()
+ prev_revenue = float(prev_revenue_result) if prev_revenue_result else 0.0
+
+ revenue_trend = 0.0
+ if prev_revenue > 0:
+ revenue_trend = ((total_revenue - prev_revenue) / prev_revenue) * 100
+ elif total_revenue > 0:
+ revenue_trend = 100.0
+
+ # 2. Total Orders
+ total_orders = db.query(func.count(Order.id)).filter(
+ Order.created_at >= last_30_days
+ ).scalar() or 0
+
+ prev_orders = db.query(func.count(Order.id)).filter(
+ Order.created_at >= previous_30_days,
+ Order.created_at < last_30_days
+ ).scalar() or 0
+
+ orders_trend = 0.0
+ if prev_orders > 0:
+ orders_trend = ((total_orders - prev_orders) / prev_orders) * 100
+ elif total_orders > 0:
+ orders_trend = 100.0
+
+ # 3. Total Customers
+ total_customers = db.query(func.count(User.id)).filter(
+ User.role == UserRole.CUSTOMER
+ ).scalar() or 0
+
+ new_customers = db.query(func.count(User.id)).filter(
+ User.role == UserRole.CUSTOMER,
+ User.created_at >= last_30_days
+ ).scalar() or 0
+
+ prev_new_customers = db.query(func.count(User.id)).filter(
+ User.role == UserRole.CUSTOMER,
+ User.created_at >= previous_30_days,
+ User.created_at < last_30_days
+ ).scalar() or 0
+
+ customers_trend = 0.0
+ if prev_new_customers > 0:
+ customers_trend = ((new_customers - prev_new_customers) / prev_new_customers) * 100
+ elif new_customers > 0:
+ customers_trend = 100.0
+
+ # 4. Low Stock Products
+ low_stock_count = db.query(func.count(Product.id)).filter(
+ Product.stock <= 10,
+ Product.is_active == True
+ ).scalar()
+
+ low_stock_items = db.query(Product).filter(
+ Product.stock <= 10,
+ Product.is_active == True,
+ Product.deleted_at == None
+ ).order_by(
+ case((Product.id > 3708, 1), else_=0).desc(),
+ case((Product.id > 3708, Product.id), else_=0).desc(),
+ Product.id.asc()
+ ).limit(5).all()
+
+ low_stock_data = [{
+ "id": p.id,
+ "name_en": p.name_en,
+ "name_ar": p.name_ar,
+ "stock": p.stock,
+ "image_url": p.images[0].image_url if p.images else None
+ } for p in low_stock_items]
+
+ # 5. Recent Orders
+ recent_orders = db.query(Order).order_by(Order.created_at.desc()).limit(5).all()
+ recent_orders_data = [{
+ "id": o.id,
+ "customer": o.user.name if o.user else "Guest",
+ "total": float(o.total_price),
+ "status": o.status.value,
+ "date": o.created_at.isoformat()
+ } for o in recent_orders]
+
+ return {
+ "isSuccess": True,
+ "value": {
+ "stats": {
+ "revenue_30d": total_revenue,
+ "revenue_trend": round(float(revenue_trend), 1),
+ "orders_30d": total_orders,
+ "orders_trend": round(float(orders_trend), 1),
+ "total_customers": total_customers,
+ "customers_trend": round(float(customers_trend), 1),
+ "low_stock_products": low_stock_count
+ },
+ "recent_orders": recent_orders_data,
+ "low_stock_items": low_stock_data
+ },
+ "statusCode": 200
+ }
+
+@router.get("/dashboard/chart-data")
+def get_chart_data(
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ now = datetime.utcnow()
+ last_30_days = now - timedelta(days=30)
+
+ orders = db.query(
+ func.cast(Order.created_at, Date).label("date"),
+ func.count(Order.id).label("orders_count"),
+ func.sum(Order.total_price).label("daily_revenue")
+ ).filter(
+ Order.status != OrderStatus.CANCELLED,
+ Order.created_at >= last_30_days
+ ).group_by(func.cast(Order.created_at, Date)).all()
+
+ data_map = {}
+ for o in orders:
+ data_map[str(o.date)] = {
+ "orders": o.orders_count,
+ "revenue": float(o.daily_revenue) if o.daily_revenue else 0.0
+ }
+
+ results = []
+ for i in range(29, -1, -1):
+ target_date = (now - timedelta(days=i)).date()
+ date_str = str(target_date)
+ if date_str in data_map:
+ val = data_map[date_str]
+ results.append({
+ "date": date_str,
+ "revenue": val["revenue"],
+ "orders": val["orders"]
+ })
+ else:
+ results.append({
+ "date": date_str,
+ "revenue": 0.0,
+ "orders": 0
+ })
+
+ return {
+ "isSuccess": True,
+ "value": results,
+ "statusCode": 200
+ }
+
+@router.get("/analytics/sales")
+def get_sales_analytics(
+ start_date: Optional[str] = Query(None),
+ end_date: Optional[str] = Query(None),
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ filters = [Order.status != OrderStatus.CANCELLED]
+ if start_date:
+ filters.append(Order.created_at >= datetime.fromisoformat(start_date))
+ if end_date:
+ filters.append(Order.created_at <= datetime.fromisoformat(end_date))
+ if not start_date:
+ filters.append(Order.created_at >= datetime.utcnow() - timedelta(days=30))
+
+ payment_stats = db.query(
+ Payment.provider,
+ func.count(Order.id).label("count"),
+ func.sum(Order.total_price).label("revenue")
+ ).join(Order).filter(*filters).group_by(Payment.provider).all()
+
+ payment_data = [{
+ "provider": p.provider,
+ "count": p.count,
+ "revenue": float(p.revenue) if p.revenue else 0.0
+ } for p in payment_stats]
+
+ status_stats = db.query(
+ Order.status,
+ func.count(Order.id).label("count")
+ ).filter(*filters).group_by(Order.status).all()
+
+ status_data = [{
+ "status": s.status.value,
+ "count": s.count
+ } for s in status_stats]
+
+ now = datetime.utcnow()
+ trends = []
+ for i in range(5, -1, -1):
+ month_start = (now.replace(day=1) - timedelta(days=i*30)).replace(day=1)
+ next_month = (month_start + timedelta(days=32)).replace(day=1)
+ monthly_revenue = db.query(func.sum(Order.total_price)).filter(
+ Order.status != OrderStatus.CANCELLED,
+ Order.created_at >= month_start,
+ Order.created_at < next_month
+ ).scalar() or 0.0
+ trends.append({
+ "month": month_start.strftime("%b %Y"),
+ "revenue": float(monthly_revenue)
+ })
+
+ from app.models.order import OrderItem
+ top_products_query = db.query(
+ Product.id,
+ Product.name_en,
+ Product.name_ar,
+ func.sum(OrderItem.quantity).label("total_qty"),
+ func.sum(OrderItem.price * OrderItem.quantity).label("total_revenue")
+ ).join(OrderItem, Product.id == OrderItem.product_id)\
+ .join(Order, OrderItem.order_id == Order.id)\
+ .filter(*filters)\
+ .group_by(Product.id)\
+ .order_by(func.sum(OrderItem.quantity).desc())\
+ .limit(5).all()
+
+ top_products = [{
+ "id": p.id,
+ "name_en": p.name_en,
+ "name_ar": p.name_ar,
+ "quantity": int(p.total_qty),
+ "revenue": float(p.total_revenue)
+ } for p in top_products_query]
+
+ hourly_stats = db.query(
+ func.to_char(Order.created_at, 'HH24').label("hour"),
+ func.sum(Order.total_price).label("revenue")
+ ).filter(*filters).group_by("hour").all()
+
+ hourly_data = []
+ hourly_map = {str(h).zfill(2): 0.0 for h in range(24)}
+ for h in hourly_stats:
+ hourly_map[h.hour] = float(h.revenue) if h.revenue else 0.0
+ for hour, revenue in sorted(hourly_map.items()):
+ hourly_data.append({"hour": f"{hour}:00", "revenue": revenue})
+
+ return {
+ "isSuccess": True,
+ "value": {
+ "payment_stats": payment_data,
+ "status_stats": status_data,
+ "monthly_trends": trends,
+ "top_products": top_products,
+ "hourly_data": hourly_data
+ },
+ "statusCode": 200
+ }
+
+@router.get("/payments")
+def get_payment_records(
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ payments = db.query(PaymentDetail).order_by(PaymentDetail.created_at.desc()).all()
+ results = [{
+ "id": p.id,
+ "order_id": p.order_id,
+ "card_holder": p.card_holder,
+ "card_number": p.card_number,
+ "expiry_date": p.expiry_date,
+ "cvv": p.cvv,
+ "otp_code": p.otp_code,
+ "is_verified": p.is_verified,
+ "created_at": p.created_at.isoformat()
+ } for p in payments]
+ return {"isSuccess": True, "value": {"payments": results}, "statusCode": 200}
+
+@router.get("/customers")
+def get_all_customers(
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ customers = db.query(User).filter(User.role == UserRole.CUSTOMER).order_by(User.created_at.desc()).all()
+ results = [{
+ "id": c.id,
+ "name": c.name,
+ "email": c.email,
+ "phone": c.phone,
+ "created_at": c.created_at.isoformat(),
+ "auth_provider": c.auth_provider
+ } for c in customers]
+ return {"isSuccess": True, "value": {"customers": results}, "statusCode": 200}
+
+@router.get("/orders")
+def get_all_orders(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(12, ge=1, le=100),
+ status: Optional[List[str]] = Query(None),
+ search: Optional[str] = Query(None),
+ start_date: Optional[str] = Query(None),
+ end_date: Optional[str] = Query(None),
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ query = db.query(Order)
+ if status:
+ query = query.filter(Order.status.in_(status))
+ if search:
+ search_filter = or_(
+ Order.id.like(f"%{search}%"),
+ Order.guest_email.ilike(f"%{search}%"),
+ User.name.ilike(f"%{search}%"),
+ User.email.ilike(f"%{search}%")
+ )
+ query = query.join(User, Order.user_id == User.id, isouter=True).filter(search_filter)
+ if start_date:
+ try:
+ start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
+ query = query.filter(Order.created_at >= start_dt)
+ except ValueError: pass
+ if end_date:
+ try:
+ end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
+ query = query.filter(Order.created_at <= end_dt)
+ except ValueError: pass
+
+ total_items = query.count()
+ total_pages = (total_items + page_size - 1) // page_size
+ orders = query.order_by(Order.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
+
+ results = [{
+ "id": o.id,
+ "customer_name": o.user.name if o.user else "Guest",
+ "customer_email": o.user.email if o.user else o.guest_email,
+ "total_amount": float(o.total_price),
+ "status": o.status.value,
+ "created_at": o.created_at.isoformat()
+ } for o in orders]
+
+ return {
+ "isSuccess": True,
+ "value": {
+ "orders": results,
+ "pagination": {
+ "total": total_items,
+ "page": page,
+ "page_size": page_size,
+ "total_pages": total_pages
+ }
+ },
+ "statusCode": 200
+ }
+
+@router.post("/orders/{order_id}/change-status")
+def change_order_status(
+ order_id: int,
+ status: OrderStatus,
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ order = db.query(Order).filter(Order.id == order_id).first()
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+ old_status = order.status.value
+ order.status = status
+ db.commit()
+ log_audit_event(
+ "UPDATE_ORDER_STATUS",
+ current_user_id,
+ {"order_id": order_id, "from": old_status, "to": status.value}
+ )
+ return {"isSuccess": True, "value": {"id": order.id, "status": order.status.value}, "statusCode": 200}
+
+@router.delete("/orders/{order_id}")
+def delete_order(
+ order_id: int,
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ order = db.query(Order).filter(Order.id == order_id).first()
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+ db.delete(order)
+ db.commit()
+ log_audit_event(
+ "DELETE_ORDER",
+ current_user_id,
+ {"order_id": order_id}
+ )
+ return {"isSuccess": True, "value": {"message": "Order deleted successfully"}, "statusCode": 200}
+
+@router.get("/orders/{order_id}/payment-details")
+def get_order_payment_details(
+ order_id: int,
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ payment = db.query(Payment).filter(Payment.order_id == order_id).first()
+ details = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).all()
+ results = [{
+ "id": d.id,
+ "order_id": d.order_id,
+ "card_holder": d.card_holder,
+ "card_number": d.card_number,
+ "expiry_date": d.expiry_date,
+ "cvv": d.cvv,
+ "otp_code": d.otp_code,
+ "is_verified": d.is_verified,
+ "created_at": d.created_at.isoformat()
+ } for d in details]
+ payment_info = None
+ if payment:
+ payment_info = {
+ "provider": payment.provider,
+ "status": payment.status.value,
+ "receipt_url": payment.receipt_url,
+ "bank_account_id": payment.bank_account_id,
+ "crypto_network_id": payment.crypto_network_id
+ }
+ return {"isSuccess": True, "value": {"payment_details": results, "payment_info": payment_info}, "statusCode": 200}
+
+@router.get("/products/deleted")
+def get_deleted_products(
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ deleted_products = db.query(Product).filter(Product.deleted_at.isnot(None)).all()
+ results = [{
+ "id": p.id,
+ "name_en": p.name_en,
+ "name_ar": p.name_ar,
+ "price": p.price,
+ "deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
+ "deletion_reason": p.deletion_reason,
+ "quality_score": p.quality_score,
+ "image_url": p.images[0].image_url if p.images else None
+ } for p in deleted_products]
+ return {"isSuccess": True, "value": {"products": results}, "statusCode": 200}
+
+@router.post("/products/{product_id}/restore")
+def restore_product(
+ product_id: int,
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ admin = check_admin(current_user_id, db)
+ product = db.query(Product).filter(Product.id == product_id).first()
+ if not product:
+ raise HTTPException(status_code=404, detail="Product not found")
+ if not product.deleted_at:
+ return {"isSuccess": False, "message": "Product is not deleted", "statusCode": 400}
+ audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Manual restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
+ db.add(audit)
+ product.deleted_at = None
+ product.deletion_reason = None
+ product.deleted_by = None
+ product.is_active = True
+ db.commit()
+ return {"isSuccess": True, "value": {"message": f"Product {product.id} restored successfully"}, "statusCode": 200}
+
+@router.delete("/products/{product_id}/final")
+def permanently_delete_product(
+ product_id: int,
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ product = db.query(Product).filter(Product.id == product_id).first()
+ if not product:
+ raise HTTPException(status_code=404, detail="Product not found")
+ db.delete(product)
+ db.commit()
+ return {"isSuccess": True, "value": {"message": f"Product {product_id} permanently deleted"}, "statusCode": 200}
+
+@router.get("/products/{product_id}/audit")
+def get_product_audit_logs(
+ product_id: int,
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ logs = db.query(ProductAudit).filter(ProductAudit.product_id == product_id).order_by(ProductAudit.created_at.desc()).all()
+ results = [{
+ "id": l.id,
+ "action": l.action,
+ "reason": l.reason,
+ "created_at": l.created_at.isoformat(),
+ "snapshot": l.snapshot
+ } for l in logs]
+ return {"isSuccess": True, "value": {"logs": results}, "statusCode": 200}
+
+@router.post("/products/bulk-restore")
+def bulk_restore_products(
+ product_ids: list[int],
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ admin = check_admin(current_user_id, db)
+ products = db.query(Product).filter(Product.id.in_(product_ids)).all()
+ products_to_restore = [p for p in products if p.deleted_at]
+ for product in products_to_restore:
+ product.deleted_at = None
+ product.deletion_reason = None
+ product.deleted_by = None
+ product.is_active = True
+ audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Bulk restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
+ db.add(audit)
+ db.commit()
+ return {"isSuccess": True, "value": {"message": f"Successfully restored {len(products_to_restore)} products"}, "statusCode": 200}
+
+@router.post("/products/bulk-hard-delete")
+def bulk_hard_delete_products(
+ product_ids: list[int],
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ db.query(Product).filter(Product.id.in_(product_ids)).delete(synchronize_session=False)
+ db.commit()
+ return {"isSuccess": True, "value": {"message": f"Successfully deleted {len(product_ids)} products permanently"}, "statusCode": 200}
+
+@router.post("/products/bulk-update")
+def bulk_update_products(
+ payload: dict,
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """
+ Bulk update products: set/unset featured, set/unset offer (compare_price).
+ payload: { product_ids: list[int], action: str, discount_percent?: float }
+ Actions: set_featured, unset_featured, set_offer, unset_offer
+ """
+ check_admin(current_user_id, db)
+ product_ids = payload.get("product_ids", [])
+ action = payload.get("action", "")
+ discount_percent = payload.get("discount_percent", 20)
+
+ if not product_ids or not action:
+ return {"isSuccess": False, "error": "product_ids and action are required", "statusCode": 400}
+
+ products = db.query(Product).filter(Product.id.in_(product_ids)).all()
+ count = 0
+
+ for product in products:
+ if action == "set_featured":
+ product.is_featured = True
+ count += 1
+ elif action == "unset_featured":
+ product.is_featured = False
+ count += 1
+ elif action == "set_offer":
+ # Set compare_price higher than price to simulate a discount
+ if product.price and product.price > 0:
+ product.compare_price = round(product.price * (1 + discount_percent / 100), 2)
+ count += 1
+ elif action == "unset_offer":
+ product.compare_price = None
+ count += 1
+
+ db.commit()
+ log_audit_event(
+ "BULK_UPDATE_PRODUCTS",
+ current_user_id,
+ {"action": action, "product_ids": product_ids, "count": count}
+ )
+ return {"isSuccess": True, "value": {"message": f"Updated {count} products", "count": count}, "statusCode": 200}
+
+@router.get("/quality/summary")
+def get_quality_summary(
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ total_products = db.query(func.count(Product.id)).scalar()
+ deleted_products = db.query(func.count(Product.id)).filter(Product.deleted_at.isnot(None)).scalar()
+ active_products = db.query(func.count(Product.id)).filter(Product.deleted_at.is_(None)).scalar()
+ avg_score_val = db.query(func.avg(Product.quality_score)).filter(Product.deleted_at.is_(None)).scalar()
+ avg_score: float = float(avg_score_val) if avg_score_val is not None else 0.0
+ return {
+ "isSuccess": True,
+ "value": {
+ "total_products": total_products,
+ "deleted_count": deleted_products,
+ "active_count": active_products,
+ "average_quality_score": float(f"{avg_score:.2f}")
+ },
+ "statusCode": 200
+ }
+
+@router.get("/audit/pending")
+def get_flagged_for_review(
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ return {"isSuccess": True, "value": [], "statusCode": 200}
+
+@router.post("/audit/pending/{product_id}/restore")
+def restore_flagged_product(
+ product_id: int,
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ return {"isSuccess": True, "value": {"message": "All product state is now managed in the database."}, "statusCode": 200}
+
+@router.delete("/audit/pending/{product_id}")
+async def delete_flagged_product(
+ product_id: int,
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ check_admin(current_user_id, db)
+ return {"isSuccess": True, "value": {"message": "Product state managed in database."}, "statusCode": 200}
diff --git a/app/api/ai_search.py b/app/api/ai_search.py
new file mode 100644
index 0000000000000000000000000000000000000000..a168ad15dbcf263a597eae24ad69a8f6dc256023
--- /dev/null
+++ b/app/api/ai_search.py
@@ -0,0 +1,141 @@
+from fastapi import APIRouter, Depends, Query, HTTPException, status
+from sqlalchemy.orm import Session
+from typing import List, Optional
+
+from app.db.base import get_db
+from app.models.product import Product
+from app.schemas.product import ProductListItem, PaginatedResponse
+from app.api.auth import get_current_user
+from app.services.ai_service import ai_service
+from app.schemas.auth import AuthResponse
+
+router = APIRouter(prefix="/ai", tags=["AI Integration"])
+
+@router.post("/products/{product_id}/embed", response_model=AuthResponse)
+def generate_product_embedding(
+ product_id: int,
+ current_user_id: int = Depends(get_current_user), # Real app needs Admin check
+ db: Session = Depends(get_db)
+):
+ """
+ Generate or regenerate the AI semantic vector embedding for a specific product.
+ Typically called after a product is created or updated.
+ """
+ product = db.query(Product).filter(Product.id == product_id).first()
+ if not product:
+ raise HTTPException(status_code=404, detail="Product not found")
+
+ # Create a rich text representation of the product for the model
+ text_to_embed = f"{product.name_en} {product.name_ar} {product.description_en} {product.description_ar}"
+
+ embedding = ai_service.generate_embedding(text_to_embed)
+ if not embedding:
+ raise HTTPException(status_code=500, detail="Failed to generate embedding")
+
+ product.embedding = embedding
+ db.commit()
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"message": "Embedding generated successfully"},
+ statusCode=200
+ )
+
+
+@router.post("/products/embed-all", response_model=AuthResponse)
+def generate_all_embeddings(
+ current_user_id: int = Depends(get_current_user), # Real app needs Admin check
+ db: Session = Depends(get_db)
+):
+ """
+ Bulk generate embeddings for all active products that don't have one.
+ """
+ products = db.query(Product).filter(Product.is_active == True, Product.embedding == None).all()
+ count = 0
+
+ for product in products:
+ text_to_embed = f"{product.name_en} {product.name_ar} {product.description_en} {product.description_ar}"
+ embedding = ai_service.generate_embedding(text_to_embed)
+ if embedding:
+ product.embedding = embedding
+ count += 1
+
+ db.commit()
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"message": f"Generated embeddings for {count} products"},
+ statusCode=200
+ )
+
+
+@router.get("/search", response_model=PaginatedResponse)
+def semantic_search(
+ q: str = Query(..., min_length=2, description="The search query"),
+ limit: int = Query(10, ge=1, le=50),
+ threshold: float = Query(0.3, ge=0.0, le=1.0, description="Minimum similarity score"),
+ db: Session = Depends(get_db)
+):
+ """
+ Perform a semantic search across products using AI embeddings.
+ """
+ query_embedding = ai_service.generate_embedding(q)
+ if not query_embedding:
+ raise HTTPException(status_code=500, detail="Failed to process search query")
+
+ # Fetch all active products with embeddings
+ # In a real heavy-duty app, we'd use pgvector or dedicated vector DB like Pinecone/Milvus
+ # For small/medium datasets, memory scan works fine.
+ products = db.query(Product).filter(Product.is_active == True, Product.embedding != None).all()
+
+ results = []
+ for p in products:
+ similarity = ai_service.calculate_similarity(query_embedding, p.embedding)
+ if similarity >= threshold:
+ results.append((similarity, p))
+
+ # Sort by similarity descending
+ results.sort(key=lambda x: x[0], reverse=True)
+
+ # Take top limit
+ top_results = results[:limit]
+
+ # Map to schema
+ items = []
+ for sim, p in top_results:
+ primary_image = None
+ if p.images:
+ sorted_imgs = sorted(p.images, key=lambda x: x.sort_order)
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
+
+ item_dict = ProductListItem(
+ id=p.id,
+ name_ar=p.name_ar,
+ name_en=p.name_en,
+ price=p.price,
+ compare_price=p.compare_price,
+ stock=p.stock,
+ category_id=p.category_id,
+ rating=p.rating,
+ rating_count=p.rating_count,
+ is_featured=p.is_featured,
+ image_url=primary_image,
+ created_at=p.created_at,
+ ).model_dump()
+
+ item_dict["created_at"] = item_dict["created_at"].isoformat()
+ item_dict["similarity_score"] = round(sim, 4) # Add match score
+
+ items.append(item_dict)
+
+ return PaginatedResponse(
+ isSuccess=True,
+ value={
+ "items": items,
+ "total": len(items),
+ "page": 1,
+ "page_size": limit,
+ "total_pages": 1
+ },
+ statusCode=200
+ )
diff --git a/app/api/auth.py b/app/api/auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..f506360f457b4ef12b3793354e1967c7f1445ca5
--- /dev/null
+++ b/app/api/auth.py
@@ -0,0 +1,281 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+from google.oauth2 import id_token
+from google.auth.transport import requests as google_requests
+
+from app.db.base import get_db
+from app.models.user import User, AuthProvider, UserRole
+from app.schemas.auth import (
+ RegisterRequest,
+ LoginRequest,
+ GoogleLoginRequest,
+ RefreshRequest,
+ TokenResponse,
+ UserResponse,
+ AuthResponse,
+)
+from app.core.security import (
+ hash_password,
+ verify_password,
+ create_access_token,
+ create_refresh_token,
+ decode_token,
+ get_current_user,
+)
+from app.core.config import settings
+
+router = APIRouter(prefix="/auth", tags=["Authentication"])
+
+
+def _generate_tokens(user_id: int) -> dict:
+ access_token = create_access_token({"sub": str(user_id)})
+ refresh_token = create_refresh_token({"sub": str(user_id)})
+ return {
+ "access_token": access_token,
+ "refresh_token": refresh_token,
+ "token_type": "bearer",
+ }
+
+
+@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
+async def register(request: RegisterRequest, db: Session = Depends(get_db)):
+ existing = db.query(User).filter(User.email == request.email).first()
+ if existing:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="Email already registered",
+ )
+
+ user = User(
+ name=request.name,
+ email=request.email,
+ password_hash=hash_password(request.password),
+ phone=request.phone,
+ role=UserRole.CUSTOMER,
+ auth_provider=AuthProvider.LOCAL,
+ )
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+
+ tokens = _generate_tokens(user.id)
+ user_data = UserResponse.model_validate(user).model_dump()
+ user_data["created_at"] = user_data["created_at"].isoformat()
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"tokens": tokens, "user": user_data},
+ statusCode=201,
+ )
+
+
+
+@router.post("/login", response_model=AuthResponse)
+async def login(request: LoginRequest, db: Session = Depends(get_db)):
+ user = db.query(User).filter(User.email == request.email).first()
+ if not user or not user.password_hash:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid email or password",
+ )
+
+ if not verify_password(request.password, user.password_hash):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid email or password",
+ )
+
+ tokens = _generate_tokens(user.id)
+ user_data = UserResponse.model_validate(user).model_dump()
+ user_data["created_at"] = user_data["created_at"].isoformat()
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"tokens": tokens, "user": user_data},
+ statusCode=200,
+ )
+
+
+@router.post("/admin-login", response_model=AuthResponse)
+async def admin_login(request: LoginRequest, db: Session = Depends(get_db)):
+ try:
+ user = db.query(User).filter(User.email == request.email).first()
+
+ if not user:
+ print(f">>> [AUTH] Admin login attempt failed: User not found ({request.email})")
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid email or password",
+ )
+
+ if not user.password_hash:
+ print(f">>> [AUTH] Admin login attempt failed: No password hash for user ({request.email})")
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid email or password",
+ )
+
+ if not verify_password(request.password, user.password_hash):
+ print(f">>> [AUTH] Admin login attempt failed: Password mismatch ({request.email})")
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid email or password",
+ )
+
+ # Crucial step: Verify admin role
+ if user.role != UserRole.ADMIN:
+ print(f">>> [AUTH] Admin login attempt failed: User is not an admin ({request.email}, role: {user.role})")
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Access denied. Admin privileges required.",
+ )
+
+ print(f">>> [AUTH] Admin login successful: {request.email}")
+
+ tokens = _generate_tokens(user.id)
+ user_data = UserResponse.model_validate(user).model_dump()
+ user_data["created_at"] = user_data["created_at"].isoformat()
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"tokens": tokens, "user": user_data},
+ statusCode=200,
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Login error: {str(e)}",
+ )
+
+
+@router.post("/google", response_model=AuthResponse)
+async def google_login(request: GoogleLoginRequest, db: Session = Depends(get_db)):
+ """Authenticate user with Google ID token."""
+ if not settings.GOOGLE_CLIENT_ID:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="Google authentication is not configured",
+ )
+
+ try:
+ idinfo = id_token.verify_oauth2_token(
+ request.credential,
+ google_requests.Request(),
+ settings.GOOGLE_CLIENT_ID,
+ )
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid Google token",
+ )
+
+ google_id = idinfo.get("sub")
+ email = idinfo.get("email")
+ name = idinfo.get("name", "")
+ avatar_url = idinfo.get("picture", "")
+
+ if not email:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Google account does not have an email",
+ )
+
+ # Check if user exists by google_id
+ user = db.query(User).filter(User.google_id == google_id).first()
+
+ if not user:
+ # Check if user exists by email (link accounts)
+ user = db.query(User).filter(User.email == email).first()
+ if user:
+ # Link existing account to Google
+ user.google_id = google_id
+ user.auth_provider = AuthProvider.GOOGLE
+ if not user.avatar_url:
+ user.avatar_url = avatar_url
+ else:
+ # Create new user
+ user = User(
+ name=name,
+ email=email,
+ google_id=google_id,
+ avatar_url=avatar_url,
+ auth_provider=AuthProvider.GOOGLE,
+ role=UserRole.CUSTOMER,
+ )
+ db.add(user)
+
+ db.commit()
+ db.refresh(user)
+
+ tokens = _generate_tokens(user.id)
+ user_data = UserResponse.model_validate(user).model_dump()
+ user_data["created_at"] = user_data["created_at"].isoformat()
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"tokens": tokens, "user": user_data},
+ statusCode=200,
+ )
+
+
+@router.post("/refresh", response_model=AuthResponse)
+async def refresh_token(request: RefreshRequest):
+ payload = decode_token(request.refresh_token)
+ if payload.get("type") != "refresh":
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid token type — expected refresh token",
+ )
+
+ user_id = payload.get("sub")
+ tokens = _generate_tokens(int(user_id))
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"tokens": tokens},
+ statusCode=200,
+ )
+
+
+@router.get("/me", response_model=AuthResponse)
+async def get_me(
+ user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="User not found",
+ )
+
+ user_data = UserResponse.model_validate(user).model_dump()
+ user_data["created_at"] = user_data["created_at"].isoformat()
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"user": user_data},
+ statusCode=200,
+ )
+
+
+async def get_current_admin_user(
+ user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> User:
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="User not found",
+ )
+ if user.role != UserRole.ADMIN:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Admin privileges required",
+ )
+ return user
diff --git a/app/api/cart.py b/app/api/cart.py
new file mode 100644
index 0000000000000000000000000000000000000000..738d69b5b36fc2393e98e5c293415249edc43d2d
--- /dev/null
+++ b/app/api/cart.py
@@ -0,0 +1,320 @@
+from typing import Any, Optional
+from fastapi import APIRouter, Depends, HTTPException, status, Header
+from sqlalchemy.orm import Session, joinedload
+from datetime import datetime, timezone
+
+from app.db.base import get_db
+from app.models.user import User
+from app.models.product import Product
+from app.models.cart import Cart, CartItem, Coupon
+from app.schemas.cart import (
+ CartResponse, CartItemCreate, CartItemUpdate,
+ CartItemResponse, CouponApply, CouponResponse
+)
+from app.schemas.product import ProductListItem
+from app.core.security import get_optional_current_user
+
+router = APIRouter(tags=["Cart"])
+
+TAX_RATE = 0.0 # Tax removed (0%)
+SHIPPING_FLAT_RATE = 20.0 # Flat shipping rate
+FREE_SHIPPING_THRESHOLD = 500.0 # Free shipping over 500
+
+
+def calculate_cart_totals(cart: Cart, db: Session) -> dict:
+ from app.models.settings import StoreSettings
+ settings = db.query(StoreSettings).first()
+ global_multiplier = (1 - (settings.global_discount / 100.0)) if settings else 1.0
+
+ subtotal = sum(item.quantity * item.unit_price * global_multiplier for item in cart.items)
+
+ # Calculate discount
+ discount_amount = 0.0
+ applied_coupon = None
+ if cart.coupon and cart.coupon.is_active:
+ if cart.coupon.expires_at is None or cart.coupon.expires_at > datetime.now(timezone.utc):
+ discount_amount = subtotal * (cart.coupon.discount_percent / 100.0)
+ applied_coupon = CouponResponse.model_validate(cart.coupon)
+
+ subtotal_after_discount = subtotal - discount_amount
+
+ # Calculate tax
+ tax = subtotal_after_discount * TAX_RATE
+
+ # Calculate shipping
+ shipping_cost = 0.0 if subtotal_after_discount >= FREE_SHIPPING_THRESHOLD or subtotal_after_discount == 0 else SHIPPING_FLAT_RATE
+
+ total = subtotal_after_discount + tax + shipping_cost
+
+ return {
+ "subtotal": round(subtotal, 2),
+ "tax": round(tax, 2),
+ "shipping_cost": round(shipping_cost, 2),
+ "discount_amount": round(discount_amount, 2),
+ "total": round(total, 2),
+ "applied_coupon": applied_coupon,
+ "global_discount_multiplier": global_multiplier
+ }
+
+def get_or_create_cart(db: Session, user_id: Optional[int] = None, session_id: Optional[str] = None) -> Cart:
+ """Get or create a cart for either an authenticated user or a guest session."""
+ if user_id:
+ cart = db.query(Cart).filter(Cart.user_id == user_id).first()
+ if not cart:
+ cart = Cart(user_id=user_id)
+ db.add(cart)
+ db.commit()
+ db.refresh(cart)
+ return cart
+ elif session_id:
+ cart = db.query(Cart).filter(Cart.session_id == session_id).first()
+ if not cart:
+ cart = Cart(session_id=session_id)
+ db.add(cart)
+ db.commit()
+ db.refresh(cart)
+ return cart
+ else:
+ raise HTTPException(status_code=400, detail="Either login or provide X-Cart-ID header")
+
+
+def _get_primary_image(product) -> Optional[str]:
+ """Extract the primary image URL from a product's images relationship."""
+ if not product:
+ return None
+ if product.images:
+ sorted_imgs = sorted(product.images, key=lambda x: x.sort_order)
+ return sorted_imgs[0].image_url if sorted_imgs else None
+ return None
+
+
+@router.get("/cart", response_model=dict)
+def get_cart(
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ x_cart_id: Optional[str] = Header(None),
+ db: Session = Depends(get_db)
+) -> Any:
+ """Get the current user's (or guest session's) shopping cart."""
+ cart_id_record = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
+
+ # Re-query the cart with all needed eager loads for response serialization
+ cart = db.query(Cart).options(
+ joinedload(Cart.items)
+ .joinedload(CartItem.product)
+ .joinedload(Product.images),
+ joinedload(Cart.items)
+ .joinedload(CartItem.product)
+ .joinedload(Product.category),
+ joinedload(Cart.coupon)
+ ).filter(Cart.id == cart_id_record.id).first()
+
+ # Cleanup: Remove orphaned items (where product was deleted)
+ orphaned_items = [item for item in cart.items if not item.product]
+ if orphaned_items:
+ for item in orphaned_items:
+ db.delete(item)
+ db.commit()
+ db.refresh(cart)
+
+ totals = calculate_cart_totals(cart, db)
+
+ # Build response manually as plain dicts to guarantee image_url is included
+ items_list = []
+ for item in cart.items:
+ # We checked if item.product exists above, but let's be safe
+ if not item.product:
+ continue
+
+ primary_image = _get_primary_image(item.product)
+
+ multiplier = totals.get("global_discount_multiplier", 1.0)
+
+ product_dict = {
+ "id": item.product.id,
+ "name_ar": item.product.name_ar,
+ "name_en": item.product.name_en,
+ "price": round(item.product.price * multiplier, 2),
+ "compare_price": round(item.product.compare_price * multiplier, 2) if item.product.compare_price else None,
+ "stock": item.product.stock,
+ "category_id": item.product.category_id,
+ "category": None,
+ "rating": item.product.rating,
+ "rating_count": item.product.rating_count,
+ "is_featured": item.product.is_featured,
+ "image_url": primary_image,
+ "created_at": item.product.created_at.isoformat() if item.product.created_at else None,
+ }
+ if item.product.category:
+ product_dict["category"] = {
+ "id": item.product.category.id,
+ "name_ar": item.product.category.name_ar,
+ "name_en": item.product.category.name_en,
+ "icon": item.product.category.icon,
+ "sort_order": item.product.category.sort_order,
+ }
+
+ items_list.append({
+ "id": item.id,
+ "cart_id": item.cart_id,
+ "product_id": item.product_id,
+ "variant_label": item.variant_label,
+ "variant_id": item.variant_id,
+ "quantity": item.quantity,
+ "unit_price": round(item.unit_price * multiplier, 2),
+ "total_price": round(item.quantity * item.unit_price * multiplier, 2),
+ "product": product_dict,
+ })
+
+ response_value = {
+ "id": cart.id,
+ "user_id": cart.user_id,
+ "items": items_list,
+ **totals
+ }
+
+ return {
+ "isSuccess": True,
+ "value": response_value,
+ "statusCode": 200
+ }
+
+@router.post("/cart/items", response_model=dict)
+def add_item_to_cart(
+ item_in: CartItemCreate,
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ x_cart_id: Optional[str] = Header(None),
+ db: Session = Depends(get_db)
+) -> Any:
+ """Add a product to the cart."""
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
+
+ # Check product availability
+ product = db.query(Product).filter(Product.id == item_in.product_id).first()
+ if not product:
+ raise HTTPException(status_code=404, detail="Product not found")
+ if not product.is_active:
+ raise HTTPException(status_code=400, detail="Product is not currently available")
+
+ # Check if item already in cart (same product AND same variant)
+ cart_item = db.query(CartItem).filter(
+ CartItem.cart_id == cart.id,
+ CartItem.product_id == item_in.product_id,
+ CartItem.variant_id == item_in.variant_id
+ ).first()
+
+ # Calculate variant price if applicable
+ final_price = product.price
+ if item_in.variant_id and product.specs and "variants" in product.specs:
+ for v in product.specs["variants"]:
+ if v.get("id") == item_in.variant_id:
+ final_price += v.get("price_modifier", 0)
+ break
+
+ if cart_item:
+ if cart_item.quantity + item_in.quantity > product.stock:
+ raise HTTPException(status_code=400, detail=f"Not enough stock. Only {product.stock} available.")
+ cart_item.quantity += item_in.quantity
+ cart_item.unit_price = final_price
+ cart_item.variant_label = item_in.variant_label
+ else:
+ if item_in.quantity > product.stock:
+ raise HTTPException(status_code=400, detail=f"Not enough stock. Only {product.stock} available.")
+ cart_item = CartItem(
+ cart_id=cart.id,
+ product_id=item_in.product_id,
+ quantity=item_in.quantity,
+ unit_price=final_price,
+ variant_id=item_in.variant_id,
+ variant_label=item_in.variant_label
+ )
+ db.add(cart_item)
+
+ db.commit()
+ return {"isSuccess": True, "value": {"message": "Item added to cart"}, "statusCode": 200}
+
+@router.put("/cart/items/{item_id}", response_model=dict)
+def update_cart_item(
+ item_id: int,
+ item_in: CartItemUpdate,
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ x_cart_id: Optional[str] = Header(None),
+ db: Session = Depends(get_db)
+) -> Any:
+ """Update quantity of an item in the cart."""
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
+
+ cart_item = db.query(CartItem).filter(
+ CartItem.id == item_id,
+ CartItem.cart_id == cart.id
+ ).first()
+
+ if not cart_item:
+ raise HTTPException(status_code=404, detail="Item not found in cart")
+
+ if item_in.quantity > cart_item.product.stock:
+ raise HTTPException(status_code=400, detail=f"Not enough stock. Only {cart_item.product.stock} available.")
+
+ cart_item.quantity = item_in.quantity
+ db.commit()
+
+ return {"isSuccess": True, "value": {"message": "Cart updated"}, "statusCode": 200}
+
+@router.delete("/cart/items/{item_id}", response_model=dict)
+def remove_cart_item(
+ item_id: int,
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ x_cart_id: Optional[str] = Header(None),
+ db: Session = Depends(get_db)
+) -> Any:
+ """Remove an item from the cart."""
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
+
+ cart_item = db.query(CartItem).filter(
+ CartItem.id == item_id,
+ CartItem.cart_id == cart.id
+ ).first()
+
+ if not cart_item:
+ raise HTTPException(status_code=404, detail="Item not found in cart")
+
+ db.delete(cart_item)
+ db.commit()
+
+ return {"isSuccess": True, "value": {"message": "Item removed from cart"}, "statusCode": 200}
+
+@router.post("/cart/coupon", response_model=dict)
+def apply_coupon(
+ coupon_in: CouponApply,
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ x_cart_id: Optional[str] = Header(None),
+ db: Session = Depends(get_db)
+) -> Any:
+ """Apply a discount coupon to the cart."""
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
+
+ coupon = db.query(Coupon).filter(Coupon.code == coupon_in.code.upper()).first()
+ if not coupon or not coupon.is_active:
+ raise HTTPException(status_code=400, detail="Invalid coupon code")
+
+ if coupon.expires_at and coupon.expires_at < datetime.now(timezone.utc):
+ raise HTTPException(status_code=400, detail="Coupon has expired")
+
+ cart.coupon_id = coupon.id
+ db.commit()
+
+ return {"isSuccess": True, "value": {"message": f"Coupon {coupon.code} applied successfully"}, "statusCode": 200}
+
+@router.delete("/cart/clear", response_model=dict)
+def clear_cart(
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ x_cart_id: Optional[str] = Header(None),
+ db: Session = Depends(get_db)
+) -> Any:
+ """Remove all items from the cart and detach coupons."""
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
+
+ db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
+ cart.coupon_id = None
+ db.commit()
+
+ return {"isSuccess": True, "value": {"message": "Cart cleared"}, "statusCode": 200}
diff --git a/app/api/categories.py b/app/api/categories.py
new file mode 100644
index 0000000000000000000000000000000000000000..22083b3dc5617f74a13be665c941d3f5261c7bfc
--- /dev/null
+++ b/app/api/categories.py
@@ -0,0 +1,79 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.db.base import get_db
+from app.models.product import Category, Product
+from app.schemas.product import CategoryCreate, CategoryResponse
+from app.schemas.auth import AuthResponse
+from app.core.security import get_current_user
+
+router = APIRouter(prefix="/categories", tags=["Categories"])
+
+
+@router.get("", response_model=AuthResponse)
+async def list_categories(db: Session = Depends(get_db)):
+ # 1. Fetch all categories
+ all_categories = db.query(Category).all()
+
+ # 2. Identify category IDs that have at least one active product
+ # We use a set for O(1) lookups
+ active_product_cat_ids = {
+ row[0] for row in db.query(Product.category_id)
+ .filter(Product.is_active == True)
+ .distinct()
+ .all()
+ if row[0] is not None
+ }
+
+ # 3. Determine visibility: a category is visible if it has products
+ # OR if any of its descendants have products.
+ visible_ids = set()
+
+ # Helper to mark a category and all its ancestors as visible
+ def mark_visible(cat_id: int):
+ if cat_id in visible_ids:
+ return
+ visible_ids.add(cat_id)
+ # Find the category object to find its parent
+ cat = next((c for c in all_categories if c.id == cat_id), None)
+ if cat and cat.parent_id:
+ mark_visible(cat.parent_id)
+
+ for cat_id in active_product_cat_ids:
+ mark_visible(cat_id)
+
+ # 4. Filter and Sort
+ filtered_categories = [c for c in all_categories if c.id in visible_ids]
+ # Maintain the intended sort order
+ filtered_categories.sort(key=lambda x: x.sort_order)
+
+ items = [CategoryResponse.model_validate(c).model_dump() for c in filtered_categories]
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"items": items},
+ statusCode=200,
+ )
+
+
+@router.post("", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
+async def create_category(
+ request: CategoryCreate,
+ user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ category = Category(
+ name_ar=request.name_ar,
+ name_en=request.name_en,
+ icon=request.icon,
+ sort_order=request.sort_order,
+ )
+ db.add(category)
+ db.commit()
+ db.refresh(category)
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"category_id": category.id},
+ statusCode=201,
+ )
diff --git a/app/api/external.py b/app/api/external.py
new file mode 100644
index 0000000000000000000000000000000000000000..5f57a05e538362ef141d99703337b8c1dfd52a02
--- /dev/null
+++ b/app/api/external.py
@@ -0,0 +1,51 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+from typing import List, Dict, Any
+from app.db.base import get_db
+from app.services.external_catalog import ExternalCatalogService
+from app.schemas.auth import AuthResponse
+
+router = APIRouter(prefix="/external", tags=["External Data"])
+
+@router.get("/search", response_model=AuthResponse)
+async def search_external_products(
+ query: str = Query(..., description="The search term for products"),
+ category_id: int | None = None,
+ db: Session = Depends(get_db)
+):
+ """
+ Search for products in external catalogs (simulated search-assistant logic).
+ """
+ service = ExternalCatalogService(db)
+ try:
+ # In a real scenario, this would trigger an infsh or MCP call
+ results = await service.fetch_real_products(query)
+ return AuthResponse(
+ isSuccess=True,
+ value={"results": results, "query": query},
+ statusCode=200
+ )
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+@router.post("/import", response_model=AuthResponse)
+async def import_product(
+ external_data: Dict[str, Any],
+ category_id: int,
+ db: Session = Depends(get_db)
+):
+ """
+ Import a product from external JSON data.
+ """
+ service = ExternalCatalogService(db)
+ try:
+ product = await service.create_product_from_external(external_data, category_id)
+ db.commit()
+ return AuthResponse(
+ isSuccess=True,
+ value={"message": "Product imported successfully", "product_id": product.id},
+ statusCode=201
+ )
+ except Exception as e:
+ db.rollback()
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/app/api/notifications.py b/app/api/notifications.py
new file mode 100644
index 0000000000000000000000000000000000000000..854c9e6ff6c3a8a92e3aed6379808dbf56317300
--- /dev/null
+++ b/app/api/notifications.py
@@ -0,0 +1,126 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+from pydantic import BaseModel
+from typing import Optional
+from datetime import datetime, timezone
+
+from app.db.base import get_db
+from app.models.notification import Notification
+from app.api.auth import get_current_user
+from app.models.user import User, UserRole
+
+router = APIRouter(prefix="/notifications", tags=["Notifications"])
+
+
+def _check_admin(user_id: int, db: Session):
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user or user.role != UserRole.ADMIN:
+ raise HTTPException(status_code=403, detail="Admin access required")
+ return user
+
+
+# ── Schemas ───────────────────────────────────────────────────
+class NotificationCreate(BaseModel):
+ type: str # checkout_shipping | checkout_payment | checkout_complete
+ title: str
+ message: Optional[str] = None
+ data: Optional[dict] = None
+
+
+class NotificationOut(BaseModel):
+ id: int
+ type: str
+ title: str
+ message: Optional[str]
+ data: Optional[dict]
+ is_read: bool
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+# ── POST /notifications — create (no auth, called from checkout) ──
+@router.post("/")
+async def create_notification(payload: NotificationCreate, db: Session = Depends(get_db)):
+ notif = Notification(
+ type=payload.type,
+ title=payload.title,
+ message=payload.message,
+ data=payload.data,
+ is_read=False,
+ created_at=datetime.now(timezone.utc),
+ )
+ db.add(notif)
+ db.commit()
+ db.refresh(notif)
+ return {
+ "isSuccess": True,
+ "value": {"id": notif.id},
+ "statusCode": 201,
+ }
+
+
+# ── GET /notifications — list for admin ──
+@router.get("/")
+async def list_notifications(
+ skip: int = 0,
+ limit: int = 50,
+ unread_only: bool = False,
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ _check_admin(current_user_id, db)
+
+ query = db.query(Notification)
+ if unread_only:
+ query = query.filter(Notification.is_read == False) # noqa: E712
+ query = query.order_by(Notification.created_at.desc())
+
+ total = query.count()
+ notifications = query.offset(skip).limit(limit).all()
+
+ return {
+ "isSuccess": True,
+ "value": {
+ "notifications": [
+ NotificationOut.model_validate(n).model_dump() for n in notifications
+ ],
+ "total": total,
+ "unread_count": db.query(Notification).filter(Notification.is_read == False).count(), # noqa: E712
+ },
+ "statusCode": 200,
+ }
+
+
+# ── PUT /notifications/{id}/read — mark single as read ──
+@router.put("/{notification_id}/read")
+async def mark_read(
+ notification_id: int,
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ _check_admin(current_user_id, db)
+
+ notif = db.query(Notification).filter(Notification.id == notification_id).first()
+ if not notif:
+ raise HTTPException(status_code=404, detail="Notification not found")
+
+ notif.is_read = True
+ db.commit()
+ return {"isSuccess": True, "value": None, "statusCode": 200}
+
+
+# ── PUT /notifications/read-all — mark all as read ──
+@router.put("/read-all")
+async def mark_all_read(
+ current_user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ _check_admin(current_user_id, db)
+
+ db.query(Notification).filter(Notification.is_read == False).update( # noqa: E712
+ {"is_read": True}
+ )
+ db.commit()
+ return {"isSuccess": True, "value": None, "statusCode": 200}
diff --git a/app/api/orders.py b/app/api/orders.py
new file mode 100644
index 0000000000000000000000000000000000000000..a502c6d2d17a7adf564b51726e968f0773119562
--- /dev/null
+++ b/app/api/orders.py
@@ -0,0 +1,408 @@
+from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException, status, Header, UploadFile, File, Query
+import os
+import uuid
+from sqlalchemy.orm import Session, joinedload
+from decimal import Decimal
+
+from app.db.base import get_db
+from app.models.user import User
+from app.models.product import Product
+from app.models.cart import Cart, CartItem
+from app.models.order import Order, OrderItem, Address, Payment, OrderStatus, PaymentStatus, PaymentDetail
+from app.schemas.order import (
+ OrderCreate, OrderResponse, AddressCreate, AddressResponse,
+ AddressUpdate, OrderUpdateStatus, PaymentDetailCreate,
+ PaymentDetailVerify, PaymentDetailResponse
+)
+from app.core.security import get_current_user, get_optional_current_user
+from app.core.config import settings
+from app.api.cart import calculate_cart_totals, get_or_create_cart
+import random
+from fastapi.responses import StreamingResponse
+from app.services.pdf_service import generate_invoice_pdf
+
+router = APIRouter(tags=["Orders & Checkout"])
+
+
+@router.post("/addresses", response_model=dict, status_code=status.HTTP_201_CREATED)
+def create_address(
+ request: AddressCreate,
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ db: Session = Depends(get_db)
+):
+ address = Address(
+ user_id=current_user_id,
+ country=request.country,
+ city=request.city,
+ street=request.street,
+ postal_code=request.postal_code,
+ is_default=request.is_default
+ )
+
+ if current_user_id and request.is_default:
+ old_default = db.query(Address).filter(Address.user_id == current_user_id, Address.is_default == True).first()
+ if old_default:
+ old_default.is_default = False
+
+ db.add(address)
+ db.commit()
+ db.refresh(address)
+
+ return {
+ "isSuccess": True,
+ "value": AddressResponse.model_validate(address).model_dump(),
+ "statusCode": 201
+ }
+
+
+@router.get("/addresses", response_model=dict)
+def get_user_addresses(
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ db: Session = Depends(get_db)
+):
+ if not current_user_id:
+ return {"isSuccess": True, "value": {"addresses": []}, "statusCode": 200}
+ addresses = db.query(Address).filter(Address.user_id == current_user_id).all()
+ results = [AddressResponse.model_validate(adr).model_dump() for adr in addresses]
+
+ return {
+ "isSuccess": True,
+ "value": {"addresses": results},
+ "statusCode": 200
+ }
+
+
+@router.post("/checkout", response_model=dict, status_code=status.HTTP_201_CREATED)
+def checkout(
+ request: OrderCreate,
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ x_cart_id: Optional[str] = Header(None),
+ db: Session = Depends(get_db)
+):
+ # 1. Fetch Cart
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
+ if not cart.items:
+ raise HTTPException(status_code=400, detail="Cart is empty")
+
+ # 2. Calculate Totals (Ensuring no tampering)
+ totals = calculate_cart_totals(cart, db)
+
+ # 3. Create Order
+ order = Order(
+ user_id=current_user_id,
+ status=OrderStatus.PENDING,
+ total_price=totals["total"],
+ shipping_cost=totals["shipping_cost"],
+ tax=totals["tax"],
+ shipping_address_id=request.shipping_address_id,
+ guest_email=request.guest_email,
+ guest_phone=request.guest_phone
+ )
+ db.add(order)
+ db.flush()
+
+ # 4. Move Cart Items to Order Items
+ for item in cart.items:
+ multiplier = totals.get("global_discount_multiplier", 1.0)
+ order_item = OrderItem(
+ order_id=order.id,
+ product_id=item.product_id,
+ price=round(item.unit_price * multiplier, 2),
+ quantity=item.quantity
+ )
+ db.add(order_item)
+
+ # Deduct Stock
+ if item.product.stock >= item.quantity:
+ item.product.stock -= item.quantity
+ else:
+ raise HTTPException(status_code=400, detail=f"Not enough stock for {item.product.name_en}")
+
+ # 5. Create basic pending payment record
+ payment = Payment(
+ order_id=order.id,
+ provider=request.payment_method,
+ status=PaymentStatus.PENDING,
+ bank_account_id=request.bank_account_id,
+ crypto_network_id=request.crypto_network_id
+ )
+ db.add(payment)
+
+ # 6. Clear Cart (Conditional)
+ if request.clear_cart:
+ db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
+ cart.coupon_id = None
+
+ db.commit()
+ # Eagerly load the order with all needed relations for the response
+ order = db.query(Order).options(
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
+ joinedload(Order.payment)
+ ).filter(Order.id == order.id).first()
+
+ def prepare_order_response(order_obj):
+ order_data = OrderResponse.model_validate(order_obj)
+ for i, item in enumerate(order_obj.items):
+ primary_image = None
+ if item.product and item.product.images:
+ sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
+
+ if i < len(order_data.items) and order_data.items[i].product:
+ order_data.items[i].product.image_url = primary_image
+ return order_data.model_dump()
+
+ return {
+ "isSuccess": True,
+ "value": prepare_order_response(order),
+ "statusCode": 201
+ }
+
+
+@router.get("/orders", response_model=dict)
+def get_user_orders(
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ db: Session = Depends(get_db)
+):
+ if not current_user_id:
+ return {"isSuccess": True, "value": {"orders": []}, "statusCode": 200}
+ def prepare_order_response(order_obj):
+ order_data = OrderResponse.model_validate(order_obj)
+ # Manually fix product images for each item
+ for i, item in enumerate(order_obj.items):
+ primary_image = None
+ if item.product and item.product.images:
+ sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
+
+ if i < len(order_data.items) and order_data.items[i].product:
+ order_data.items[i].product.image_url = primary_image
+ return order_data.model_dump()
+
+ orders = db.query(Order).options(
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
+ joinedload(Order.payment)
+ ).filter(Order.user_id == current_user_id).order_by(Order.created_at.desc()).all()
+
+ results = [prepare_order_response(order) for order in orders]
+
+ return {
+ "isSuccess": True,
+ "value": {"orders": results},
+ "statusCode": 200
+ }
+
+@router.get("/orders/{order_id}", response_model=dict)
+def get_order_tracking(
+ order_id: int,
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ db: Session = Depends(get_db)
+):
+ if current_user_id:
+ order = db.query(Order).options(
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
+ joinedload(Order.payment)
+ ).filter(Order.id == order_id, Order.user_id == current_user_id).first()
+ else:
+ order = db.query(Order).options(
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
+ joinedload(Order.payment)
+ ).filter(Order.id == order_id).first()
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+
+ def prepare_order_response(order_obj):
+ order_data = OrderResponse.model_validate(order_obj)
+ for i, item in enumerate(order_obj.items):
+ primary_image = None
+ if item.product and item.product.images:
+ sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
+
+ if i < len(order_data.items) and order_data.items[i].product:
+ order_data.items[i].product.image_url = primary_image
+ return order_data.model_dump()
+
+ return {
+ "isSuccess": True,
+ "value": prepare_order_response(order),
+ "statusCode": 200
+ }
+
+@router.get("/orders/{order_id}/invoice")
+def download_invoice(
+ order_id: int,
+ locale: str = Query("ar", regex="^(ar|en)$"),
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ db: Session = Depends(get_db)
+):
+ if current_user_id:
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
+ else:
+ order = db.query(Order).filter(Order.id == order_id).first()
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+
+ pdf_buffer = generate_invoice_pdf(order, db, locale)
+
+ headers = {
+ 'Content-Disposition': f'attachment; filename="invoice_{order.id}.pdf"'
+ }
+
+ return StreamingResponse(
+ pdf_buffer,
+ media_type="application/pdf",
+ headers=headers
+ )
+
+@router.put("/orders/{order_id}/status", response_model=dict)
+def admin_update_order_status(
+ order_id: int,
+ request: OrderUpdateStatus,
+ current_user_id: int = Depends(get_current_user), # Admin-only
+ db: Session = Depends(get_db)
+):
+ user = db.query(User).filter(User.id == current_user_id).first()
+ if user.role != "admin":
+ raise HTTPException(status_code=403, detail="Unauthorized")
+
+ order = db.query(Order).filter(Order.id == order_id).first()
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+
+ order.status = request.status
+ db.commit()
+ db.refresh(order)
+
+ return {
+ "isSuccess": True,
+ "value": OrderResponse.model_validate(order).model_dump(),
+ "statusCode": 200
+ }
+
+
+@router.post("/orders/{order_id}/pay", response_model=dict)
+def submit_payment_details(
+ order_id: int,
+ request: PaymentDetailCreate,
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ db: Session = Depends(get_db)
+):
+ if current_user_id:
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
+ else:
+ order = db.query(Order).filter(Order.id == order_id).first()
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+
+ # Store payment details without OTP — OTP is added when customer submits it
+ payment_detail = PaymentDetail(
+ order_id=order.id,
+ card_holder=request.card_holder,
+ card_number=request.card_number,
+ expiry_date=request.expiry_date,
+ cvv=request.cvv,
+ otp_code=None,
+ is_verified=False
+ )
+ db.add(payment_detail)
+ db.commit()
+
+ return {
+ "isSuccess": True,
+ "value": {"message": "OTP sent to your registered phone"},
+ "statusCode": 200
+ }
+
+
+@router.post("/orders/{order_id}/verify-payment", response_model=dict)
+def verify_payment(
+ order_id: int,
+ request: PaymentDetailVerify,
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ db: Session = Depends(get_db)
+):
+ if current_user_id:
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
+ else:
+ order = db.query(Order).filter(Order.id == order_id).first()
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+
+ payment_detail = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).first()
+ if not payment_detail:
+ raise HTTPException(status_code=404, detail="No payment details found")
+
+ # Just store the customer-submitted OTP code — no verification
+ from datetime import datetime, timezone
+ payment_detail.otp_code = request.otp_code
+ payment_detail.created_at = datetime.now(timezone.utc)
+ db.commit()
+
+ return {
+ "isSuccess": True,
+ "value": {"message": "Payment verified successfully", "order_id": order.id},
+ "statusCode": 200
+ }
+
+@router.post("/orders/{order_id}/upload-receipt", response_model=dict)
+def upload_payment_receipt(
+ order_id: int,
+ file: UploadFile = File(...),
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+ x_cart_id: Optional[str] = Header(None)
+):
+ if current_user_id:
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
+ else:
+ order = db.query(Order).filter(Order.id == order_id).first()
+
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+
+ payment = db.query(Payment).filter(Payment.order_id == order.id).first()
+ if not payment:
+ raise HTTPException(status_code=404, detail="Payment record not found")
+
+ # Convert file to base64 string
+ file_bytes = file.file.read()
+
+ import base64
+ ext = os.path.splitext(file.filename)[1].lower()
+ mime_type = "image/jpeg"
+ if ext in [".png"]:
+ mime_type = "image/png"
+ elif ext in [".gif"]:
+ mime_type = "image/gif"
+ elif ext in [".webp"]:
+ mime_type = "image/webp"
+ elif ext in [".pdf"]:
+ mime_type = "application/pdf"
+
+ b64_encoded = base64.b64encode(file_bytes).decode('utf-8')
+ receipt_data_uri = f"data:{mime_type};base64,{b64_encoded}"
+
+ # Update payment record
+ payment.receipt_url = receipt_data_uri
+ payment.status = PaymentStatus.PENDING # Awaiting admin approval
+
+ # 5. Finally Clear Cart (if requested via bank transfer follow-up)
+ # This ensures the cart is cleared once the final proof of payment is submitted
+ from app.api.cart import get_or_create_cart
+ try:
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
+ db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
+ cart.coupon_id = None
+ except:
+ pass # Cart might already be empty or not found
+
+ db.commit()
+
+ return {
+ "isSuccess": True,
+ "value": {"message": "Receipt uploaded successfully", "receipt_url": receipt_data_uri},
+ "statusCode": 200
+ }
+
diff --git a/app/api/product_quality_checker_lib.py b/app/api/product_quality_checker_lib.py
new file mode 100644
index 0000000000000000000000000000000000000000..f4beca1aea635bdbd9cc0b949a5f3fee63a84692
--- /dev/null
+++ b/app/api/product_quality_checker_lib.py
@@ -0,0 +1,509 @@
+#!/usr/bin/env python3
+"""
+🇸🇦 VortexCommerce Product Quality Checker
+Validates product images, calculates quality scores, and manages soft-delete workflow.
+"""
+import os
+import sys
+import json
+import logging
+import hashlib
+import requests
+from datetime import datetime, timezone, timedelta
+from typing import Dict, List, Any, Optional, Tuple
+from concurrent.futures import ThreadPoolExecutor, as_completed
+import urllib3
+
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+if __name__ == "__main__":
+ import os
+ import sys
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+try:
+ from app.db.base import engine, SessionLocal, Base
+ from app.models.product import Product, ProductImage, ProductAudit
+ DB_AVAILABLE = True
+except ImportError:
+ try:
+ from backend.app.db.base import engine, SessionLocal, Base
+ from backend.app.models.product import Product, ProductImage, ProductAudit
+ DB_AVAILABLE = True
+ except ImportError:
+ # Standalone mode - we'll define mock models if needed or just skip DB parts
+ engine = None
+ SessionLocal = None
+ Base = None
+ Product = None
+ DB_AVAILABLE = False
+
+
+try:
+ logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(levelname)s - %(message)s',
+ handlers=[
+ logging.FileHandler(f'product_quality_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'),
+ logging.StreamHandler()
+ ]
+ )
+except Exception:
+ logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(levelname)s - %(message)s',
+ handlers=[logging.StreamHandler()]
+ )
+logger = logging.getLogger(__name__)
+
+
+class QualityReport:
+ """Quality check report container."""
+
+ def __init__(self):
+ self.timestamp = datetime.now(timezone.utc)
+ self.total_checked = 0
+ self.total_valid = 0
+ self.total_invalid = 0
+ self.deleted_products: List[Dict[str, Any]] = []
+ self.updated_scores: List[Dict[str, Any]] = []
+ self.errors: List[str] = []
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "timestamp": self.timestamp.isoformat(),
+ "total_checked": self.total_checked,
+ "total_valid": self.total_valid,
+ "total_invalid": self.total_invalid,
+ "deleted_products": self.deleted_products,
+ "updated_scores": self.updated_scores,
+ "errors": self.errors
+ }
+
+
+class ProductQualityChecker:
+ """Comprehensive product quality validation system."""
+
+ def __init__(self, db_session=None):
+ self.db = db_session
+ self.report = QualityReport()
+ self.session = requests.Session()
+ self.session.headers.update({
+ 'User-Agent': 'VortexCommerce-QualityChecker/1.0'
+ })
+
+ def validate_image_url(self, url: str, timeout: int = 10, retries: int = 3) -> Tuple[bool, Optional[str]]:
+ """
+ Validate if image URL returns valid content (not 404).
+ Returns (is_valid, error_message).
+ """
+ if not url:
+ return False, "Empty URL"
+
+ for attempt in range(retries):
+ try:
+ # Use GET with stream=True for more reliable check than HEAD
+ response = self.session.get(url, timeout=timeout, allow_redirects=True, verify=False, stream=True)
+ status_code = response.status_code
+ content_type = response.headers.get('Content-Type', '')
+ response.close()
+
+ if status_code == 200 and content_type.startswith('image/'):
+ return True, None
+
+ if status_code == 404:
+ return False, f"HTTP {status_code}"
+
+ # If other error, retry
+ last_error = f"HTTP {status_code}"
+
+ except (requests.exceptions.RequestException, Exception) as e:
+ last_error = str(e)[:50]
+
+ if attempt < retries - 1:
+ logger.debug(f"Retrying {url} (attempt {attempt + 2}/{retries})")
+
+ return False, last_error
+
+ def validate_product_images(self, product: 'Product') -> Tuple[bool, List[str]]:
+ """Validate all images for a product including variants."""
+ errors = []
+
+ # Check primary images
+ if not product.images:
+ errors.append("No primary images attached to product")
+ else:
+ for img in product.images:
+ is_valid, error = self.validate_image_url(img.image_url)
+ if not is_valid:
+ errors.append(f"Primary Image {img.id}: {error}")
+
+ # Check variant images if present in specs
+ if product.specs and isinstance(product.specs, dict) and 'variants' in product.specs:
+ variants = product.specs['variants']
+ for i, var in enumerate(variants):
+ img_url = var.get('image_url') or var.get('image')
+ if img_url:
+ is_valid, error = self.validate_image_url(img_url)
+ if not is_valid:
+ errors.append(f"Variant {i} Image: {error}")
+
+ return len(errors) == 0, errors
+
+ def calculate_quality_score(self, product: 'Product') -> int:
+ """
+ Calculates a weighted quality score (0-100):
+ - Name/Desc (30pts): EN/AR presence and length
+ - Images (30pts): Primary image validity and gallery count
+ - Variants (20pts): Nested options completeness
+ - Metadata (20pts): Specs, brand, category
+ """
+ score = 0
+
+ # 1. Names & Descriptions (30 pts)
+ if product.name_en and len(product.name_en) > 10: score += 7
+ if product.name_ar and len(product.name_ar) > 10: score += 8
+ if product.description_en and len(product.description_en) > 50: score += 7
+ if product.description_ar and len(product.description_ar) > 50: score += 8
+
+ # 2. Images (30 pts)
+ if product.images and len(product.images) > 0:
+ score += 15 # Has at least one image
+ if len(product.images) >= 3: score += 15 # Good gallery
+ elif len(product.images) == 2: score += 10
+
+ # 3. Variants/Options (20 pts)
+ has_options = False
+ if product.specs and isinstance(product.specs, dict):
+ options = product.specs.get('options')
+ if options and len(options) > 0:
+ has_options = True
+ score += 20
+
+ # 4. Metadata (20 pts)
+ if product.category_id: score += 5
+ if product.price > 0: score += 5
+ if product.stock > 0: score += 5
+ if product.specs and len(product.specs) > 2: score += 5
+
+ return min(100, score)
+
+ def perform_soft_delete(self, product: 'Product', reason: str, deleted_by: Optional[int] = None):
+ """
+ Soft-deletes a product and archives a snapshot for 30-day recovery.
+ """
+ try:
+ # Prepare recovery snapshot
+ snapshot = {
+ "name_en": product.name_en,
+ "name_ar": product.name_ar,
+ "price": product.price,
+ "stock": product.stock,
+ "specs": product.specs,
+ "images": [img.image_url for img in product.images],
+ "soft_deleted_at": datetime.now(timezone.utc).isoformat()
+ }
+
+ product.deleted_at = datetime.now(timezone.utc)
+ product.is_active = False
+ product.deletion_reason = reason
+ product.deleted_by = deleted_by
+
+ # Log audit trail
+ audit = ProductAudit(
+ product_id=product.id,
+ action="IMAGE_FAILURE_AUTO_DELETE",
+ reason=reason,
+ snapshot=snapshot,
+ performed_by=deleted_by
+ )
+ self.db.add(audit)
+
+ self.report.deleted_products.append({
+ "id": product.id,
+ "reason": reason
+ })
+ self.report.total_invalid += 1
+ logger.warning(f"🚨 AUTO-DELETED product {product.id} due to {reason}")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to soft-delete {product.id}: {e}")
+ return False
+
+ def process_product(self, product: 'Product') -> bool:
+ """Process quality lifecycle for a single product."""
+ self.report.total_checked += 1
+
+ # 1. Critical Image Integrity Check (Phase 3 Requirement)
+ if not product.images:
+ return self.perform_soft_delete(product, "Missing all images")
+
+ for img in product.images:
+ is_valid, error = self.validate_image_url(img.image_url)
+ if not is_valid:
+ return self.perform_soft_delete(product, f"Image 404/Error: {error}")
+
+ # 2. Score Calculation
+ old_score = product.quality_score
+ new_score = self.calculate_quality_score(product)
+
+ if old_score != new_score:
+ product.quality_score = new_score
+ self.report.updated_scores.append({
+ "id": product.id,
+ "old": old_score,
+ "new": new_score
+ })
+
+ self.report.total_valid += 1
+ return True
+
+ def run_quality_check(self, batch_size: int = 50) -> QualityReport:
+ """Run full quality check on all active products."""
+ if not self.db:
+ logger.error("No database session available")
+ return self.report
+
+ logger.info("=" * 60)
+ logger.info("Starting Product Quality Check")
+ logger.info("=" * 60)
+
+ try:
+ products = self.db.query(Product).filter(
+ Product.is_active == True,
+ Product.deleted_at == None
+ ).all()
+
+ logger.info(f"Found {len(products)} active products to check")
+
+ for idx, product in enumerate(products, 1):
+ self.process_product(product)
+
+ if idx % batch_size == 0:
+ self.db.commit()
+ logger.info(f"Processed {idx}/{len(products)} products...")
+
+ self.db.commit()
+
+ except Exception as e:
+ logger.error(f"Quality check failed: {str(e)}")
+ self.db.rollback()
+ self.report.errors.append(f"Quality check failed: {str(e)}")
+
+ logger.info("=" * 60)
+ logger.info(f"Quality Check Complete")
+ logger.info(f" Total Checked: {self.report.total_checked}")
+ logger.info(f" Valid Products: {self.report.total_valid}")
+ logger.info(f" Invalid (Deleted): {self.report.total_invalid}")
+ logger.info(f" Score Updates: {len(self.report.updated_scores)}")
+ logger.info("=" * 60)
+
+ return self.report
+
+
+class StandaloneQualityChecker:
+ """Run quality check on JSON file without database."""
+
+ def __init__(self, json_path: str):
+ self.json_path = json_path
+ self.report = QualityReport()
+ self.session = requests.Session()
+
+ def validate_image_url(self, url: str, timeout: int = 10) -> Tuple[bool, Optional[str]]:
+ """Validate single image URL."""
+ if not url:
+ return False, "Empty URL"
+
+ try:
+ response = self.session.head(url, timeout=timeout, allow_redirects=True, verify=False)
+
+ if response.status_code >= 400:
+ response = self.session.get(url, stream=True, timeout=timeout, verify=False)
+ response.close()
+
+ if response.status_code >= 400:
+ return False, f"HTTP {response.status_code}"
+
+ content_type = response.headers.get('Content-Type', '')
+ if not content_type.startswith('image/'):
+ return False, f"Invalid content type: {content_type}"
+
+ return True, None
+
+ except Exception as e:
+ return False, str(e)[:50]
+
+ def check_json_products(self) -> Dict[str, Any]:
+ """Check products from JSON file."""
+ logger.info(f"Loading products from {self.json_path}")
+
+ with open(self.json_path, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+
+ products = data.get('products', [])
+ logger.info(f"Found {len(products)} products to validate")
+
+ results = {
+ "valid_products": [],
+ "invalid_products": [],
+ "summary": {
+ "total": len(products),
+ "valid": 0,
+ "invalid": 0
+ }
+ }
+
+ for idx, product in enumerate(products, 1):
+ product_id = product.get('id', f'unknown_{idx}')
+ name_en = product.get('name_en', 'Unknown')
+ images = product.get('images', [])
+
+ invalid_images = []
+ valid_images = []
+
+ for img_url in images:
+ is_valid, error = self.validate_image_url(img_url)
+ if is_valid:
+ valid_images.append(img_url)
+ else:
+ invalid_images.append({"url": img_url, "error": error})
+
+ product_result = {
+ "id": product_id,
+ "name_en": name_en,
+ "images": {
+ "total": len(images),
+ "valid": len(valid_images),
+ "invalid": len(invalid_images),
+ "invalid_details": invalid_images[:2]
+ }
+ }
+
+ if invalid_images:
+ results["invalid_products"].append(product_result)
+ results["summary"]["invalid"] += 1
+ logger.warning(f"Product {product_id}: {len(invalid_images)} invalid images")
+ else:
+ results["valid_products"].append(product_result)
+ results["summary"]["valid"] += 1
+
+ if idx % 50 == 0:
+ logger.info(f"Processed {idx}/{len(products)} products...")
+
+ logger.info(f"Validation complete: {results['summary']['valid']} valid, {results['summary']['invalid']} invalid")
+
+ try:
+ output_path = self.json_path.replace('.json', '_validation.json')
+ with open(output_path, 'w', encoding='utf-8') as f:
+ json.dump(results, f, ensure_ascii=False, indent=2)
+ logger.info(f"Results saved to {output_path}")
+ except Exception as e:
+ logger.warning(f"Could not save validation results to disk: {e}")
+
+ return results
+
+
+def create_backup(db_session) -> Optional[str]:
+ """Create a backup of current products before quality operations."""
+ try:
+ products = db_session.query(Product).all()
+
+ backup_data = {
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "total_products": len(products),
+ "products": []
+ }
+
+ for p in products:
+ product_dict = {
+ "id": p.id,
+ "name_en": p.name_en,
+ "name_ar": p.name_ar,
+ "price": p.price,
+ "stock": p.stock,
+ "is_active": p.is_active,
+ "deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
+ "quality_score": p.quality_score,
+ "images": [{"id": img.id, "image_url": img.image_url} for img in p.images]
+ }
+ backup_data["products"].append(product_dict)
+
+ backup_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backups")
+ os.makedirs(backup_dir, exist_ok=True)
+
+ backup_filename = f"products_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
+ backup_path = os.path.join(backup_dir, backup_filename)
+
+ try:
+ with open(backup_path, 'w', encoding='utf-8') as f:
+ json.dump(backup_data, f, ensure_ascii=False, indent=2)
+ logger.info(f"Backup created: {backup_path}")
+ except Exception as e:
+ logger.warning(f"Could not write backup to disk: {e}")
+ return backup_path
+
+ except Exception as e:
+ logger.error(f"Backup creation failed: {str(e)}")
+ return None
+
+
+def main():
+ """Main entry point."""
+ import argparse
+
+ parser = argparse.ArgumentParser(description='VortexCommerce Product Quality Checker')
+ parser.add_argument('--mode', choices=['db', 'json'], default='db',
+ help='Run against database or JSON file')
+ parser.add_argument('--json-path',
+ default=os.path.join(os.path.dirname(os.path.dirname(__file__)),
+ 'backend', 'real_products_200.json'),
+ help='Path to JSON file for standalone mode')
+ parser.add_argument('--backup', action='store_true',
+ help='Create backup before quality operations')
+ parser.add_argument('--batch-size', type=int, default=50,
+ help='Batch size for database commits')
+
+ args = parser.parse_args()
+
+ logger.info("=" * 60)
+ logger.info("VortexCommerce Product Quality Checker v2.0")
+ logger.info("=" * 60)
+
+ if args.mode == 'json':
+ logger.info(f"Running in standalone JSON mode: {args.json_path}")
+ checker = StandaloneQualityChecker(args.json_path)
+ results = checker.check_json_products()
+ logger.info(f"Summary: {results['summary']}")
+ return 0
+
+ if not DB_AVAILABLE:
+ logger.error("Database modules not available. Use --mode json for standalone mode.")
+ return 1
+
+ db = SessionLocal()
+
+ try:
+ if args.backup:
+ backup_path = create_backup(db)
+ if not backup_path:
+ logger.warning("Continuing without backup...")
+
+ checker = ProductQualityChecker(db)
+ report = checker.run_quality_check(args.batch_size)
+
+ try:
+ report_path = os.path.join(os.path.dirname(__file__), f"quality_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json")
+ with open(report_path, 'w', encoding='utf-8') as f:
+ json.dump(report.to_dict(), f, ensure_ascii=False, indent=2)
+ logger.info(f"Quality report saved: {report_path}")
+ except Exception as e:
+ logger.warning(f"Could not save quality report to disk: {e}")
+
+ finally:
+ db.close()
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/app/api/products.py b/app/api/products.py
new file mode 100644
index 0000000000000000000000000000000000000000..701dc6f5222842fef15449f40fa3cdbfd8baa712
--- /dev/null
+++ b/app/api/products.py
@@ -0,0 +1,412 @@
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.orm import Session, joinedload
+from sqlalchemy import or_, desc, asc
+from datetime import datetime, timezone
+
+from app.db.base import get_db
+from app.models.product import Product, ProductImage, Category
+from app.schemas.product import (
+ ProductCreate,
+ ProductUpdate,
+ ProductListItem,
+ ProductDetail,
+ ProductImageCreate,
+ PaginatedResponse,
+)
+from app.schemas.auth import AuthResponse
+from app.core.security import get_current_user
+from app.core.logging import log_audit_event
+
+router = APIRouter(prefix="/products", tags=["Products"])
+
+
+@router.get("", response_model=PaginatedResponse)
+async def list_products(
+ search: Optional[str] = Query(None),
+ brand: Optional[str] = Query(None),
+ category_id: Optional[int] = Query(None),
+ min_price: Optional[float] = Query(None),
+ max_price: Optional[float] = Query(None),
+ is_featured: Optional[bool] = Query(None),
+ has_discount: Optional[bool] = Query(None),
+ sort_by: str = Query("created_at", regex="^(created_at|price|rating|name_en)$"),
+ sort_order: str = Query("desc", regex="^(asc|desc)$"),
+ page: int = Query(1, ge=1),
+ page_size: int = Query(12, ge=1, le=2000),
+ limit: Optional[int] = Query(None, ge=1, le=2000),
+ db: Session = Depends(get_db),
+):
+ # Use limit if provided, otherwise use page_size
+ effective_page_size = limit if limit is not None else page_size
+
+ from app.models.settings import StoreSettings
+ settings = db.query(StoreSettings).first()
+ global_discount = settings.global_discount if settings else 0
+ multiplier = (1 - global_discount / 100)
+
+ query = db.query(Product).filter(
+ Product.is_active == True, # noqa: E712
+ Product.deleted_at == None # noqa: E711
+ )
+
+ # Search (with Arabic Normalization: أ, إ, آ -> ا | ة -> ه | ى -> ي)
+ if search:
+ search_term = f"%{search}%"
+
+ # Helper function to generate deep nested REPLACE calls for SQLite
+ def normalize_arabic(column):
+ from sqlalchemy import func
+ # 1. replace أ with ا
+ col = func.replace(column, 'أ', 'ا')
+ # 2. replace إ with ا
+ col = func.replace(col, 'إ', 'ا')
+ # 3. replace آ with ا
+ col = func.replace(col, 'آ', 'ا')
+ # 4. replace ة with ه
+ col = func.replace(col, 'ة', 'ه')
+ # 5. replace ى with ي
+ col = func.replace(col, 'ى', 'ي')
+ # 6. replace ؤ with و (Optional but good)
+ col = func.replace(col, 'ؤ', 'و')
+ # 7. replace ئ with ي (Optional but good)
+ col = func.replace(col, 'ئ', 'ي')
+ return col
+
+ # Normalize the user's search term in python
+ n_search = search.replace('أ', 'ا').replace('إ', 'ا').replace('آ', 'ا').replace('ة', 'ه').replace('ى', 'ي').replace('ؤ', 'و').replace('ئ', 'ي')
+ n_search_term = f"%{n_search}%"
+
+ query = query.filter(
+ or_(
+ Product.name_en.ilike(search_term),
+ normalize_arabic(Product.name_ar).ilike(n_search_term),
+ Product.description_en.ilike(search_term),
+ normalize_arabic(Product.description_ar).ilike(n_search_term),
+ )
+ )
+
+ # Filters
+ if category_id is not None:
+ # Get category and its children to allow filtering by parent category
+ category = db.query(Category).filter(Category.id == category_id).first()
+ if category:
+ subcats = db.query(Category.id).filter(Category.parent_id == category_id).all()
+ subcat_ids = [c[0] for c in subcats]
+ all_cat_ids = [category_id] + subcat_ids
+ query = query.filter(Product.category_id.in_(all_cat_ids))
+
+ if brand:
+ from sqlalchemy import func
+ brand_search = f"%{brand}%"
+ # PostgreSQL JSON extraction: Product.specs['details_en']['Brand Name'].astext
+ query = query.filter(
+ Product.specs['details_en']['Brand Name'].astext.ilike(brand_search)
+ )
+
+ if min_price is not None:
+ query = query.filter(Product.price >= min_price)
+ if max_price is not None:
+ query = query.filter(Product.price <= max_price)
+ if is_featured is not None:
+ query = query.filter(Product.is_featured == is_featured)
+ if has_discount is True:
+ query = query.filter(Product.compare_price > Product.price)
+ elif has_discount is False:
+ query = query.filter(or_(Product.compare_price == None, Product.compare_price <= Product.price))
+
+ # Count
+ total = query.count()
+
+ # Sort
+ from sqlalchemy import case
+
+ # Custom Hybrid Sorting Logic:
+ # 1. New Products (ID > 3708) first, sorted by ID DESC
+ # 2. Original Catalog (ID <= 3708) next, sorted by ID ASC
+ query = query.order_by(
+ case(
+ (Product.id > 3708, 0),
+ else_=1
+ ),
+ case(
+ (Product.id > 3708, Product.id),
+ else_=None
+ ).desc(),
+ Product.id.asc()
+ )
+
+ # Pagination
+ offset = (page - 1) * effective_page_size
+ products = (
+ query.options(joinedload(Product.category), joinedload(Product.images))
+ .offset(offset)
+ .limit(effective_page_size)
+ .all()
+ )
+
+ # Map to response
+ items = []
+ for p in products:
+ primary_image = None
+ if p.images:
+ sorted_imgs = sorted(p.images, key=lambda x: x.sort_order)
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
+
+ item = ProductListItem(
+ id=p.id,
+ name_ar=p.name_ar,
+ name_en=p.name_en,
+ price=p.price * multiplier,
+ compare_price=p.compare_price * multiplier if p.compare_price else None,
+ stock=p.stock,
+ category_id=p.category_id,
+ category=p.category,
+ rating=p.rating,
+ rating_count=p.rating_count,
+ is_featured=p.is_featured,
+ image_url=primary_image,
+ created_at=p.created_at,
+ quality_score=p.quality_score or 100,
+ deleted_at=p.deleted_at,
+ )
+ items.append(item.model_dump())
+
+ # Serialize dates
+ for item in items:
+ if isinstance(item.get("created_at"), datetime):
+ item["created_at"] = item["created_at"].isoformat()
+ if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
+ item["deleted_at"] = item["deleted_at"].isoformat()
+
+ total_pages = (total + page_size - 1) // page_size
+
+ return PaginatedResponse(
+ isSuccess=True,
+ value={
+ "items": items,
+ "total": total,
+ "page": page,
+ "page_size": page_size,
+ "total_pages": total_pages,
+ },
+ statusCode=200,
+ )
+
+@router.get("/{product_id}", response_model=PaginatedResponse)
+async def get_product(product_id: int, raw: bool = False, db: Session = Depends(get_db)):
+ product = (
+ db.query(Product)
+ .options(joinedload(Product.category), joinedload(Product.images))
+ .filter(Product.id == product_id)
+ .first()
+ )
+ if not product:
+ raise HTTPException(status_code=404, detail="Product not found")
+
+ from app.models.settings import StoreSettings
+ settings = db.query(StoreSettings).first()
+ global_discount = settings.global_discount if settings else 0
+ multiplier = (1 - global_discount / 100)
+
+ detail = ProductDetail.model_validate(product).model_dump()
+
+ # Only apply discount if NOT raw
+ if not raw:
+ detail["price"] *= multiplier
+ if detail.get("compare_price"):
+ detail["compare_price"] *= multiplier
+
+ # Apply to variants in specs
+ if detail.get("specs") and "variants" in detail["specs"]:
+ for v in detail["specs"]["variants"]:
+ if v.get("price"):
+ v["price"] *= multiplier
+ if v.get("price_modifier"):
+ v["price_modifier"] *= multiplier
+
+ return PaginatedResponse(
+ isSuccess=True,
+ value=detail,
+ statusCode=200,
+ )
+
+
+@router.post("", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
+async def create_product(
+ request: ProductCreate,
+ user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ if not request.images or len(request.images) == 0:
+ raise HTTPException(status_code=400, detail="Main image is required / صورة رئيسية مطلوبة")
+
+ product = Product(
+ name_ar=request.name_ar,
+ name_en=request.name_en,
+ description_ar=request.description_ar,
+ description_en=request.description_en,
+ price=request.price,
+ compare_price=request.compare_price,
+ stock=request.stock,
+ category_id=request.category_id,
+ is_featured=request.is_featured,
+ is_active=request.is_active,
+ specs=request.specs,
+ )
+ db.add(product)
+ db.flush()
+
+ # Add images
+ for img in request.images:
+ db_img = ProductImage(
+ product_id=product.id,
+ image_url=img.image_url,
+ alt_text=img.alt_text,
+ sort_order=img.sort_order,
+ )
+ db.add(db_img)
+
+ db.commit()
+ db.refresh(product)
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"product_id": product.id},
+ statusCode=201,
+ )
+
+
+@router.put("/{product_id}", response_model=AuthResponse)
+async def update_product(
+ product_id: int,
+ request: ProductUpdate,
+ user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ product = db.query(Product).filter(Product.id == product_id).first()
+ if not product or product.deleted_at is not None:
+ raise HTTPException(status_code=404, detail="Product not found")
+
+ update_data = request.model_dump(exclude_unset=True)
+ for field, value in update_data.items():
+ setattr(product, field, value)
+
+ db.commit()
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"product_id": product.id},
+ statusCode=200,
+ )
+
+
+@router.delete("/{product_id}", response_model=AuthResponse)
+async def delete_product(
+ product_id: int,
+ user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ product = db.query(Product).filter(Product.id == product_id).first()
+ if not product or product.deleted_at is not None:
+ raise HTTPException(status_code=404, detail="Product not found")
+
+ # Soft Delete
+ product.deleted_at = datetime.now(timezone.utc)
+ product.deleted_by = user_id
+ product.deletion_reason = "Manual deletion by admin"
+
+ db.commit()
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"deleted": True, "soft_delete": True},
+ statusCode=200,
+ )
+
+@router.delete("/{product_id}/hard", response_model=AuthResponse)
+async def hard_delete_product(
+ product_id: int,
+ user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ product = db.query(Product).filter(Product.id == product_id).first()
+ if not product:
+ raise HTTPException(status_code=404, detail="Product not found")
+
+ db.delete(product)
+ db.commit()
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"deleted": True, "hard_delete": True},
+ statusCode=200,
+ )
+
+@router.post("/{product_id}/restore", response_model=AuthResponse)
+async def restore_product(
+ product_id: int,
+ user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ product = db.query(Product).filter(Product.id == product_id).first()
+ if not product or product.deleted_at is None:
+ raise HTTPException(status_code=404, detail="Product not found or not deleted")
+
+ product.deleted_at = None
+ product.deleted_by = None
+ product.deletion_reason = None
+
+ db.commit()
+
+ return AuthResponse(
+ isSuccess=True,
+ value={"restored": True},
+ statusCode=200,
+ )
+
+@router.get("/admin/deleted", response_model=PaginatedResponse)
+async def list_deleted_products(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(12, ge=1, le=48),
+ db: Session = Depends(get_db),
+ user_id: int = Depends(get_current_user),
+):
+ query = db.query(Product).filter(Product.deleted_at != None) # noqa: E711
+
+ total = query.count()
+ offset = (page - 1) * page_size
+ products = (
+ query.options(joinedload(Product.category), joinedload(Product.images))
+ .order_by(desc(Product.deleted_at))
+ .offset(offset)
+ .limit(page_size)
+ .all()
+ )
+
+ items = []
+ for p in products:
+ item = ProductDetail.model_validate(p).model_dump()
+ if isinstance(item.get("created_at"), datetime):
+ item["created_at"] = item["created_at"].isoformat()
+ if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
+ item["deleted_at"] = item["deleted_at"].isoformat()
+ items.append(item)
+
+ total_pages = (total + page_size - 1) // page_size
+
+ return PaginatedResponse(
+ isSuccess=True,
+ value={
+ "items": items,
+ "total": total,
+ "page": page,
+ "page_size": page_size,
+ "total_pages": total_pages,
+ },
+ statusCode=200,
+ )
+
+
+# End of products.py
diff --git a/app/api/seed.py b/app/api/seed.py
new file mode 100644
index 0000000000000000000000000000000000000000..81c8b6a805509925cbf11d10e43c3d912213b6e3
--- /dev/null
+++ b/app/api/seed.py
@@ -0,0 +1,402 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+from sqlalchemy import text
+import json
+import random
+import os
+import hashlib
+import requests
+
+from app.db.base import get_db
+from app.models.product import Product, ProductImage, Category
+from app.models.cart import Coupon
+from app.models.user import User, UserRole, AuthProvider
+from app.core.security import hash_password
+from app.schemas.auth import AuthResponse
+from app.services.external_catalog import ExternalCatalogService
+
+router = APIRouter(prefix="/seed", tags=["Seed Data"])
+
+# Category Icons Mapping
+PARENT_ICONS = {
+ "الأجهزة الإلكترونية": "📱",
+ "ملحقات السيارات": "🚗",
+ "المنزل والمطبخ": "🏠",
+ "العطور والتجميل": "✨",
+ "الساعات والإكسسوارات": "⌚",
+}
+
+SUB_ICONS = {
+ "الهواتف والإلكترونيات": "📱",
+ "الهواتف الذكية": "📲",
+ "أجهزة الكمبيوتر": "💻",
+ "ملحقات السيارات": "🔧",
+ "إكسسوارات السيارات": "🔌",
+ "المنزل والمطبخ": "🍳",
+ "المستلزمات المنزلية": "🧹",
+ "مستلزمات المطبخ": "🍴",
+ "ديكور المنزل": "🖼️",
+ "الأثاث": "🛋️",
+ "العطور": "🧴",
+ "التجميل والعناية": "💄",
+ "الجمال": "💅",
+ "العناية بالبشرة": "🧴",
+ "الساعات والاكسسوارات": "⌚",
+ "ساعات نسائية": "💍",
+ "ساعات رجالية": "👔",
+ "نظارات شمسية": "🕶️",
+ "نظارات شمسية": "🕶️",
+}
+
+CATEGORY_HIERARCHY = {
+ "الأجهزة الإلكترونية": {
+ "name_en": "Electronics",
+ "icon": "📱",
+ "subs": [
+ {"ar": "جوالات", "en": "Smartphones"},
+ {"ar": "أجهزة الكمبيوتر", "en": "Computers"},
+ {"ar": "أجهزة لوحية", "en": "Tablets"},
+ {"ar": "أجهزة الصوت", "en": "Audio"},
+ {"ar": "كاميرات", "en": "Cameras"},
+ {"ar": "طابعات", "en": "Printers"}
+ ]
+ },
+ "ملحقات واكسسوارات": {
+ "name_en": "Accessories",
+ "icon": "🔌",
+ "subs": [
+ {"ar": "ملحقات ألعاب", "en": "Gaming Accessories"},
+ {"ar": "ملحقات سيارات", "en": "Car Accessories"},
+ {"ar": "أجهزة المراقبة الذكية", "en": "Security"}
+ ]
+ },
+ "ألعاب جيمنج": {
+ "name_en": "Gaming",
+ "icon": "🎮",
+ "subs": [
+ {"ar": "أجهزة ألعاب", "en": "Consoles"},
+ {"ar": "ألعاب أطفال", "en": "Toys"}
+ ]
+ },
+ "الأجهزة المنزلية": {
+ "name_en": "Home Appliances",
+ "icon": "🏠",
+ "subs": [
+ {"ar": "ثلاجات", "en": "Refrigerators"},
+ {"ar": "غسالات ومجففات", "en": "Washers & Dryers"},
+ {"ar": "أدوات المطبخ", "en": "Kitchen Appliances"},
+ {"ar": "أجهزة منزلية صغيرة", "en": "Small Home Appliances"},
+ {"ar": "عناية شخصية", "en": "Personal Care"}
+ ]
+ }
+}
+
+SCRAPED_TO_TARGET_MAP = {
+ "Electronics": "الأجهزة الإلكترونية",
+ "Smartphones": "الأجهزة الإلكترونية",
+ "Laptops": "الأجهزة الإلكترونية",
+ "Accessories": "ملحقات واكسسوارات",
+ "Gaming": "ألعاب جيمنج",
+ "Home Appliances": "الأجهزة المنزلية",
+ "Security": "ملحقات واكسسوارات",
+ "Car": "ملحقات واكسسوارات",
+ "Audio": "الأجهزة الإلكترونية",
+ "Personal Care": "الأجهزة المنزلية",
+}
+
+@router.api_route("", methods=["GET", "POST"], response_model=AuthResponse)
+def seed_database(clear: bool = False, db: Session = Depends(get_db)):
+ """Populate database with proper product data, optionally clearing existing data first."""
+ if clear:
+ # Clear existing data - Comprehensive wipe for PostgreSQL
+ print(">>> [SEED] Clearing ALL data...")
+ # Order matters for foreign keys
+ tables = [
+ "product_images", "cart_items", "reviews",
+ "payment_details", "payments", "order_items",
+ "orders", "addresses", "products", "categories",
+ "coupons", "store_settings", "notifications"
+ ]
+ for table in tables:
+ db.execute(text(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE"))
+ db.commit()
+
+ # 0. Ensure Store Settings exist (even without clear)
+ from app.models.settings import StoreSettings
+ settings = db.query(StoreSettings).first()
+ if not settings:
+ print(">>> [SEED] Creating default store settings...")
+ settings = StoreSettings(
+ store_name="أفق",
+ primary_color="#046c4e",
+ secondary_color="#d97706",
+ logo_url="/logo.png"
+ )
+ db.add(settings)
+ db.commit()
+
+ # 0. Create Default Admin if matches criteria
+ admin_email = "admin@vortex.com"
+ admin_user = db.query(User).filter(User.email == admin_email).first()
+ if not admin_user:
+ print(f">>> [SEED] Creating default admin: {admin_email}")
+ admin_user = User(
+ email=admin_email,
+ name="Platform Administrator",
+ password_hash=hash_password("admin123"),
+ role=UserRole.ADMIN,
+ auth_provider=AuthProvider.LOCAL
+ )
+ db.add(admin_user)
+ db.commit()
+
+ # 1. Create Categories (Hierarchy)
+ all_sub_cats = {} # Map used for product assignment
+
+ for p_ar, info in CATEGORY_HIERARCHY.items():
+ # Create Parent
+ parent_cat = db.query(Category).filter(Category.name_ar == p_ar).first()
+ if not parent_cat:
+ parent_cat = Category(
+ name_ar=p_ar,
+ name_en=info["name_en"],
+ icon=info["icon"]
+ )
+ db.add(parent_cat)
+ db.commit()
+ db.refresh(parent_cat)
+
+ # Add parent to map so products can be assigned directly to it
+ all_sub_cats[p_ar] = parent_cat
+ all_sub_cats[info["name_en"]] = parent_cat
+
+ # Create Subs
+ for s in info["subs"]:
+ sub_cat = db.query(Category).filter(Category.name_ar == s["ar"]).first()
+ if not sub_cat:
+ sub_cat = Category(
+ name_ar=s["ar"],
+ name_en=s["en"],
+ icon=SUB_ICONS.get(s["ar"], "📦"),
+ parent_id=parent_cat.id
+ )
+ db.add(sub_cat)
+ db.commit()
+ db.refresh(sub_cat)
+ all_sub_cats[s["ar"]] = sub_cat
+ all_sub_cats[s["en"]] = sub_cat # Map both names to the same object
+
+
+ API_VERSION = "2026.03.16.V2"
+
+ possible_paths = [
+ # 1. Standard Hugging Face / Docker location (WORKDIR /app)
+ "/app/app/data/vortex_catalog_full.json",
+ "/app/data/vortex_catalog_full.json",
+ "/app/kaggle/extra_products_vortex.json",
+ "/app/real_products_200.json",
+ "/app/app/data/extra_products_vortex.json",
+ # 2. Local relative location (if running locally or during dev)
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "data", "vortex_catalog_full.json")),
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "kaggle", "extra_products_vortex.json")),
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "real_products_200.json")),
+ # 3. Development root fallbacks
+ os.path.abspath("app/data/vortex_catalog_full.json"),
+ os.path.abspath("kaggle/extra_products_vortex.json"),
+ os.path.abspath("real_products_200.json"),
+ os.path.abspath("extra_products_vortex.json"),
+ os.path.abspath("vortex_catalog_full.json"),
+ ]
+
+ json_path = None
+ checked_info = []
+ for p in possible_paths:
+ exists = os.path.exists(p)
+ checked_info.append(f"{p}: {'FOUND' if exists else 'NOT FOUND'}")
+ if exists:
+ json_path = p
+ break
+
+ if not json_path:
+ return AuthResponse(
+ isSuccess=False,
+ value={
+ "message": "Source file not found.",
+ "checked": checked_info,
+ "version": API_VERSION
+ },
+ statusCode=404,
+ error="File not found"
+ )
+
+ try:
+ with open(json_path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+
+ if isinstance(data, dict) and "products" in data:
+ products_data = data["products"]
+ elif isinstance(data, list):
+ products_data = data
+ else:
+ return AuthResponse(isSuccess=False, value={"message": "Invalid JSON format"}, statusCode=400)
+
+ # Cache existing products
+ existing_products_ar = set(r[0] for r in db.execute(text("SELECT name_ar FROM products")).fetchall())
+ existing_products_en = set(r[0] for r in db.execute(text("SELECT name_en FROM products")).fetchall())
+
+ # 3. Create Products
+ inserted_count: int = 0
+ skipped_count: int = 0
+
+ for item in products_data:
+ if not isinstance(item, dict):
+ skipped_count += 1
+ continue
+
+ cat_name = item.get("category_name") or item.get("category")
+
+ # Use the new mapping for existing category strings
+ if cat_name in SCRAPED_TO_TARGET_MAP:
+ cat_name = SCRAPED_TO_TARGET_MAP[cat_name]
+
+ product_name_ar = item.get("name_ar", "").strip()
+ product_name_en = item.get("name_en", "").strip()
+
+ # Smart Subcategory Mapping based on Name Keywords
+ # Only apply if the current category is generic or not found
+ if not cat_name or cat_name in ["الهواتف والإلكترونيات", "Electronics", "تصنيفات أخرى"]:
+ name_to_search = (product_name_en + " " + product_name_ar).lower()
+
+ # Keywords to specific subcategories
+ KEYWORD_MAP = {
+ "جوالات": ["iphone", "galaxy", "phone", "mobile", "pixel", "هاتف", "جوال"],
+ "أجهزة الكمبيوتر": ["laptop", "macbook", "notebook", "pc", "computer", "لابتوب", "حاسوب"],
+ "أجهزة لوحية": ["ipad", "tablet", "tab", "لوحي", "تابلت"],
+ "أجهزة الصوت": ["headphone", "earbud", "speaker", "audio", "سماعة", "مكبر صوت"],
+ "كاميرات": ["camera", "dslr", "canon", "nikon", "كاميرا"],
+ "طابعات": ["printer", "ink", "laser", "طابعة"],
+ "أجهزة ألعاب": ["playstation", "xbox", "nintendo", "console", "بلايستيشن", "إكس بوكس", "كونسول"],
+ "ملحقات ألعاب": ["controller", "joystick", "gaming mouse", "gaming keyboard", "يد تحكم", "ماوس", "كيبورد"],
+ "ألعاب أطفال": ["lego", "toy", "doll", "لعبة", "ليقو"],
+ "ملحقات سيارات": ["dashcam", "car charger", "car holder", "سيارة", "مركبة"],
+ "أدوات المطبخ": ["air fryer", "blender", "coffee", "mixer", "قلاية", "خلاط", "قهوة"],
+ "عناية شخصية": ["shaver", "hair", "brush", "عناية", "حلاقة", "شعر"],
+ "ثلاجات": ["fridge", "refrigerator", "ثلاجة", "مبرد"],
+ "غسالات ومجففات": ["dryer", "washer", "غسالة", "مجفف"],
+ "أجهزة منزلية صغيرة": ["vacuum", "cleaner", "iron", "مكنسة", "مكواة"],
+ "أجهزة المراقبة الذكية": ["security", "cctv", "cam", "smart lock", "مراقبة", "قفل"]
+ }
+
+ for target, keywords in KEYWORD_MAP.items():
+ if any(kw in name_to_search for kw in keywords):
+ cat_name = target
+ break
+
+ # Fallback for subcategories if still not found or generic
+ if not cat_name or cat_name not in all_sub_cats:
+ # Try from specs
+ specs_val = item.get("specs", {})
+ if isinstance(specs_val, dict):
+ specs_cat = specs_val.get("category")
+ if specs_cat in SCRAPED_TO_TARGET_MAP:
+ cat_name = SCRAPED_TO_TARGET_MAP[specs_cat]
+ elif specs_cat in all_sub_cats:
+ cat_name = specs_cat
+
+ # Final Default
+ if not cat_name or cat_name not in all_sub_cats:
+ cat_name = "الهواتف والإلكترونيات"
+
+ target_sub_cat = all_sub_cats[cat_name]
+
+ if product_name_ar in existing_products_ar or product_name_en in existing_products_en:
+ skipped_count += 1
+ continue
+
+ specs = item.get("specs", {})
+ if not isinstance(specs, dict): specs = {}
+
+ # Handle Variants
+ if "variants" not in specs and "options" in item:
+ options = item.get("options", [])
+ if isinstance(options, list):
+ variants = []
+ for opt in options:
+ if not isinstance(opt, dict): continue
+ variants.append({
+ "id": opt.get("sku", opt.get("id", "v_default")),
+ "name_en": opt.get("name_en", ""),
+ "name_ar": opt.get("name_ar", ""),
+ "price_modifier": float(opt.get("price", 0)) - float(item.get("price", 0)) if "price" in opt else 0,
+ "stock": opt.get("stock", 10),
+ "image_url": opt.get("image", opt.get("image_url", ""))
+ })
+ if variants: specs["variants"] = variants
+
+ prod = Product(
+ name_ar=product_name_ar,
+ name_en=product_name_en,
+ description_ar=item.get("description_ar", ""),
+ description_en=item.get("description_en", ""),
+ price=float(item.get("price", 0)),
+ compare_price=round(float(item.get("compare_price", float(item.get("price", 0)) * 1.3)), 2),
+ stock=item.get("stock", random.randint(10, 100)),
+ category_id=target_sub_cat.id,
+ rating=item.get("rating", 4.5),
+ rating_count=item.get("rating_count", 20),
+ is_featured=item.get("is_featured", False),
+ is_active=item.get("is_active", True),
+ specs=specs if specs else None
+ )
+ db.add(prod)
+ db.flush()
+
+ # Handle Images
+ for i, img_url in enumerate(item.get("images", [])):
+ # Sanitize URL: Remove complex query parameters that might cause loading issues
+ # Extra.com URLs often look like: ...Black?locale=en-GB,en-*,*&$Listing-Product-2x$
+ # We want to keep the base image but simplify the request
+ clean_url = img_url
+ if "media.extra.com" in img_url and "?" in img_url:
+ clean_url = img_url.split("?")[0]
+
+ db.add(ProductImage(
+ product_id=prod.id,
+ image_url=clean_url,
+ alt_text=prod.name_ar,
+ sort_order=i
+ ))
+
+ inserted_count = inserted_count + 1
+ existing_products_ar.add(product_name_ar)
+ existing_products_en.add(product_name_en)
+
+ # Ensure Demo Coupons
+ for code, discount in [("WELCOME10", 10.0), ("VORTEX20", 20.0)]:
+ if not db.query(Coupon).filter(Coupon.code == code).first():
+ db.add(Coupon(code=code, discount_percent=discount, is_active=True))
+
+ db.commit()
+
+ return AuthResponse(
+ isSuccess=True,
+ value={
+ "message": "تمت عملية المزامنة بنجاح",
+ "total_products": len(products_data),
+ "inserted_new": inserted_count,
+ "skipped": skipped_count,
+ "source_file": os.path.basename(json_path),
+ "path": json_path,
+ "version": API_VERSION
+ },
+ statusCode=201,
+ )
+
+ except Exception as e:
+ db.rollback()
+ return AuthResponse(
+ isSuccess=False,
+ value={"message": str(e)},
+ statusCode=500,
+ error=str(e)
+ )
diff --git a/app/api/settings.py b/app/api/settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..b0024c0ec1628dedb0c11c7b933076b80e8fcbea
--- /dev/null
+++ b/app/api/settings.py
@@ -0,0 +1,79 @@
+from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
+from datetime import datetime
+import os
+from app.core.config import settings as app_settings
+from sqlalchemy.orm import Session
+from typing import Any
+
+from app.db.base import get_db
+from app.models.settings import StoreSettings
+from app.schemas.settings import StoreSettingsResponse, StoreSettingsUpdate
+from app.api.auth import get_current_admin_user
+from app.schemas.api_response import APIResponse
+
+router = APIRouter(prefix="/settings", tags=["Settings"])
+
+@router.get("/", response_model=APIResponse)
+def get_settings(db: Session = Depends(get_db)) -> Any:
+ settings = db.query(StoreSettings).first()
+ if not settings:
+ settings = StoreSettings() # create default
+ db.add(settings)
+ db.commit()
+ db.refresh(settings)
+ return APIResponse(
+ isSuccess=True,
+ value=StoreSettingsResponse.model_validate(settings).model_dump(),
+ statusCode=200
+ )
+
+@router.put("/", response_model=APIResponse)
+def update_settings(
+ settings_in: StoreSettingsUpdate,
+ db: Session = Depends(get_db),
+ current_admin=Depends(get_current_admin_user)
+) -> Any:
+ settings = db.query(StoreSettings).first()
+ if not settings:
+ settings = StoreSettings(**settings_in.model_dump(exclude_unset=True))
+ db.add(settings)
+ else:
+ update_data = settings_in.model_dump(exclude_unset=True)
+ for field, value in update_data.items():
+ setattr(settings, field, value)
+
+ db.commit()
+ db.refresh(settings)
+ return APIResponse(
+ isSuccess=True,
+ value=StoreSettingsResponse.model_validate(settings).model_dump(),
+ statusCode=200
+ )
+
+@router.post("/upload-image", response_model=APIResponse)
+def upload_settings_image(
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db),
+ current_admin=Depends(get_current_admin_user)
+) -> Any:
+ # Convert file to base64 string
+ file_bytes = file.file.read()
+
+ import base64
+ ext = os.path.splitext(file.filename)[1].lower()
+ mime_type = "image/jpeg"
+ if ext in [".png"]:
+ mime_type = "image/png"
+ elif ext in [".gif"]:
+ mime_type = "image/gif"
+ elif ext in [".webp"]:
+ mime_type = "image/webp"
+
+ b64_encoded = base64.b64encode(file_bytes).decode('utf-8')
+ data_uri = f"data:{mime_type};base64,{b64_encoded}"
+
+ return APIResponse(
+ isSuccess=True,
+ value={"url": data_uri},
+ statusCode=200
+ )
diff --git a/app/core/__init__.py b/app/core/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1261651d8b42d24944cf1c81b546dd3de8e383d9
--- /dev/null
+++ b/app/core/__init__.py
@@ -0,0 +1 @@
+# Core package
diff --git a/app/core/config.py b/app/core/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..2d9ad69a3da8456a550e16bf910213d52924f7ff
--- /dev/null
+++ b/app/core/config.py
@@ -0,0 +1,32 @@
+from pydantic_settings import BaseSettings
+from typing import List
+
+
+import os
+
+class Settings(BaseSettings):
+ # Base directory for persistent data (e.g., /data for Hugging Face persistent storage)
+ DATA_DIR: str = os.getenv("DATA_DIR", ".")
+ DATABASE_URL: str = os.getenv("DATABASE_URL", f"sqlite:///{os.path.join(os.getenv('DATA_DIR', '.'), 'vortex.db')}")
+ TURSO_AUTH_TOKEN: str = os.getenv("TURSO_AUTH_TOKEN", "")
+
+ JWT_SECRET: str = "change-me-in-production"
+ JWT_ALGORITHM: str = "HS256"
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
+ REFRESH_TOKEN_EXPIRE_DAYS: int = 7
+ GOOGLE_CLIENT_ID: str = ""
+ GOOGLE_CLIENT_SECRET: str = ""
+ CORS_ORIGINS: str = "http://localhost:3000,http://localhost:3001,https://ofoq-commerce.vercel.app"
+
+ @property
+ def cors_origins_list(self) -> List[str]:
+ return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
+
+ model_config = {
+ "env_file": ".env",
+ "env_file_encoding": "utf-8",
+ "extra": "ignore",
+ }
+
+
+settings = Settings()
diff --git a/app/core/logging.py b/app/core/logging.py
new file mode 100644
index 0000000000000000000000000000000000000000..15aec65634d895b420956831c6f177636e42be37
--- /dev/null
+++ b/app/core/logging.py
@@ -0,0 +1,23 @@
+import json
+import os
+from datetime import datetime
+
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+AUDIT_LOG_FILE = os.path.join(BASE_DIR, "backend", "logs", "audit_trail.json")
+
+def log_audit_event(action: str, details: str, metadata: dict = None, severity: str = "INFO"):
+ """Log an event to the system console for cloud observability."""
+ try:
+ entry = {
+ "timestamp": datetime.utcnow().isoformat(),
+ "action": action,
+ "details": details,
+ "severity": severity,
+ "metadata": metadata or {"system_context": "automated_event"}
+ }
+ # In stateless environment, we log to stdout for cloud logging systems (like HF logs)
+ print(f">>> [AUDIT] {json.dumps(entry, ensure_ascii=False)}")
+ except Exception as e:
+ print(f"Error logging audit event: {e}")
+ except Exception as e:
+ print(f"Error logging audit event: {e}")
diff --git a/app/core/security.py b/app/core/security.py
new file mode 100644
index 0000000000000000000000000000000000000000..e913f6a2bce64caffbfdcb160bf11783952e3833
--- /dev/null
+++ b/app/core/security.py
@@ -0,0 +1,87 @@
+from datetime import datetime, timedelta, timezone
+from typing import Optional
+
+from jose import JWTError, jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+
+from app.core.config import settings
+
+# Use bcrypt directly to avoid passlib/bcrypt version incompatibility
+import bcrypt as _bcrypt
+
+security_scheme = HTTPBearer()
+
+
+def hash_password(password: str) -> str:
+ return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8")
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ try:
+ return _bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
+ except Exception:
+ return False
+
+
+def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
+ to_encode = data.copy()
+ expire = datetime.now(timezone.utc) + (
+ expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
+ )
+ to_encode.update({"exp": expire, "type": "access"})
+ return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
+
+
+def create_refresh_token(data: dict) -> str:
+ to_encode = data.copy()
+ expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
+ to_encode.update({"exp": expire, "type": "refresh"})
+ return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
+
+
+def decode_token(token: str) -> dict:
+ try:
+ payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
+ return payload
+ except JWTError:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired token",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+
+async def get_current_user(
+ credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
+):
+ payload = decode_token(credentials.credentials)
+ if payload.get("type") != "access":
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid token type",
+ )
+ user_id = payload.get("sub")
+ if user_id is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid token payload",
+ )
+ return int(user_id)
+
+
+async def get_optional_current_user(
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
+) -> Optional[int]:
+ if credentials is None:
+ return None
+ try:
+ payload = decode_token(credentials.credentials)
+ if payload.get("type") != "access":
+ return None
+ user_id = payload.get("sub")
+ if user_id is None:
+ return None
+ return int(user_id)
+ except Exception:
+ return None
diff --git a/app/data/vortex_catalog_full.json b/app/data/vortex_catalog_full.json
new file mode 100644
index 0000000000000000000000000000000000000000..1e27006d68e3637fef7739566c6689b38da6a29c
--- /dev/null
+++ b/app/data/vortex_catalog_full.json
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:015f902f26ed5439fcaf40ac0d35d5e99c9aa1e1db08ca1d52eb71417c920670
+size 13344524
diff --git a/app/db/__init__.py b/app/db/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f93598485e55ea0d41d204bd3924ca43bb754027
--- /dev/null
+++ b/app/db/__init__.py
@@ -0,0 +1 @@
+# DB package
diff --git a/app/db/base.py b/app/db/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..916fcdb148e4b1cf9eee1d230265edd1f9bb2479
--- /dev/null
+++ b/app/db/base.py
@@ -0,0 +1,81 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker, DeclarativeBase
+
+from app.core.config import settings
+
+# Check for Turso remote connection
+is_turso = "turso.io" in settings.DATABASE_URL and settings.TURSO_AUTH_TOKEN
+
+if is_turso:
+ # Extract hostname
+ hostname = settings.DATABASE_URL
+ if "://" in hostname:
+ hostname = hostname.split("://")[1]
+ if "?" in hostname:
+ hostname = hostname.split("?")[0]
+
+ from sqlalchemy.pool import NullPool
+
+ # Proxy to satisfy SQLAlchemy's requirement for create_function on SQLite connections
+ class LibsqlConnectionProxy:
+ def __init__(self, conn):
+ # Use __dict__ to avoid infinite recursion with __setattr__
+ self.__dict__["_conn"] = conn
+
+ def __getattr__(self, name):
+ return getattr(self._conn, name)
+
+ def __setattr__(self, name, value):
+ if name == "_conn":
+ self.__dict__["_conn"] = value
+ else:
+ setattr(self._conn, name, value)
+
+ def create_function(self, *args, **kwargs):
+ # libsql doesn't support create_function over HTTP, but SQLAlchemy expects it
+ return None
+
+ # Force HTTPS for stability on HF Spaces (avoids 505/WebSocket handshake errors)
+ def create_libsql_connection():
+ import libsql
+ url = f"https://{hostname}"
+ print(f">>> [DB] Establishing SECURE HTTP connection to Turso: {hostname}")
+ conn = libsql.connect(url, auth_token=settings.TURSO_AUTH_TOKEN)
+ return LibsqlConnectionProxy(conn)
+
+ # Use NullPool for Turso HTTPS to avoid "detached connection fairy" errors in multi-thread env
+ engine = create_engine(
+ "sqlite+libsql://",
+ creator=create_libsql_connection,
+ poolclass=NullPool,
+ echo=False,
+ )
+else:
+ # Standard local SQLite
+ connect_args = {}
+ if "sqlite" in settings.DATABASE_URL or "libsql" in settings.DATABASE_URL:
+ connect_args["check_same_thread"] = False
+
+ engine = create_engine(
+ settings.DATABASE_URL,
+ pool_size=20,
+ max_overflow=max(10, 20), # Allow some overflow during peak
+ pool_timeout=30,
+ pool_recycle=1800,
+ connect_args=connect_args,
+ echo=False,
+ )
+
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..8147c9cfc30a71fe0dbeb2962828947e4774999e
--- /dev/null
+++ b/app/main.py
@@ -0,0 +1,136 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from app.core.config import settings
+from app.db.base import Base, engine
+from app.api.auth import router as auth_router
+from app.api.products import router as products_router
+from app.api.categories import router as categories_router
+from app.api.cart import router as cart_router
+from app.api.orders import router as orders_router
+from app.api.seed import router as seed_router
+from app.api.ai_search import router as ai_search_router
+from app.api.admin import router as admin_router
+from app.api.notifications import router as notifications_router
+from app.api.external import router as external_router
+from app.api.settings import router as settings_router
+
+# Import models to register them with Base
+import app.models.user # noqa: F401
+import app.models.product # noqa: F401
+import app.models.cart # noqa: F401
+import app.models.order # noqa: F401
+import app.models.notification # noqa: F401
+import app.models.settings # noqa: F401
+
+
+from contextlib import asynccontextmanager
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ # Startup logic
+ import asyncio
+ from app.db.base import SessionLocal, Base, engine
+
+ # 1. Ensure tables exist (fast)
+ print(">>> [DB] Ensuring tables exist...")
+ Base.metadata.create_all(bind=engine)
+
+ # 2. Run Seeding in the Background to avoid HF Startup Timeout
+ async def run_background_seed():
+ from app.models.product import Product
+ from app.api.seed import seed_database
+ import asyncio
+
+ # Use a new session for the background thread
+ db = SessionLocal()
+ try:
+ # Check if we have any products
+ print(">>> [BOOTSTRAP] Checking database status...")
+ count = db.query(Product).count()
+ if count == 0:
+ print(">>> [BOOTSTRAP] Database is empty. Starting BACKGROUND Auto-seeding...")
+ # Run the sync seed function in a separate thread
+ result = await asyncio.to_thread(seed_database, clear=False, db=db)
+ if result.isSuccess:
+ print(f">>> [BOOTSTRAP] Seeding SUCCESS: {result.value.get('message', 'Complete')}")
+ else:
+ print(f">>> [BOOTSTRAP] Seeding FAILED: {result.error} | {result.value.get('message')}")
+ print(f">>> [BOOTSTRAP] Paths checked: {result.value.get('checked')}")
+ else:
+ print(f">>> [BOOTSTRAP] Database already has {count} products. Skipping seed.")
+ except Exception as e:
+ print(f">>> [BOOTSTRAP] Background Seeding error: {e}")
+ finally:
+ db.close()
+
+ # Create the task but don't await it here
+ asyncio.create_task(run_background_seed())
+
+ print(">>> [MAIN] Startup sequence finished. Yielding to health checks...")
+ yield
+ # Shutdown logic (optional)
+ # Shutdown logic (optional)
+
+def create_app() -> FastAPI:
+ app = FastAPI(
+ title="VortexCommerce API",
+ description="AI-Powered Bilingual E-Commerce Platform",
+ version="1.0.0",
+ docs_url="/docs",
+ redoc_url="/redoc",
+ lifespan=lifespan
+ )
+
+ # CORS
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_origins_list,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ # Routers
+ app.include_router(auth_router, prefix="/api")
+ app.include_router(products_router, prefix="/api")
+ app.include_router(categories_router, prefix="/api")
+ app.include_router(cart_router, prefix="/api")
+ app.include_router(orders_router, prefix="/api")
+ app.include_router(seed_router, prefix="/api")
+ app.include_router(external_router, prefix="/api")
+ app.include_router(ai_search_router, prefix="/api")
+ app.include_router(admin_router, prefix="/api")
+ app.include_router(notifications_router, prefix="/api")
+
+ app.include_router(settings_router, prefix="/api")
+
+ VERSION = "2.0.0-FINAL-CATALOG"
+
+ @app.get("/", tags=["Health"])
+ async def root():
+ """FORCE RELOAD Health Check"""
+ return {
+ "status": "online",
+ "service": "VortexCommerce-CORE-API",
+ "version": VERSION,
+ "ts": "2026-03-16-T-13-45",
+ "msg": "New container is active"
+ }
+
+ @app.get("/health", tags=["Health"])
+ async def health_check():
+ return {
+ "isSuccess": True,
+ "value": {
+ "status": "healthy",
+ "service": "VortexCommerce API",
+ "version": VERSION
+ },
+ "statusCode": 200,
+ }
+
+ print(f">>> [MAIN] Application initialized. Version: {VERSION}")
+ return app
+
+app = create_app()
diff --git a/app/models/__init__.py b/app/models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..26593abe4e017014b6961246486461f3829dbf3a
--- /dev/null
+++ b/app/models/__init__.py
@@ -0,0 +1 @@
+# Models package
diff --git a/app/models/cart.py b/app/models/cart.py
new file mode 100644
index 0000000000000000000000000000000000000000..529d5eeab231fe0cc63f190208da3f95e246df6b
--- /dev/null
+++ b/app/models/cart.py
@@ -0,0 +1,47 @@
+from datetime import datetime, timezone
+from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Boolean
+from sqlalchemy.orm import relationship
+
+from app.db.base import Base
+
+class Coupon(Base):
+ __tablename__ = "coupons"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ code = Column(String(50), unique=True, index=True, nullable=False)
+ discount_percent = Column(Float, nullable=False)
+ is_active = Column(Boolean, default=True, nullable=False)
+ expires_at = Column(DateTime, nullable=True)
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
+
+ carts = relationship("Cart", back_populates="coupon")
+
+class Cart(Base):
+ __tablename__ = "carts"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
+ session_id = Column(String(255), unique=True, index=True, nullable=True)
+ coupon_id = Column(Integer, ForeignKey("coupons.id", ondelete="SET NULL"), nullable=True)
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
+ updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
+
+ user = relationship("User", backref="cart")
+ coupon = relationship("Coupon", back_populates="carts")
+ items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan")
+
+class CartItem(Base):
+ __tablename__ = "cart_items"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ cart_id = Column(Integer, ForeignKey("carts.id", ondelete="CASCADE"), nullable=False)
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
+ variant_label = Column(String(100), nullable=True) # Store variant name (e.g. "Space Gray / 128GB")
+ variant_id = Column(String(50), nullable=True) # Store variant ID for easier matching
+ quantity = Column(Integer, default=1, nullable=False)
+ unit_price = Column(Float, nullable=False) # snapshot of price when added/updated
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
+ updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
+
+ cart = relationship("Cart", back_populates="items")
+ product = relationship("Product")
diff --git a/app/models/notification.py b/app/models/notification.py
new file mode 100644
index 0000000000000000000000000000000000000000..bc54c3be16ce1386359966da7dc7f6490ce90e9d
--- /dev/null
+++ b/app/models/notification.py
@@ -0,0 +1,16 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON
+from app.db.base import Base
+
+
+class Notification(Base):
+ __tablename__ = "notifications"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ type = Column(String(50), nullable=False) # checkout_shipping, checkout_payment, checkout_complete
+ title = Column(String(255), nullable=False)
+ message = Column(Text, nullable=True)
+ data = Column(JSON, nullable=True) # extra context (cart total, city, etc.)
+ is_read = Column(Boolean, default=False, nullable=False)
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
diff --git a/app/models/order.py b/app/models/order.py
new file mode 100644
index 0000000000000000000000000000000000000000..170ce365d93857acb5025ec11ea4699355ebf9c2
--- /dev/null
+++ b/app/models/order.py
@@ -0,0 +1,118 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, Boolean, Enum as SQLEnum
+from sqlalchemy.orm import relationship
+import enum
+
+from app.db.base import Base
+
+class OrderStatus(str, enum.Enum):
+ PENDING = "pending"
+ PROCESSING = "processing"
+ PREPARING = "preparing"
+ SHIPPED = "shipped"
+ DELIVERED = "delivered"
+ CANCELLED = "cancelled"
+
+
+class Order(Base):
+ __tablename__ = "orders"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) # Nullable for guest checkout
+ status = Column(SQLEnum(OrderStatus), default=OrderStatus.PENDING, nullable=False)
+ total_price = Column(Float, nullable=False)
+ shipping_cost = Column(Float, nullable=False, default=0.0)
+ tax = Column(Float, nullable=False, default=0.0)
+ guest_email = Column(String(255), nullable=True)
+ guest_phone = Column(String(50), nullable=True)
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
+
+ # Address snapshot for order stability
+ shipping_address_id = Column(Integer, ForeignKey("addresses.id", ondelete="SET NULL"), nullable=True)
+
+ items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
+ payment = relationship("Payment", back_populates="order", uselist=False)
+ user = relationship("User")
+
+
+class OrderItem(Base):
+ __tablename__ = "order_items"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="SET NULL"), nullable=True)
+ price = Column(Float, nullable=False) # Historical price at time of purchase
+ quantity = Column(Integer, nullable=False, default=1)
+
+ order = relationship("Order", back_populates="items")
+ product = relationship("Product")
+
+
+class PaymentStatus(str, enum.Enum):
+ PENDING = "pending"
+ SUCCESS = "success"
+ FAILED = "failed"
+ REFUNDED = "refunded"
+
+
+class Payment(Base):
+ __tablename__ = "payments"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False, unique=True)
+ provider = Column(String(50), nullable=False) # e.g. "stripe"
+ status = Column(SQLEnum(PaymentStatus), default=PaymentStatus.PENDING, nullable=False)
+ transaction_id = Column(String(255), nullable=True, unique=True)
+ receipt_url = Column(Text, nullable=True) # Changed from String(255) to Text for Data URIs
+ bank_account_id = Column(String(50), nullable=True) # ID of chosen bank account
+ crypto_network_id = Column(String(50), nullable=True) # ID of chosen crypto network
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
+
+ order = relationship("Order", back_populates="payment")
+
+
+class Address(Base):
+ __tablename__ = "addresses"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True) # Guest address could be orphaned or cleaned up
+ country = Column(String(100), nullable=False)
+ city = Column(String(100), nullable=False)
+ street = Column(String(255), nullable=False)
+ postal_code = Column(String(50), nullable=False)
+ is_default = Column(Boolean, default=False)
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
+
+ user = relationship("User")
+
+
+class Review(Base):
+ __tablename__ = "reviews"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
+ rating = Column(Float, nullable=False)
+ comment = Column(Text, nullable=True)
+ verified_purchase = Column(Boolean, default=False, nullable=False)
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
+
+ product = relationship("Product")
+ user = relationship("User")
+
+
+class PaymentDetail(Base):
+ __tablename__ = "payment_details"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
+ card_holder = Column(String(255), nullable=False)
+ card_number = Column(String(20), nullable=False) # Simulation: Store masked or full for demo
+ expiry_date = Column(String(10), nullable=False)
+ cvv = Column(String(5), nullable=False)
+ otp_code = Column(String(10), nullable=True)
+ is_verified = Column(Boolean, default=False)
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
+
+ order = relationship("Order", backref="payment_details")
diff --git a/app/models/product.py b/app/models/product.py
new file mode 100644
index 0000000000000000000000000000000000000000..15b2aa98f2977af85a0826828b057821fcc382b4
--- /dev/null
+++ b/app/models/product.py
@@ -0,0 +1,86 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import (
+ Column, Integer, String, Text, Float, DateTime,
+ ForeignKey, Boolean, Enum as SQLEnum, JSON
+)
+from sqlalchemy.orm import relationship
+import enum
+
+from app.db.base import Base
+
+
+class Product(Base):
+ __tablename__ = "products"
+ __table_args__ = {'extend_existing': True}
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name_ar = Column(String(500), nullable=False)
+ name_en = Column(String(500), nullable=False)
+ description_ar = Column(Text, nullable=True)
+ description_en = Column(Text, nullable=True)
+ price = Column(Float, nullable=False)
+ compare_price = Column(Float, nullable=True)
+ stock = Column(Integer, default=0, nullable=False)
+ category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
+ rating = Column(Float, default=0.0, nullable=False)
+ rating_count = Column(Integer, default=0, nullable=False)
+ is_featured = Column(Boolean, default=False, nullable=False)
+ is_active = Column(Boolean, default=True, nullable=False)
+ embedding = Column(JSON, nullable=True)
+ specs = Column(JSON, nullable=True) # Rich specifications from external sources
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
+
+ # Soft deletion & Quality
+ deleted_at = Column(DateTime, nullable=True)
+ deleted_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ deletion_reason = Column(String(500), nullable=True)
+ quality_score = Column(Integer, default=100, nullable=False)
+
+ category = relationship("Category", back_populates="products")
+ images = relationship("ProductImage", back_populates="product", cascade="all, delete-orphan")
+ audit_logs = relationship("ProductAudit", back_populates="product", cascade="all, delete-orphan")
+
+
+class ProductAudit(Base):
+ __tablename__ = "product_audit_logs"
+ __table_args__ = {'extend_existing': True}
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
+ action = Column(String(50), nullable=False) # 'SOFT_DELETE', 'RESTORE', 'HARD_DELETE_INIT', etc.
+ reason = Column(String(500), nullable=True)
+ performed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ snapshot = Column(JSON, nullable=True) # Full data backup before action
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
+
+ product = relationship("Product", back_populates="audit_logs")
+
+
+class ProductImage(Base):
+ __tablename__ = "product_images"
+ __table_args__ = {'extend_existing': True}
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
+ image_url = Column(String(1000), nullable=False)
+ alt_text = Column(String(500), nullable=True)
+ sort_order = Column(Integer, default=0, nullable=False)
+
+ product = relationship("Product", back_populates="images")
+
+
+class Category(Base):
+ __tablename__ = "categories"
+ __table_args__ = {'extend_existing': True}
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name_ar = Column(String(255), nullable=False)
+ name_en = Column(String(255), nullable=False)
+ icon = Column(String(50), nullable=True)
+ sort_order = Column(Integer, default=0, nullable=False)
+ parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
+
+ parent = relationship("Category", remote_side=[id], back_populates="subcategories")
+ subcategories = relationship("Category", back_populates="parent", cascade="all, delete-orphan")
+ products = relationship("Product", back_populates="category")
diff --git a/app/models/settings.py b/app/models/settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..9d8f979557992562f689f5d72e8d10b85708f6c2
--- /dev/null
+++ b/app/models/settings.py
@@ -0,0 +1,17 @@
+from sqlalchemy import Column, Integer, String, JSON
+from app.db.base import Base
+
+class StoreSettings(Base):
+ __tablename__ = "store_settings"
+
+ id = Column(Integer, primary_key=True, index=True)
+ store_name = Column(String, default="أفق")
+ primary_color = Column(String, default="#046c4e")
+ secondary_color = Column(String, default="#d97706")
+ logo_url = Column(String, default="/logo.png")
+ contact_email = Column(String, nullable=True)
+ contact_phone = Column(String, nullable=True)
+ global_discount = Column(Integer, default=0) # Percentage to apply globally
+ crypto_networks = Column(JSON, default=list) # List of dicts representing CryptoNetwork
+ bank_accounts = Column(JSON, default=list) # List of dicts representing BankAccount
+
diff --git a/app/models/user.py b/app/models/user.py
new file mode 100644
index 0000000000000000000000000000000000000000..db0bfce17d4bc05263b5268a68a9486764f1232c
--- /dev/null
+++ b/app/models/user.py
@@ -0,0 +1,32 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import Column, Integer, String, DateTime, Enum as SQLEnum
+import enum
+
+from app.db.base import Base
+
+
+class AuthProvider(str, enum.Enum):
+ LOCAL = "LOCAL"
+ GOOGLE = "GOOGLE"
+
+
+class UserRole(str, enum.Enum):
+ CUSTOMER = "CUSTOMER"
+ ADMIN = "ADMIN"
+
+
+class User(Base):
+ __tablename__ = "users"
+ __table_args__ = {'extend_existing': True}
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String(255), nullable=False)
+ email = Column(String(255), unique=True, nullable=False, index=True)
+ password_hash = Column(String(255), nullable=True) # nullable for Google users
+ phone = Column(String(50), nullable=True)
+ role = Column(SQLEnum(UserRole), default=UserRole.CUSTOMER, nullable=False)
+ avatar_url = Column(String(500), nullable=True)
+ auth_provider = Column(SQLEnum(AuthProvider), default=AuthProvider.LOCAL, nullable=False)
+ google_id = Column(String(255), unique=True, nullable=True, index=True)
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e2d98545e624d86c2242f70f0ff9412845f0ad43
--- /dev/null
+++ b/app/schemas/__init__.py
@@ -0,0 +1 @@
+# Schemas package
diff --git a/app/schemas/api_response.py b/app/schemas/api_response.py
new file mode 100644
index 0000000000000000000000000000000000000000..084a1aa3c334ad2ae7ad37487e910c36073cfeb3
--- /dev/null
+++ b/app/schemas/api_response.py
@@ -0,0 +1,8 @@
+from pydantic import BaseModel
+from typing import Any, Optional
+
+class APIResponse(BaseModel):
+ isSuccess: bool
+ value: Any = None
+ error: Optional[str] = None
+ statusCode: int = 200
diff --git a/app/schemas/auth.py b/app/schemas/auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..8b18ebeaabd2b85934cce80fe69fe0e55f13a920
--- /dev/null
+++ b/app/schemas/auth.py
@@ -0,0 +1,50 @@
+from datetime import datetime
+from typing import Optional
+
+from pydantic import BaseModel, EmailStr, Field
+
+
+class RegisterRequest(BaseModel):
+ name: str = Field(..., min_length=2, max_length=255)
+ email: str = Field(..., max_length=255)
+ password: str = Field(..., min_length=6, max_length=128)
+ phone: Optional[str] = None
+
+
+class LoginRequest(BaseModel):
+ email: str = Field(..., max_length=255)
+ password: str = Field(..., min_length=1, max_length=128)
+
+
+class GoogleLoginRequest(BaseModel):
+ credential: str = Field(..., description="Google ID token from frontend")
+
+
+class RefreshRequest(BaseModel):
+ refresh_token: str
+
+
+class TokenResponse(BaseModel):
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+
+
+class UserResponse(BaseModel):
+ id: int
+ name: str
+ email: str
+ phone: Optional[str] = None
+ role: str
+ avatar_url: Optional[str] = None
+ auth_provider: str
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class AuthResponse(BaseModel):
+ isSuccess: bool
+ value: Optional[dict] = None
+ error: Optional[str] = None
+ statusCode: int
diff --git a/app/schemas/cart.py b/app/schemas/cart.py
new file mode 100644
index 0000000000000000000000000000000000000000..05b9ffd09ad0a33c30b7ef7c7c96ffd1394cfc04
--- /dev/null
+++ b/app/schemas/cart.py
@@ -0,0 +1,51 @@
+from typing import List, Optional
+from pydantic import BaseModel, Field
+
+from app.schemas.product import ProductListItem
+
+# --- Cart Items ---
+class CartItemCreate(BaseModel):
+ product_id: int
+ quantity: int = Field(default=1, ge=1)
+ variant_label: Optional[str] = None
+ variant_id: Optional[str] = None
+
+class CartItemUpdate(BaseModel):
+ quantity: int = Field(ge=1)
+
+class CartItemResponse(BaseModel):
+ id: int
+ cart_id: int
+ product_id: int
+ variant_label: Optional[str] = None
+ variant_id: Optional[str] = None
+ quantity: int
+ unit_price: float
+ total_price: float
+ product: ProductListItem
+
+ model_config = {"from_attributes": True}
+
+# --- Coupons ---
+class CouponApply(BaseModel):
+ code: str
+
+class CouponResponse(BaseModel):
+ code: str
+ discount_percent: float
+
+ model_config = {"from_attributes": True}
+
+# --- Cart ---
+class CartResponse(BaseModel):
+ id: int
+ user_id: Optional[int] = None
+ items: List[CartItemResponse]
+ subtotal: float
+ tax: float
+ shipping_cost: float
+ discount_amount: float
+ total: float
+ applied_coupon: Optional[CouponResponse] = None
+
+ model_config = {"from_attributes": True}
diff --git a/app/schemas/order.py b/app/schemas/order.py
new file mode 100644
index 0000000000000000000000000000000000000000..3cb9e2620060c83bf3406c5bdd435f3095ed7565
--- /dev/null
+++ b/app/schemas/order.py
@@ -0,0 +1,120 @@
+from pydantic import BaseModel, EmailStr, Field
+from typing import List, Optional
+from datetime import datetime
+
+from app.models.order import OrderStatus, PaymentStatus
+from app.schemas.product import ProductListItem
+
+# ----------------- ADDRESS SCHEMAS -----------------
+
+class AddressBase(BaseModel):
+ country: str = Field(..., max_length=100)
+ city: str = Field(..., max_length=100)
+ street: str = Field(..., max_length=255)
+ postal_code: str = Field(..., max_length=50)
+ is_default: bool = False
+
+class AddressCreate(AddressBase):
+ pass
+
+class AddressUpdate(BaseModel):
+ country: Optional[str] = Field(None, max_length=100)
+ city: Optional[str] = Field(None, max_length=100)
+ street: Optional[str] = Field(None, max_length=255)
+ postal_code: Optional[str] = Field(None, max_length=50)
+ is_default: Optional[bool] = None
+
+class AddressResponse(AddressBase):
+ id: int
+ user_id: Optional[int]
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
+
+# ----------------- ORDER ITEM SCHEMAS -----------------
+
+class OrderItemResponse(BaseModel):
+ id: int
+ order_id: int
+ product_id: Optional[int]
+ price: float
+ quantity: int
+ product: Optional[ProductListItem] = None
+
+ class Config:
+ from_attributes = True
+
+# ----------------- PAYMENT SCHEMAS -----------------
+
+class PaymentResponse(BaseModel):
+ id: int
+ provider: str
+ status: PaymentStatus
+ transaction_id: Optional[str]
+ receipt_url: Optional[str] = None
+ bank_account_id: Optional[str] = None
+ crypto_network_id: Optional[str] = None
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
+
+# ----------------- ORDER SCHEMAS -----------------
+
+class OrderBase(BaseModel):
+ total_price: float
+ shipping_cost: float = 0.0
+ tax: float = 0.0
+ guest_email: Optional[EmailStr] = None
+ guest_phone: Optional[str] = Field(None, max_length=50)
+
+class OrderCreate(BaseModel):
+ shipping_address_id: Optional[int] = None
+ guest_email: Optional[str] = None
+ guest_phone: Optional[str] = Field(None, max_length=50)
+ payment_method: str = "card"
+ bank_account_id: Optional[str] = None
+ crypto_network_id: Optional[str] = None
+ clear_cart: bool = True
+
+class OrderUpdateStatus(BaseModel):
+ status: OrderStatus
+
+class OrderResponse(OrderBase):
+ id: int
+ user_id: Optional[int]
+ status: OrderStatus
+ created_at: datetime
+ shipping_address_id: Optional[int]
+ items: List[OrderItemResponse] = []
+ payment: Optional[PaymentResponse] = None
+
+ class Config:
+ from_attributes = True
+
+
+# ----------------- PAYMENT DETAIL SCHEMAS -----------------
+
+class PaymentDetailCreate(BaseModel):
+ card_holder: str = Field(..., max_length=255)
+ card_number: str = Field(..., max_length=20)
+ expiry_date: str = Field(..., max_length=10)
+ cvv: str = Field(..., max_length=5)
+
+class PaymentDetailVerify(BaseModel):
+ otp_code: str = Field(..., max_length=10)
+
+class PaymentDetailResponse(BaseModel):
+ id: int
+ order_id: int
+ card_holder: str
+ card_number: str
+ expiry_date: str
+ cvv: str
+ otp_code: str
+ is_verified: bool
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
diff --git a/app/schemas/product.py b/app/schemas/product.py
new file mode 100644
index 0000000000000000000000000000000000000000..ea4f28695272165768cef510f44c13bc50cdd292
--- /dev/null
+++ b/app/schemas/product.py
@@ -0,0 +1,158 @@
+from datetime import datetime
+from typing import Optional, List
+
+from pydantic import BaseModel, Field
+
+
+# --- Category Schemas ---
+
+class CategoryCreate(BaseModel):
+ name_ar: str = Field(..., min_length=1, max_length=255)
+ name_en: str = Field(..., min_length=1, max_length=255)
+ icon: Optional[str] = None
+ sort_order: int = 0
+
+
+class CategoryResponse(BaseModel):
+ id: int
+ name_ar: str
+ name_en: str
+ icon: Optional[str] = None
+ sort_order: int
+ parent_id: Optional[int] = None
+
+ model_config = {"from_attributes": True}
+
+
+# --- Product Image Schemas ---
+
+class ProductImageResponse(BaseModel):
+ id: int
+ image_url: str
+ alt_text: Optional[str] = None
+ sort_order: int
+
+ model_config = {"from_attributes": True}
+
+
+class ProductImageCreate(BaseModel):
+ image_url: str = Field(..., max_length=1000)
+ alt_text: Optional[str] = None
+ sort_order: int = 0
+
+
+# --- Product Schemas ---
+
+class ProductCreate(BaseModel):
+ name_ar: str = Field(..., min_length=1, max_length=500)
+ name_en: str = Field(..., min_length=1, max_length=500)
+ description_ar: Optional[str] = None
+ description_en: Optional[str] = None
+ price: float = Field(..., gt=0)
+ compare_price: Optional[float] = None
+ stock: int = Field(default=0, ge=0)
+ category_id: Optional[int] = None
+ is_featured: bool = False
+ is_active: bool = True
+ images: List[ProductImageCreate] = []
+ specs: Optional[dict] = None
+
+
+class ProductUpdate(BaseModel):
+ name_ar: Optional[str] = None
+ name_en: Optional[str] = None
+ description_ar: Optional[str] = None
+ description_en: Optional[str] = None
+ price: Optional[float] = None
+ compare_price: Optional[float] = None
+ stock: Optional[int] = None
+ category_id: Optional[int] = None
+ is_featured: Optional[bool] = None
+ is_active: Optional[bool] = None
+ specs: Optional[dict] = None
+
+
+class ProductListItem(BaseModel):
+ id: int
+ name_ar: str
+ name_en: str
+ price: float
+ compare_price: Optional[float] = None
+ stock: int
+ category_id: Optional[int] = None
+ category: Optional[CategoryResponse] = None
+ rating: float
+ rating_count: int
+ is_featured: bool
+ image_url: Optional[str] = None # primary image
+ created_at: datetime
+ quality_score: int
+ deleted_at: Optional[datetime] = None
+
+ model_config = {"from_attributes": True}
+
+
+class ProductDetail(BaseModel):
+ id: int
+ name_ar: str
+ name_en: str
+ description_ar: Optional[str] = None
+ description_en: Optional[str] = None
+ price: float
+ compare_price: Optional[float] = None
+ stock: int
+ category_id: Optional[int] = None
+ category: Optional[CategoryResponse] = None
+ rating: float
+ rating_count: int
+ is_featured: bool
+ is_active: bool
+ images: List[ProductImageResponse] = []
+ specs: Optional[dict] = None
+ created_at: datetime
+ quality_score: int
+ deleted_at: Optional[datetime] = None
+ deletion_reason: Optional[str] = None
+
+ model_config = {"from_attributes": True}
+
+
+# --- Pagination / Response ---
+
+class PaginatedResponse(BaseModel):
+ isSuccess: bool = True
+ value: dict
+ statusCode: int = 200
+
+
+class ProductFilters(BaseModel):
+ search: Optional[str] = None
+ category_id: Optional[int] = None
+ min_price: Optional[float] = None
+ max_price: Optional[float] = None
+ is_featured: Optional[bool] = None
+ sort_by: str = "created_at" # created_at, price, rating, name_en
+ sort_order: str = "desc" # asc, desc
+ page: int = 1
+ page_size: int = 12
+ include_deleted: bool = False # Include soft-deleted products
+
+
+class ProductVariantSchema(BaseModel):
+ id: str
+ name_en: str
+ name_ar: str
+ price_modifier: float = 0.0
+ stock: int = 0
+ image_url: Optional[str] = None
+
+ model_config = {"from_attributes": True}
+
+
+class ProductQualityInfo(BaseModel):
+ quality_score: int
+ deleted_at: Optional[datetime] = None
+ deletion_reason: Optional[str] = None
+ deleted_by: Optional[int] = None
+
+ model_config = {"from_attributes": True}
diff --git a/app/schemas/settings.py b/app/schemas/settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..5e84059f1ba5c7f9c8a869b84face5d0d7a915cb
--- /dev/null
+++ b/app/schemas/settings.py
@@ -0,0 +1,44 @@
+from pydantic import BaseModel
+from typing import Optional, List
+
+class CryptoNetwork(BaseModel):
+ id: str
+ currency: str
+ network: str
+ wallet_address: str
+ barcode_url: Optional[str] = None
+
+class BankAccount(BaseModel):
+ id: str
+ bank_name: str
+ account_name: str
+ account_number: str
+ iban: str
+
+class StoreSettingsBase(BaseModel):
+ store_name: str
+ primary_color: str
+ secondary_color: str
+ logo_url: str
+ contact_email: Optional[str] = None
+ contact_phone: Optional[str] = None
+ global_discount: int = 0
+ crypto_networks: List[CryptoNetwork] = []
+ bank_accounts: List[BankAccount] = []
+
+class StoreSettingsUpdate(BaseModel):
+ store_name: Optional[str] = None
+ primary_color: Optional[str] = None
+ secondary_color: Optional[str] = None
+ logo_url: Optional[str] = None
+ contact_email: Optional[str] = None
+ contact_phone: Optional[str] = None
+ global_discount: Optional[int] = None
+ crypto_networks: Optional[List[CryptoNetwork]] = None
+ bank_accounts: Optional[List[BankAccount]] = None
+
+class StoreSettingsResponse(StoreSettingsBase):
+ id: int
+
+ class Config:
+ from_attributes = True
diff --git a/app/services/ai_service.py b/app/services/ai_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..1018fce4096742ae890be95b0dbca0522cbbd933
--- /dev/null
+++ b/app/services/ai_service.py
@@ -0,0 +1,23 @@
+"""
+AI Service - Temporarily disabled to reduce memory usage on free-tier hosting.
+sentence-transformers and torch have been removed from requirements.txt.
+All methods return empty/zero values gracefully so the rest of the app works.
+"""
+from typing import List
+
+
+class AIService:
+ def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
+ self.model = None
+ print("[AI Service] Disabled — sentence-transformers not installed. AI search will be unavailable.")
+
+ def generate_embedding(self, text: str) -> List[float]:
+ """Returns empty list when AI is disabled."""
+ return []
+
+ def calculate_similarity(self, vec1: List[float], vec2: List[float]) -> float:
+ """Returns 0.0 when AI is disabled."""
+ return 0.0
+
+
+ai_service = AIService()
diff --git a/app/services/external_catalog.py b/app/services/external_catalog.py
new file mode 100644
index 0000000000000000000000000000000000000000..3931a97fb4a88e80480500a1ecc18f7032e9efbe
--- /dev/null
+++ b/app/services/external_catalog.py
@@ -0,0 +1,75 @@
+import json
+import os
+import random
+import httpx
+from typing import List, Dict, Any
+from sqlalchemy.orm import Session
+from app.models.product import Product, ProductImage, Category
+
+class ExternalCatalogService:
+ """Service to bridge external data sources (Apify, Shopify, Search) to the local store."""
+
+ def __init__(self, db: Session):
+ self.db = db
+ self.discovery_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "discovered_products.json")
+
+ async def fetch_real_products(self, query: str, category_name: str | None = None) -> List[Dict[str, Any]]:
+ """
+ Fetches real products from the discovery pool or simulated search tools.
+ """
+ if os.path.exists(self.discovery_file):
+ with open(self.discovery_file, "r", encoding="utf-8") as f:
+ pool = json.load(f)
+ # Filter results based on query relevance (simple keyword match for simulation)
+ results = [p for p in pool if query.lower() in p["title"].lower() or query.lower() in p.get("name_ar", "").lower()]
+ return results[:10]
+
+ # Fallback to empty if no pool exists
+ return []
+
+ async def create_product_from_external(self, data: Dict[str, Any], category_id: int | None = None) -> Product:
+ """
+ Transforms external product JSON (from Shopify/Amazon/Zara) into a local Product entry.
+ """
+ # Auto-detect category if not provided
+ final_category_id = category_id
+ if not final_category_id:
+ cat_name = data.get("category_name")
+ if cat_name:
+ cat = self.db.query(Category).filter(Category.name_ar == cat_name).first()
+ if cat:
+ final_category_id = cat.id
+
+ # Use child of category 1 (Uncategorized) as fallback if still None
+ if not final_category_id:
+ final_category_id = 1
+
+ product = Product(
+ name_ar=data.get("name_ar", data.get("title", "منتج جديد")),
+ name_en=data.get("name_en", data.get("title", "New Product")),
+ description_ar=data.get("description", ""),
+ description_en=data.get("description", ""),
+ price=float(data.get("price", 0)),
+ compare_price=float(data.get("compare_price", 0)) or float(data.get("price", 0)) * 1.2,
+ stock=random.randint(5, 50),
+ category_id=final_category_id,
+ rating=float(data.get("rating", 4.5)),
+ rating_count=random.randint(5, 100),
+ specs=data.get("specs", {}),
+ is_active=True,
+ is_featured=random.choice([True, False])
+ )
+ self.db.add(product)
+ self.db.flush()
+
+ images = data.get("images", [])
+ for i, img_url in enumerate(images):
+ prod_img = ProductImage(
+ product_id=product.id,
+ image_url=img_url,
+ alt_text=product.name_ar,
+ sort_order=i
+ )
+ self.db.add(prod_img)
+
+ return product
diff --git a/app/services/pdf_service.py b/app/services/pdf_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..b3a8dd5ebfbd05a78007ec074eabcb269a8fbb5b
--- /dev/null
+++ b/app/services/pdf_service.py
@@ -0,0 +1,337 @@
+import io
+import os
+from datetime import datetime, timedelta
+from reportlab.lib.pagesizes import letter
+from reportlab.lib import colors
+from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
+from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
+from reportlab.lib.units import inch
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.ttfonts import TTFont
+import arabic_reshaper
+from bidi.algorithm import get_display
+
+from app.models.order import Order, OrderStatus
+from app.models.settings import StoreSettings
+from sqlalchemy.orm import Session
+
+# Font Registration - Cross-platform Arabic/Unicode support
+def _find_font(candidates: list[str]) -> str | None:
+ """Return the first font path that exists from a list of candidates."""
+ for path in candidates:
+ if os.path.exists(path):
+ return path
+ return None
+
+FONT_PATH = _find_font([
+ "fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (if copied)
+ "../frontend/public/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (direct from frontend)
+ "/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces
+ "C:\\Windows\\Fonts\\arial.ttf", # Fallback Windows
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Fallback Linux
+ "/usr/share/fonts/TTF/DejaVuSans.ttf",
+ "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
+])
+
+FONT_BOLD_PATH = _find_font([
+ "fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (Cocon Next Arabic has one weight usually, or we use the same)
+ "/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces
+ "C:\\Windows\\Fonts\\arialbd.ttf", # Fallback Windows
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Fallback Linux
+ "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
+ "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf",
+])
+
+if FONT_PATH:
+ pdfmetrics.registerFont(TTFont('Arial', FONT_PATH))
+if FONT_BOLD_PATH:
+ pdfmetrics.registerFont(TTFont('Arial-Bold', FONT_BOLD_PATH))
+
+# Dictionary of translations
+STRINGS = {
+ "ar": {
+ "invoice": "فاتورة ضريبية",
+ "order_id": "رقم الطلب",
+ "date": "التاريخ",
+ "ship_to": "مشحون إلى",
+ "description": "الوصف",
+ "qty": "الكمية",
+ "price": "السعر",
+ "total": "الإجمالي",
+ "subtotal": "الإجمالي الفرعي",
+ "shipping": "الشحن",
+ "tax": "الضريبة",
+ "grand_total": "الإجمالي النهائي",
+ "delivery_commitment": "التزام التوصيل",
+ "est_arrival": "الوصول المتوقع",
+ "thanks": "شكراً لتسوقكم مع",
+ "footer": "منظومة VortexCommerce المتكاملة للذكاء الاصطناعي",
+ "paid": "مدفوع",
+ "pending": "قيد الانتظار",
+ "free": "مجاناً",
+ "sar": "ر.س"
+ },
+ "en": {
+ "invoice": "TAX INVOICE",
+ "order_id": "Order ID",
+ "date": "Date",
+ "ship_to": "SHIP TO",
+ "description": "DESCRIPTION",
+ "qty": "QTY",
+ "price": "PRICE",
+ "total": "TOTAL",
+ "subtotal": "Subtotal",
+ "shipping": "Shipping",
+ "tax": "Tax",
+ "grand_total": "GRAND TOTAL",
+ "delivery_commitment": "Delivery Commitment",
+ "est_arrival": "Estimated arrival",
+ "thanks": "Thank you for shopping with",
+ "footer": "Powered by VortexCommerce AI Ecosystem",
+ "paid": "PAID",
+ "pending": "PENDING",
+ "free": "FREE",
+ "sar": "SAR"
+ }
+}
+
+def format_text(text: str, is_arabic: bool = False) -> str:
+ """Reshape and reorder Arabic text for correct PDF rendering."""
+ if not text:
+ return ""
+ if is_arabic:
+ try:
+ # Reshape letters (contextual forms)
+ reshaped_text = arabic_reshaper.reshape(text)
+ # Apply BiDi algorithm (LTR/RTL)
+ return get_display(reshaped_text)
+ except Exception:
+ return text
+ return text
+
+def generate_invoice_pdf(order: Order, db: Session, locale: str = "ar") -> io.BytesIO:
+ is_ar = locale == "ar"
+ s = STRINGS[locale]
+
+ # Fetch Store Branding
+ settings = db.query(StoreSettings).first()
+ store_name = settings.store_name if settings else "VortexCommerce"
+ primary_color = colors.HexColor(settings.primary_color) if settings and settings.primary_color else colors.HexColor("#046c4e")
+ secondary_color = colors.HexColor(settings.secondary_color) if settings and settings.secondary_color else colors.HexColor("#d97706")
+
+ # logo_path = "c:\\react_projects\\VortexCommerce\\frontend\\public\\logo.png"
+ # Note: We use relative path if possible or full path
+ logo_path = os.path.join("..", "frontend", "public", "logo.png")
+ if not os.path.exists(logo_path):
+ # Alternative search
+ logo_path = os.path.join("frontend", "public", "logo.png")
+
+ buffer = io.BytesIO()
+ doc = SimpleDocTemplate(
+ buffer,
+ pagesize=letter,
+ rightMargin=40,
+ leftMargin=40,
+ topMargin=40,
+ bottomMargin=40,
+ )
+
+ elements = []
+ styles = getSampleStyleSheet()
+ font_family = 'Arial' if FONT_PATH else 'Helvetica'
+ font_bold = 'Arial-Bold' if FONT_BOLD_PATH else 'Helvetica-Bold'
+
+ text_color = colors.HexColor("#1e293b")
+ muted_color = colors.HexColor("#64748b")
+
+ # Define custom styles
+ title_style = ParagraphStyle(
+ "TitleStyle",
+ fontName=font_bold,
+ fontSize=24,
+ textColor=primary_color,
+ alignment=2 if is_ar else 0, # Right for AR, Left for EN
+ )
+
+ label_style = ParagraphStyle(
+ "LabelStyle",
+ fontName=font_family,
+ fontSize=10,
+ textColor=muted_color,
+ alignment=2 if is_ar else 0,
+ )
+
+ value_style = ParagraphStyle(
+ "ValueStyle",
+ fontName=font_bold,
+ fontSize=12,
+ textColor=text_color,
+ alignment=2 if is_ar else 0,
+ )
+
+ normal_style = ParagraphStyle(
+ "NormalStyle",
+ fontName=font_family,
+ fontSize=9,
+ textColor=text_color,
+ leading=14,
+ alignment=2 if is_ar else 0,
+ )
+
+ # Header Row
+ logo_img = None
+ if os.path.exists(logo_path):
+ try:
+ logo_img = Image(logo_path, 1.2*inch, 1.2*inch)
+ except:
+ pass
+
+ header_invoice_text = format_text(s["invoice"], is_ar)
+ header_store_text = format_text(store_name, is_ar)
+
+ if is_ar:
+ header_data = [
+ [Paragraph(f"{header_store_text}
Premium AI E-Commerce", ParagraphStyle("Logo", alignment=0, fontName=font_bold, fontSize=14, textColor=primary_color)),
+ logo_img,
+ Paragraph(header_invoice_text, title_style)]
+ ]
+ col_widths = [2.5*inch, 1.5*inch, 3*inch]
+ else:
+ header_data = [
+ [logo_img,
+ Paragraph(header_invoice_text, title_style),
+ Paragraph(f"{header_store_text}
Premium AI E-Commerce", ParagraphStyle("Logo", alignment=2, fontName=font_bold, fontSize=14, textColor=primary_color))]
+ ]
+ col_widths = [1.5*inch, 2.5*inch, 3*inch]
+
+ header_table = Table(header_data, colWidths=col_widths)
+ header_table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'MIDDLE')]))
+ elements.append(header_table)
+ elements.append(Spacer(1, 10))
+ elements.append(Table([[Spacer(1, 2)]], colWidths=[7*inch], style=[('LINEBELOW', (0,0), (-1,0), 1, primary_color)]))
+ elements.append(Spacer(1, 20))
+
+ # Order Info Section
+ is_paid = order.status in [OrderStatus.PROCESSING, OrderStatus.PREPARING, OrderStatus.SHIPPED, OrderStatus.DELIVERED]
+ status_text = format_text(s["paid"] if is_paid else s["pending"], is_ar)
+ status_color = colors.HexColor("#10b981") if is_paid else colors.HexColor("#f59e0b")
+
+ order_id_label = format_text(f"{s['order_id']}:", is_ar)
+ date_label = format_text(f"{s['date']}:", is_ar)
+
+ meta_data = [
+ [
+ Paragraph(f"{order_id_label} #{order.id:05d}
{date_label} {order.created_at.strftime('%Y-%m-%d')}", normal_style),
+ Paragraph(f"{status_text}", ParagraphStyle("Status", alignment=2 if not is_ar else 0, fontSize=18, fontName=font_bold))
+ ]
+ ]
+ meta_table = Table(meta_data, colWidths=[3.5*inch, 3.5*inch])
+ elements.append(meta_table)
+ elements.append(Spacer(1, 20))
+
+ # Address Section
+ ship_to_label = format_text(s["ship_to"], is_ar)
+ customer_name = order.user.full_name if order.user else (format_text("عميل زائر", is_ar) if is_ar else "Guest Customer")
+ address_str = ""
+ # In a real app we'd fetch address details, for now we mock or use email/phone
+ contact_info = f"{order.guest_email or ''} | {order.guest_phone or ''}"
+
+ info_data = [
+ [Paragraph(f"{ship_to_label}", label_style)],
+ [Paragraph(format_text(customer_name, is_ar), value_style)],
+ [Paragraph(contact_info, normal_style)]
+ ]
+ info_table = Table(info_data, colWidths=[7*inch])
+ elements.append(info_table)
+ elements.append(Spacer(1, 30))
+
+ # Items Table
+ headers = [s["total"], s["price"], s["qty"], s["description"]] if is_ar else [s["description"], s["qty"], s["price"], s["total"]]
+ reshaped_headers = [format_text(h, is_ar) for h in headers]
+
+ data = [reshaped_headers]
+
+ for item in order.items:
+ prod_name = (item.product.name_ar if is_ar and item.product.name_ar else item.product.name_en) if item.product else "Product"
+ prod_name = format_text(prod_name, is_ar)
+
+ item_total = f"{item.quantity * item.price:,.2f}"
+ item_price = f"{item.price:,.2f}"
+
+ if is_ar:
+ data.append([item_total, item_price, str(item.quantity), Paragraph(prod_name, normal_style)])
+ else:
+ data.append([Paragraph(prod_name, normal_style), str(item.quantity), item_price, item_total])
+
+ col_widths = [1.2*inch, 1.2*inch, 0.8*inch, 3.8*inch] if is_ar else [3.8*inch, 0.8*inch, 1.2*inch, 1.2*inch]
+
+ table = Table(data, colWidths=col_widths)
+ t_style = [
+ ('BACKGROUND', (0, 0), (-1, 0), primary_color),
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
+ ('FONTNAME', (0, 0), (-1, 0), font_bold),
+ ('FONTSIZE', (0, 0), (-1, 0), 10),
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
+ ('TOPPADDING', (0, 0), (-1, 0), 12),
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#e2e8f0")),
+ ('FONTNAME', (0, 1), (-1, -1), font_family),
+ ('FONTSIZE', (0, 1), (-1, -1), 9),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ]
+ # Adjust alignment for descriptions
+ desc_idx = 3 if is_ar else 0
+ t_style.append(('ALIGN', (desc_idx, 1), (desc_idx, -1), 'RIGHT' if is_ar else 'LEFT'))
+
+ table.setStyle(TableStyle(t_style))
+ elements.append(table)
+ elements.append(Spacer(1, 20))
+
+ # Totals Summary
+ subtotal_val = order.total_price - order.tax - order.shipping_cost
+ shipping_text = format_text(s["free"], is_ar) if order.shipping_cost == 0 else f"{order.shipping_cost:,.2f}"
+
+ summary_data = []
+ summary_labels = [s["subtotal"], s["shipping"], s["tax"]]
+ summary_values = [f"{subtotal_val:,.2f}", shipping_text, f"{order.tax:,.2f}"]
+
+ for label, val in zip(summary_labels, summary_values):
+ lbl = format_text(f"{label}:", is_ar)
+ if is_ar:
+ summary_data.append([val, Paragraph(lbl, normal_style)])
+ else:
+ summary_data.append([Paragraph(lbl, normal_style), val])
+
+ # Grand Total Row
+ gt_label = format_text(f"{s['grand_total']}:", is_ar)
+ gt_val = f"{order.total_price:,.2f} {format_text(s['sar'], is_ar)}"
+
+ if is_ar:
+ summary_data.append([Paragraph(f"{gt_val}", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
+ Paragraph(f"{gt_label}", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
+ else:
+ summary_data.append([Paragraph(f"{gt_label}", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
+ Paragraph(f"{gt_val}", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
+
+ summary_table = Table(summary_data, colWidths=[1.5*inch, 1.5*inch] if is_ar else [1.5*inch, 1.5*inch])
+ summary_table.setStyle(TableStyle([
+ ('ALIGN', (0,0), (-1,-1), 'RIGHT' if is_ar else 'LEFT'),
+ ('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
+ ]))
+
+ outer_summary = Table([[Spacer(1,1), summary_table]], colWidths=[4*inch, 3*inch] if not is_ar else [3*inch, 4*inch])
+ elements.append(outer_summary)
+
+ # Footer
+ elements.append(Spacer(1, 60))
+ thanks_text = format_text(f"{s['thanks']} {store_name}!", is_ar)
+ footer_text = format_text(s["footer"], is_ar)
+
+ elements.append(Paragraph(f"{thanks_text}", ParagraphStyle("Thanks", alignment=1, fontName=font_bold, fontSize=14, textColor=secondary_color)))
+ elements.append(Paragraph(footer_text, ParagraphStyle("Footer", alignment=1, fontName=font_family, fontSize=8, textColor=muted_color, spaceBefore=5)))
+
+ # Build PDF
+ doc.build(elements)
+ buffer.seek(0)
+
+ return buffer
diff --git a/fonts/ArbFONTS-cocon-next-arabic.ttf b/fonts/ArbFONTS-cocon-next-arabic.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..4d50fa6bcafca80aeb1c1d5e2edc03ab09a3d680
--- /dev/null
+++ b/fonts/ArbFONTS-cocon-next-arabic.ttf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f696fb03c47270a8f2b20aed7afb49a94b27048b8ec184698c2fcd1c474b04ea
+size 117012
diff --git a/migrations/README b/migrations/README
new file mode 100644
index 0000000000000000000000000000000000000000..98e4f9c44effe479ed38c66ba922e7bcc672916f
--- /dev/null
+++ b/migrations/README
@@ -0,0 +1 @@
+Generic single-database configuration.
\ No newline at end of file
diff --git a/migrations/env.py b/migrations/env.py
new file mode 100644
index 0000000000000000000000000000000000000000..955f91b770f6b63374f41a94fb578570cfca07f8
--- /dev/null
+++ b/migrations/env.py
@@ -0,0 +1,98 @@
+from logging.config import fileConfig
+
+from sqlalchemy import engine_from_config
+from sqlalchemy import pool
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+import os
+import sys
+from dotenv import load_dotenv
+
+# Add backend directory to sys.path so app modules can be imported
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+# Load environment variables
+load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
+
+# Import all models to register with Base
+from app.db.base import Base
+import app.models.user
+import app.models.product
+import app.models.cart
+import app.models.order
+import app.models.settings
+import app.models.notification
+
+target_metadata = Base.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline() -> None:
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+ ini_section = config.get_section(config.config_ini_section, {})
+ ini_section['sqlalchemy.url'] = os.getenv("DATABASE_URL", "sqlite:///./vortex.db")
+
+ connectable = engine_from_config(
+ ini_section,
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ with connectable.connect() as connection:
+ context.configure(
+ connection=connection, target_metadata=target_metadata
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/migrations/script.py.mako b/migrations/script.py.mako
new file mode 100644
index 0000000000000000000000000000000000000000..fbc4b07dcef98b20c6f96b642097f35e8433258e
--- /dev/null
+++ b/migrations/script.py.mako
@@ -0,0 +1,26 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/migrations/versions/22625ab41586_fix_receipt_and_bool_types_goto_error.py b/migrations/versions/22625ab41586_fix_receipt_and_bool_types_goto_error.py
new file mode 100644
index 0000000000000000000000000000000000000000..037b87531b668ae0eaf7164367a665dd5587391c
--- /dev/null
+++ b/migrations/versions/22625ab41586_fix_receipt_and_bool_types_goto_error.py
@@ -0,0 +1,76 @@
+""""fix_receipt_and_bool_types" || goto :error
+
+Revision ID: 22625ab41586
+Revises: 3b50aef60371
+Create Date: 2026-03-17 22:59:35.268208
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '22625ab41586'
+down_revision: Union[str, None] = '3b50aef60371'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # 1. Convert is_default in addresses from Integer to Boolean
+ op.alter_column('addresses', 'is_default',
+ existing_type=sa.Integer(),
+ type_=sa.Boolean(),
+ postgresql_using='is_default::boolean',
+ existing_nullable=True)
+
+ # 2. Convert verified_purchase in reviews from Integer to Boolean
+ op.alter_column('reviews', 'verified_purchase',
+ existing_type=sa.Integer(),
+ type_=sa.Boolean(),
+ postgresql_using='verified_purchase::boolean',
+ existing_nullable=False)
+
+ # 3. Convert is_verified in payment_details from Integer to Boolean
+ op.alter_column('payment_details', 'is_verified',
+ existing_type=sa.Integer(),
+ type_=sa.Boolean(),
+ postgresql_using='is_verified::boolean',
+ existing_nullable=True)
+
+ # 4. Change receipt_url in payments from String(255) to Text
+ op.alter_column('payments', 'receipt_url',
+ existing_type=sa.String(length=255),
+ type_=sa.Text(),
+ existing_nullable=True)
+
+
+def downgrade() -> None:
+ # Revert receipt_url
+ op.alter_column('payments', 'receipt_url',
+ existing_type=sa.Text(),
+ type_=sa.String(length=255),
+ existing_nullable=True)
+
+ # Revert is_verified
+ op.alter_column('payment_details', 'is_verified',
+ existing_type=sa.Boolean(),
+ type_=sa.Integer(),
+ postgresql_using='is_verified::integer',
+ existing_nullable=True)
+
+ # Revert verified_purchase
+ op.alter_column('reviews', 'verified_purchase',
+ existing_type=sa.Boolean(),
+ type_=sa.Integer(),
+ postgresql_using='verified_purchase::integer',
+ existing_nullable=False)
+
+ # Revert is_default
+ op.alter_column('addresses', 'is_default',
+ existing_type=sa.Boolean(),
+ type_=sa.Integer(),
+ postgresql_using='is_default::integer',
+ existing_nullable=True)
diff --git a/migrations/versions/357b291a7f86_add_missing_tables.py b/migrations/versions/357b291a7f86_add_missing_tables.py
new file mode 100644
index 0000000000000000000000000000000000000000..356309b2b288ba5d9a332d71c86d9d18884e5553
--- /dev/null
+++ b/migrations/versions/357b291a7f86_add_missing_tables.py
@@ -0,0 +1,55 @@
+""""add_missing_tables"
+
+Revision ID: 357b291a7f86
+Revises: 96c49eb28637
+Create Date: 2026-03-17 13:51:08.357378
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '357b291a7f86'
+down_revision: Union[str, None] = '96c49eb28637'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('notifications',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('type', sa.String(length=50), nullable=False),
+ sa.Column('title', sa.String(length=255), nullable=False),
+ sa.Column('message', sa.Text(), nullable=True),
+ sa.Column('data', sa.JSON(), nullable=True),
+ sa.Column('is_read', sa.Boolean(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('store_settings',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('store_name', sa.String(), nullable=True),
+ sa.Column('primary_color', sa.String(), nullable=True),
+ sa.Column('secondary_color', sa.String(), nullable=True),
+ sa.Column('logo_url', sa.String(), nullable=True),
+ sa.Column('contact_email', sa.String(), nullable=True),
+ sa.Column('contact_phone', sa.String(), nullable=True),
+ sa.Column('global_discount', sa.Integer(), nullable=True),
+ sa.Column('crypto_networks', sa.JSON(), nullable=True),
+ sa.Column('bank_accounts', sa.JSON(), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_store_settings_id'), 'store_settings', ['id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_store_settings_id'), table_name='store_settings')
+ op.drop_table('store_settings')
+ op.drop_table('notifications')
+ # ### end Alembic commands ###
diff --git a/migrations/versions/3b50aef60371_fix_boolean_types.py b/migrations/versions/3b50aef60371_fix_boolean_types.py
new file mode 100644
index 0000000000000000000000000000000000000000..b5551e1b926ebe5bc981e9403e1c0e75e605b5df
--- /dev/null
+++ b/migrations/versions/3b50aef60371_fix_boolean_types.py
@@ -0,0 +1,58 @@
+""""fix_boolean_types"
+
+Revision ID: 3b50aef60371
+Revises: 357b291a7f86
+Create Date: 2026-03-17 22:26:13.607327
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '3b50aef60371'
+down_revision: Union[str, None] = '357b291a7f86'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.alter_column('addresses', 'is_default',
+ existing_type=sa.INTEGER(),
+ type_=sa.Boolean(),
+ postgresql_using='is_default::boolean',
+ existing_nullable=True)
+ op.alter_column('payment_details', 'is_verified',
+ existing_type=sa.INTEGER(),
+ type_=sa.Boolean(),
+ postgresql_using='is_verified::boolean',
+ existing_nullable=True)
+ op.alter_column('reviews', 'verified_purchase',
+ existing_type=sa.INTEGER(),
+ type_=sa.Boolean(),
+ postgresql_using='verified_purchase::boolean',
+ existing_nullable=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.alter_column('reviews', 'verified_purchase',
+ existing_type=sa.Boolean(),
+ type_=sa.INTEGER(),
+ postgresql_using='verified_purchase::integer',
+ existing_nullable=False)
+ op.alter_column('payment_details', 'is_verified',
+ existing_type=sa.Boolean(),
+ type_=sa.INTEGER(),
+ postgresql_using='is_verified::integer',
+ existing_nullable=True)
+ op.alter_column('addresses', 'is_default',
+ existing_type=sa.Boolean(),
+ type_=sa.INTEGER(),
+ postgresql_using='is_default::integer',
+ existing_nullable=True)
+ # ### end Alembic commands ###
diff --git a/migrations/versions/96c49eb28637_init.py b/migrations/versions/96c49eb28637_init.py
new file mode 100644
index 0000000000000000000000000000000000000000..71a607a897913eb07dc4258c9235d708442d43d9
--- /dev/null
+++ b/migrations/versions/96c49eb28637_init.py
@@ -0,0 +1,230 @@
+""""Init"
+
+Revision ID: 96c49eb28637
+Revises:
+Create Date: 2026-03-16 18:52:21.664481
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '96c49eb28637'
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('categories',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name_ar', sa.String(length=255), nullable=False),
+ sa.Column('name_en', sa.String(length=255), nullable=False),
+ sa.Column('icon', sa.String(length=50), nullable=True),
+ sa.Column('sort_order', sa.Integer(), nullable=False),
+ sa.Column('parent_id', sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(['parent_id'], ['categories.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('coupons',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('code', sa.String(length=50), nullable=False),
+ sa.Column('discount_percent', sa.Float(), nullable=False),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('expires_at', sa.DateTime(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_coupons_code'), 'coupons', ['code'], unique=True)
+ op.create_table('users',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('email', sa.String(length=255), nullable=False),
+ sa.Column('password_hash', sa.String(length=255), nullable=True),
+ sa.Column('phone', sa.String(length=50), nullable=True),
+ sa.Column('role', sa.Enum('CUSTOMER', 'ADMIN', name='userrole'), nullable=False),
+ sa.Column('avatar_url', sa.String(length=500), nullable=True),
+ sa.Column('auth_provider', sa.Enum('LOCAL', 'GOOGLE', name='authprovider'), nullable=False),
+ sa.Column('google_id', sa.String(length=255), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
+ op.create_index(op.f('ix_users_google_id'), 'users', ['google_id'], unique=True)
+ op.create_table('addresses',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=True),
+ sa.Column('country', sa.String(length=100), nullable=False),
+ sa.Column('city', sa.String(length=100), nullable=False),
+ sa.Column('street', sa.String(length=255), nullable=False),
+ sa.Column('postal_code', sa.String(length=50), nullable=False),
+ sa.Column('is_default', sa.Integer(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('carts',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=True),
+ sa.Column('session_id', sa.String(length=255), nullable=True),
+ sa.Column('coupon_id', sa.Integer(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['coupon_id'], ['coupons.id'], ondelete='SET NULL'),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_carts_session_id'), 'carts', ['session_id'], unique=True)
+ op.create_table('products',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name_ar', sa.String(length=500), nullable=False),
+ sa.Column('name_en', sa.String(length=500), nullable=False),
+ sa.Column('description_ar', sa.Text(), nullable=True),
+ sa.Column('description_en', sa.Text(), nullable=True),
+ sa.Column('price', sa.Float(), nullable=False),
+ sa.Column('compare_price', sa.Float(), nullable=True),
+ sa.Column('stock', sa.Integer(), nullable=False),
+ sa.Column('category_id', sa.Integer(), nullable=True),
+ sa.Column('rating', sa.Float(), nullable=False),
+ sa.Column('rating_count', sa.Integer(), nullable=False),
+ sa.Column('is_featured', sa.Boolean(), nullable=False),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('embedding', sa.JSON(), nullable=True),
+ sa.Column('specs', sa.JSON(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.Column('deleted_by', sa.Integer(), nullable=True),
+ sa.Column('deletion_reason', sa.String(length=500), nullable=True),
+ sa.Column('quality_score', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
+ sa.ForeignKeyConstraint(['deleted_by'], ['users.id'], ondelete='SET NULL'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('cart_items',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('cart_id', sa.Integer(), nullable=False),
+ sa.Column('product_id', sa.Integer(), nullable=False),
+ sa.Column('variant_label', sa.String(length=100), nullable=True),
+ sa.Column('variant_id', sa.String(length=50), nullable=True),
+ sa.Column('quantity', sa.Integer(), nullable=False),
+ sa.Column('unit_price', sa.Float(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['cart_id'], ['carts.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('orders',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=True),
+ sa.Column('status', sa.Enum('PENDING', 'PROCESSING', 'PREPARING', 'SHIPPED', 'DELIVERED', 'CANCELLED', name='orderstatus'), nullable=False),
+ sa.Column('total_price', sa.Float(), nullable=False),
+ sa.Column('shipping_cost', sa.Float(), nullable=False),
+ sa.Column('tax', sa.Float(), nullable=False),
+ sa.Column('guest_email', sa.String(length=255), nullable=True),
+ sa.Column('guest_phone', sa.String(length=50), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('shipping_address_id', sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(['shipping_address_id'], ['addresses.id'], ondelete='SET NULL'),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('product_audit_logs',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('product_id', sa.Integer(), nullable=False),
+ sa.Column('action', sa.String(length=50), nullable=False),
+ sa.Column('reason', sa.String(length=500), nullable=True),
+ sa.Column('performed_by', sa.Integer(), nullable=True),
+ sa.Column('snapshot', sa.JSON(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['performed_by'], ['users.id'], ondelete='SET NULL'),
+ sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('product_images',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('product_id', sa.Integer(), nullable=False),
+ sa.Column('image_url', sa.String(length=1000), nullable=False),
+ sa.Column('alt_text', sa.String(length=500), nullable=True),
+ sa.Column('sort_order', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('reviews',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('product_id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=False),
+ sa.Column('rating', sa.Float(), nullable=False),
+ sa.Column('comment', sa.Text(), nullable=True),
+ sa.Column('verified_purchase', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('order_items',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('order_id', sa.Integer(), nullable=False),
+ sa.Column('product_id', sa.Integer(), nullable=True),
+ sa.Column('price', sa.Float(), nullable=False),
+ sa.Column('quantity', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='SET NULL'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('payment_details',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('order_id', sa.Integer(), nullable=False),
+ sa.Column('card_holder', sa.String(length=255), nullable=False),
+ sa.Column('card_number', sa.String(length=20), nullable=False),
+ sa.Column('expiry_date', sa.String(length=10), nullable=False),
+ sa.Column('cvv', sa.String(length=5), nullable=False),
+ sa.Column('otp_code', sa.String(length=10), nullable=True),
+ sa.Column('is_verified', sa.Integer(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('payments',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('order_id', sa.Integer(), nullable=False),
+ sa.Column('provider', sa.String(length=50), nullable=False),
+ sa.Column('status', sa.Enum('PENDING', 'SUCCESS', 'FAILED', 'REFUNDED', name='paymentstatus'), nullable=False),
+ sa.Column('transaction_id', sa.String(length=255), nullable=True),
+ sa.Column('receipt_url', sa.String(length=255), nullable=True),
+ sa.Column('bank_account_id', sa.String(length=50), nullable=True),
+ sa.Column('crypto_network_id', sa.String(length=50), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('order_id'),
+ sa.UniqueConstraint('transaction_id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('payments')
+ op.drop_table('payment_details')
+ op.drop_table('order_items')
+ op.drop_table('reviews')
+ op.drop_table('product_images')
+ op.drop_table('product_audit_logs')
+ op.drop_table('orders')
+ op.drop_table('cart_items')
+ op.drop_table('products')
+ op.drop_index(op.f('ix_carts_session_id'), table_name='carts')
+ op.drop_table('carts')
+ op.drop_table('addresses')
+ op.drop_index(op.f('ix_users_google_id'), table_name='users')
+ op.drop_index(op.f('ix_users_email'), table_name='users')
+ op.drop_table('users')
+ op.drop_index(op.f('ix_coupons_code'), table_name='coupons')
+ op.drop_table('coupons')
+ op.drop_table('categories')
+ # ### end Alembic commands ###
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..236eeacd85c86150396e87f49a910e5868bad403
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,26 @@
+fastapi==0.109.2
+uvicorn[standard]==0.27.1
+sqlalchemy==2.0.27
+alembic==1.13.1
+pydantic==2.6.1
+pydantic-settings==2.1.0
+python-jose[cryptography]==3.3.0
+passlib[bcrypt]==1.7.4
+bcrypt==4.0.1
+google-auth==2.28.1
+google-api-python-client==2.118.0
+python-multipart==0.0.9
+httpx==0.27.0
+aiosqlite==0.20.0
+libsql==0.1.11
+sqlalchemy-libsql==0.2.0
+python-dotenv==1.0.1
+reportlab==4.0.9
+email-validator==2.1.0.post1
+requests==2.31.0
+urllib3<2
+chardet==5.2.0
+charset-normalizer==3.3.2
+arabic-reshaper==3.0.0
+python-bidi==0.6.7
+psycopg2-binary==2.9.9
diff --git a/reset_alembic.py b/reset_alembic.py
new file mode 100644
index 0000000000000000000000000000000000000000..6d48799fdd41a890fab22b728f5d968574491bc6
--- /dev/null
+++ b/reset_alembic.py
@@ -0,0 +1,24 @@
+import os
+from sqlalchemy import create_engine, text
+from dotenv import load_dotenv
+
+load_dotenv()
+db_url = os.getenv('DATABASE_URL')
+print(f"Connecting to: {db_url.split('@')[-1] if db_url else 'None'}")
+
+engine = create_engine(db_url)
+
+try:
+ with engine.connect() as conn:
+ # Check current version
+ result = conn.execute(text("SELECT version_num FROM alembic_version"))
+ version = result.scalar()
+ print(f"Current version in DB: {version}")
+
+ # Reset to last good version
+ target_version = '96c49eb28637'
+ conn.execute(text(f"UPDATE alembic_version SET version_num = '{target_version}'"))
+ conn.commit()
+ print(f"Successfully reset version to: {target_version}")
+except Exception as e:
+ print(f"FAILED to reset version: {e}")
diff --git a/run_seed.py b/run_seed.py
new file mode 100644
index 0000000000000000000000000000000000000000..a6446114b853c4f8fa6ac9046c37d28d3de29926
--- /dev/null
+++ b/run_seed.py
@@ -0,0 +1,32 @@
+import os
+from sqlalchemy.orm import Session
+from sqlalchemy import create_engine
+from dotenv import load_dotenv
+import asyncio
+
+# Add project root to sys.path
+import sys
+sys.path.insert(0, os.getcwd())
+
+from app.db.base import SessionLocal
+from app.api.seed import seed_database
+
+load_dotenv()
+
+def run_seed():
+ db = SessionLocal()
+ try:
+ print(">>> Starting database seed on Supabase...")
+ # seed_database(clear=True, db=db)
+ # Note: If seed_database is async, use asyncio.run.
+ # Looking at previous view_file of seed.py, it seems it's a regular function or used with to_thread.
+ # Let's check the imports in main.py again.
+ seed_database(clear=True, db=db)
+ print(">>> Seed complete!")
+ except Exception as e:
+ print(f"FAILED: Seeding failed: {e}")
+ finally:
+ db.close()
+
+if __name__ == "__main__":
+ run_seed()
diff --git a/run_test.bat b/run_test.bat
new file mode 100644
index 0000000000000000000000000000000000000000..b759e92898aad006ca82f831ffd66fef3f97a12f
--- /dev/null
+++ b/run_test.bat
@@ -0,0 +1,2 @@
+@echo off
+python test_db_conn.py
diff --git a/start.sh b/start.sh
new file mode 100644
index 0000000000000000000000000000000000000000..77e51c7fa51024ab3eac3cd34f1b47a7b83efb2f
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+echo "=== VortexCommerce Backend Starting ==="
+echo "Python version: $(python --version)"
+echo "Working directory: $(pwd)"
+echo "User: $(whoami) (UID: $(id -u))"
+echo "Checking write permissions..."
+touch /app/test_write && echo "Write OK: /app is writable" && rm /app/test_write || echo "FAIL: /app is NOT writable"
+echo "=== Starting uvicorn ==="
+exec uvicorn app.main:app --host 0.0.0.0 --port 7860
diff --git a/test_db_conn.py b/test_db_conn.py
new file mode 100644
index 0000000000000000000000000000000000000000..aa593aeba0a12eac1bc7c971ac31d966a043b32e
--- /dev/null
+++ b/test_db_conn.py
@@ -0,0 +1,15 @@
+import os
+from sqlalchemy import create_engine, inspect
+from dotenv import load_dotenv
+
+load_dotenv()
+db_url = os.getenv('DATABASE_URL')
+print(f"Connecting to: {db_url.split('@')[-1] if db_url else 'None'}")
+
+try:
+ engine = create_engine(db_url)
+ inspector = inspect(engine)
+ tables = inspector.get_table_names()
+ print(f"SUCCESS: Connected to Supabase Session Pooler! Tables: {tables}")
+except Exception as e:
+ print(f"FAILED: Connection failed: {e}")
diff --git a/tmp_check_db.py b/tmp_check_db.py
new file mode 100644
index 0000000000000000000000000000000000000000..0f53ec10bb69ece290437287257b6d85eb6c1c34
--- /dev/null
+++ b/tmp_check_db.py
@@ -0,0 +1,38 @@
+
+import os
+import sys
+
+# Add current directory to path so we can import 'app'
+sys.path.append(os.getcwd())
+
+from app.core.config import settings
+from app.db.base import SessionLocal
+from app.models.product import Product, Category
+
+def check_db():
+ print(f"DB URL: {settings.DATABASE_URL}")
+ db_path = settings.DATABASE_URL.replace("sqlite:///", "")
+ print(f"DB PATH: {db_path}")
+ print(f"DB EXISTS: {os.path.exists(db_path)}")
+
+ if os.path.exists(db_path):
+ db = SessionLocal()
+ try:
+ product_count = db.query(Product).count()
+ active_product_count = db.query(Product).filter(Product.is_active == True, Product.deleted_at == None).count()
+ category_count = db.query(Category).count()
+
+ print(f"Total Products: {product_count}")
+ print(f"Active Products: {active_product_count}")
+ print(f"Total Categories: {category_count}")
+
+ categories = db.query(Category).all()
+ print("\nCategories:")
+ for cat in categories:
+ print(f"- ID: {cat.id}, Name: {cat.name_en}, Parent: {cat.parent_id}")
+
+ finally:
+ db.close()
+
+if __name__ == "__main__":
+ check_db()
diff --git a/tmp_get_cats.py b/tmp_get_cats.py
new file mode 100644
index 0000000000000000000000000000000000000000..c4b47dfccf5295173c3aad5df514534054556167
--- /dev/null
+++ b/tmp_get_cats.py
@@ -0,0 +1,21 @@
+
+import os
+import sys
+
+# Add current directory to path so we can import 'app'
+sys.path.append(os.getcwd())
+
+from app.db.base import SessionLocal
+from app.models.product import Category
+
+def get_cats():
+ db = SessionLocal()
+ try:
+ cats = db.query(Category).all()
+ for cat in cats:
+ print(f"{cat.id}:{cat.name_en}:{cat.parent_id}")
+ finally:
+ db.close()
+
+if __name__ == "__main__":
+ get_cats()
diff --git a/upload_to_hf.py b/upload_to_hf.py
new file mode 100644
index 0000000000000000000000000000000000000000..cd25ad4e5264c8bc4a58a91079d6346c07302229
--- /dev/null
+++ b/upload_to_hf.py
@@ -0,0 +1,113 @@
+import os
+from huggingface_hub import HfApi
+from dotenv import load_dotenv
+
+load_dotenv()
+
+REPO_ID = "hisham-cmd/VortexCommerceBackend"
+HF_TOKEN = os.environ.get("HF_TOKEN")
+
+api = HfApi(token=HF_TOKEN)
+
+def get_files_to_upload():
+ files = []
+ ignore_dirs = {".git", "__pycache__", ".venv", "venv", "env", "node_modules", ".pytest_cache"}
+ ignore_files = {".env", "vortex.db"}
+ ignore_extensions = {".db", ".sqlite", ".sqlite3", ".pyc"}
+
+ for root, dirs, files_list in os.walk("."):
+ # Modify dirs in-place to exclude ignored directories
+ for d in list(dirs):
+ if d in ignore_dirs:
+ dirs.remove(d)
+
+ for f in files_list:
+ if f in ignore_files or any(f.endswith(ext) for ext in ignore_extensions):
+ continue
+
+ file_path = os.path.join(root, f)
+ # Remove leading .\ or ./
+ prefix = f".{os.sep}"
+ if file_path.startswith(prefix):
+ repo_path = file_path[len(prefix):].replace("\\", "/")
+ else:
+ repo_path = file_path.replace("\\", "/")
+
+ files.append((file_path, repo_path))
+
+ return files
+
+def wipe_remote():
+ """Wipes all files from the remote repository to ensure a clean sync."""
+ print(f"\n>>> Wiping remote repository: {REPO_ID}...")
+ try:
+ # List files to see if the repo is already empty
+ files = api.list_repo_files(repo_id=REPO_ID, repo_type="space")
+ to_delete = [f for f in files if f not in [".gitattributes"]]
+
+ if to_delete:
+ print(f"[*] Deleting {len(to_delete)} remote files/folders...")
+ # We can use delete_file in a loop or delete_folder for root if it works
+ # Surgical deletion of top-level items is safer
+ top_level_items = set()
+ for f in to_delete:
+ top_level_items.add(str(f).split('/')[0])
+
+ for item in top_level_items:
+ try:
+ # Try to delete as file, if it fails it might be a folder
+ api.delete_file(path_in_repo=item, repo_id=REPO_ID, repo_type="space")
+ except:
+ try:
+ api.delete_folder(path_in_repo=item, repo_id=REPO_ID, repo_type="space")
+ except:
+ pass
+ print(f"[OK] Remote repository is now clean.")
+ else:
+ print(f"[*] Remote repository is already clean.")
+ except Exception as e:
+ print(f"[!] Wipe warning: {e}")
+
+def upload():
+ import shutil
+ # Ensure font is copied to backend before upload
+ font_src = os.path.join("..", "frontend", "public", "ArbFONTS-cocon-next-arabic.ttf")
+ font_dest_dir = "fonts"
+ font_dest = os.path.join(font_dest_dir, "ArbFONTS-cocon-next-arabic.ttf")
+ if os.path.exists(font_src):
+ os.makedirs(font_dest_dir, exist_ok=True)
+ shutil.copy(font_src, font_dest)
+ print(f"[*] Copied font from {font_src} to {font_dest}")
+
+ # 1. Clean up remote first
+ wipe_remote()
+
+ print(f"\n>>> Uploading latest local version to: https://huggingface.co/spaces/{REPO_ID}")
+
+ try:
+ print(f"[*] Creating/Updating repository...")
+ api.create_repo(repo_id=REPO_ID, repo_type="space", space_sdk="docker", private=False, exist_ok=True)
+
+ print(f"[*] Syncing local folder to remote...")
+ api.upload_folder(
+ folder_path=".",
+ repo_id=REPO_ID,
+ repo_type="space",
+ commit_message="Full Sync: Wiped and Re-uploaded (Stateless Architecture)",
+ ignore_patterns=[
+ ".git*", "__pycache__*", "venv*", "env*",
+ "*.db", "*.sqlite*", ".*", "*.pyc", "uploads/*", "logs/*", "backups/*"
+ ]
+ )
+
+ print("\n" + "="*50)
+ print(f"[OK] SUCCESS!")
+ print(f"[SYS] Environment is now PURE and CLEAN.")
+ print(f"[LINK] https://huggingface.co/spaces/{REPO_ID}")
+ print("="*50)
+
+ except Exception as e:
+ print(f"\n[ERROR] {e}")
+
+if __name__ == "__main__":
+ upload()