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..07d4ca0487590444aef0cb9ad3a7939936bb22d8 --- /dev/null +++ b/app/api/admin.py @@ -0,0 +1,613 @@ +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.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..33cd04f58acc481e2f2ef915d36669b8c24c5b35 --- /dev/null +++ b/app/api/products.py @@ -0,0 +1,410 @@ +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=48), + db: Session = Depends(get_db), +): + 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) * page_size + products = ( + query.options(joinedload(Product.category), joinedload(Product.images)) + .offset(offset) + .limit(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=AuthResponse) +async def get_product(product_id: int, 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 or product.deleted_at is not None: + 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() + 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 + if isinstance(detail.get("created_at"), datetime): + detail["created_at"] = detail["created_at"].isoformat() + if detail.get("deleted_at") and isinstance(detail["deleted_at"], datetime): + detail["deleted_at"] = detail["deleted_at"].isoformat() + + return AuthResponse( + 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..d646ddd2affd73bbeedc342ff41c96fed4697a0c --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,77 @@ +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, + 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..fd33dcfe866cf0042d8b069fa43b78a6a3c881aa --- /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, 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/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()