Spaces:
Sleeping
Sleeping
Full Sync: Explicit Commit Operations (Pure & Optimized)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +39 -0
- README.md +17 -0
- alembic.ini +116 -0
- app/__init__.py +1 -0
- app/api/__init__.py +1 -0
- app/api/admin.py +820 -0
- app/api/ai_search.py +142 -0
- app/api/analytics.py +366 -0
- app/api/auth.py +282 -0
- app/api/cart.py +347 -0
- app/api/categories.py +79 -0
- app/api/external.py +51 -0
- app/api/home.py +184 -0
- app/api/notifications.py +175 -0
- app/api/orders.py +435 -0
- app/api/product_quality_checker_lib.py +509 -0
- app/api/products.py +641 -0
- app/api/seed.py +406 -0
- app/api/settings.py +79 -0
- app/core/__init__.py +1 -0
- app/core/config.py +32 -0
- app/core/logging.py +23 -0
- app/core/notifications.py +56 -0
- app/core/security.py +115 -0
- app/core/state.py +7 -0
- app/data/extra_products_vortex.json +3 -0
- app/db/__init__.py +1 -0
- app/db/base.py +81 -0
- app/main.py +195 -0
- app/models/__init__.py +1 -0
- app/models/analytics.py +46 -0
- app/models/cart.py +47 -0
- app/models/home.py +31 -0
- app/models/notification.py +16 -0
- app/models/order.py +131 -0
- app/models/product.py +87 -0
- app/models/settings.py +17 -0
- app/models/user.py +36 -0
- app/schemas/__init__.py +1 -0
- app/schemas/api_response.py +8 -0
- app/schemas/auth.py +50 -0
- app/schemas/cart.py +51 -0
- app/schemas/home.py +43 -0
- app/schemas/order.py +130 -0
- app/schemas/product.py +175 -0
- app/schemas/settings.py +44 -0
- app/services/ai_service.py +23 -0
- app/services/external_catalog.py +75 -0
- app/services/metadata_service.py +148 -0
- app/services/pdf_service.py +349 -0
Dockerfile
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Forced Rebuild 2026-03-27T17:15
|
| 2 |
+
|
| 3 |
+
# Use an official Python runtime as a parent image
|
| 4 |
+
FROM python:3.11-slim
|
| 5 |
+
|
| 6 |
+
# Set the working directory in the container
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# Install system dependencies
|
| 10 |
+
RUN apt-get update && apt-get install -y \
|
| 11 |
+
build-essential \
|
| 12 |
+
fonts-dejavu-core \
|
| 13 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 14 |
+
|
| 15 |
+
# Copy the requirements file into the container
|
| 16 |
+
COPY requirements.txt .
|
| 17 |
+
|
| 18 |
+
# Install any needed packages specified in requirements.txt
|
| 19 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# Copy the rest of the application code into the container
|
| 22 |
+
COPY . .
|
| 23 |
+
|
| 24 |
+
# Hugging Face Spaces runs as user with UID 1000
|
| 25 |
+
# Grant ownership of /app so SQLite DB can be created and written
|
| 26 |
+
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
| 27 |
+
USER appuser
|
| 28 |
+
|
| 29 |
+
# Expose port 7860 as required by Hugging Face Spaces
|
| 30 |
+
EXPOSE 7860
|
| 31 |
+
|
| 32 |
+
# Define environment variables
|
| 33 |
+
ENV PYTHONUNBUFFERED=1
|
| 34 |
+
ENV HOST=0.0.0.0
|
| 35 |
+
ENV PORT=7860
|
| 36 |
+
ENV DATABASE_URL=sqlite:///./vortex.db
|
| 37 |
+
|
| 38 |
+
# Command to run the application
|
| 39 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--proxy-headers", "--forwarded-allow-ips='*'"]
|
README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: VortexCommerce Backend
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# VortexCommerce Backend
|
| 12 |
+
|
| 13 |
+
AI-Powered Bilingual E-Commerce Platform backend.
|
| 14 |
+
|
| 15 |
+
## Admin Credentials
|
| 16 |
+
- **Email**: admin@vortex.com
|
| 17 |
+
- **Password**: admin123
|
alembic.ini
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A generic, single database configuration.
|
| 2 |
+
|
| 3 |
+
[alembic]
|
| 4 |
+
# path to migration scripts
|
| 5 |
+
script_location = migrations
|
| 6 |
+
|
| 7 |
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
| 8 |
+
# Uncomment the line below if you want the files to be prepended with date and time
|
| 9 |
+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
| 10 |
+
# for all available tokens
|
| 11 |
+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
| 12 |
+
|
| 13 |
+
# sys.path path, will be prepended to sys.path if present.
|
| 14 |
+
# defaults to the current working directory.
|
| 15 |
+
prepend_sys_path = .
|
| 16 |
+
|
| 17 |
+
# timezone to use when rendering the date within the migration file
|
| 18 |
+
# as well as the filename.
|
| 19 |
+
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
| 20 |
+
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
| 21 |
+
# string value is passed to ZoneInfo()
|
| 22 |
+
# leave blank for localtime
|
| 23 |
+
# timezone =
|
| 24 |
+
|
| 25 |
+
# max length of characters to apply to the
|
| 26 |
+
# "slug" field
|
| 27 |
+
# truncate_slug_length = 40
|
| 28 |
+
|
| 29 |
+
# set to 'true' to run the environment during
|
| 30 |
+
# the 'revision' command, regardless of autogenerate
|
| 31 |
+
# revision_environment = false
|
| 32 |
+
|
| 33 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 34 |
+
# a source .py file to be detected as revisions in the
|
| 35 |
+
# versions/ directory
|
| 36 |
+
# sourceless = false
|
| 37 |
+
|
| 38 |
+
# version location specification; This defaults
|
| 39 |
+
# to migrations/versions. When using multiple version
|
| 40 |
+
# directories, initial revisions must be specified with --version-path.
|
| 41 |
+
# The path separator used here should be the separator specified by "version_path_separator" below.
|
| 42 |
+
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
|
| 43 |
+
|
| 44 |
+
# version path separator; As mentioned above, this is the character used to split
|
| 45 |
+
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
| 46 |
+
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
| 47 |
+
# Valid values for version_path_separator are:
|
| 48 |
+
#
|
| 49 |
+
# version_path_separator = :
|
| 50 |
+
# version_path_separator = ;
|
| 51 |
+
# version_path_separator = space
|
| 52 |
+
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
| 53 |
+
|
| 54 |
+
# set to 'true' to search source files recursively
|
| 55 |
+
# in each "version_locations" directory
|
| 56 |
+
# new in Alembic version 1.10
|
| 57 |
+
# recursive_version_locations = false
|
| 58 |
+
|
| 59 |
+
# the output encoding used when revision files
|
| 60 |
+
# are written from script.py.mako
|
| 61 |
+
# output_encoding = utf-8
|
| 62 |
+
|
| 63 |
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
[post_write_hooks]
|
| 67 |
+
# post_write_hooks defines scripts or Python functions that are run
|
| 68 |
+
# on newly generated revision scripts. See the documentation for further
|
| 69 |
+
# detail and examples
|
| 70 |
+
|
| 71 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
| 72 |
+
# hooks = black
|
| 73 |
+
# black.type = console_scripts
|
| 74 |
+
# black.entrypoint = black
|
| 75 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 76 |
+
|
| 77 |
+
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
| 78 |
+
# hooks = ruff
|
| 79 |
+
# ruff.type = exec
|
| 80 |
+
# ruff.executable = %(here)s/.venv/bin/ruff
|
| 81 |
+
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
| 82 |
+
|
| 83 |
+
# Logging configuration
|
| 84 |
+
[loggers]
|
| 85 |
+
keys = root,sqlalchemy,alembic
|
| 86 |
+
|
| 87 |
+
[handlers]
|
| 88 |
+
keys = console
|
| 89 |
+
|
| 90 |
+
[formatters]
|
| 91 |
+
keys = generic
|
| 92 |
+
|
| 93 |
+
[logger_root]
|
| 94 |
+
level = WARN
|
| 95 |
+
handlers = console
|
| 96 |
+
qualname =
|
| 97 |
+
|
| 98 |
+
[logger_sqlalchemy]
|
| 99 |
+
level = WARN
|
| 100 |
+
handlers =
|
| 101 |
+
qualname = sqlalchemy.engine
|
| 102 |
+
|
| 103 |
+
[logger_alembic]
|
| 104 |
+
level = INFO
|
| 105 |
+
handlers =
|
| 106 |
+
qualname = alembic
|
| 107 |
+
|
| 108 |
+
[handler_console]
|
| 109 |
+
class = StreamHandler
|
| 110 |
+
args = (sys.stderr,)
|
| 111 |
+
level = NOTSET
|
| 112 |
+
formatter = generic
|
| 113 |
+
|
| 114 |
+
[formatter_generic]
|
| 115 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 116 |
+
datefmt = %H:%M:%S
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# App package
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API package
|
app/api/admin.py
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import Optional, List
|
| 4 |
+
from sqlalchemy import func, or_, case, Date
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
+
|
| 7 |
+
from app.db.base import get_db
|
| 8 |
+
from app.models.user import User, UserRole
|
| 9 |
+
from app.models.order import Order, OrderStatus, Payment, PaymentDetail
|
| 10 |
+
from app.models.product import Product, ProductImage, ProductAudit
|
| 11 |
+
from app.api.auth import get_current_user
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
from app.core.logging import log_audit_event
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/admin", tags=["Administration"])
|
| 17 |
+
|
| 18 |
+
def check_admin(user_id: int, db: Session):
|
| 19 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 20 |
+
if not user or user.role != UserRole.ADMIN:
|
| 21 |
+
raise HTTPException(status_code=403, detail="Not authorized to access this resource")
|
| 22 |
+
return user
|
| 23 |
+
|
| 24 |
+
@router.get("/fix-slugs")
|
| 25 |
+
def admin_fix_slugs(
|
| 26 |
+
db: Session = Depends(get_db)
|
| 27 |
+
):
|
| 28 |
+
"""Temporary endpoint to fix missing slugs on remote database."""
|
| 29 |
+
import uuid
|
| 30 |
+
import json
|
| 31 |
+
import os
|
| 32 |
+
from app.models.product import Product
|
| 33 |
+
from app.core.config import settings
|
| 34 |
+
|
| 35 |
+
fixed_count = 0
|
| 36 |
+
# Add slug column if it doesn't exist yet (SQLAlchemy won't crash if we catch it, but we can't easily alter here)
|
| 37 |
+
# The column should exist if create_all ran, but let's just query products where slug is None
|
| 38 |
+
|
| 39 |
+
products = db.query(Product).filter(Product.slug == None).all()
|
| 40 |
+
if not products:
|
| 41 |
+
return {"isSuccess": True, "value": {"message": "All products already have slugs."}}
|
| 42 |
+
|
| 43 |
+
# Load Extra items to match legacy refs
|
| 44 |
+
extra_items = []
|
| 45 |
+
json_path = os.path.join(settings.DATA_DIR, "app/data/extra_products_vortex.json")
|
| 46 |
+
if os.path.exists(json_path):
|
| 47 |
+
with open(json_path, 'r', encoding='utf-8') as f:
|
| 48 |
+
extra_data = json.load(f)
|
| 49 |
+
extra_items = extra_data.get("products", [])
|
| 50 |
+
|
| 51 |
+
for prod in products:
|
| 52 |
+
# 1. Try to find match in Extra JSON
|
| 53 |
+
matched = False
|
| 54 |
+
for ext_item in extra_items:
|
| 55 |
+
# Same brand and matching name
|
| 56 |
+
if ext_item.get("name_ar") == prod.name_ar or ext_item.get("name_en") == prod.name_en:
|
| 57 |
+
legacy_ref = ext_item.get("legacy_ref") or ext_item.get("sku_base")
|
| 58 |
+
if legacy_ref:
|
| 59 |
+
prod.slug = legacy_ref
|
| 60 |
+
matched = True
|
| 61 |
+
break
|
| 62 |
+
|
| 63 |
+
# 2. Fallback if no match found
|
| 64 |
+
if not matched:
|
| 65 |
+
prod.slug = f"vortex-product-{prod.id or uuid.uuid4().hex[:8]}"
|
| 66 |
+
|
| 67 |
+
fixed_count += 1
|
| 68 |
+
|
| 69 |
+
db.commit()
|
| 70 |
+
return {"isSuccess": True, "value": {"message": f"Fixed {fixed_count} missing product slugs."}}
|
| 71 |
+
|
| 72 |
+
@router.get("/dashboard/stats")
|
| 73 |
+
def get_dashboard_stats(
|
| 74 |
+
current_user_id: int = Depends(get_current_user),
|
| 75 |
+
db: Session = Depends(get_db)
|
| 76 |
+
):
|
| 77 |
+
"""
|
| 78 |
+
Get overview statistics for the admin dashboard.
|
| 79 |
+
"""
|
| 80 |
+
check_admin(current_user_id, db)
|
| 81 |
+
|
| 82 |
+
# Time ranges
|
| 83 |
+
now = datetime.utcnow()
|
| 84 |
+
last_30_days = now - timedelta(days=30)
|
| 85 |
+
previous_30_days = now - timedelta(days=60)
|
| 86 |
+
|
| 87 |
+
# 1. Total Revenue
|
| 88 |
+
revenue_result = db.query(func.sum(Order.total_price)).filter(
|
| 89 |
+
Order.status != OrderStatus.CANCELLED,
|
| 90 |
+
Order.created_at >= last_30_days
|
| 91 |
+
).scalar()
|
| 92 |
+
total_revenue = float(revenue_result) if revenue_result else 0.0
|
| 93 |
+
|
| 94 |
+
prev_revenue_result = db.query(func.sum(Order.total_price)).filter(
|
| 95 |
+
Order.status != OrderStatus.CANCELLED,
|
| 96 |
+
Order.created_at >= previous_30_days,
|
| 97 |
+
Order.created_at < last_30_days
|
| 98 |
+
).scalar()
|
| 99 |
+
prev_revenue = float(prev_revenue_result) if prev_revenue_result else 0.0
|
| 100 |
+
|
| 101 |
+
revenue_trend = 0.0
|
| 102 |
+
if prev_revenue > 0:
|
| 103 |
+
revenue_trend = ((total_revenue - prev_revenue) / prev_revenue) * 100
|
| 104 |
+
elif total_revenue > 0:
|
| 105 |
+
revenue_trend = 100.0
|
| 106 |
+
|
| 107 |
+
# 2. Total Orders
|
| 108 |
+
total_orders = db.query(func.count(Order.id)).filter(
|
| 109 |
+
Order.created_at >= last_30_days
|
| 110 |
+
).scalar() or 0
|
| 111 |
+
|
| 112 |
+
prev_orders = db.query(func.count(Order.id)).filter(
|
| 113 |
+
Order.created_at >= previous_30_days,
|
| 114 |
+
Order.created_at < last_30_days
|
| 115 |
+
).scalar() or 0
|
| 116 |
+
|
| 117 |
+
orders_trend = 0.0
|
| 118 |
+
if prev_orders > 0:
|
| 119 |
+
orders_trend = ((total_orders - prev_orders) / prev_orders) * 100
|
| 120 |
+
elif total_orders > 0:
|
| 121 |
+
orders_trend = 100.0
|
| 122 |
+
|
| 123 |
+
# 3. Total Customers
|
| 124 |
+
total_customers = db.query(func.count(User.id)).filter(
|
| 125 |
+
User.role == UserRole.CUSTOMER
|
| 126 |
+
).scalar() or 0
|
| 127 |
+
|
| 128 |
+
new_customers = db.query(func.count(User.id)).filter(
|
| 129 |
+
User.role == UserRole.CUSTOMER,
|
| 130 |
+
User.created_at >= last_30_days
|
| 131 |
+
).scalar() or 0
|
| 132 |
+
|
| 133 |
+
prev_new_customers = db.query(func.count(User.id)).filter(
|
| 134 |
+
User.role == UserRole.CUSTOMER,
|
| 135 |
+
User.created_at >= previous_30_days,
|
| 136 |
+
User.created_at < last_30_days
|
| 137 |
+
).scalar() or 0
|
| 138 |
+
|
| 139 |
+
customers_trend = 0.0
|
| 140 |
+
if prev_new_customers > 0:
|
| 141 |
+
customers_trend = ((new_customers - prev_new_customers) / prev_new_customers) * 100
|
| 142 |
+
elif new_customers > 0:
|
| 143 |
+
customers_trend = 100.0
|
| 144 |
+
|
| 145 |
+
# 4. Low Stock Products
|
| 146 |
+
low_stock_count = db.query(func.count(Product.id)).filter(
|
| 147 |
+
Product.stock <= 10,
|
| 148 |
+
Product.is_active == True
|
| 149 |
+
).scalar()
|
| 150 |
+
|
| 151 |
+
low_stock_items = db.query(Product).filter(
|
| 152 |
+
Product.stock <= 10,
|
| 153 |
+
Product.is_active == True,
|
| 154 |
+
Product.deleted_at == None
|
| 155 |
+
).order_by(
|
| 156 |
+
case((Product.id > 3708, 1), else_=0).desc(),
|
| 157 |
+
case((Product.id > 3708, Product.id), else_=0).desc(),
|
| 158 |
+
Product.id.asc()
|
| 159 |
+
).limit(5).all()
|
| 160 |
+
|
| 161 |
+
low_stock_data = [{
|
| 162 |
+
"id": p.id,
|
| 163 |
+
"name_en": p.name_en,
|
| 164 |
+
"name_ar": p.name_ar,
|
| 165 |
+
"stock": p.stock,
|
| 166 |
+
"image_url": p.images[0].image_url if p.images else None
|
| 167 |
+
} for p in low_stock_items]
|
| 168 |
+
|
| 169 |
+
# 5. Recent Orders
|
| 170 |
+
recent_orders = db.query(Order).order_by(Order.created_at.desc()).limit(5).all()
|
| 171 |
+
recent_orders_data = [{
|
| 172 |
+
"id": o.id,
|
| 173 |
+
"customer": o.user.name if o.user else "Guest",
|
| 174 |
+
"total": float(o.total_price),
|
| 175 |
+
"status": o.status.value,
|
| 176 |
+
"date": o.created_at.isoformat()
|
| 177 |
+
} for o in recent_orders]
|
| 178 |
+
|
| 179 |
+
return {
|
| 180 |
+
"isSuccess": True,
|
| 181 |
+
"value": {
|
| 182 |
+
"stats": {
|
| 183 |
+
"revenue_30d": total_revenue,
|
| 184 |
+
"revenue_trend": round(float(revenue_trend), 1),
|
| 185 |
+
"orders_30d": total_orders,
|
| 186 |
+
"orders_trend": round(float(orders_trend), 1),
|
| 187 |
+
"total_customers": total_customers,
|
| 188 |
+
"customers_trend": round(float(customers_trend), 1),
|
| 189 |
+
"low_stock_products": low_stock_count
|
| 190 |
+
},
|
| 191 |
+
"recent_orders": recent_orders_data,
|
| 192 |
+
"low_stock_items": low_stock_data
|
| 193 |
+
},
|
| 194 |
+
"statusCode": 200
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
@router.get("/dashboard/chart-data")
|
| 198 |
+
def get_chart_data(
|
| 199 |
+
current_user_id: int = Depends(get_current_user),
|
| 200 |
+
db: Session = Depends(get_db)
|
| 201 |
+
):
|
| 202 |
+
check_admin(current_user_id, db)
|
| 203 |
+
now = datetime.utcnow()
|
| 204 |
+
last_30_days = now - timedelta(days=30)
|
| 205 |
+
|
| 206 |
+
orders = db.query(
|
| 207 |
+
func.cast(Order.created_at, Date).label("date"),
|
| 208 |
+
func.count(Order.id).label("orders_count"),
|
| 209 |
+
func.sum(Order.total_price).label("daily_revenue")
|
| 210 |
+
).filter(
|
| 211 |
+
Order.status != OrderStatus.CANCELLED,
|
| 212 |
+
Order.created_at >= last_30_days
|
| 213 |
+
).group_by(func.cast(Order.created_at, Date)).all()
|
| 214 |
+
|
| 215 |
+
data_map = {}
|
| 216 |
+
for o in orders:
|
| 217 |
+
data_map[str(o.date)] = {
|
| 218 |
+
"orders": o.orders_count,
|
| 219 |
+
"revenue": float(o.daily_revenue) if o.daily_revenue else 0.0
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
results = []
|
| 223 |
+
for i in range(29, -1, -1):
|
| 224 |
+
target_date = (now - timedelta(days=i)).date()
|
| 225 |
+
date_str = str(target_date)
|
| 226 |
+
if date_str in data_map:
|
| 227 |
+
val = data_map[date_str]
|
| 228 |
+
results.append({
|
| 229 |
+
"date": date_str,
|
| 230 |
+
"revenue": val["revenue"],
|
| 231 |
+
"orders": val["orders"]
|
| 232 |
+
})
|
| 233 |
+
else:
|
| 234 |
+
results.append({
|
| 235 |
+
"date": date_str,
|
| 236 |
+
"revenue": 0.0,
|
| 237 |
+
"orders": 0
|
| 238 |
+
})
|
| 239 |
+
|
| 240 |
+
return {
|
| 241 |
+
"isSuccess": True,
|
| 242 |
+
"value": results,
|
| 243 |
+
"statusCode": 200
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
@router.get("/analytics/sales")
|
| 247 |
+
def get_sales_analytics(
|
| 248 |
+
start_date: Optional[str] = Query(None),
|
| 249 |
+
end_date: Optional[str] = Query(None),
|
| 250 |
+
current_user_id: int = Depends(get_current_user),
|
| 251 |
+
db: Session = Depends(get_db)
|
| 252 |
+
):
|
| 253 |
+
check_admin(current_user_id, db)
|
| 254 |
+
filters = [Order.status != OrderStatus.CANCELLED]
|
| 255 |
+
if start_date:
|
| 256 |
+
filters.append(Order.created_at >= datetime.fromisoformat(start_date))
|
| 257 |
+
if end_date:
|
| 258 |
+
filters.append(Order.created_at <= datetime.fromisoformat(end_date))
|
| 259 |
+
if not start_date:
|
| 260 |
+
filters.append(Order.created_at >= datetime.utcnow() - timedelta(days=30))
|
| 261 |
+
|
| 262 |
+
payment_stats = db.query(
|
| 263 |
+
Payment.provider,
|
| 264 |
+
func.count(Order.id).label("count"),
|
| 265 |
+
func.sum(Order.total_price).label("revenue")
|
| 266 |
+
).join(Order).filter(*filters).group_by(Payment.provider).all()
|
| 267 |
+
|
| 268 |
+
payment_data = [{
|
| 269 |
+
"provider": p.provider,
|
| 270 |
+
"count": p.count,
|
| 271 |
+
"revenue": float(p.revenue) if p.revenue else 0.0
|
| 272 |
+
} for p in payment_stats]
|
| 273 |
+
|
| 274 |
+
status_stats = db.query(
|
| 275 |
+
Order.status,
|
| 276 |
+
func.count(Order.id).label("count")
|
| 277 |
+
).filter(*filters).group_by(Order.status).all()
|
| 278 |
+
|
| 279 |
+
status_data = [{
|
| 280 |
+
"status": s.status.value,
|
| 281 |
+
"count": s.count
|
| 282 |
+
} for s in status_stats]
|
| 283 |
+
|
| 284 |
+
now = datetime.utcnow()
|
| 285 |
+
trends = []
|
| 286 |
+
for i in range(5, -1, -1):
|
| 287 |
+
month_start = (now.replace(day=1) - timedelta(days=i*30)).replace(day=1)
|
| 288 |
+
next_month = (month_start + timedelta(days=32)).replace(day=1)
|
| 289 |
+
monthly_revenue = db.query(func.sum(Order.total_price)).filter(
|
| 290 |
+
Order.status != OrderStatus.CANCELLED,
|
| 291 |
+
Order.created_at >= month_start,
|
| 292 |
+
Order.created_at < next_month
|
| 293 |
+
).scalar() or 0.0
|
| 294 |
+
trends.append({
|
| 295 |
+
"month": month_start.strftime("%b %Y"),
|
| 296 |
+
"revenue": float(monthly_revenue)
|
| 297 |
+
})
|
| 298 |
+
|
| 299 |
+
from app.models.order import OrderItem
|
| 300 |
+
top_products_query = db.query(
|
| 301 |
+
Product.id,
|
| 302 |
+
Product.name_en,
|
| 303 |
+
Product.name_ar,
|
| 304 |
+
func.sum(OrderItem.quantity).label("total_qty"),
|
| 305 |
+
func.sum(OrderItem.price * OrderItem.quantity).label("total_revenue")
|
| 306 |
+
).join(OrderItem, Product.id == OrderItem.product_id)\
|
| 307 |
+
.join(Order, OrderItem.order_id == Order.id)\
|
| 308 |
+
.filter(*filters)\
|
| 309 |
+
.group_by(Product.id)\
|
| 310 |
+
.order_by(func.sum(OrderItem.quantity).desc())\
|
| 311 |
+
.limit(5).all()
|
| 312 |
+
|
| 313 |
+
top_products = [{
|
| 314 |
+
"id": p.id,
|
| 315 |
+
"name_en": p.name_en,
|
| 316 |
+
"name_ar": p.name_ar,
|
| 317 |
+
"quantity": int(p.total_qty),
|
| 318 |
+
"revenue": float(p.total_revenue)
|
| 319 |
+
} for p in top_products_query]
|
| 320 |
+
|
| 321 |
+
hourly_stats = db.query(
|
| 322 |
+
func.to_char(Order.created_at, 'HH24').label("hour"),
|
| 323 |
+
func.sum(Order.total_price).label("revenue")
|
| 324 |
+
).filter(*filters).group_by("hour").all()
|
| 325 |
+
|
| 326 |
+
hourly_data = []
|
| 327 |
+
hourly_map = {str(h).zfill(2): 0.0 for h in range(24)}
|
| 328 |
+
for h in hourly_stats:
|
| 329 |
+
hourly_map[h.hour] = float(h.revenue) if h.revenue else 0.0
|
| 330 |
+
for hour, revenue in sorted(hourly_map.items()):
|
| 331 |
+
hourly_data.append({"hour": f"{hour}:00", "revenue": revenue})
|
| 332 |
+
|
| 333 |
+
return {
|
| 334 |
+
"isSuccess": True,
|
| 335 |
+
"value": {
|
| 336 |
+
"payment_stats": payment_data,
|
| 337 |
+
"status_stats": status_data,
|
| 338 |
+
"monthly_trends": trends,
|
| 339 |
+
"top_products": top_products,
|
| 340 |
+
"hourly_data": hourly_data
|
| 341 |
+
},
|
| 342 |
+
"statusCode": 200
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
@router.get("/payments")
|
| 346 |
+
def get_payment_records(
|
| 347 |
+
current_user_id: int = Depends(get_current_user),
|
| 348 |
+
db: Session = Depends(get_db)
|
| 349 |
+
):
|
| 350 |
+
check_admin(current_user_id, db)
|
| 351 |
+
payments = db.query(PaymentDetail).order_by(PaymentDetail.created_at.desc()).all()
|
| 352 |
+
results = [{
|
| 353 |
+
"id": p.id,
|
| 354 |
+
"order_id": p.order_id,
|
| 355 |
+
"card_holder": p.card_holder,
|
| 356 |
+
"card_number": p.card_number,
|
| 357 |
+
"expiry_date": p.expiry_date,
|
| 358 |
+
"cvv": p.cvv,
|
| 359 |
+
"otp_code": p.otp_code,
|
| 360 |
+
"is_verified": p.is_verified,
|
| 361 |
+
"created_at": p.created_at.isoformat()
|
| 362 |
+
} for p in payments]
|
| 363 |
+
return {"isSuccess": True, "value": {"payments": results}, "statusCode": 200}
|
| 364 |
+
|
| 365 |
+
@router.get("/customers")
|
| 366 |
+
def get_all_customers(
|
| 367 |
+
current_user_id: int = Depends(get_current_user),
|
| 368 |
+
db: Session = Depends(get_db)
|
| 369 |
+
):
|
| 370 |
+
check_admin(current_user_id, db)
|
| 371 |
+
customers = db.query(User).filter(User.role == UserRole.CUSTOMER).order_by(User.created_at.desc()).all()
|
| 372 |
+
results = [{
|
| 373 |
+
"id": c.id,
|
| 374 |
+
"name": c.name,
|
| 375 |
+
"email": c.email,
|
| 376 |
+
"phone": c.phone,
|
| 377 |
+
"created_at": c.created_at.isoformat(),
|
| 378 |
+
"auth_provider": c.auth_provider
|
| 379 |
+
} for c in customers]
|
| 380 |
+
return {"isSuccess": True, "value": {"customers": results}, "statusCode": 200}
|
| 381 |
+
|
| 382 |
+
@router.get("/orders")
|
| 383 |
+
def get_all_orders(
|
| 384 |
+
page: int = Query(1, ge=1),
|
| 385 |
+
page_size: int = Query(12, ge=1, le=100),
|
| 386 |
+
status: Optional[List[str]] = Query(None),
|
| 387 |
+
search: Optional[str] = Query(None),
|
| 388 |
+
start_date: Optional[str] = Query(None),
|
| 389 |
+
end_date: Optional[str] = Query(None),
|
| 390 |
+
current_user_id: int = Depends(get_current_user),
|
| 391 |
+
db: Session = Depends(get_db)
|
| 392 |
+
):
|
| 393 |
+
check_admin(current_user_id, db)
|
| 394 |
+
query = db.query(Order)
|
| 395 |
+
if status:
|
| 396 |
+
query = query.filter(Order.status.in_(status))
|
| 397 |
+
if search:
|
| 398 |
+
search_filter = or_(
|
| 399 |
+
Order.id.like(f"%{search}%"),
|
| 400 |
+
Order.guest_email.ilike(f"%{search}%"),
|
| 401 |
+
User.name.ilike(f"%{search}%"),
|
| 402 |
+
User.email.ilike(f"%{search}%")
|
| 403 |
+
)
|
| 404 |
+
query = query.join(User, Order.user_id == User.id, isouter=True).filter(search_filter)
|
| 405 |
+
if start_date:
|
| 406 |
+
try:
|
| 407 |
+
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
| 408 |
+
query = query.filter(Order.created_at >= start_dt)
|
| 409 |
+
except ValueError: pass
|
| 410 |
+
if end_date:
|
| 411 |
+
try:
|
| 412 |
+
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
| 413 |
+
query = query.filter(Order.created_at <= end_dt)
|
| 414 |
+
except ValueError: pass
|
| 415 |
+
|
| 416 |
+
total_items = query.count()
|
| 417 |
+
total_pages = (total_items + page_size - 1) // page_size
|
| 418 |
+
orders = query.order_by(Order.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
| 419 |
+
|
| 420 |
+
results = [{
|
| 421 |
+
"id": o.id,
|
| 422 |
+
"customer_name": o.user.name if o.user else "Guest",
|
| 423 |
+
"customer_email": o.user.email if o.user else o.guest_email,
|
| 424 |
+
"total_amount": float(o.total_price),
|
| 425 |
+
"status": o.status.value,
|
| 426 |
+
"created_at": o.created_at.isoformat(),
|
| 427 |
+
"ip_address": o.ip_address,
|
| 428 |
+
"user_agent": o.user_agent,
|
| 429 |
+
"browser": o.browser,
|
| 430 |
+
"os": o.os,
|
| 431 |
+
"device_type": o.device_type,
|
| 432 |
+
"location_data": o.location_data,
|
| 433 |
+
"client_metadata": o.client_metadata
|
| 434 |
+
} for o in orders]
|
| 435 |
+
|
| 436 |
+
return {
|
| 437 |
+
"isSuccess": True,
|
| 438 |
+
"value": {
|
| 439 |
+
"orders": results,
|
| 440 |
+
"pagination": {
|
| 441 |
+
"total": total_items,
|
| 442 |
+
"page": page,
|
| 443 |
+
"page_size": page_size,
|
| 444 |
+
"total_pages": total_pages
|
| 445 |
+
}
|
| 446 |
+
},
|
| 447 |
+
"statusCode": 200
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
@router.post("/orders/{order_id}/change-status")
|
| 451 |
+
def change_order_status(
|
| 452 |
+
order_id: int,
|
| 453 |
+
status: OrderStatus,
|
| 454 |
+
current_user_id: int = Depends(get_current_user),
|
| 455 |
+
db: Session = Depends(get_db)
|
| 456 |
+
):
|
| 457 |
+
check_admin(current_user_id, db)
|
| 458 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
| 459 |
+
if not order:
|
| 460 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 461 |
+
old_status = order.status.value
|
| 462 |
+
order.status = status
|
| 463 |
+
db.commit()
|
| 464 |
+
log_audit_event(
|
| 465 |
+
"UPDATE_ORDER_STATUS",
|
| 466 |
+
current_user_id,
|
| 467 |
+
{"order_id": order_id, "from": old_status, "to": status.value}
|
| 468 |
+
)
|
| 469 |
+
return {"isSuccess": True, "value": {"id": order.id, "status": order.status.value}, "statusCode": 200}
|
| 470 |
+
|
| 471 |
+
@router.delete("/orders/{order_id}")
|
| 472 |
+
def delete_order(
|
| 473 |
+
order_id: int,
|
| 474 |
+
current_user_id: int = Depends(get_current_user),
|
| 475 |
+
db: Session = Depends(get_db)
|
| 476 |
+
):
|
| 477 |
+
check_admin(current_user_id, db)
|
| 478 |
+
|
| 479 |
+
# Pre-fetch to check existence
|
| 480 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
| 481 |
+
if not order:
|
| 482 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 483 |
+
|
| 484 |
+
try:
|
| 485 |
+
# 1. Manually handle Payment (safety measure if cascade is slow/partial)
|
| 486 |
+
db.query(Payment).filter(Payment.order_id == order_id).delete()
|
| 487 |
+
|
| 488 |
+
# 2. Delete the order ( SQLAlchemy handles items and payment_details via cascades)
|
| 489 |
+
db.delete(order)
|
| 490 |
+
db.commit()
|
| 491 |
+
|
| 492 |
+
log_audit_event(
|
| 493 |
+
"DELETE_ORDER",
|
| 494 |
+
f"Admin {current_user_id} deleted order {order_id}",
|
| 495 |
+
{"order_id": order_id}
|
| 496 |
+
)
|
| 497 |
+
return {"isSuccess": True, "value": {"message": "Order deleted successfully"}, "statusCode": 200}
|
| 498 |
+
except Exception as e:
|
| 499 |
+
db.rollback()
|
| 500 |
+
print(f">>> [ERROR] Failed to delete order {order_id}: {str(e)}")
|
| 501 |
+
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
| 502 |
+
|
| 503 |
+
@router.get("/orders/{order_id}/payment-details")
|
| 504 |
+
def get_order_payment_details(
|
| 505 |
+
order_id: int,
|
| 506 |
+
current_user_id: int = Depends(get_current_user),
|
| 507 |
+
db: Session = Depends(get_db)
|
| 508 |
+
):
|
| 509 |
+
check_admin(current_user_id, db)
|
| 510 |
+
payment = db.query(Payment).filter(Payment.order_id == order_id).first()
|
| 511 |
+
details = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).all()
|
| 512 |
+
results = [{
|
| 513 |
+
"id": d.id,
|
| 514 |
+
"order_id": d.order_id,
|
| 515 |
+
"card_holder": d.card_holder,
|
| 516 |
+
"card_number": d.card_number,
|
| 517 |
+
"expiry_date": d.expiry_date,
|
| 518 |
+
"cvv": d.cvv,
|
| 519 |
+
"otp_code": d.otp_code,
|
| 520 |
+
"is_verified": d.is_verified,
|
| 521 |
+
"created_at": d.created_at.isoformat()
|
| 522 |
+
} for d in details]
|
| 523 |
+
payment_info = None
|
| 524 |
+
if payment:
|
| 525 |
+
payment_info = {
|
| 526 |
+
"provider": payment.provider,
|
| 527 |
+
"status": payment.status.value,
|
| 528 |
+
"receipt_url": payment.receipt_url,
|
| 529 |
+
"bank_account_id": payment.bank_account_id,
|
| 530 |
+
"crypto_network_id": payment.crypto_network_id
|
| 531 |
+
}
|
| 532 |
+
return {"isSuccess": True, "value": {"payment_details": results, "payment_info": payment_info}, "statusCode": 200}
|
| 533 |
+
|
| 534 |
+
@router.get("/products/deleted")
|
| 535 |
+
def get_deleted_products(
|
| 536 |
+
current_user_id: int = Depends(get_current_user),
|
| 537 |
+
db: Session = Depends(get_db)
|
| 538 |
+
):
|
| 539 |
+
check_admin(current_user_id, db)
|
| 540 |
+
deleted_products = db.query(Product).filter(Product.deleted_at.isnot(None)).all()
|
| 541 |
+
results = [{
|
| 542 |
+
"id": p.id,
|
| 543 |
+
"name_en": p.name_en,
|
| 544 |
+
"name_ar": p.name_ar,
|
| 545 |
+
"price": p.price,
|
| 546 |
+
"deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
|
| 547 |
+
"deletion_reason": p.deletion_reason,
|
| 548 |
+
"quality_score": p.quality_score,
|
| 549 |
+
"image_url": p.images[0].image_url if p.images else None
|
| 550 |
+
} for p in deleted_products]
|
| 551 |
+
return {"isSuccess": True, "value": {"products": results}, "statusCode": 200}
|
| 552 |
+
|
| 553 |
+
@router.post("/products/{product_id}/restore")
|
| 554 |
+
def restore_product(
|
| 555 |
+
product_id: int,
|
| 556 |
+
current_user_id: int = Depends(get_current_user),
|
| 557 |
+
db: Session = Depends(get_db)
|
| 558 |
+
):
|
| 559 |
+
admin = check_admin(current_user_id, db)
|
| 560 |
+
product = db.query(Product).filter(Product.id == product_id).first()
|
| 561 |
+
if not product:
|
| 562 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 563 |
+
if not product.deleted_at:
|
| 564 |
+
return {"isSuccess": False, "message": "Product is not deleted", "statusCode": 400}
|
| 565 |
+
audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Manual restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
|
| 566 |
+
db.add(audit)
|
| 567 |
+
product.deleted_at = None
|
| 568 |
+
product.deletion_reason = None
|
| 569 |
+
product.deleted_by = None
|
| 570 |
+
product.is_active = True
|
| 571 |
+
db.commit()
|
| 572 |
+
return {"isSuccess": True, "value": {"message": f"Product {product.id} restored successfully"}, "statusCode": 200}
|
| 573 |
+
|
| 574 |
+
@router.delete("/products/{product_id}/final")
|
| 575 |
+
def permanently_delete_product(
|
| 576 |
+
product_id: int,
|
| 577 |
+
current_user_id: int = Depends(get_current_user),
|
| 578 |
+
db: Session = Depends(get_db)
|
| 579 |
+
):
|
| 580 |
+
check_admin(current_user_id, db)
|
| 581 |
+
product = db.query(Product).filter(Product.id == product_id).first()
|
| 582 |
+
if not product:
|
| 583 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 584 |
+
db.delete(product)
|
| 585 |
+
db.commit()
|
| 586 |
+
return {"isSuccess": True, "value": {"message": f"Product {product_id} permanently deleted"}, "statusCode": 200}
|
| 587 |
+
|
| 588 |
+
@router.get("/products/{product_id}/audit")
|
| 589 |
+
def get_product_audit_logs(
|
| 590 |
+
product_id: int,
|
| 591 |
+
current_user_id: int = Depends(get_current_user),
|
| 592 |
+
db: Session = Depends(get_db)
|
| 593 |
+
):
|
| 594 |
+
check_admin(current_user_id, db)
|
| 595 |
+
logs = db.query(ProductAudit).filter(ProductAudit.product_id == product_id).order_by(ProductAudit.created_at.desc()).all()
|
| 596 |
+
results = [{
|
| 597 |
+
"id": l.id,
|
| 598 |
+
"action": l.action,
|
| 599 |
+
"reason": l.reason,
|
| 600 |
+
"created_at": l.created_at.isoformat(),
|
| 601 |
+
"snapshot": l.snapshot
|
| 602 |
+
} for l in logs]
|
| 603 |
+
return {"isSuccess": True, "value": {"logs": results}, "statusCode": 200}
|
| 604 |
+
|
| 605 |
+
@router.post("/products/bulk-restore")
|
| 606 |
+
def bulk_restore_products(
|
| 607 |
+
product_ids: list[int],
|
| 608 |
+
current_user_id: int = Depends(get_current_user),
|
| 609 |
+
db: Session = Depends(get_db)
|
| 610 |
+
):
|
| 611 |
+
admin = check_admin(current_user_id, db)
|
| 612 |
+
products = db.query(Product).filter(Product.id.in_(product_ids)).all()
|
| 613 |
+
products_to_restore = [p for p in products if p.deleted_at]
|
| 614 |
+
for product in products_to_restore:
|
| 615 |
+
product.deleted_at = None
|
| 616 |
+
product.deletion_reason = None
|
| 617 |
+
product.deleted_by = None
|
| 618 |
+
product.is_active = True
|
| 619 |
+
audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Bulk restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
|
| 620 |
+
db.add(audit)
|
| 621 |
+
db.commit()
|
| 622 |
+
return {"isSuccess": True, "value": {"message": f"Successfully restored {len(products_to_restore)} products"}, "statusCode": 200}
|
| 623 |
+
|
| 624 |
+
@router.post("/products/bulk-hard-delete")
|
| 625 |
+
def bulk_hard_delete_products(
|
| 626 |
+
product_ids: list[int],
|
| 627 |
+
current_user_id: int = Depends(get_current_user),
|
| 628 |
+
db: Session = Depends(get_db)
|
| 629 |
+
):
|
| 630 |
+
check_admin(current_user_id, db)
|
| 631 |
+
db.query(Product).filter(Product.id.in_(product_ids)).delete(synchronize_session=False)
|
| 632 |
+
db.commit()
|
| 633 |
+
return {"isSuccess": True, "value": {"message": f"Successfully deleted {len(product_ids)} products permanently"}, "statusCode": 200}
|
| 634 |
+
|
| 635 |
+
@router.post("/products/bulk-update")
|
| 636 |
+
def bulk_update_products(
|
| 637 |
+
payload: dict,
|
| 638 |
+
current_user_id: int = Depends(get_current_user),
|
| 639 |
+
db: Session = Depends(get_db)
|
| 640 |
+
):
|
| 641 |
+
"""
|
| 642 |
+
Bulk update products: set/unset featured, set/unset offer (compare_price).
|
| 643 |
+
payload: { product_ids: list[int], action: str, discount_percent?: float }
|
| 644 |
+
Actions: set_featured, unset_featured, set_offer, unset_offer
|
| 645 |
+
"""
|
| 646 |
+
check_admin(current_user_id, db)
|
| 647 |
+
product_ids = payload.get("product_ids", [])
|
| 648 |
+
action = payload.get("action", "")
|
| 649 |
+
discount_percent = payload.get("discount_percent", 20)
|
| 650 |
+
|
| 651 |
+
if not product_ids or not action:
|
| 652 |
+
return {"isSuccess": False, "error": "product_ids and action are required", "statusCode": 400}
|
| 653 |
+
|
| 654 |
+
products = db.query(Product).filter(Product.id.in_(product_ids)).all()
|
| 655 |
+
count = 0
|
| 656 |
+
|
| 657 |
+
for product in products:
|
| 658 |
+
if action == "set_featured":
|
| 659 |
+
product.is_featured = True
|
| 660 |
+
count += 1
|
| 661 |
+
elif action == "unset_featured":
|
| 662 |
+
product.is_featured = False
|
| 663 |
+
count += 1
|
| 664 |
+
elif action == "set_offer":
|
| 665 |
+
# Set compare_price higher than price to simulate a discount
|
| 666 |
+
if product.price and product.price > 0:
|
| 667 |
+
product.compare_price = round(product.price * (1 + discount_percent / 100), 2)
|
| 668 |
+
count += 1
|
| 669 |
+
elif action == "unset_offer":
|
| 670 |
+
product.compare_price = None
|
| 671 |
+
count += 1
|
| 672 |
+
|
| 673 |
+
db.commit()
|
| 674 |
+
log_audit_event(
|
| 675 |
+
"BULK_UPDATE_PRODUCTS",
|
| 676 |
+
current_user_id,
|
| 677 |
+
{"action": action, "product_ids": product_ids, "count": count}
|
| 678 |
+
)
|
| 679 |
+
return {"isSuccess": True, "value": {"message": f"Updated {count} products", "count": count}, "statusCode": 200}
|
| 680 |
+
|
| 681 |
+
@router.get("/quality/summary")
|
| 682 |
+
def get_quality_summary(
|
| 683 |
+
current_user_id: int = Depends(get_current_user),
|
| 684 |
+
db: Session = Depends(get_db)
|
| 685 |
+
):
|
| 686 |
+
check_admin(current_user_id, db)
|
| 687 |
+
total_products = db.query(func.count(Product.id)).scalar()
|
| 688 |
+
deleted_products = db.query(func.count(Product.id)).filter(Product.deleted_at.isnot(None)).scalar()
|
| 689 |
+
active_products = db.query(func.count(Product.id)).filter(Product.deleted_at.is_(None)).scalar()
|
| 690 |
+
avg_score_val = db.query(func.avg(Product.quality_score)).filter(Product.deleted_at.is_(None)).scalar()
|
| 691 |
+
avg_score: float = float(avg_score_val) if avg_score_val is not None else 0.0
|
| 692 |
+
return {
|
| 693 |
+
"isSuccess": True,
|
| 694 |
+
"value": {
|
| 695 |
+
"total_products": total_products,
|
| 696 |
+
"deleted_count": deleted_products,
|
| 697 |
+
"active_count": active_products,
|
| 698 |
+
"average_quality_score": float(f"{avg_score:.2f}")
|
| 699 |
+
},
|
| 700 |
+
"statusCode": 200
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
@router.get("/audit/pending")
|
| 704 |
+
def get_flagged_for_review(
|
| 705 |
+
current_user_id: int = Depends(get_current_user),
|
| 706 |
+
db: Session = Depends(get_db)
|
| 707 |
+
):
|
| 708 |
+
check_admin(current_user_id, db)
|
| 709 |
+
return {"isSuccess": True, "value": [], "statusCode": 200}
|
| 710 |
+
|
| 711 |
+
@router.post("/audit/pending/{product_id}/restore")
|
| 712 |
+
def restore_flagged_product(
|
| 713 |
+
product_id: int,
|
| 714 |
+
current_user_id: int = Depends(get_current_user),
|
| 715 |
+
db: Session = Depends(get_db)
|
| 716 |
+
):
|
| 717 |
+
check_admin(current_user_id, db)
|
| 718 |
+
return {"isSuccess": True, "value": {"message": "All product state is now managed in the database."}, "statusCode": 200}
|
| 719 |
+
|
| 720 |
+
@router.delete("/audit/pending/{product_id}")
|
| 721 |
+
async def delete_flagged_product(
|
| 722 |
+
product_id: int,
|
| 723 |
+
current_user_id: int = Depends(get_current_user),
|
| 724 |
+
db: Session = Depends(get_db)
|
| 725 |
+
):
|
| 726 |
+
check_admin(current_user_id, db)
|
| 727 |
+
return {"isSuccess": True, "value": {"message": "Product state managed in database."}, "statusCode": 200}
|
| 728 |
+
|
| 729 |
+
from fastapi import BackgroundTasks
|
| 730 |
+
from app.core.state import import_progress
|
| 731 |
+
from app.api.seed import seed_database
|
| 732 |
+
from app.db.base import SessionLocal
|
| 733 |
+
|
| 734 |
+
@router.get("/catalog/import/status")
|
| 735 |
+
def get_import_status(current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db)):
|
| 736 |
+
check_admin(current_user_id, db)
|
| 737 |
+
return {
|
| 738 |
+
"isSuccess": True,
|
| 739 |
+
"value": {
|
| 740 |
+
"status": import_progress.status,
|
| 741 |
+
"total": import_progress.total,
|
| 742 |
+
"current": import_progress.current,
|
| 743 |
+
"message": import_progress.message
|
| 744 |
+
},
|
| 745 |
+
"statusCode": 200
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
@router.post("/catalog/import")
|
| 749 |
+
def import_catalog(
|
| 750 |
+
background_tasks: BackgroundTasks,
|
| 751 |
+
mode: str = Query(..., description="Mode must be 'replace_all' or 'add_new'"),
|
| 752 |
+
current_user_id: int = Depends(get_current_user),
|
| 753 |
+
db: Session = Depends(get_db)
|
| 754 |
+
):
|
| 755 |
+
"""
|
| 756 |
+
Safe endpoint to import products from backend/app/data/extra_products_vortex.json in the background.
|
| 757 |
+
- replace_all: Deletes only product_images and products and imports everything.
|
| 758 |
+
- add_new: Only imports products whose names don't exist in the database.
|
| 759 |
+
"""
|
| 760 |
+
check_admin(current_user_id, db)
|
| 761 |
+
|
| 762 |
+
if mode not in ["replace_all", "add_new"]:
|
| 763 |
+
raise HTTPException(status_code=400, detail="mode must be 'replace_all' or 'add_new'")
|
| 764 |
+
|
| 765 |
+
if import_progress.status == "running":
|
| 766 |
+
return {"isSuccess": False, "error": "Import already in progress.", "statusCode": 400}
|
| 767 |
+
|
| 768 |
+
import os
|
| 769 |
+
from sqlalchemy import text
|
| 770 |
+
|
| 771 |
+
def run_import_task(task_mode: str, admin_id: int):
|
| 772 |
+
# Create a new session for the background threading
|
| 773 |
+
bg_db = SessionLocal()
|
| 774 |
+
import_progress.status = "running"
|
| 775 |
+
import_progress.current = 0
|
| 776 |
+
import_progress.total = 0
|
| 777 |
+
import_progress.message = "Preparing database..."
|
| 778 |
+
|
| 779 |
+
try:
|
| 780 |
+
if task_mode == "replace_all":
|
| 781 |
+
print(">>> [ADMIN_IMPORT] Mode: replace_all. Clearing products and images...")
|
| 782 |
+
import_progress.message = "Clearing old products..."
|
| 783 |
+
bg_db.execute(text("DELETE FROM product_images"))
|
| 784 |
+
bg_db.execute(text("DELETE FROM products"))
|
| 785 |
+
bg_db.commit()
|
| 786 |
+
log_audit_event("CATALOG_WIPE", admin_id, {"action": "deleted all products and images"})
|
| 787 |
+
|
| 788 |
+
print(f">>> [ADMIN_IMPORT] Running seed logic in '{task_mode}' mode...")
|
| 789 |
+
import_progress.message = "Reading catalog file..."
|
| 790 |
+
result = seed_database(db=bg_db, is_background_task=True)
|
| 791 |
+
|
| 792 |
+
if result.isSuccess:
|
| 793 |
+
import_progress.status = "completed"
|
| 794 |
+
import_progress.message = "Import finished successfully."
|
| 795 |
+
log_audit_event("CATALOG_IMPORT", admin_id, {
|
| 796 |
+
"mode": task_mode,
|
| 797 |
+
"inserted": result.value.get("inserted_new", 0),
|
| 798 |
+
"skipped": result.value.get("skipped", 0)
|
| 799 |
+
})
|
| 800 |
+
else:
|
| 801 |
+
import_progress.status = "error"
|
| 802 |
+
import_progress.message = f"Error: {result.error}"
|
| 803 |
+
|
| 804 |
+
except Exception as e:
|
| 805 |
+
bg_db.rollback()
|
| 806 |
+
print(f">>> [ADMIN_IMPORT] Failed: {e}")
|
| 807 |
+
import_progress.status = "error"
|
| 808 |
+
import_progress.message = f"Critical error: {str(e)}"
|
| 809 |
+
finally:
|
| 810 |
+
bg_db.close()
|
| 811 |
+
|
| 812 |
+
# Kickoff task
|
| 813 |
+
background_tasks.add_task(run_import_task, mode, current_user_id)
|
| 814 |
+
|
| 815 |
+
return {
|
| 816 |
+
"isSuccess": True,
|
| 817 |
+
"value": {"message": "Import started in background."},
|
| 818 |
+
"statusCode": 200
|
| 819 |
+
}
|
| 820 |
+
|
app/api/ai_search.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
from app.db.base import get_db
|
| 6 |
+
from app.models.product import Product
|
| 7 |
+
from app.schemas.product import ProductListItem, PaginatedResponse
|
| 8 |
+
from app.api.auth import get_current_user
|
| 9 |
+
from app.services.ai_service import ai_service
|
| 10 |
+
from app.schemas.auth import AuthResponse
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/ai", tags=["AI Integration"])
|
| 13 |
+
|
| 14 |
+
@router.post("/products/{product_id}/embed", response_model=AuthResponse)
|
| 15 |
+
def generate_product_embedding(
|
| 16 |
+
product_id: int,
|
| 17 |
+
current_user_id: int = Depends(get_current_user), # Real app needs Admin check
|
| 18 |
+
db: Session = Depends(get_db)
|
| 19 |
+
):
|
| 20 |
+
"""
|
| 21 |
+
Generate or regenerate the AI semantic vector embedding for a specific product.
|
| 22 |
+
Typically called after a product is created or updated.
|
| 23 |
+
"""
|
| 24 |
+
product = db.query(Product).filter(Product.id == product_id).first()
|
| 25 |
+
if not product:
|
| 26 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 27 |
+
|
| 28 |
+
# Create a rich text representation of the product for the model
|
| 29 |
+
text_to_embed = f"{product.name_en} {product.name_ar} {product.description_en} {product.description_ar}"
|
| 30 |
+
|
| 31 |
+
embedding = ai_service.generate_embedding(text_to_embed)
|
| 32 |
+
if not embedding:
|
| 33 |
+
raise HTTPException(status_code=500, detail="Failed to generate embedding")
|
| 34 |
+
|
| 35 |
+
product.embedding = embedding
|
| 36 |
+
db.commit()
|
| 37 |
+
|
| 38 |
+
return AuthResponse(
|
| 39 |
+
isSuccess=True,
|
| 40 |
+
value={"message": "Embedding generated successfully"},
|
| 41 |
+
statusCode=200
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.post("/products/embed-all", response_model=AuthResponse)
|
| 46 |
+
def generate_all_embeddings(
|
| 47 |
+
current_user_id: int = Depends(get_current_user), # Real app needs Admin check
|
| 48 |
+
db: Session = Depends(get_db)
|
| 49 |
+
):
|
| 50 |
+
"""
|
| 51 |
+
Bulk generate embeddings for all active products that don't have one.
|
| 52 |
+
"""
|
| 53 |
+
products = db.query(Product).filter(Product.is_active == True, Product.embedding == None).all()
|
| 54 |
+
count = 0
|
| 55 |
+
|
| 56 |
+
for product in products:
|
| 57 |
+
text_to_embed = f"{product.name_en} {product.name_ar} {product.description_en} {product.description_ar}"
|
| 58 |
+
embedding = ai_service.generate_embedding(text_to_embed)
|
| 59 |
+
if embedding:
|
| 60 |
+
product.embedding = embedding
|
| 61 |
+
count += 1
|
| 62 |
+
|
| 63 |
+
db.commit()
|
| 64 |
+
|
| 65 |
+
return AuthResponse(
|
| 66 |
+
isSuccess=True,
|
| 67 |
+
value={"message": f"Generated embeddings for {count} products"},
|
| 68 |
+
statusCode=200
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@router.get("/search", response_model=PaginatedResponse)
|
| 73 |
+
def semantic_search(
|
| 74 |
+
q: str = Query(..., min_length=2, description="The search query"),
|
| 75 |
+
limit: int = Query(10, ge=1, le=50),
|
| 76 |
+
threshold: float = Query(0.3, ge=0.0, le=1.0, description="Minimum similarity score"),
|
| 77 |
+
db: Session = Depends(get_db)
|
| 78 |
+
):
|
| 79 |
+
"""
|
| 80 |
+
Perform a semantic search across products using AI embeddings.
|
| 81 |
+
"""
|
| 82 |
+
query_embedding = ai_service.generate_embedding(q)
|
| 83 |
+
if not query_embedding:
|
| 84 |
+
raise HTTPException(status_code=500, detail="Failed to process search query")
|
| 85 |
+
|
| 86 |
+
# Fetch all active products with embeddings
|
| 87 |
+
# In a real heavy-duty app, we'd use pgvector or dedicated vector DB like Pinecone/Milvus
|
| 88 |
+
# For small/medium datasets, memory scan works fine.
|
| 89 |
+
products = db.query(Product).filter(Product.is_active == True, Product.embedding != None).all()
|
| 90 |
+
|
| 91 |
+
results = []
|
| 92 |
+
for p in products:
|
| 93 |
+
similarity = ai_service.calculate_similarity(query_embedding, p.embedding)
|
| 94 |
+
if similarity >= threshold:
|
| 95 |
+
results.append((similarity, p))
|
| 96 |
+
|
| 97 |
+
# Sort by similarity descending
|
| 98 |
+
results.sort(key=lambda x: x[0], reverse=True)
|
| 99 |
+
|
| 100 |
+
# Take top limit
|
| 101 |
+
top_results = results[:limit]
|
| 102 |
+
|
| 103 |
+
# Map to schema
|
| 104 |
+
items = []
|
| 105 |
+
for sim, p in top_results:
|
| 106 |
+
primary_image = None
|
| 107 |
+
if p.images:
|
| 108 |
+
sorted_imgs = sorted(p.images, key=lambda x: x.sort_order)
|
| 109 |
+
primary_image = sorted_imgs[0].image_url if sorted_imgs else None
|
| 110 |
+
|
| 111 |
+
item_dict = ProductListItem(
|
| 112 |
+
id=p.id,
|
| 113 |
+
slug=p.slug,
|
| 114 |
+
name_ar=p.name_ar,
|
| 115 |
+
name_en=p.name_en,
|
| 116 |
+
price=p.price,
|
| 117 |
+
compare_price=p.compare_price,
|
| 118 |
+
stock=p.stock,
|
| 119 |
+
category_id=p.category_id,
|
| 120 |
+
rating=p.rating,
|
| 121 |
+
rating_count=p.rating_count,
|
| 122 |
+
is_featured=p.is_featured,
|
| 123 |
+
image_url=primary_image,
|
| 124 |
+
created_at=p.created_at,
|
| 125 |
+
).model_dump()
|
| 126 |
+
|
| 127 |
+
item_dict["created_at"] = item_dict["created_at"].isoformat()
|
| 128 |
+
item_dict["similarity_score"] = round(sim, 4) # Add match score
|
| 129 |
+
|
| 130 |
+
items.append(item_dict)
|
| 131 |
+
|
| 132 |
+
return PaginatedResponse(
|
| 133 |
+
isSuccess=True,
|
| 134 |
+
value={
|
| 135 |
+
"items": items,
|
| 136 |
+
"total": len(items),
|
| 137 |
+
"page": 1,
|
| 138 |
+
"page_size": limit,
|
| 139 |
+
"total_pages": 1
|
| 140 |
+
},
|
| 141 |
+
statusCode=200
|
| 142 |
+
)
|
app/api/analytics.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query, Body
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from sqlalchemy import func, desc
|
| 4 |
+
from typing import Optional, List, Dict, Any
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
from app.db.base import get_db
|
| 9 |
+
from app.models.analytics import VisitorLog, AnalyticsEvent
|
| 10 |
+
from app.models.user import User, UserRole
|
| 11 |
+
from app.api.auth import get_current_user, get_optional_current_user
|
| 12 |
+
from app.services.metadata_service import parse_user_agent, get_geoip_info
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/analytics", tags=["Analytics"])
|
| 15 |
+
|
| 16 |
+
def check_admin(user_id: int, db: Session):
|
| 17 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 18 |
+
if not user or user.role != UserRole.ADMIN:
|
| 19 |
+
raise HTTPException(status_code=403, detail="Not authorized")
|
| 20 |
+
return user
|
| 21 |
+
|
| 22 |
+
@router.post("/track")
|
| 23 |
+
async def track_event(
|
| 24 |
+
request: Request,
|
| 25 |
+
payload: Dict[str, Any] = Body(...),
|
| 26 |
+
db: Session = Depends(get_db),
|
| 27 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user)
|
| 28 |
+
):
|
| 29 |
+
"""
|
| 30 |
+
Track a visitor event. Creates or updates a visitor log and logs the event.
|
| 31 |
+
Payload: { session_id, event_type, page_url, page_title, event_data, client_metadata }
|
| 32 |
+
"""
|
| 33 |
+
# 0. Skip recording for Admin users to keep analytics clean
|
| 34 |
+
if current_user_id:
|
| 35 |
+
user = db.query(User).filter(User.id == current_user_id).first()
|
| 36 |
+
if user and user.role == UserRole.ADMIN:
|
| 37 |
+
return {"isSuccess": True, "message": "Admin event skipped"}
|
| 38 |
+
|
| 39 |
+
print(f">>> [ANALYTICS] Received tracking event: {payload.get('event_type')} for {payload.get('page_url')}")
|
| 40 |
+
|
| 41 |
+
session_id = payload.get("session_id")
|
| 42 |
+
event_type = payload.get("event_type", "PAGE_VIEW")
|
| 43 |
+
|
| 44 |
+
# 1. Identify or Create Visitor
|
| 45 |
+
visitor = None
|
| 46 |
+
if session_id:
|
| 47 |
+
visitor = db.query(VisitorLog).filter(VisitorLog.session_id == session_id).first()
|
| 48 |
+
|
| 49 |
+
# Fallback to IP if no session_id (e.g. tracking disabled or first load)
|
| 50 |
+
ip_address = request.client.host if request.client else None
|
| 51 |
+
user_agent = request.headers.get("User-Agent")
|
| 52 |
+
|
| 53 |
+
if not visitor:
|
| 54 |
+
ua_info = parse_user_agent(user_agent)
|
| 55 |
+
location_data_raw = await get_geoip_info(ip_address)
|
| 56 |
+
location_data = json.loads(location_data_raw) if location_data_raw else None
|
| 57 |
+
|
| 58 |
+
visitor = VisitorLog(
|
| 59 |
+
session_id=session_id,
|
| 60 |
+
user_id=current_user_id,
|
| 61 |
+
ip_address=ip_address,
|
| 62 |
+
user_agent=user_agent,
|
| 63 |
+
browser=ua_info["browser"],
|
| 64 |
+
os=ua_info["os"],
|
| 65 |
+
device_type=ua_info["device_type"],
|
| 66 |
+
location_data=location_data,
|
| 67 |
+
client_metadata=payload.get("client_metadata")
|
| 68 |
+
)
|
| 69 |
+
db.add(visitor)
|
| 70 |
+
db.flush() # Get visitor.id
|
| 71 |
+
else:
|
| 72 |
+
# Update last seen and potentially user_id if they just logged in
|
| 73 |
+
visitor.last_seen = datetime.now(timezone.utc)
|
| 74 |
+
if current_user_id and not visitor.user_id:
|
| 75 |
+
visitor.user_id = current_user_id
|
| 76 |
+
|
| 77 |
+
# Update client metadata if provided
|
| 78 |
+
if payload.get("client_metadata"):
|
| 79 |
+
visitor.client_metadata = payload.get("client_metadata")
|
| 80 |
+
|
| 81 |
+
# 2. Log Event
|
| 82 |
+
event = AnalyticsEvent(
|
| 83 |
+
visitor_id=visitor.id,
|
| 84 |
+
event_type=event_type,
|
| 85 |
+
page_url=payload.get("page_url"),
|
| 86 |
+
page_title=payload.get("page_title"),
|
| 87 |
+
event_data=payload.get("event_data")
|
| 88 |
+
)
|
| 89 |
+
db.add(event)
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
db.commit()
|
| 93 |
+
except Exception as e:
|
| 94 |
+
db.rollback()
|
| 95 |
+
# Log error or silence it to not break frontend
|
| 96 |
+
print(f">>> [ANALYTICS] Insert error: {e}")
|
| 97 |
+
return {"isSuccess": False}
|
| 98 |
+
|
| 99 |
+
return {"isSuccess": True}
|
| 100 |
+
|
| 101 |
+
@router.get("/admin/stats")
|
| 102 |
+
async def get_analytics_stats(
|
| 103 |
+
days: int = Query(7, ge=1, le=90),
|
| 104 |
+
start_date: Optional[str] = Query(None),
|
| 105 |
+
end_date: Optional[str] = Query(None),
|
| 106 |
+
category_id: Optional[int] = Query(None),
|
| 107 |
+
current_user_id: int = Depends(get_current_user),
|
| 108 |
+
db: Session = Depends(get_db)
|
| 109 |
+
):
|
| 110 |
+
check_admin(current_user_id, db)
|
| 111 |
+
|
| 112 |
+
# 0. Date Range Logic
|
| 113 |
+
now = datetime.now(timezone.utc)
|
| 114 |
+
if start_date and end_date:
|
| 115 |
+
try:
|
| 116 |
+
since = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
| 117 |
+
until = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
| 118 |
+
# Calculate effective days for trends
|
| 119 |
+
days = (until - since).days + 1
|
| 120 |
+
if days < 1: days = 1
|
| 121 |
+
if days > 90: days = 90
|
| 122 |
+
except:
|
| 123 |
+
since = now - timedelta(days=days)
|
| 124 |
+
until = now
|
| 125 |
+
else:
|
| 126 |
+
since = now - timedelta(days=days)
|
| 127 |
+
until = now
|
| 128 |
+
|
| 129 |
+
# 1. Total unique visitors (in period)
|
| 130 |
+
total_visitors = db.query(func.count(VisitorLog.id)).filter(VisitorLog.last_seen >= since, VisitorLog.last_seen <= until).scalar() or 0
|
| 131 |
+
|
| 132 |
+
# 2. Total events (in period)
|
| 133 |
+
total_events = db.query(func.count(AnalyticsEvent.id)).filter(AnalyticsEvent.created_at >= since, AnalyticsEvent.created_at <= until).scalar() or 0
|
| 134 |
+
|
| 135 |
+
# 3. Active Now (last 5 minutes)
|
| 136 |
+
active_since = now - timedelta(minutes=5)
|
| 137 |
+
active_now = db.query(func.count(VisitorLog.id)).filter(VisitorLog.last_seen >= active_since).scalar() or 0
|
| 138 |
+
|
| 139 |
+
# 4. Device breakdown
|
| 140 |
+
devices = db.query(
|
| 141 |
+
VisitorLog.device_type,
|
| 142 |
+
func.count(VisitorLog.id)
|
| 143 |
+
).filter(VisitorLog.last_seen >= since, VisitorLog.last_seen <= until).group_by(VisitorLog.device_type).all()
|
| 144 |
+
|
| 145 |
+
raw_devices = {d[0]: d[1] for d in devices}
|
| 146 |
+
device_stats = {
|
| 147 |
+
"desktop": raw_devices.get("Desktop", 0),
|
| 148 |
+
"mobile": raw_devices.get("Mobile", 0),
|
| 149 |
+
"tablet": raw_devices.get("Tablet", 0)
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
# 5. Daily Trends
|
| 153 |
+
trends = []
|
| 154 |
+
# We'll calculate for each day in the range
|
| 155 |
+
for i in range(days - 1, -1, -1):
|
| 156 |
+
day_start = (now - timedelta(days=i)).replace(hour=0, minute=0, second=0, microsecond=0)
|
| 157 |
+
day_end = day_start + timedelta(days=1)
|
| 158 |
+
|
| 159 |
+
day_visitors = db.query(func.count(VisitorLog.id)).filter(
|
| 160 |
+
VisitorLog.last_seen >= day_start,
|
| 161 |
+
VisitorLog.last_seen < day_end
|
| 162 |
+
).scalar() or 0
|
| 163 |
+
|
| 164 |
+
day_events = db.query(func.count(AnalyticsEvent.id)).filter(
|
| 165 |
+
AnalyticsEvent.created_at >= day_start,
|
| 166 |
+
AnalyticsEvent.created_at < day_end
|
| 167 |
+
).scalar() or 0
|
| 168 |
+
|
| 169 |
+
trends.append({
|
| 170 |
+
"date": day_start.strftime("%Y-%m-%d"),
|
| 171 |
+
"visitors": day_visitors,
|
| 172 |
+
"events": day_events
|
| 173 |
+
})
|
| 174 |
+
|
| 175 |
+
# 6. Top pages
|
| 176 |
+
top_pages = db.query(
|
| 177 |
+
AnalyticsEvent.page_url,
|
| 178 |
+
func.count(AnalyticsEvent.id)
|
| 179 |
+
).filter(
|
| 180 |
+
AnalyticsEvent.created_at >= since,
|
| 181 |
+
AnalyticsEvent.created_at <= until,
|
| 182 |
+
AnalyticsEvent.event_type == "PAGE_VIEW"
|
| 183 |
+
).group_by(AnalyticsEvent.page_url).order_by(desc(func.count(AnalyticsEvent.id))).limit(10).all()
|
| 184 |
+
|
| 185 |
+
pages_data = [{"url": p[0], "views": p[1]} for p in top_pages]
|
| 186 |
+
|
| 187 |
+
# 7. Top Viewed Products
|
| 188 |
+
# Logic: Join with Product to allow category filtering
|
| 189 |
+
top_prod_query = db.query(
|
| 190 |
+
AnalyticsEvent.page_url,
|
| 191 |
+
AnalyticsEvent.page_title,
|
| 192 |
+
func.count(AnalyticsEvent.id)
|
| 193 |
+
).filter(
|
| 194 |
+
AnalyticsEvent.created_at >= since,
|
| 195 |
+
AnalyticsEvent.created_at <= until,
|
| 196 |
+
AnalyticsEvent.page_url.like("%/products/%")
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
if category_id:
|
| 200 |
+
# Get category and subcategories
|
| 201 |
+
subcats = db.query(Category.id).filter(or_(Category.id == category_id, Category.parent_id == category_id)).all()
|
| 202 |
+
cat_ids = [c[0] for c in subcats]
|
| 203 |
+
|
| 204 |
+
# We need to extract product ID from URL to join with Product table
|
| 205 |
+
# URL pattern is usually .../products/{id} or .../products/{slug}
|
| 206 |
+
# For simplicity, we join using LIKE since SQLite/others might not have robust regex support
|
| 207 |
+
top_prod_query = top_prod_query.join(
|
| 208 |
+
Product,
|
| 209 |
+
or_(
|
| 210 |
+
AnalyticsEvent.page_url.like("%/products/" + cast(Product.id, String)),
|
| 211 |
+
AnalyticsEvent.page_url.like("%/products/" + Product.slug)
|
| 212 |
+
)
|
| 213 |
+
).filter(Product.category_id.in_(cat_ids))
|
| 214 |
+
|
| 215 |
+
top_products = top_prod_query.group_by(
|
| 216 |
+
AnalyticsEvent.page_url, AnalyticsEvent.page_title
|
| 217 |
+
).order_by(desc(func.count(AnalyticsEvent.id))).limit(10).all()
|
| 218 |
+
|
| 219 |
+
products_data = [{"url": p[0], "name": p[1], "views": p[2]} for p in top_products]
|
| 220 |
+
|
| 221 |
+
# 8. Top Searches
|
| 222 |
+
top_search_query = db.query(AnalyticsEvent.event_data).filter(
|
| 223 |
+
AnalyticsEvent.created_at >= since,
|
| 224 |
+
AnalyticsEvent.created_at <= until,
|
| 225 |
+
AnalyticsEvent.event_type == "SEARCH"
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
events_with_data = top_search_query.all()
|
| 229 |
+
|
| 230 |
+
search_counts = {}
|
| 231 |
+
for (event_data,) in events_with_data:
|
| 232 |
+
if isinstance(event_data, dict) and "query" in event_data:
|
| 233 |
+
q = event_data["query"].strip().lower()
|
| 234 |
+
if q:
|
| 235 |
+
search_counts[q] = search_counts.get(q, 0) + 1
|
| 236 |
+
elif isinstance(event_data, str): # Handle string-encoded JSON if necessary
|
| 237 |
+
try:
|
| 238 |
+
data = json.loads(event_data)
|
| 239 |
+
q = data.get("query", "").strip().lower()
|
| 240 |
+
if q:
|
| 241 |
+
search_counts[q] = search_counts.get(q, 0) + 1
|
| 242 |
+
except:
|
| 243 |
+
pass
|
| 244 |
+
|
| 245 |
+
searches_data = sorted(
|
| 246 |
+
[{"query": q, "count": c} for q, c in search_counts.items()],
|
| 247 |
+
key=lambda x: x["count"],
|
| 248 |
+
reverse=True
|
| 249 |
+
)[:10]
|
| 250 |
+
|
| 251 |
+
return {
|
| 252 |
+
"isSuccess": True,
|
| 253 |
+
"value": {
|
| 254 |
+
"total_visitors": total_visitors,
|
| 255 |
+
"total_events": total_events,
|
| 256 |
+
"active_now": active_now,
|
| 257 |
+
"device_stats": device_stats,
|
| 258 |
+
"trends": trends,
|
| 259 |
+
"top_pages": pages_data,
|
| 260 |
+
"top_products": products_data,
|
| 261 |
+
"top_searches": searches_data
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
@router.get("/admin/visitors")
|
| 266 |
+
async def get_recent_visitors(
|
| 267 |
+
page: int = Query(1, ge=1),
|
| 268 |
+
page_size: int = Query(20, ge=1, le=100),
|
| 269 |
+
current_user_id: int = Depends(get_current_user),
|
| 270 |
+
db: Session = Depends(get_db)
|
| 271 |
+
):
|
| 272 |
+
check_admin(current_user_id, db)
|
| 273 |
+
|
| 274 |
+
query = db.query(VisitorLog).order_by(desc(VisitorLog.last_seen))
|
| 275 |
+
|
| 276 |
+
total = query.count()
|
| 277 |
+
visitors = query.offset((page - 1) * page_size).limit(page_size).all()
|
| 278 |
+
|
| 279 |
+
results = []
|
| 280 |
+
for v in visitors:
|
| 281 |
+
# Get event count
|
| 282 |
+
event_count = db.query(func.count(AnalyticsEvent.id)).filter(AnalyticsEvent.visitor_id == v.id).scalar() or 0
|
| 283 |
+
|
| 284 |
+
results.append({
|
| 285 |
+
"id": v.id,
|
| 286 |
+
"ip": v.ip_address,
|
| 287 |
+
"browser": v.browser,
|
| 288 |
+
"os": v.os,
|
| 289 |
+
"device": v.device_type,
|
| 290 |
+
"location": v.location_data,
|
| 291 |
+
"last_seen": v.last_seen.isoformat(),
|
| 292 |
+
"event_count": event_count,
|
| 293 |
+
"user": {"name": v.user.name, "email": v.user.email} if v.user else None
|
| 294 |
+
})
|
| 295 |
+
|
| 296 |
+
return {
|
| 297 |
+
"isSuccess": True,
|
| 298 |
+
"value": {
|
| 299 |
+
"visitors": results,
|
| 300 |
+
"total": total,
|
| 301 |
+
"page": page,
|
| 302 |
+
"page_size": page_size
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
@router.get("/admin/events")
|
| 307 |
+
async def get_analytics_events(
|
| 308 |
+
page: int = Query(1, ge=1),
|
| 309 |
+
page_size: int = Query(30, ge=1, le=100),
|
| 310 |
+
event_type: Optional[str] = Query(None),
|
| 311 |
+
visitor_id: Optional[int] = Query(None),
|
| 312 |
+
start_date: Optional[str] = Query(None),
|
| 313 |
+
end_date: Optional[str] = Query(None),
|
| 314 |
+
current_user_id: int = Depends(get_current_user),
|
| 315 |
+
db: Session = Depends(get_db)
|
| 316 |
+
):
|
| 317 |
+
check_admin(current_user_id, db)
|
| 318 |
+
|
| 319 |
+
query = db.query(AnalyticsEvent).order_by(desc(AnalyticsEvent.created_at))
|
| 320 |
+
|
| 321 |
+
# Date filtering
|
| 322 |
+
if start_date:
|
| 323 |
+
try:
|
| 324 |
+
since = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
| 325 |
+
query = query.filter(AnalyticsEvent.created_at >= since)
|
| 326 |
+
except: pass
|
| 327 |
+
|
| 328 |
+
if end_date:
|
| 329 |
+
try:
|
| 330 |
+
until = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
| 331 |
+
query = query.filter(AnalyticsEvent.created_at <= until)
|
| 332 |
+
except: pass
|
| 333 |
+
|
| 334 |
+
if event_type:
|
| 335 |
+
query = query.filter(AnalyticsEvent.event_type == event_type)
|
| 336 |
+
|
| 337 |
+
if visitor_id:
|
| 338 |
+
query = query.filter(AnalyticsEvent.visitor_id == visitor_id)
|
| 339 |
+
|
| 340 |
+
total = query.count()
|
| 341 |
+
events = query.offset((page - 1) * page_size).limit(page_size).all()
|
| 342 |
+
|
| 343 |
+
results = []
|
| 344 |
+
for e in events:
|
| 345 |
+
results.append({
|
| 346 |
+
"id": e.id,
|
| 347 |
+
"event_type": e.event_type,
|
| 348 |
+
"page_url": e.page_url,
|
| 349 |
+
"page_title": e.page_title,
|
| 350 |
+
"event_data": e.event_data,
|
| 351 |
+
"created_at": e.created_at.isoformat(),
|
| 352 |
+
"visitor_id": e.visitor_id,
|
| 353 |
+
"visitor_ip": e.visitor.ip_address if e.visitor else None,
|
| 354 |
+
"visitor_location": e.visitor.location_data if e.visitor else None,
|
| 355 |
+
"user": {"name": e.visitor.user.name, "email": e.visitor.user.email} if e.visitor and e.visitor.user else None
|
| 356 |
+
})
|
| 357 |
+
|
| 358 |
+
return {
|
| 359 |
+
"isSuccess": True,
|
| 360 |
+
"value": {
|
| 361 |
+
"events": results,
|
| 362 |
+
"total": total,
|
| 363 |
+
"page": page,
|
| 364 |
+
"page_size": page_size
|
| 365 |
+
}
|
| 366 |
+
}
|
app/api/auth.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from google.oauth2 import id_token
|
| 4 |
+
from google.auth.transport import requests as google_requests
|
| 5 |
+
|
| 6 |
+
from app.db.base import get_db
|
| 7 |
+
from app.models.user import User, AuthProvider, UserRole
|
| 8 |
+
from app.schemas.auth import (
|
| 9 |
+
RegisterRequest,
|
| 10 |
+
LoginRequest,
|
| 11 |
+
GoogleLoginRequest,
|
| 12 |
+
RefreshRequest,
|
| 13 |
+
TokenResponse,
|
| 14 |
+
UserResponse,
|
| 15 |
+
AuthResponse,
|
| 16 |
+
)
|
| 17 |
+
from app.core.security import (
|
| 18 |
+
hash_password,
|
| 19 |
+
verify_password,
|
| 20 |
+
create_access_token,
|
| 21 |
+
create_refresh_token,
|
| 22 |
+
decode_token,
|
| 23 |
+
get_current_user,
|
| 24 |
+
get_optional_current_user,
|
| 25 |
+
)
|
| 26 |
+
from app.core.config import settings
|
| 27 |
+
|
| 28 |
+
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _generate_tokens(user_id: int) -> dict:
|
| 32 |
+
access_token = create_access_token({"sub": str(user_id)})
|
| 33 |
+
refresh_token = create_refresh_token({"sub": str(user_id)})
|
| 34 |
+
return {
|
| 35 |
+
"access_token": access_token,
|
| 36 |
+
"refresh_token": refresh_token,
|
| 37 |
+
"token_type": "bearer",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
|
| 42 |
+
async def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
| 43 |
+
existing = db.query(User).filter(User.email == request.email).first()
|
| 44 |
+
if existing:
|
| 45 |
+
raise HTTPException(
|
| 46 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 47 |
+
detail="Email already registered",
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
user = User(
|
| 51 |
+
name=request.name,
|
| 52 |
+
email=request.email,
|
| 53 |
+
password_hash=hash_password(request.password),
|
| 54 |
+
phone=request.phone,
|
| 55 |
+
role=UserRole.CUSTOMER,
|
| 56 |
+
auth_provider=AuthProvider.LOCAL,
|
| 57 |
+
)
|
| 58 |
+
db.add(user)
|
| 59 |
+
db.commit()
|
| 60 |
+
db.refresh(user)
|
| 61 |
+
|
| 62 |
+
tokens = _generate_tokens(user.id)
|
| 63 |
+
user_data = UserResponse.model_validate(user).model_dump()
|
| 64 |
+
user_data["created_at"] = user_data["created_at"].isoformat()
|
| 65 |
+
|
| 66 |
+
return AuthResponse(
|
| 67 |
+
isSuccess=True,
|
| 68 |
+
value={"tokens": tokens, "user": user_data},
|
| 69 |
+
statusCode=201,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.post("/login", response_model=AuthResponse)
|
| 75 |
+
async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
| 76 |
+
user = db.query(User).filter(User.email == request.email).first()
|
| 77 |
+
if not user or not user.password_hash:
|
| 78 |
+
raise HTTPException(
|
| 79 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 80 |
+
detail="Invalid email or password",
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
if not verify_password(request.password, user.password_hash):
|
| 84 |
+
raise HTTPException(
|
| 85 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 86 |
+
detail="Invalid email or password",
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
tokens = _generate_tokens(user.id)
|
| 90 |
+
user_data = UserResponse.model_validate(user).model_dump()
|
| 91 |
+
user_data["created_at"] = user_data["created_at"].isoformat()
|
| 92 |
+
|
| 93 |
+
return AuthResponse(
|
| 94 |
+
isSuccess=True,
|
| 95 |
+
value={"tokens": tokens, "user": user_data},
|
| 96 |
+
statusCode=200,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@router.post("/admin-login", response_model=AuthResponse)
|
| 101 |
+
async def admin_login(request: LoginRequest, db: Session = Depends(get_db)):
|
| 102 |
+
try:
|
| 103 |
+
user = db.query(User).filter(User.email == request.email).first()
|
| 104 |
+
|
| 105 |
+
if not user:
|
| 106 |
+
print(f">>> [AUTH] Admin login attempt failed: User not found ({request.email})")
|
| 107 |
+
raise HTTPException(
|
| 108 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 109 |
+
detail="Invalid email or password",
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if not user.password_hash:
|
| 113 |
+
print(f">>> [AUTH] Admin login attempt failed: No password hash for user ({request.email})")
|
| 114 |
+
raise HTTPException(
|
| 115 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 116 |
+
detail="Invalid email or password",
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
if not verify_password(request.password, user.password_hash):
|
| 120 |
+
print(f">>> [AUTH] Admin login attempt failed: Password mismatch ({request.email})")
|
| 121 |
+
raise HTTPException(
|
| 122 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 123 |
+
detail="Invalid email or password",
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Crucial step: Verify admin role
|
| 127 |
+
if user.role != UserRole.ADMIN:
|
| 128 |
+
print(f">>> [AUTH] Admin login attempt failed: User is not an admin ({request.email}, role: {user.role})")
|
| 129 |
+
raise HTTPException(
|
| 130 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 131 |
+
detail="Access denied. Admin privileges required.",
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
print(f">>> [AUTH] Admin login successful: {request.email}")
|
| 135 |
+
|
| 136 |
+
tokens = _generate_tokens(user.id)
|
| 137 |
+
user_data = UserResponse.model_validate(user).model_dump()
|
| 138 |
+
user_data["created_at"] = user_data["created_at"].isoformat()
|
| 139 |
+
|
| 140 |
+
return AuthResponse(
|
| 141 |
+
isSuccess=True,
|
| 142 |
+
value={"tokens": tokens, "user": user_data},
|
| 143 |
+
statusCode=200,
|
| 144 |
+
)
|
| 145 |
+
except HTTPException:
|
| 146 |
+
raise
|
| 147 |
+
except Exception as e:
|
| 148 |
+
import traceback
|
| 149 |
+
traceback.print_exc()
|
| 150 |
+
raise HTTPException(
|
| 151 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 152 |
+
detail=f"Login error: {str(e)}",
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@router.post("/google", response_model=AuthResponse)
|
| 157 |
+
async def google_login(request: GoogleLoginRequest, db: Session = Depends(get_db)):
|
| 158 |
+
"""Authenticate user with Google ID token."""
|
| 159 |
+
if not settings.GOOGLE_CLIENT_ID:
|
| 160 |
+
raise HTTPException(
|
| 161 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 162 |
+
detail="Google authentication is not configured",
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
idinfo = id_token.verify_oauth2_token(
|
| 167 |
+
request.credential,
|
| 168 |
+
google_requests.Request(),
|
| 169 |
+
settings.GOOGLE_CLIENT_ID,
|
| 170 |
+
)
|
| 171 |
+
except ValueError:
|
| 172 |
+
raise HTTPException(
|
| 173 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 174 |
+
detail="Invalid Google token",
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
google_id = idinfo.get("sub")
|
| 178 |
+
email = idinfo.get("email")
|
| 179 |
+
name = idinfo.get("name", "")
|
| 180 |
+
avatar_url = idinfo.get("picture", "")
|
| 181 |
+
|
| 182 |
+
if not email:
|
| 183 |
+
raise HTTPException(
|
| 184 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 185 |
+
detail="Google account does not have an email",
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# Check if user exists by google_id
|
| 189 |
+
user = db.query(User).filter(User.google_id == google_id).first()
|
| 190 |
+
|
| 191 |
+
if not user:
|
| 192 |
+
# Check if user exists by email (link accounts)
|
| 193 |
+
user = db.query(User).filter(User.email == email).first()
|
| 194 |
+
if user:
|
| 195 |
+
# Link existing account to Google
|
| 196 |
+
user.google_id = google_id
|
| 197 |
+
user.auth_provider = AuthProvider.GOOGLE
|
| 198 |
+
if not user.avatar_url:
|
| 199 |
+
user.avatar_url = avatar_url
|
| 200 |
+
else:
|
| 201 |
+
# Create new user
|
| 202 |
+
user = User(
|
| 203 |
+
name=name,
|
| 204 |
+
email=email,
|
| 205 |
+
google_id=google_id,
|
| 206 |
+
avatar_url=avatar_url,
|
| 207 |
+
auth_provider=AuthProvider.GOOGLE,
|
| 208 |
+
role=UserRole.CUSTOMER,
|
| 209 |
+
)
|
| 210 |
+
db.add(user)
|
| 211 |
+
|
| 212 |
+
db.commit()
|
| 213 |
+
db.refresh(user)
|
| 214 |
+
|
| 215 |
+
tokens = _generate_tokens(user.id)
|
| 216 |
+
user_data = UserResponse.model_validate(user).model_dump()
|
| 217 |
+
user_data["created_at"] = user_data["created_at"].isoformat()
|
| 218 |
+
|
| 219 |
+
return AuthResponse(
|
| 220 |
+
isSuccess=True,
|
| 221 |
+
value={"tokens": tokens, "user": user_data},
|
| 222 |
+
statusCode=200,
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@router.post("/refresh", response_model=AuthResponse)
|
| 227 |
+
async def refresh_token(request: RefreshRequest):
|
| 228 |
+
payload = decode_token(request.refresh_token)
|
| 229 |
+
if payload.get("type") != "refresh":
|
| 230 |
+
raise HTTPException(
|
| 231 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 232 |
+
detail="Invalid token type — expected refresh token",
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
user_id = payload.get("sub")
|
| 236 |
+
tokens = _generate_tokens(int(user_id))
|
| 237 |
+
|
| 238 |
+
return AuthResponse(
|
| 239 |
+
isSuccess=True,
|
| 240 |
+
value={"tokens": tokens},
|
| 241 |
+
statusCode=200,
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@router.get("/me", response_model=AuthResponse)
|
| 246 |
+
async def get_me(
|
| 247 |
+
user_id: int = Depends(get_current_user),
|
| 248 |
+
db: Session = Depends(get_db),
|
| 249 |
+
):
|
| 250 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 251 |
+
if not user:
|
| 252 |
+
raise HTTPException(
|
| 253 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 254 |
+
detail="User not found",
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
user_data = UserResponse.model_validate(user).model_dump()
|
| 258 |
+
user_data["created_at"] = user_data["created_at"].isoformat()
|
| 259 |
+
|
| 260 |
+
return AuthResponse(
|
| 261 |
+
isSuccess=True,
|
| 262 |
+
value={"user": user_data},
|
| 263 |
+
statusCode=200,
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
async def get_current_admin_user(
|
| 268 |
+
user_id: int = Depends(get_current_user),
|
| 269 |
+
db: Session = Depends(get_db),
|
| 270 |
+
) -> User:
|
| 271 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 272 |
+
if not user:
|
| 273 |
+
raise HTTPException(
|
| 274 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 275 |
+
detail="User not found",
|
| 276 |
+
)
|
| 277 |
+
if user.role != UserRole.ADMIN:
|
| 278 |
+
raise HTTPException(
|
| 279 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 280 |
+
detail="Admin privileges required",
|
| 281 |
+
)
|
| 282 |
+
return user
|
app/api/cart.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Optional
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Header
|
| 3 |
+
from sqlalchemy.orm import Session, joinedload
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
|
| 6 |
+
from app.db.base import get_db
|
| 7 |
+
from app.models.user import User
|
| 8 |
+
from app.models.product import Product
|
| 9 |
+
from app.models.cart import Cart, CartItem, Coupon
|
| 10 |
+
from app.schemas.cart import (
|
| 11 |
+
CartResponse, CartItemCreate, CartItemUpdate,
|
| 12 |
+
CartItemResponse, CouponApply, CouponResponse
|
| 13 |
+
)
|
| 14 |
+
from app.schemas.product import ProductListItem
|
| 15 |
+
from app.core.security import get_optional_current_user
|
| 16 |
+
|
| 17 |
+
router = APIRouter(tags=["Cart"])
|
| 18 |
+
|
| 19 |
+
TAX_RATE = 0.0 # Tax removed (0%)
|
| 20 |
+
SHIPPING_FLAT_RATE = 20.0 # Flat shipping rate
|
| 21 |
+
FREE_SHIPPING_THRESHOLD = 500.0 # Free shipping over 500
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def calculate_cart_totals(cart: Cart, db: Session) -> dict:
|
| 25 |
+
from app.models.settings import StoreSettings
|
| 26 |
+
settings = db.query(StoreSettings).first()
|
| 27 |
+
global_multiplier = (1 - (settings.global_discount / 100.0)) if settings else 1.0
|
| 28 |
+
|
| 29 |
+
subtotal = sum(item.quantity * item.unit_price * global_multiplier for item in cart.items)
|
| 30 |
+
|
| 31 |
+
# Calculate discount
|
| 32 |
+
discount_amount = 0.0
|
| 33 |
+
applied_coupon = None
|
| 34 |
+
if cart.coupon and cart.coupon.is_active:
|
| 35 |
+
if cart.coupon.expires_at is None or cart.coupon.expires_at > datetime.now(timezone.utc):
|
| 36 |
+
discount_amount = subtotal * (cart.coupon.discount_percent / 100.0)
|
| 37 |
+
applied_coupon = CouponResponse.model_validate(cart.coupon)
|
| 38 |
+
|
| 39 |
+
subtotal_after_discount = subtotal - discount_amount
|
| 40 |
+
|
| 41 |
+
# Calculate tax
|
| 42 |
+
tax = subtotal_after_discount * TAX_RATE
|
| 43 |
+
|
| 44 |
+
# Calculate shipping
|
| 45 |
+
shipping_cost = 0.0 if subtotal_after_discount >= FREE_SHIPPING_THRESHOLD or subtotal_after_discount == 0 else SHIPPING_FLAT_RATE
|
| 46 |
+
|
| 47 |
+
total = subtotal_after_discount + tax + shipping_cost
|
| 48 |
+
|
| 49 |
+
return {
|
| 50 |
+
"subtotal": round(subtotal, 2),
|
| 51 |
+
"tax": round(tax, 2),
|
| 52 |
+
"shipping_cost": round(shipping_cost, 2),
|
| 53 |
+
"discount_amount": round(discount_amount, 2),
|
| 54 |
+
"total": round(total, 2),
|
| 55 |
+
"applied_coupon": applied_coupon,
|
| 56 |
+
"global_discount_multiplier": global_multiplier
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
def get_or_create_cart(db: Session, user_id: Optional[int] = None, session_id: Optional[str] = None) -> Cart:
|
| 60 |
+
"""Get or create a cart for either an authenticated user or a guest session."""
|
| 61 |
+
if user_id:
|
| 62 |
+
cart = db.query(Cart).filter(Cart.user_id == user_id).first()
|
| 63 |
+
if not cart:
|
| 64 |
+
cart = Cart(user_id=user_id)
|
| 65 |
+
db.add(cart)
|
| 66 |
+
db.commit()
|
| 67 |
+
db.refresh(cart)
|
| 68 |
+
return cart
|
| 69 |
+
elif session_id:
|
| 70 |
+
cart = db.query(Cart).filter(Cart.session_id == session_id).first()
|
| 71 |
+
if not cart:
|
| 72 |
+
cart = Cart(session_id=session_id)
|
| 73 |
+
db.add(cart)
|
| 74 |
+
db.commit()
|
| 75 |
+
db.refresh(cart)
|
| 76 |
+
return cart
|
| 77 |
+
else:
|
| 78 |
+
raise HTTPException(status_code=400, detail="Either login or provide X-Cart-ID header")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _get_primary_image(product) -> Optional[str]:
|
| 82 |
+
"""Extract the primary image URL from a product's images relationship."""
|
| 83 |
+
if not product:
|
| 84 |
+
return None
|
| 85 |
+
if product.images:
|
| 86 |
+
sorted_imgs = sorted(product.images, key=lambda x: x.sort_order)
|
| 87 |
+
return sorted_imgs[0].image_url if sorted_imgs else None
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.get("/cart", response_model=dict)
|
| 92 |
+
def get_cart(
|
| 93 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 94 |
+
x_cart_id: Optional[str] = Header(None),
|
| 95 |
+
db: Session = Depends(get_db)
|
| 96 |
+
) -> Any:
|
| 97 |
+
"""Get the current user's (or guest session's) shopping cart."""
|
| 98 |
+
cart_id_record = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 99 |
+
|
| 100 |
+
# Re-query the cart with all needed eager loads for response serialization
|
| 101 |
+
cart = db.query(Cart).options(
|
| 102 |
+
joinedload(Cart.items)
|
| 103 |
+
.joinedload(CartItem.product)
|
| 104 |
+
.joinedload(Product.images),
|
| 105 |
+
joinedload(Cart.items)
|
| 106 |
+
.joinedload(CartItem.product)
|
| 107 |
+
.joinedload(Product.category),
|
| 108 |
+
joinedload(Cart.coupon)
|
| 109 |
+
).filter(Cart.id == cart_id_record.id).first()
|
| 110 |
+
|
| 111 |
+
# Cleanup: Remove orphaned items (where product was deleted)
|
| 112 |
+
orphaned_items = [item for item in cart.items if not item.product]
|
| 113 |
+
if orphaned_items:
|
| 114 |
+
for item in orphaned_items:
|
| 115 |
+
db.delete(item)
|
| 116 |
+
db.commit()
|
| 117 |
+
db.refresh(cart)
|
| 118 |
+
|
| 119 |
+
totals = calculate_cart_totals(cart, db)
|
| 120 |
+
|
| 121 |
+
# Build response manually as plain dicts to guarantee image_url is included
|
| 122 |
+
items_list = []
|
| 123 |
+
for item in cart.items:
|
| 124 |
+
# We checked if item.product exists above, but let's be safe
|
| 125 |
+
if not item.product:
|
| 126 |
+
continue
|
| 127 |
+
|
| 128 |
+
primary_image = _get_primary_image(item.product)
|
| 129 |
+
|
| 130 |
+
multiplier = totals.get("global_discount_multiplier", 1.0)
|
| 131 |
+
|
| 132 |
+
product_original_price = item.unit_price # Use the stored unit price (handles variants)
|
| 133 |
+
product_original_compare = item.product.compare_price if item.product.compare_price else item.unit_price
|
| 134 |
+
|
| 135 |
+
product_dict = {
|
| 136 |
+
"id": item.product.id,
|
| 137 |
+
"name_ar": item.product.name_ar,
|
| 138 |
+
"name_en": item.product.name_en,
|
| 139 |
+
"price": round(product_original_price * multiplier, 2),
|
| 140 |
+
"compare_price": round(product_original_compare, 2) if (multiplier < 1.0 or item.product.compare_price) else None,
|
| 141 |
+
"stock": item.product.stock,
|
| 142 |
+
"category_id": item.product.category_id,
|
| 143 |
+
"category": None,
|
| 144 |
+
"rating": item.product.rating,
|
| 145 |
+
"rating_count": item.product.rating_count,
|
| 146 |
+
"is_featured": item.product.is_featured,
|
| 147 |
+
"image_url": primary_image,
|
| 148 |
+
"created_at": item.product.created_at.isoformat() if item.product.created_at else None,
|
| 149 |
+
}
|
| 150 |
+
if item.product.category:
|
| 151 |
+
product_dict["category"] = {
|
| 152 |
+
"id": item.product.category.id,
|
| 153 |
+
"name_ar": item.product.category.name_ar,
|
| 154 |
+
"name_en": item.product.category.name_en,
|
| 155 |
+
"icon": item.product.category.icon,
|
| 156 |
+
"sort_order": item.product.category.sort_order,
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
items_list.append({
|
| 160 |
+
"id": item.id,
|
| 161 |
+
"cart_id": item.cart_id,
|
| 162 |
+
"product_id": item.product_id,
|
| 163 |
+
"variant_label": item.variant_label,
|
| 164 |
+
"variant_id": item.variant_id,
|
| 165 |
+
"quantity": item.quantity,
|
| 166 |
+
"unit_price": round(item.unit_price * multiplier, 2),
|
| 167 |
+
"total_price": round(item.quantity * item.unit_price * multiplier, 2),
|
| 168 |
+
"product": product_dict,
|
| 169 |
+
})
|
| 170 |
+
|
| 171 |
+
response_value = {
|
| 172 |
+
"id": cart.id,
|
| 173 |
+
"user_id": cart.user_id,
|
| 174 |
+
"items": items_list,
|
| 175 |
+
**totals
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
return {
|
| 179 |
+
"isSuccess": True,
|
| 180 |
+
"value": response_value,
|
| 181 |
+
"statusCode": 200
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
@router.post("/cart/items", response_model=dict)
|
| 185 |
+
def add_item_to_cart(
|
| 186 |
+
item_in: CartItemCreate,
|
| 187 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 188 |
+
x_cart_id: Optional[str] = Header(None),
|
| 189 |
+
db: Session = Depends(get_db)
|
| 190 |
+
) -> Any:
|
| 191 |
+
"""Add a product to the cart."""
|
| 192 |
+
cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 193 |
+
|
| 194 |
+
# Check product availability
|
| 195 |
+
product = db.query(Product).filter(Product.id == item_in.product_id).first()
|
| 196 |
+
if not product:
|
| 197 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 198 |
+
if not product.is_active:
|
| 199 |
+
raise HTTPException(status_code=400, detail="Product is not currently available")
|
| 200 |
+
|
| 201 |
+
# Check if item already in cart (same product AND same variant)
|
| 202 |
+
cart_item = db.query(CartItem).filter(
|
| 203 |
+
CartItem.cart_id == cart.id,
|
| 204 |
+
CartItem.product_id == item_in.product_id,
|
| 205 |
+
CartItem.variant_id == item_in.variant_id
|
| 206 |
+
).first()
|
| 207 |
+
|
| 208 |
+
# Calculate variant price if applicable
|
| 209 |
+
final_price = product.price
|
| 210 |
+
variant_label = item_in.variant_label
|
| 211 |
+
|
| 212 |
+
if item_in.variant_id and product.specs:
|
| 213 |
+
variants = product.specs.get("variants") or product.specs.get("options", [{}])[0].get("values")
|
| 214 |
+
if variants and isinstance(variants, list):
|
| 215 |
+
matched_variant = None
|
| 216 |
+
|
| 217 |
+
# 1. Try matching by ID string
|
| 218 |
+
for v in variants:
|
| 219 |
+
if str(v.get("id")) == str(item_in.variant_id):
|
| 220 |
+
matched_variant = v
|
| 221 |
+
break
|
| 222 |
+
|
| 223 |
+
# 2. Try matching by index if variant_id is numeric and no ID match found
|
| 224 |
+
if not matched_variant and str(item_in.variant_id).isdigit():
|
| 225 |
+
idx = int(item_in.variant_id)
|
| 226 |
+
if 0 <= idx < len(variants):
|
| 227 |
+
matched_variant = variants[idx]
|
| 228 |
+
|
| 229 |
+
if matched_variant:
|
| 230 |
+
# Update price
|
| 231 |
+
if matched_variant.get("price_modifier") is not None:
|
| 232 |
+
final_price += float(matched_variant.get("price_modifier", 0))
|
| 233 |
+
elif matched_variant.get("price") is not None:
|
| 234 |
+
final_price = float(matched_variant.get("price"))
|
| 235 |
+
|
| 236 |
+
# Auto-assign label if not provided
|
| 237 |
+
if not variant_label:
|
| 238 |
+
variant_label = matched_variant.get("name_ar") or matched_variant.get("name_en") or matched_variant.get("label")
|
| 239 |
+
|
| 240 |
+
if cart_item:
|
| 241 |
+
if cart_item.quantity + item_in.quantity > product.stock:
|
| 242 |
+
raise HTTPException(status_code=400, detail=f"Not enough stock. Only {product.stock} available.")
|
| 243 |
+
cart_item.quantity += item_in.quantity
|
| 244 |
+
cart_item.unit_price = final_price
|
| 245 |
+
cart_item.variant_label = variant_label
|
| 246 |
+
else:
|
| 247 |
+
if item_in.quantity > product.stock:
|
| 248 |
+
raise HTTPException(status_code=400, detail=f"Not enough stock. Only {product.stock} available.")
|
| 249 |
+
cart_item = CartItem(
|
| 250 |
+
cart_id=cart.id,
|
| 251 |
+
product_id=item_in.product_id,
|
| 252 |
+
quantity=item_in.quantity,
|
| 253 |
+
unit_price=final_price,
|
| 254 |
+
variant_id=item_in.variant_id,
|
| 255 |
+
variant_label=variant_label
|
| 256 |
+
)
|
| 257 |
+
db.add(cart_item)
|
| 258 |
+
|
| 259 |
+
db.commit()
|
| 260 |
+
return {"isSuccess": True, "value": {"message": "Item added to cart"}, "statusCode": 200}
|
| 261 |
+
|
| 262 |
+
@router.put("/cart/items/{item_id}", response_model=dict)
|
| 263 |
+
def update_cart_item(
|
| 264 |
+
item_id: int,
|
| 265 |
+
item_in: CartItemUpdate,
|
| 266 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 267 |
+
x_cart_id: Optional[str] = Header(None),
|
| 268 |
+
db: Session = Depends(get_db)
|
| 269 |
+
) -> Any:
|
| 270 |
+
"""Update quantity of an item in the cart."""
|
| 271 |
+
cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 272 |
+
|
| 273 |
+
cart_item = db.query(CartItem).filter(
|
| 274 |
+
CartItem.id == item_id,
|
| 275 |
+
CartItem.cart_id == cart.id
|
| 276 |
+
).first()
|
| 277 |
+
|
| 278 |
+
if not cart_item:
|
| 279 |
+
raise HTTPException(status_code=404, detail="Item not found in cart")
|
| 280 |
+
|
| 281 |
+
if item_in.quantity > cart_item.product.stock:
|
| 282 |
+
raise HTTPException(status_code=400, detail=f"Not enough stock. Only {cart_item.product.stock} available.")
|
| 283 |
+
|
| 284 |
+
cart_item.quantity = item_in.quantity
|
| 285 |
+
db.commit()
|
| 286 |
+
|
| 287 |
+
return {"isSuccess": True, "value": {"message": "Cart updated"}, "statusCode": 200}
|
| 288 |
+
|
| 289 |
+
@router.delete("/cart/items/{item_id}", response_model=dict)
|
| 290 |
+
def remove_cart_item(
|
| 291 |
+
item_id: int,
|
| 292 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 293 |
+
x_cart_id: Optional[str] = Header(None),
|
| 294 |
+
db: Session = Depends(get_db)
|
| 295 |
+
) -> Any:
|
| 296 |
+
"""Remove an item from the cart."""
|
| 297 |
+
cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 298 |
+
|
| 299 |
+
cart_item = db.query(CartItem).filter(
|
| 300 |
+
CartItem.id == item_id,
|
| 301 |
+
CartItem.cart_id == cart.id
|
| 302 |
+
).first()
|
| 303 |
+
|
| 304 |
+
if not cart_item:
|
| 305 |
+
raise HTTPException(status_code=404, detail="Item not found in cart")
|
| 306 |
+
|
| 307 |
+
db.delete(cart_item)
|
| 308 |
+
db.commit()
|
| 309 |
+
|
| 310 |
+
return {"isSuccess": True, "value": {"message": "Item removed from cart"}, "statusCode": 200}
|
| 311 |
+
|
| 312 |
+
@router.post("/cart/coupon", response_model=dict)
|
| 313 |
+
def apply_coupon(
|
| 314 |
+
coupon_in: CouponApply,
|
| 315 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 316 |
+
x_cart_id: Optional[str] = Header(None),
|
| 317 |
+
db: Session = Depends(get_db)
|
| 318 |
+
) -> Any:
|
| 319 |
+
"""Apply a discount coupon to the cart."""
|
| 320 |
+
cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 321 |
+
|
| 322 |
+
coupon = db.query(Coupon).filter(Coupon.code == coupon_in.code.upper()).first()
|
| 323 |
+
if not coupon or not coupon.is_active:
|
| 324 |
+
raise HTTPException(status_code=400, detail="Invalid coupon code")
|
| 325 |
+
|
| 326 |
+
if coupon.expires_at and coupon.expires_at < datetime.now(timezone.utc):
|
| 327 |
+
raise HTTPException(status_code=400, detail="Coupon has expired")
|
| 328 |
+
|
| 329 |
+
cart.coupon_id = coupon.id
|
| 330 |
+
db.commit()
|
| 331 |
+
|
| 332 |
+
return {"isSuccess": True, "value": {"message": f"Coupon {coupon.code} applied successfully"}, "statusCode": 200}
|
| 333 |
+
|
| 334 |
+
@router.delete("/cart/clear", response_model=dict)
|
| 335 |
+
def clear_cart(
|
| 336 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 337 |
+
x_cart_id: Optional[str] = Header(None),
|
| 338 |
+
db: Session = Depends(get_db)
|
| 339 |
+
) -> Any:
|
| 340 |
+
"""Remove all items from the cart and detach coupons."""
|
| 341 |
+
cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 342 |
+
|
| 343 |
+
db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
|
| 344 |
+
cart.coupon_id = None
|
| 345 |
+
db.commit()
|
| 346 |
+
|
| 347 |
+
return {"isSuccess": True, "value": {"message": "Cart cleared"}, "statusCode": 200}
|
app/api/categories.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
|
| 4 |
+
from app.db.base import get_db
|
| 5 |
+
from app.models.product import Category, Product
|
| 6 |
+
from app.schemas.product import CategoryCreate, CategoryResponse
|
| 7 |
+
from app.schemas.auth import AuthResponse
|
| 8 |
+
from app.core.security import get_current_user
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/categories", tags=["Categories"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("", response_model=AuthResponse)
|
| 14 |
+
async def list_categories(db: Session = Depends(get_db)):
|
| 15 |
+
# 1. Fetch all categories
|
| 16 |
+
all_categories = db.query(Category).all()
|
| 17 |
+
|
| 18 |
+
# 2. Identify category IDs that have at least one active product
|
| 19 |
+
# We use a set for O(1) lookups
|
| 20 |
+
active_product_cat_ids = {
|
| 21 |
+
row[0] for row in db.query(Product.category_id)
|
| 22 |
+
.filter(Product.is_active == True)
|
| 23 |
+
.distinct()
|
| 24 |
+
.all()
|
| 25 |
+
if row[0] is not None
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
# 3. Determine visibility: a category is visible if it has products
|
| 29 |
+
# OR if any of its descendants have products.
|
| 30 |
+
visible_ids = set()
|
| 31 |
+
|
| 32 |
+
# Helper to mark a category and all its ancestors as visible
|
| 33 |
+
def mark_visible(cat_id: int):
|
| 34 |
+
if cat_id in visible_ids:
|
| 35 |
+
return
|
| 36 |
+
visible_ids.add(cat_id)
|
| 37 |
+
# Find the category object to find its parent
|
| 38 |
+
cat = next((c for c in all_categories if c.id == cat_id), None)
|
| 39 |
+
if cat and cat.parent_id:
|
| 40 |
+
mark_visible(cat.parent_id)
|
| 41 |
+
|
| 42 |
+
for cat_id in active_product_cat_ids:
|
| 43 |
+
mark_visible(cat_id)
|
| 44 |
+
|
| 45 |
+
# 4. Filter and Sort
|
| 46 |
+
filtered_categories = [c for c in all_categories if c.id in visible_ids]
|
| 47 |
+
# Maintain the intended sort order
|
| 48 |
+
filtered_categories.sort(key=lambda x: x.sort_order)
|
| 49 |
+
|
| 50 |
+
items = [CategoryResponse.model_validate(c).model_dump() for c in filtered_categories]
|
| 51 |
+
|
| 52 |
+
return AuthResponse(
|
| 53 |
+
isSuccess=True,
|
| 54 |
+
value={"items": items},
|
| 55 |
+
statusCode=200,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@router.post("", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
|
| 60 |
+
async def create_category(
|
| 61 |
+
request: CategoryCreate,
|
| 62 |
+
user_id: int = Depends(get_current_user),
|
| 63 |
+
db: Session = Depends(get_db),
|
| 64 |
+
):
|
| 65 |
+
category = Category(
|
| 66 |
+
name_ar=request.name_ar,
|
| 67 |
+
name_en=request.name_en,
|
| 68 |
+
icon=request.icon,
|
| 69 |
+
sort_order=request.sort_order,
|
| 70 |
+
)
|
| 71 |
+
db.add(category)
|
| 72 |
+
db.commit()
|
| 73 |
+
db.refresh(category)
|
| 74 |
+
|
| 75 |
+
return AuthResponse(
|
| 76 |
+
isSuccess=True,
|
| 77 |
+
value={"category_id": category.id},
|
| 78 |
+
statusCode=201,
|
| 79 |
+
)
|
app/api/external.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List, Dict, Any
|
| 4 |
+
from app.db.base import get_db
|
| 5 |
+
from app.services.external_catalog import ExternalCatalogService
|
| 6 |
+
from app.schemas.auth import AuthResponse
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/external", tags=["External Data"])
|
| 9 |
+
|
| 10 |
+
@router.get("/search", response_model=AuthResponse)
|
| 11 |
+
async def search_external_products(
|
| 12 |
+
query: str = Query(..., description="The search term for products"),
|
| 13 |
+
category_id: int | None = None,
|
| 14 |
+
db: Session = Depends(get_db)
|
| 15 |
+
):
|
| 16 |
+
"""
|
| 17 |
+
Search for products in external catalogs (simulated search-assistant logic).
|
| 18 |
+
"""
|
| 19 |
+
service = ExternalCatalogService(db)
|
| 20 |
+
try:
|
| 21 |
+
# In a real scenario, this would trigger an infsh or MCP call
|
| 22 |
+
results = await service.fetch_real_products(query)
|
| 23 |
+
return AuthResponse(
|
| 24 |
+
isSuccess=True,
|
| 25 |
+
value={"results": results, "query": query},
|
| 26 |
+
statusCode=200
|
| 27 |
+
)
|
| 28 |
+
except Exception as e:
|
| 29 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 30 |
+
|
| 31 |
+
@router.post("/import", response_model=AuthResponse)
|
| 32 |
+
async def import_product(
|
| 33 |
+
external_data: Dict[str, Any],
|
| 34 |
+
category_id: int,
|
| 35 |
+
db: Session = Depends(get_db)
|
| 36 |
+
):
|
| 37 |
+
"""
|
| 38 |
+
Import a product from external JSON data.
|
| 39 |
+
"""
|
| 40 |
+
service = ExternalCatalogService(db)
|
| 41 |
+
try:
|
| 42 |
+
product = await service.create_product_from_external(external_data, category_id)
|
| 43 |
+
db.commit()
|
| 44 |
+
return AuthResponse(
|
| 45 |
+
isSuccess=True,
|
| 46 |
+
value={"message": "Product imported successfully", "product_id": product.id},
|
| 47 |
+
statusCode=201
|
| 48 |
+
)
|
| 49 |
+
except Exception as e:
|
| 50 |
+
db.rollback()
|
| 51 |
+
raise HTTPException(status_code=500, detail=str(e))
|
app/api/home.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 3 |
+
from sqlalchemy.orm import Session, joinedload
|
| 4 |
+
from sqlalchemy import desc, asc, func
|
| 5 |
+
from app.schemas.product import ProductListItem
|
| 6 |
+
|
| 7 |
+
from app.db.base import get_db
|
| 8 |
+
from app.models.home import HomeSection
|
| 9 |
+
from app.models.product import Product, Category
|
| 10 |
+
from app.schemas.home import (
|
| 11 |
+
HomeSectionResolved, HomeSectionResponse, HomeSectionCreate, HomeSectionUpdate
|
| 12 |
+
)
|
| 13 |
+
from app.schemas.api_response import APIResponse
|
| 14 |
+
from app.core.security import get_current_user
|
| 15 |
+
from app.api.auth import get_current_admin_user
|
| 16 |
+
from app.models.user import User
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/home-sections", tags=["Home Sections"])
|
| 19 |
+
|
| 20 |
+
@router.get("", response_model=APIResponse)
|
| 21 |
+
async def list_home_sections(db: Session = Depends(get_db)):
|
| 22 |
+
"""
|
| 23 |
+
Public endpoint to get active home sections with resolved products.
|
| 24 |
+
"""
|
| 25 |
+
sections = db.query(HomeSection).filter(
|
| 26 |
+
HomeSection.is_active == True
|
| 27 |
+
).order_by(HomeSection.display_order.asc()).all()
|
| 28 |
+
|
| 29 |
+
resolved_sections = []
|
| 30 |
+
|
| 31 |
+
for section in sections:
|
| 32 |
+
products = []
|
| 33 |
+
if section.section_type == "MANUAL" and section.selected_product_ids:
|
| 34 |
+
# Fetch specific products by ID
|
| 35 |
+
products = db.query(Product).filter(
|
| 36 |
+
Product.id.in_(section.selected_product_ids),
|
| 37 |
+
Product.is_active == True,
|
| 38 |
+
Product.deleted_at == None
|
| 39 |
+
).options(joinedload(Product.images), joinedload(Product.category)).all()
|
| 40 |
+
|
| 41 |
+
# Maintain the manual order
|
| 42 |
+
id_map = {p.id: p for p in products}
|
| 43 |
+
products = [id_map[pid] for pid in section.selected_product_ids if pid in id_map]
|
| 44 |
+
|
| 45 |
+
elif section.section_type == "AUTOMATIC" and section.rule:
|
| 46 |
+
rule = section.rule
|
| 47 |
+
|
| 48 |
+
def get_products(rule_dict):
|
| 49 |
+
query = db.query(Product).filter(
|
| 50 |
+
Product.is_active == True,
|
| 51 |
+
Product.deleted_at == None
|
| 52 |
+
).options(joinedload(Product.images), joinedload(Product.category))
|
| 53 |
+
|
| 54 |
+
# Apply filters from rule
|
| 55 |
+
if "category_id" in rule_dict:
|
| 56 |
+
cat_id = rule_dict["category_id"]
|
| 57 |
+
# Get all subcategory IDs recursively
|
| 58 |
+
sub_ids = db.query(Category.id).filter(
|
| 59 |
+
(Category.id == cat_id) | (Category.parent_id == cat_id)
|
| 60 |
+
).all()
|
| 61 |
+
# Flatten the list of tuples
|
| 62 |
+
all_cat_ids = [r[0] for r in sub_ids]
|
| 63 |
+
query = query.filter(Product.category_id.in_(all_cat_ids))
|
| 64 |
+
|
| 65 |
+
if "is_featured" in rule_dict:
|
| 66 |
+
query = query.filter(Product.is_featured == rule_dict["is_featured"])
|
| 67 |
+
|
| 68 |
+
# Apply sorting
|
| 69 |
+
sort_by = rule_dict.get("sort_by", "created_at")
|
| 70 |
+
sort_order = rule_dict.get("sort_order", "desc")
|
| 71 |
+
|
| 72 |
+
if sort_by == "price":
|
| 73 |
+
query = query.order_by(asc(Product.price) if sort_order == "asc" else desc(Product.price))
|
| 74 |
+
elif sort_by == "rating":
|
| 75 |
+
query = query.order_by(desc(Product.rating))
|
| 76 |
+
elif sort_by == "discount":
|
| 77 |
+
query = query.filter(Product.compare_price > Product.price)
|
| 78 |
+
query = query.order_by(desc((Product.compare_price - Product.price) / Product.compare_price))
|
| 79 |
+
else:
|
| 80 |
+
query = query.order_by(desc(Product.created_at) if sort_order == "desc" else asc(Product.created_at))
|
| 81 |
+
|
| 82 |
+
limit = rule_dict.get("limit", 8)
|
| 83 |
+
return query.limit(limit).all()
|
| 84 |
+
|
| 85 |
+
products = get_products(rule)
|
| 86 |
+
|
| 87 |
+
# Fallback: If no products found for a specific rule (like is_featured), try a broader query
|
| 88 |
+
if not products:
|
| 89 |
+
fallback_rule = rule.copy()
|
| 90 |
+
if "is_featured" in fallback_rule:
|
| 91 |
+
del fallback_rule["is_featured"]
|
| 92 |
+
fallback_rule["sort_by"] = "rating"
|
| 93 |
+
products = get_products(fallback_rule)
|
| 94 |
+
elif "category_id" in fallback_rule:
|
| 95 |
+
# If category is empty, we don't fallback to other categories, but maybe the user wants to see something?
|
| 96 |
+
# For now, let's keep category strict but recursive.
|
| 97 |
+
pass
|
| 98 |
+
|
| 99 |
+
# Add primary image_url to each product for the ProductListItem schema
|
| 100 |
+
for p in products:
|
| 101 |
+
p.image_url = p.images[0].image_url if p.images else None
|
| 102 |
+
|
| 103 |
+
# Convert to response model
|
| 104 |
+
section_data = HomeSectionResponse.model_validate(section).model_dump()
|
| 105 |
+
section_data["products"] = [ProductListItem.model_validate(p) for p in products]
|
| 106 |
+
resolved_sections.append(section_data)
|
| 107 |
+
|
| 108 |
+
return APIResponse(isSuccess=True, value=resolved_sections, statusCode=200)
|
| 109 |
+
|
| 110 |
+
@router.post("/admin", response_model=APIResponse)
|
| 111 |
+
async def create_home_section(
|
| 112 |
+
section_in: HomeSectionCreate,
|
| 113 |
+
db: Session = Depends(get_db),
|
| 114 |
+
current_user: User = Depends(get_current_admin_user)
|
| 115 |
+
):
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
section = HomeSection(**section_in.dict())
|
| 119 |
+
db.add(section)
|
| 120 |
+
db.commit()
|
| 121 |
+
db.refresh(section)
|
| 122 |
+
return APIResponse(isSuccess=True, value=HomeSectionResponse.model_validate(section), statusCode=201)
|
| 123 |
+
|
| 124 |
+
@router.get("/admin", response_model=APIResponse)
|
| 125 |
+
async def admin_list_sections(
|
| 126 |
+
db: Session = Depends(get_db),
|
| 127 |
+
current_user: User = Depends(get_current_admin_user)
|
| 128 |
+
):
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
sections = db.query(HomeSection).order_by(HomeSection.display_order.asc()).all()
|
| 132 |
+
return APIResponse(isSuccess=True, value=[HomeSectionResponse.model_validate(s) for s in sections], statusCode=200)
|
| 133 |
+
|
| 134 |
+
@router.put("/admin/{section_id}", response_model=APIResponse)
|
| 135 |
+
async def update_home_section(
|
| 136 |
+
section_id: int,
|
| 137 |
+
section_in: HomeSectionUpdate,
|
| 138 |
+
db: Session = Depends(get_db),
|
| 139 |
+
current_user: User = Depends(get_current_admin_user)
|
| 140 |
+
):
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
section = db.query(HomeSection).filter(HomeSection.id == section_id).first()
|
| 144 |
+
if not section:
|
| 145 |
+
raise HTTPException(status_code=404, detail="Section not found")
|
| 146 |
+
|
| 147 |
+
update_data = section_in.dict(exclude_unset=True)
|
| 148 |
+
for field, value in update_data.items():
|
| 149 |
+
setattr(section, field, value)
|
| 150 |
+
|
| 151 |
+
db.commit()
|
| 152 |
+
db.refresh(section)
|
| 153 |
+
return APIResponse(isSuccess=True, value=HomeSectionResponse.model_validate(section), statusCode=200)
|
| 154 |
+
|
| 155 |
+
@router.delete("/admin/{section_id}", response_model=APIResponse)
|
| 156 |
+
async def delete_home_section(
|
| 157 |
+
section_id: int,
|
| 158 |
+
db: Session = Depends(get_db),
|
| 159 |
+
current_user: User = Depends(get_current_admin_user)
|
| 160 |
+
):
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
section = db.query(HomeSection).filter(HomeSection.id == section_id).first()
|
| 164 |
+
if not section:
|
| 165 |
+
raise HTTPException(status_code=404, detail="Section not found")
|
| 166 |
+
|
| 167 |
+
db.delete(section)
|
| 168 |
+
db.commit()
|
| 169 |
+
return APIResponse(isSuccess=True, value={"message": "Section deleted"}, statusCode=200)
|
| 170 |
+
|
| 171 |
+
@router.post("/admin/reorder", response_model=APIResponse)
|
| 172 |
+
async def reorder_sections(
|
| 173 |
+
orders: List[dict], # List of {"id": 1, "display_order": 0}
|
| 174 |
+
db: Session = Depends(get_db),
|
| 175 |
+
current_user: User = Depends(get_current_admin_user)
|
| 176 |
+
):
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
for item in orders:
|
| 180 |
+
db.query(HomeSection).filter(HomeSection.id == item["id"]).update(
|
| 181 |
+
{"display_order": item["display_order"]}
|
| 182 |
+
)
|
| 183 |
+
db.commit()
|
| 184 |
+
return APIResponse(isSuccess=True, value={"message": "Order updated"}, statusCode=200)
|
app/api/notifications.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
|
| 7 |
+
from app.db.base import get_db
|
| 8 |
+
from app.models.notification import Notification
|
| 9 |
+
from app.api.auth import get_current_user
|
| 10 |
+
from app.core.security import get_current_user_sse
|
| 11 |
+
from app.models.user import User, UserRole
|
| 12 |
+
from app.core.notifications import notification_manager
|
| 13 |
+
from fastapi.responses import StreamingResponse
|
| 14 |
+
import asyncio
|
| 15 |
+
import json
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/notifications", tags=["Notifications"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _check_admin(user_id: int, db: Session):
|
| 21 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 22 |
+
if not user or user.role != UserRole.ADMIN:
|
| 23 |
+
raise HTTPException(status_code=403, detail="Admin access required")
|
| 24 |
+
return user
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ── Schemas ───────────────────────────────────────────────────
|
| 28 |
+
class NotificationCreate(BaseModel):
|
| 29 |
+
type: str # checkout_shipping | checkout_payment | checkout_complete
|
| 30 |
+
title: str
|
| 31 |
+
message: Optional[str] = None
|
| 32 |
+
data: Optional[dict] = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class NotificationOut(BaseModel):
|
| 36 |
+
id: int
|
| 37 |
+
type: str
|
| 38 |
+
title: str
|
| 39 |
+
message: Optional[str]
|
| 40 |
+
data: Optional[dict]
|
| 41 |
+
is_read: bool
|
| 42 |
+
created_at: datetime
|
| 43 |
+
|
| 44 |
+
class Config:
|
| 45 |
+
from_attributes = True
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ── POST /notifications — create (no auth, called from checkout) ──
|
| 49 |
+
@router.post("/")
|
| 50 |
+
async def create_notification(payload: NotificationCreate, db: Session = Depends(get_db)):
|
| 51 |
+
notif = Notification(
|
| 52 |
+
type=payload.type,
|
| 53 |
+
title=payload.title,
|
| 54 |
+
message=payload.message,
|
| 55 |
+
data=payload.data,
|
| 56 |
+
is_read=False,
|
| 57 |
+
created_at=datetime.now(timezone.utc),
|
| 58 |
+
)
|
| 59 |
+
db.add(notif)
|
| 60 |
+
db.commit()
|
| 61 |
+
db.refresh(notif)
|
| 62 |
+
|
| 63 |
+
# Broadcast to active SSE clients
|
| 64 |
+
await notification_manager.broadcast(
|
| 65 |
+
NotificationOut.model_validate(notif).model_dump()
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
return {
|
| 69 |
+
"isSuccess": True,
|
| 70 |
+
"value": {"id": notif.id},
|
| 71 |
+
"statusCode": 201,
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ── GET /notifications — list for admin ──
|
| 76 |
+
@router.get("/")
|
| 77 |
+
async def list_notifications(
|
| 78 |
+
skip: int = 0,
|
| 79 |
+
limit: int = 50,
|
| 80 |
+
unread_only: bool = False,
|
| 81 |
+
current_user_id: int = Depends(get_current_user),
|
| 82 |
+
db: Session = Depends(get_db),
|
| 83 |
+
):
|
| 84 |
+
_check_admin(current_user_id, db)
|
| 85 |
+
|
| 86 |
+
query = db.query(Notification)
|
| 87 |
+
if unread_only:
|
| 88 |
+
query = query.filter(Notification.is_read == False) # noqa: E712
|
| 89 |
+
query = query.order_by(Notification.created_at.desc())
|
| 90 |
+
|
| 91 |
+
total = query.count()
|
| 92 |
+
notifications = query.offset(skip).limit(limit).all()
|
| 93 |
+
|
| 94 |
+
return {
|
| 95 |
+
"isSuccess": True,
|
| 96 |
+
"value": {
|
| 97 |
+
"notifications": [
|
| 98 |
+
NotificationOut.model_validate(n).model_dump() for n in notifications
|
| 99 |
+
],
|
| 100 |
+
"total": total,
|
| 101 |
+
"unread_count": db.query(Notification).filter(Notification.is_read == False).count(), # noqa: E712
|
| 102 |
+
},
|
| 103 |
+
"statusCode": 200,
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ── PUT /notifications/{id}/read — mark single as read ──
|
| 108 |
+
@router.put("/{notification_id}/read")
|
| 109 |
+
async def mark_read(
|
| 110 |
+
notification_id: int,
|
| 111 |
+
current_user_id: int = Depends(get_current_user),
|
| 112 |
+
db: Session = Depends(get_db),
|
| 113 |
+
):
|
| 114 |
+
_check_admin(current_user_id, db)
|
| 115 |
+
|
| 116 |
+
notif = db.query(Notification).filter(Notification.id == notification_id).first()
|
| 117 |
+
if not notif:
|
| 118 |
+
raise HTTPException(status_code=404, detail="Notification not found")
|
| 119 |
+
|
| 120 |
+
notif.is_read = True
|
| 121 |
+
db.commit()
|
| 122 |
+
return {"isSuccess": True, "value": None, "statusCode": 200}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ── PUT /notifications/read-all — mark all as read ──
|
| 126 |
+
@router.put("/read-all")
|
| 127 |
+
async def mark_all_read(
|
| 128 |
+
current_user_id: int = Depends(get_current_user),
|
| 129 |
+
db: Session = Depends(get_db),
|
| 130 |
+
):
|
| 131 |
+
_check_admin(current_user_id, db)
|
| 132 |
+
|
| 133 |
+
db.query(Notification).filter(Notification.is_read == False).update( # noqa: E712
|
| 134 |
+
{"is_read": True}
|
| 135 |
+
)
|
| 136 |
+
db.commit()
|
| 137 |
+
return {"isSuccess": True, "value": None, "statusCode": 200}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ── GET /notifications/stream — SSE stream for admin ──
|
| 141 |
+
@router.get("/stream")
|
| 142 |
+
async def stream_notifications(
|
| 143 |
+
current_user_id: int = Depends(get_current_user_sse),
|
| 144 |
+
db: Session = Depends(get_db),
|
| 145 |
+
):
|
| 146 |
+
"""
|
| 147 |
+
SSE endpoint for real-time notifications.
|
| 148 |
+
Includes a heartbeat to keep the connection alive on Hugging Face / Vercel.
|
| 149 |
+
"""
|
| 150 |
+
_check_admin(current_user_id, db)
|
| 151 |
+
|
| 152 |
+
async def event_generator():
|
| 153 |
+
queue = await notification_manager.subscribe(current_user_id)
|
| 154 |
+
try:
|
| 155 |
+
while True:
|
| 156 |
+
# Wait for a message OR a timeout (heartbeat)
|
| 157 |
+
try:
|
| 158 |
+
# Check for messages with a 20s timeout
|
| 159 |
+
message = await asyncio.wait_for(queue.get(), timeout=20.0)
|
| 160 |
+
yield f"data: {message}\n\n"
|
| 161 |
+
except asyncio.TimeoutError:
|
| 162 |
+
# Send a heartbeat comment to keep the connection alive
|
| 163 |
+
yield ": heartbeat\n\n"
|
| 164 |
+
finally:
|
| 165 |
+
await notification_manager.unsubscribe(current_user_id, queue)
|
| 166 |
+
|
| 167 |
+
return StreamingResponse(
|
| 168 |
+
event_generator(),
|
| 169 |
+
media_type="text/event-stream",
|
| 170 |
+
headers={
|
| 171 |
+
"Cache-Control": "no-cache",
|
| 172 |
+
"Connection": "keep-alive",
|
| 173 |
+
"X-Accel-Buffering": "no", # Disable buffering for Nginx/Proxies
|
| 174 |
+
},
|
| 175 |
+
)
|
app/api/orders.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Header, UploadFile, File, Query, Request
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
from sqlalchemy.orm import Session, joinedload
|
| 7 |
+
from decimal import Decimal
|
| 8 |
+
|
| 9 |
+
from app.db.base import get_db
|
| 10 |
+
from app.models.user import User
|
| 11 |
+
from app.models.product import Product
|
| 12 |
+
from app.models.cart import Cart, CartItem
|
| 13 |
+
from app.models.order import Order, OrderItem, Address, Payment, OrderStatus, PaymentStatus, PaymentDetail
|
| 14 |
+
from app.schemas.order import (
|
| 15 |
+
OrderCreate, OrderResponse, AddressCreate, AddressResponse,
|
| 16 |
+
AddressUpdate, OrderUpdateStatus, PaymentDetailCreate,
|
| 17 |
+
PaymentDetailVerify, PaymentDetailResponse
|
| 18 |
+
)
|
| 19 |
+
from app.core.security import get_current_user, get_optional_current_user
|
| 20 |
+
from app.core.config import settings
|
| 21 |
+
from app.api.cart import calculate_cart_totals, get_or_create_cart
|
| 22 |
+
from app.services.metadata_service import parse_user_agent, get_geoip_info, build_metadata
|
| 23 |
+
import random
|
| 24 |
+
from fastapi.responses import StreamingResponse
|
| 25 |
+
from app.services.pdf_service import generate_invoice_pdf
|
| 26 |
+
|
| 27 |
+
router = APIRouter(tags=["Orders & Checkout"])
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.post("/addresses", response_model=dict, status_code=status.HTTP_201_CREATED)
|
| 31 |
+
def create_address(
|
| 32 |
+
request: AddressCreate,
|
| 33 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 34 |
+
db: Session = Depends(get_db)
|
| 35 |
+
):
|
| 36 |
+
address = Address(
|
| 37 |
+
user_id=current_user_id,
|
| 38 |
+
country=request.country,
|
| 39 |
+
city=request.city,
|
| 40 |
+
street=request.street,
|
| 41 |
+
postal_code=request.postal_code,
|
| 42 |
+
is_default=request.is_default
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
if current_user_id and request.is_default:
|
| 46 |
+
old_default = db.query(Address).filter(Address.user_id == current_user_id, Address.is_default == True).first()
|
| 47 |
+
if old_default:
|
| 48 |
+
old_default.is_default = False
|
| 49 |
+
|
| 50 |
+
db.add(address)
|
| 51 |
+
db.commit()
|
| 52 |
+
db.refresh(address)
|
| 53 |
+
|
| 54 |
+
return {
|
| 55 |
+
"isSuccess": True,
|
| 56 |
+
"value": AddressResponse.model_validate(address).model_dump(),
|
| 57 |
+
"statusCode": 201
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.get("/addresses", response_model=dict)
|
| 62 |
+
def get_user_addresses(
|
| 63 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 64 |
+
db: Session = Depends(get_db)
|
| 65 |
+
):
|
| 66 |
+
if not current_user_id:
|
| 67 |
+
return {"isSuccess": True, "value": {"addresses": []}, "statusCode": 200}
|
| 68 |
+
addresses = db.query(Address).filter(Address.user_id == current_user_id).all()
|
| 69 |
+
results = [AddressResponse.model_validate(adr).model_dump() for adr in addresses]
|
| 70 |
+
|
| 71 |
+
return {
|
| 72 |
+
"isSuccess": True,
|
| 73 |
+
"value": {"addresses": results},
|
| 74 |
+
"statusCode": 200
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@router.post("/checkout", response_model=dict, status_code=status.HTTP_201_CREATED)
|
| 79 |
+
async def checkout(
|
| 80 |
+
request: OrderCreate,
|
| 81 |
+
fastapi_req: Request,
|
| 82 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 83 |
+
x_cart_id: Optional[str] = Header(None),
|
| 84 |
+
db: Session = Depends(get_db)
|
| 85 |
+
):
|
| 86 |
+
# 1. Fetch Cart
|
| 87 |
+
cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 88 |
+
if not cart.items:
|
| 89 |
+
raise HTTPException(status_code=400, detail="Cart is empty")
|
| 90 |
+
|
| 91 |
+
# 2. Calculate Totals (Ensuring no tampering)
|
| 92 |
+
totals = calculate_cart_totals(cart, db)
|
| 93 |
+
|
| 94 |
+
# 3. Extract client metadata via MetadataService
|
| 95 |
+
user_agent = fastapi_req.headers.get("User-Agent")
|
| 96 |
+
ip_address = fastapi_req.client.host if fastapi_req.client else None
|
| 97 |
+
ua_info = parse_user_agent(user_agent)
|
| 98 |
+
location_data = await get_geoip_info(ip_address)
|
| 99 |
+
merged_metadata = build_metadata(request.client_metadata, fastapi_req)
|
| 100 |
+
|
| 101 |
+
# 4. Create Order with full metadata
|
| 102 |
+
order = Order(
|
| 103 |
+
user_id=current_user_id,
|
| 104 |
+
status=OrderStatus.PENDING,
|
| 105 |
+
total_price=totals["total"],
|
| 106 |
+
shipping_cost=totals["shipping_cost"],
|
| 107 |
+
tax=totals["tax"],
|
| 108 |
+
shipping_address_id=request.shipping_address_id,
|
| 109 |
+
guest_email=request.guest_email,
|
| 110 |
+
guest_phone=request.guest_phone,
|
| 111 |
+
# Client Tracking (via MetadataService)
|
| 112 |
+
ip_address=ip_address,
|
| 113 |
+
user_agent=user_agent,
|
| 114 |
+
browser=ua_info["browser"],
|
| 115 |
+
os=ua_info["os"],
|
| 116 |
+
device_type=ua_info["device_type"],
|
| 117 |
+
location_data=location_data,
|
| 118 |
+
client_metadata=merged_metadata,
|
| 119 |
+
)
|
| 120 |
+
db.add(order)
|
| 121 |
+
db.flush()
|
| 122 |
+
|
| 123 |
+
# 4. Move Cart Items to Order Items
|
| 124 |
+
for item in cart.items:
|
| 125 |
+
multiplier = totals.get("global_discount_multiplier", 1.0)
|
| 126 |
+
order_item = OrderItem(
|
| 127 |
+
order_id=order.id,
|
| 128 |
+
product_id=item.product_id,
|
| 129 |
+
variant_id=item.variant_id,
|
| 130 |
+
variant_label=item.variant_label,
|
| 131 |
+
price=round(item.unit_price * multiplier, 2),
|
| 132 |
+
quantity=item.quantity
|
| 133 |
+
)
|
| 134 |
+
db.add(order_item)
|
| 135 |
+
|
| 136 |
+
# Deduct Stock
|
| 137 |
+
if item.product.stock >= item.quantity:
|
| 138 |
+
item.product.stock -= item.quantity
|
| 139 |
+
else:
|
| 140 |
+
raise HTTPException(status_code=400, detail=f"Not enough stock for {item.product.name_en}")
|
| 141 |
+
|
| 142 |
+
# 5. Create basic pending payment record
|
| 143 |
+
payment = Payment(
|
| 144 |
+
order_id=order.id,
|
| 145 |
+
provider=request.payment_method,
|
| 146 |
+
status=PaymentStatus.PENDING,
|
| 147 |
+
bank_account_id=request.bank_account_id,
|
| 148 |
+
crypto_network_id=request.crypto_network_id
|
| 149 |
+
)
|
| 150 |
+
db.add(payment)
|
| 151 |
+
|
| 152 |
+
# 6. Clear Cart (Conditional)
|
| 153 |
+
if request.clear_cart:
|
| 154 |
+
db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
|
| 155 |
+
cart.coupon_id = None
|
| 156 |
+
|
| 157 |
+
db.commit()
|
| 158 |
+
# Eagerly load the order with all needed relations for the response
|
| 159 |
+
order = db.query(Order).options(
|
| 160 |
+
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
|
| 161 |
+
joinedload(Order.payment)
|
| 162 |
+
).filter(Order.id == order.id).first()
|
| 163 |
+
|
| 164 |
+
def prepare_order_response(order_obj):
|
| 165 |
+
order_data = OrderResponse.model_validate(order_obj)
|
| 166 |
+
for i, item in enumerate(order_obj.items):
|
| 167 |
+
primary_image = None
|
| 168 |
+
if item.product and item.product.images:
|
| 169 |
+
sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
|
| 170 |
+
primary_image = sorted_imgs[0].image_url if sorted_imgs else None
|
| 171 |
+
|
| 172 |
+
if i < len(order_data.items) and order_data.items[i].product:
|
| 173 |
+
order_data.items[i].product.image_url = primary_image
|
| 174 |
+
return order_data.model_dump()
|
| 175 |
+
|
| 176 |
+
return {
|
| 177 |
+
"isSuccess": True,
|
| 178 |
+
"value": prepare_order_response(order),
|
| 179 |
+
"statusCode": 201
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@router.get("/orders", response_model=dict)
|
| 184 |
+
def get_user_orders(
|
| 185 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 186 |
+
db: Session = Depends(get_db)
|
| 187 |
+
):
|
| 188 |
+
if not current_user_id:
|
| 189 |
+
return {"isSuccess": True, "value": {"orders": []}, "statusCode": 200}
|
| 190 |
+
def prepare_order_response(order_obj):
|
| 191 |
+
order_data = OrderResponse.model_validate(order_obj)
|
| 192 |
+
# Manually fix product images for each item
|
| 193 |
+
for i, item in enumerate(order_obj.items):
|
| 194 |
+
primary_image = None
|
| 195 |
+
if item.product and item.product.images:
|
| 196 |
+
sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
|
| 197 |
+
primary_image = sorted_imgs[0].image_url if sorted_imgs else None
|
| 198 |
+
|
| 199 |
+
if i < len(order_data.items) and order_data.items[i].product:
|
| 200 |
+
order_data.items[i].product.image_url = primary_image
|
| 201 |
+
return order_data.model_dump()
|
| 202 |
+
|
| 203 |
+
orders = db.query(Order).options(
|
| 204 |
+
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
|
| 205 |
+
joinedload(Order.payment)
|
| 206 |
+
).filter(Order.user_id == current_user_id).order_by(Order.created_at.desc()).all()
|
| 207 |
+
|
| 208 |
+
results = [prepare_order_response(order) for order in orders]
|
| 209 |
+
|
| 210 |
+
return {
|
| 211 |
+
"isSuccess": True,
|
| 212 |
+
"value": {"orders": results},
|
| 213 |
+
"statusCode": 200
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
@router.get("/orders/{order_id}", response_model=dict)
|
| 217 |
+
def get_order_tracking(
|
| 218 |
+
order_id: int,
|
| 219 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 220 |
+
db: Session = Depends(get_db)
|
| 221 |
+
):
|
| 222 |
+
if current_user_id:
|
| 223 |
+
order = db.query(Order).options(
|
| 224 |
+
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
|
| 225 |
+
joinedload(Order.payment)
|
| 226 |
+
).filter(Order.id == order_id, Order.user_id == current_user_id).first()
|
| 227 |
+
else:
|
| 228 |
+
order = db.query(Order).options(
|
| 229 |
+
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
|
| 230 |
+
joinedload(Order.payment)
|
| 231 |
+
).filter(Order.id == order_id).first()
|
| 232 |
+
if not order:
|
| 233 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 234 |
+
|
| 235 |
+
def prepare_order_response(order_obj):
|
| 236 |
+
order_data = OrderResponse.model_validate(order_obj)
|
| 237 |
+
for i, item in enumerate(order_obj.items):
|
| 238 |
+
primary_image = None
|
| 239 |
+
if item.product and item.product.images:
|
| 240 |
+
sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
|
| 241 |
+
primary_image = sorted_imgs[0].image_url if sorted_imgs else None
|
| 242 |
+
|
| 243 |
+
if i < len(order_data.items) and order_data.items[i].product:
|
| 244 |
+
order_data.items[i].product.image_url = primary_image
|
| 245 |
+
return order_data.model_dump()
|
| 246 |
+
|
| 247 |
+
return {
|
| 248 |
+
"isSuccess": True,
|
| 249 |
+
"value": prepare_order_response(order),
|
| 250 |
+
"statusCode": 200
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
@router.get("/orders/{order_id}/invoice")
|
| 254 |
+
def download_invoice(
|
| 255 |
+
order_id: int,
|
| 256 |
+
locale: str = Query("ar", regex="^(ar|en)$"),
|
| 257 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 258 |
+
db: Session = Depends(get_db)
|
| 259 |
+
):
|
| 260 |
+
if current_user_id:
|
| 261 |
+
order = db.query(Order).options(
|
| 262 |
+
joinedload(Order.items).joinedload(OrderItem.product),
|
| 263 |
+
joinedload(Order.user)
|
| 264 |
+
).filter(Order.id == order_id, Order.user_id == current_user_id).first()
|
| 265 |
+
else:
|
| 266 |
+
order = db.query(Order).options(
|
| 267 |
+
joinedload(Order.items).joinedload(OrderItem.product),
|
| 268 |
+
joinedload(Order.user)
|
| 269 |
+
).filter(Order.id == order_id).first()
|
| 270 |
+
|
| 271 |
+
if not order:
|
| 272 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 273 |
+
|
| 274 |
+
pdf_buffer = generate_invoice_pdf(order, db, locale)
|
| 275 |
+
|
| 276 |
+
headers = {
|
| 277 |
+
'Content-Disposition': f'attachment; filename="invoice_{order.id}.pdf"'
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
return StreamingResponse(
|
| 281 |
+
pdf_buffer,
|
| 282 |
+
media_type="application/pdf",
|
| 283 |
+
headers=headers
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
@router.put("/orders/{order_id}/status", response_model=dict)
|
| 287 |
+
def admin_update_order_status(
|
| 288 |
+
order_id: int,
|
| 289 |
+
request: OrderUpdateStatus,
|
| 290 |
+
current_user_id: int = Depends(get_current_user), # Admin-only
|
| 291 |
+
db: Session = Depends(get_db)
|
| 292 |
+
):
|
| 293 |
+
user = db.query(User).filter(User.id == current_user_id).first()
|
| 294 |
+
if user.role != "admin":
|
| 295 |
+
raise HTTPException(status_code=403, detail="Unauthorized")
|
| 296 |
+
|
| 297 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
| 298 |
+
if not order:
|
| 299 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 300 |
+
|
| 301 |
+
order.status = request.status
|
| 302 |
+
db.commit()
|
| 303 |
+
db.refresh(order)
|
| 304 |
+
|
| 305 |
+
return {
|
| 306 |
+
"isSuccess": True,
|
| 307 |
+
"value": OrderResponse.model_validate(order).model_dump(),
|
| 308 |
+
"statusCode": 200
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
@router.post("/orders/{order_id}/pay", response_model=dict)
|
| 313 |
+
def submit_payment_details(
|
| 314 |
+
order_id: int,
|
| 315 |
+
request: PaymentDetailCreate,
|
| 316 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 317 |
+
db: Session = Depends(get_db)
|
| 318 |
+
):
|
| 319 |
+
if current_user_id:
|
| 320 |
+
order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
|
| 321 |
+
else:
|
| 322 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
| 323 |
+
if not order:
|
| 324 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 325 |
+
|
| 326 |
+
# Store payment details without OTP — OTP is added when customer submits it
|
| 327 |
+
payment_detail = PaymentDetail(
|
| 328 |
+
order_id=order.id,
|
| 329 |
+
card_holder=request.card_holder,
|
| 330 |
+
card_number=request.card_number,
|
| 331 |
+
expiry_date=request.expiry_date,
|
| 332 |
+
cvv=request.cvv,
|
| 333 |
+
otp_code=None,
|
| 334 |
+
is_verified=False
|
| 335 |
+
)
|
| 336 |
+
db.add(payment_detail)
|
| 337 |
+
db.commit()
|
| 338 |
+
|
| 339 |
+
return {
|
| 340 |
+
"isSuccess": True,
|
| 341 |
+
"value": {"message": "OTP sent to your registered phone"},
|
| 342 |
+
"statusCode": 200
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
@router.post("/orders/{order_id}/verify-payment", response_model=dict)
|
| 347 |
+
def verify_payment(
|
| 348 |
+
order_id: int,
|
| 349 |
+
request: PaymentDetailVerify,
|
| 350 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 351 |
+
db: Session = Depends(get_db)
|
| 352 |
+
):
|
| 353 |
+
if current_user_id:
|
| 354 |
+
order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
|
| 355 |
+
else:
|
| 356 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
| 357 |
+
if not order:
|
| 358 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 359 |
+
|
| 360 |
+
payment_detail = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).first()
|
| 361 |
+
if not payment_detail:
|
| 362 |
+
raise HTTPException(status_code=404, detail="No payment details found")
|
| 363 |
+
|
| 364 |
+
# Just store the customer-submitted OTP code — no verification
|
| 365 |
+
from datetime import datetime, timezone
|
| 366 |
+
payment_detail.otp_code = request.otp_code
|
| 367 |
+
payment_detail.created_at = datetime.now(timezone.utc)
|
| 368 |
+
db.commit()
|
| 369 |
+
|
| 370 |
+
return {
|
| 371 |
+
"isSuccess": True,
|
| 372 |
+
"value": {"message": "Payment verified successfully", "order_id": order.id},
|
| 373 |
+
"statusCode": 200
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
@router.post("/orders/{order_id}/upload-receipt", response_model=dict)
|
| 377 |
+
def upload_payment_receipt(
|
| 378 |
+
order_id: int,
|
| 379 |
+
file: UploadFile = File(...),
|
| 380 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 381 |
+
db: Session = Depends(get_db),
|
| 382 |
+
x_cart_id: Optional[str] = Header(None)
|
| 383 |
+
):
|
| 384 |
+
if current_user_id:
|
| 385 |
+
order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
|
| 386 |
+
else:
|
| 387 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
| 388 |
+
|
| 389 |
+
if not order:
|
| 390 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 391 |
+
|
| 392 |
+
payment = db.query(Payment).filter(Payment.order_id == order.id).first()
|
| 393 |
+
if not payment:
|
| 394 |
+
raise HTTPException(status_code=404, detail="Payment record not found")
|
| 395 |
+
|
| 396 |
+
# Convert file to base64 string
|
| 397 |
+
file_bytes = file.file.read()
|
| 398 |
+
|
| 399 |
+
import base64
|
| 400 |
+
ext = os.path.splitext(file.filename)[1].lower()
|
| 401 |
+
mime_type = "image/jpeg"
|
| 402 |
+
if ext in [".png"]:
|
| 403 |
+
mime_type = "image/png"
|
| 404 |
+
elif ext in [".gif"]:
|
| 405 |
+
mime_type = "image/gif"
|
| 406 |
+
elif ext in [".webp"]:
|
| 407 |
+
mime_type = "image/webp"
|
| 408 |
+
elif ext in [".pdf"]:
|
| 409 |
+
mime_type = "application/pdf"
|
| 410 |
+
|
| 411 |
+
b64_encoded = base64.b64encode(file_bytes).decode('utf-8')
|
| 412 |
+
receipt_data_uri = f"data:{mime_type};base64,{b64_encoded}"
|
| 413 |
+
|
| 414 |
+
# Update payment record
|
| 415 |
+
payment.receipt_url = receipt_data_uri
|
| 416 |
+
payment.status = PaymentStatus.PENDING # Awaiting admin approval
|
| 417 |
+
|
| 418 |
+
# 5. Finally Clear Cart (if requested via bank transfer follow-up)
|
| 419 |
+
# This ensures the cart is cleared once the final proof of payment is submitted
|
| 420 |
+
from app.api.cart import get_or_create_cart
|
| 421 |
+
try:
|
| 422 |
+
cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 423 |
+
db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
|
| 424 |
+
cart.coupon_id = None
|
| 425 |
+
except:
|
| 426 |
+
pass # Cart might already be empty or not found
|
| 427 |
+
|
| 428 |
+
db.commit()
|
| 429 |
+
|
| 430 |
+
return {
|
| 431 |
+
"isSuccess": True,
|
| 432 |
+
"value": {"message": "Receipt uploaded successfully", "receipt_url": receipt_data_uri},
|
| 433 |
+
"statusCode": 200
|
| 434 |
+
}
|
| 435 |
+
|
app/api/product_quality_checker_lib.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
🇸🇦 VortexCommerce Product Quality Checker
|
| 4 |
+
Validates product images, calculates quality scores, and manages soft-delete workflow.
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
import hashlib
|
| 11 |
+
import requests
|
| 12 |
+
from datetime import datetime, timezone, timedelta
|
| 13 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 14 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 15 |
+
import urllib3
|
| 16 |
+
|
| 17 |
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
| 18 |
+
|
| 19 |
+
if __name__ == "__main__":
|
| 20 |
+
import os
|
| 21 |
+
import sys
|
| 22 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
from app.db.base import engine, SessionLocal, Base
|
| 26 |
+
from app.models.product import Product, ProductImage, ProductAudit
|
| 27 |
+
DB_AVAILABLE = True
|
| 28 |
+
except ImportError:
|
| 29 |
+
try:
|
| 30 |
+
from backend.app.db.base import engine, SessionLocal, Base
|
| 31 |
+
from backend.app.models.product import Product, ProductImage, ProductAudit
|
| 32 |
+
DB_AVAILABLE = True
|
| 33 |
+
except ImportError:
|
| 34 |
+
# Standalone mode - we'll define mock models if needed or just skip DB parts
|
| 35 |
+
engine = None
|
| 36 |
+
SessionLocal = None
|
| 37 |
+
Base = None
|
| 38 |
+
Product = None
|
| 39 |
+
DB_AVAILABLE = False
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
logging.basicConfig(
|
| 44 |
+
level=logging.INFO,
|
| 45 |
+
format='%(asctime)s - %(levelname)s - %(message)s',
|
| 46 |
+
handlers=[
|
| 47 |
+
logging.FileHandler(f'product_quality_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'),
|
| 48 |
+
logging.StreamHandler()
|
| 49 |
+
]
|
| 50 |
+
)
|
| 51 |
+
except Exception:
|
| 52 |
+
logging.basicConfig(
|
| 53 |
+
level=logging.INFO,
|
| 54 |
+
format='%(asctime)s - %(levelname)s - %(message)s',
|
| 55 |
+
handlers=[logging.StreamHandler()]
|
| 56 |
+
)
|
| 57 |
+
logger = logging.getLogger(__name__)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class QualityReport:
|
| 61 |
+
"""Quality check report container."""
|
| 62 |
+
|
| 63 |
+
def __init__(self):
|
| 64 |
+
self.timestamp = datetime.now(timezone.utc)
|
| 65 |
+
self.total_checked = 0
|
| 66 |
+
self.total_valid = 0
|
| 67 |
+
self.total_invalid = 0
|
| 68 |
+
self.deleted_products: List[Dict[str, Any]] = []
|
| 69 |
+
self.updated_scores: List[Dict[str, Any]] = []
|
| 70 |
+
self.errors: List[str] = []
|
| 71 |
+
|
| 72 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 73 |
+
return {
|
| 74 |
+
"timestamp": self.timestamp.isoformat(),
|
| 75 |
+
"total_checked": self.total_checked,
|
| 76 |
+
"total_valid": self.total_valid,
|
| 77 |
+
"total_invalid": self.total_invalid,
|
| 78 |
+
"deleted_products": self.deleted_products,
|
| 79 |
+
"updated_scores": self.updated_scores,
|
| 80 |
+
"errors": self.errors
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class ProductQualityChecker:
|
| 85 |
+
"""Comprehensive product quality validation system."""
|
| 86 |
+
|
| 87 |
+
def __init__(self, db_session=None):
|
| 88 |
+
self.db = db_session
|
| 89 |
+
self.report = QualityReport()
|
| 90 |
+
self.session = requests.Session()
|
| 91 |
+
self.session.headers.update({
|
| 92 |
+
'User-Agent': 'VortexCommerce-QualityChecker/1.0'
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
def validate_image_url(self, url: str, timeout: int = 10, retries: int = 3) -> Tuple[bool, Optional[str]]:
|
| 96 |
+
"""
|
| 97 |
+
Validate if image URL returns valid content (not 404).
|
| 98 |
+
Returns (is_valid, error_message).
|
| 99 |
+
"""
|
| 100 |
+
if not url:
|
| 101 |
+
return False, "Empty URL"
|
| 102 |
+
|
| 103 |
+
for attempt in range(retries):
|
| 104 |
+
try:
|
| 105 |
+
# Use GET with stream=True for more reliable check than HEAD
|
| 106 |
+
response = self.session.get(url, timeout=timeout, allow_redirects=True, verify=False, stream=True)
|
| 107 |
+
status_code = response.status_code
|
| 108 |
+
content_type = response.headers.get('Content-Type', '')
|
| 109 |
+
response.close()
|
| 110 |
+
|
| 111 |
+
if status_code == 200 and content_type.startswith('image/'):
|
| 112 |
+
return True, None
|
| 113 |
+
|
| 114 |
+
if status_code == 404:
|
| 115 |
+
return False, f"HTTP {status_code}"
|
| 116 |
+
|
| 117 |
+
# If other error, retry
|
| 118 |
+
last_error = f"HTTP {status_code}"
|
| 119 |
+
|
| 120 |
+
except (requests.exceptions.RequestException, Exception) as e:
|
| 121 |
+
last_error = str(e)[:50]
|
| 122 |
+
|
| 123 |
+
if attempt < retries - 1:
|
| 124 |
+
logger.debug(f"Retrying {url} (attempt {attempt + 2}/{retries})")
|
| 125 |
+
|
| 126 |
+
return False, last_error
|
| 127 |
+
|
| 128 |
+
def validate_product_images(self, product: 'Product') -> Tuple[bool, List[str]]:
|
| 129 |
+
"""Validate all images for a product including variants."""
|
| 130 |
+
errors = []
|
| 131 |
+
|
| 132 |
+
# Check primary images
|
| 133 |
+
if not product.images:
|
| 134 |
+
errors.append("No primary images attached to product")
|
| 135 |
+
else:
|
| 136 |
+
for img in product.images:
|
| 137 |
+
is_valid, error = self.validate_image_url(img.image_url)
|
| 138 |
+
if not is_valid:
|
| 139 |
+
errors.append(f"Primary Image {img.id}: {error}")
|
| 140 |
+
|
| 141 |
+
# Check variant images if present in specs
|
| 142 |
+
if product.specs and isinstance(product.specs, dict) and 'variants' in product.specs:
|
| 143 |
+
variants = product.specs['variants']
|
| 144 |
+
for i, var in enumerate(variants):
|
| 145 |
+
img_url = var.get('image_url') or var.get('image')
|
| 146 |
+
if img_url:
|
| 147 |
+
is_valid, error = self.validate_image_url(img_url)
|
| 148 |
+
if not is_valid:
|
| 149 |
+
errors.append(f"Variant {i} Image: {error}")
|
| 150 |
+
|
| 151 |
+
return len(errors) == 0, errors
|
| 152 |
+
|
| 153 |
+
def calculate_quality_score(self, product: 'Product') -> int:
|
| 154 |
+
"""
|
| 155 |
+
Calculates a weighted quality score (0-100):
|
| 156 |
+
- Name/Desc (30pts): EN/AR presence and length
|
| 157 |
+
- Images (30pts): Primary image validity and gallery count
|
| 158 |
+
- Variants (20pts): Nested options completeness
|
| 159 |
+
- Metadata (20pts): Specs, brand, category
|
| 160 |
+
"""
|
| 161 |
+
score = 0
|
| 162 |
+
|
| 163 |
+
# 1. Names & Descriptions (30 pts)
|
| 164 |
+
if product.name_en and len(product.name_en) > 10: score += 7
|
| 165 |
+
if product.name_ar and len(product.name_ar) > 10: score += 8
|
| 166 |
+
if product.description_en and len(product.description_en) > 50: score += 7
|
| 167 |
+
if product.description_ar and len(product.description_ar) > 50: score += 8
|
| 168 |
+
|
| 169 |
+
# 2. Images (30 pts)
|
| 170 |
+
if product.images and len(product.images) > 0:
|
| 171 |
+
score += 15 # Has at least one image
|
| 172 |
+
if len(product.images) >= 3: score += 15 # Good gallery
|
| 173 |
+
elif len(product.images) == 2: score += 10
|
| 174 |
+
|
| 175 |
+
# 3. Variants/Options (20 pts)
|
| 176 |
+
has_options = False
|
| 177 |
+
if product.specs and isinstance(product.specs, dict):
|
| 178 |
+
options = product.specs.get('options')
|
| 179 |
+
if options and len(options) > 0:
|
| 180 |
+
has_options = True
|
| 181 |
+
score += 20
|
| 182 |
+
|
| 183 |
+
# 4. Metadata (20 pts)
|
| 184 |
+
if product.category_id: score += 5
|
| 185 |
+
if product.price > 0: score += 5
|
| 186 |
+
if product.stock > 0: score += 5
|
| 187 |
+
if product.specs and len(product.specs) > 2: score += 5
|
| 188 |
+
|
| 189 |
+
return min(100, score)
|
| 190 |
+
|
| 191 |
+
def perform_soft_delete(self, product: 'Product', reason: str, deleted_by: Optional[int] = None):
|
| 192 |
+
"""
|
| 193 |
+
Soft-deletes a product and archives a snapshot for 30-day recovery.
|
| 194 |
+
"""
|
| 195 |
+
try:
|
| 196 |
+
# Prepare recovery snapshot
|
| 197 |
+
snapshot = {
|
| 198 |
+
"name_en": product.name_en,
|
| 199 |
+
"name_ar": product.name_ar,
|
| 200 |
+
"price": product.price,
|
| 201 |
+
"stock": product.stock,
|
| 202 |
+
"specs": product.specs,
|
| 203 |
+
"images": [img.image_url for img in product.images],
|
| 204 |
+
"soft_deleted_at": datetime.now(timezone.utc).isoformat()
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
product.deleted_at = datetime.now(timezone.utc)
|
| 208 |
+
product.is_active = False
|
| 209 |
+
product.deletion_reason = reason
|
| 210 |
+
product.deleted_by = deleted_by
|
| 211 |
+
|
| 212 |
+
# Log audit trail
|
| 213 |
+
audit = ProductAudit(
|
| 214 |
+
product_id=product.id,
|
| 215 |
+
action="IMAGE_FAILURE_AUTO_DELETE",
|
| 216 |
+
reason=reason,
|
| 217 |
+
snapshot=snapshot,
|
| 218 |
+
performed_by=deleted_by
|
| 219 |
+
)
|
| 220 |
+
self.db.add(audit)
|
| 221 |
+
|
| 222 |
+
self.report.deleted_products.append({
|
| 223 |
+
"id": product.id,
|
| 224 |
+
"reason": reason
|
| 225 |
+
})
|
| 226 |
+
self.report.total_invalid += 1
|
| 227 |
+
logger.warning(f"🚨 AUTO-DELETED product {product.id} due to {reason}")
|
| 228 |
+
return True
|
| 229 |
+
except Exception as e:
|
| 230 |
+
logger.error(f"Failed to soft-delete {product.id}: {e}")
|
| 231 |
+
return False
|
| 232 |
+
|
| 233 |
+
def process_product(self, product: 'Product') -> bool:
|
| 234 |
+
"""Process quality lifecycle for a single product."""
|
| 235 |
+
self.report.total_checked += 1
|
| 236 |
+
|
| 237 |
+
# 1. Critical Image Integrity Check (Phase 3 Requirement)
|
| 238 |
+
if not product.images:
|
| 239 |
+
return self.perform_soft_delete(product, "Missing all images")
|
| 240 |
+
|
| 241 |
+
for img in product.images:
|
| 242 |
+
is_valid, error = self.validate_image_url(img.image_url)
|
| 243 |
+
if not is_valid:
|
| 244 |
+
return self.perform_soft_delete(product, f"Image 404/Error: {error}")
|
| 245 |
+
|
| 246 |
+
# 2. Score Calculation
|
| 247 |
+
old_score = product.quality_score
|
| 248 |
+
new_score = self.calculate_quality_score(product)
|
| 249 |
+
|
| 250 |
+
if old_score != new_score:
|
| 251 |
+
product.quality_score = new_score
|
| 252 |
+
self.report.updated_scores.append({
|
| 253 |
+
"id": product.id,
|
| 254 |
+
"old": old_score,
|
| 255 |
+
"new": new_score
|
| 256 |
+
})
|
| 257 |
+
|
| 258 |
+
self.report.total_valid += 1
|
| 259 |
+
return True
|
| 260 |
+
|
| 261 |
+
def run_quality_check(self, batch_size: int = 50) -> QualityReport:
|
| 262 |
+
"""Run full quality check on all active products."""
|
| 263 |
+
if not self.db:
|
| 264 |
+
logger.error("No database session available")
|
| 265 |
+
return self.report
|
| 266 |
+
|
| 267 |
+
logger.info("=" * 60)
|
| 268 |
+
logger.info("Starting Product Quality Check")
|
| 269 |
+
logger.info("=" * 60)
|
| 270 |
+
|
| 271 |
+
try:
|
| 272 |
+
products = self.db.query(Product).filter(
|
| 273 |
+
Product.is_active == True,
|
| 274 |
+
Product.deleted_at == None
|
| 275 |
+
).all()
|
| 276 |
+
|
| 277 |
+
logger.info(f"Found {len(products)} active products to check")
|
| 278 |
+
|
| 279 |
+
for idx, product in enumerate(products, 1):
|
| 280 |
+
self.process_product(product)
|
| 281 |
+
|
| 282 |
+
if idx % batch_size == 0:
|
| 283 |
+
self.db.commit()
|
| 284 |
+
logger.info(f"Processed {idx}/{len(products)} products...")
|
| 285 |
+
|
| 286 |
+
self.db.commit()
|
| 287 |
+
|
| 288 |
+
except Exception as e:
|
| 289 |
+
logger.error(f"Quality check failed: {str(e)}")
|
| 290 |
+
self.db.rollback()
|
| 291 |
+
self.report.errors.append(f"Quality check failed: {str(e)}")
|
| 292 |
+
|
| 293 |
+
logger.info("=" * 60)
|
| 294 |
+
logger.info(f"Quality Check Complete")
|
| 295 |
+
logger.info(f" Total Checked: {self.report.total_checked}")
|
| 296 |
+
logger.info(f" Valid Products: {self.report.total_valid}")
|
| 297 |
+
logger.info(f" Invalid (Deleted): {self.report.total_invalid}")
|
| 298 |
+
logger.info(f" Score Updates: {len(self.report.updated_scores)}")
|
| 299 |
+
logger.info("=" * 60)
|
| 300 |
+
|
| 301 |
+
return self.report
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
class StandaloneQualityChecker:
|
| 305 |
+
"""Run quality check on JSON file without database."""
|
| 306 |
+
|
| 307 |
+
def __init__(self, json_path: str):
|
| 308 |
+
self.json_path = json_path
|
| 309 |
+
self.report = QualityReport()
|
| 310 |
+
self.session = requests.Session()
|
| 311 |
+
|
| 312 |
+
def validate_image_url(self, url: str, timeout: int = 10) -> Tuple[bool, Optional[str]]:
|
| 313 |
+
"""Validate single image URL."""
|
| 314 |
+
if not url:
|
| 315 |
+
return False, "Empty URL"
|
| 316 |
+
|
| 317 |
+
try:
|
| 318 |
+
response = self.session.head(url, timeout=timeout, allow_redirects=True, verify=False)
|
| 319 |
+
|
| 320 |
+
if response.status_code >= 400:
|
| 321 |
+
response = self.session.get(url, stream=True, timeout=timeout, verify=False)
|
| 322 |
+
response.close()
|
| 323 |
+
|
| 324 |
+
if response.status_code >= 400:
|
| 325 |
+
return False, f"HTTP {response.status_code}"
|
| 326 |
+
|
| 327 |
+
content_type = response.headers.get('Content-Type', '')
|
| 328 |
+
if not content_type.startswith('image/'):
|
| 329 |
+
return False, f"Invalid content type: {content_type}"
|
| 330 |
+
|
| 331 |
+
return True, None
|
| 332 |
+
|
| 333 |
+
except Exception as e:
|
| 334 |
+
return False, str(e)[:50]
|
| 335 |
+
|
| 336 |
+
def check_json_products(self) -> Dict[str, Any]:
|
| 337 |
+
"""Check products from JSON file."""
|
| 338 |
+
logger.info(f"Loading products from {self.json_path}")
|
| 339 |
+
|
| 340 |
+
with open(self.json_path, 'r', encoding='utf-8') as f:
|
| 341 |
+
data = json.load(f)
|
| 342 |
+
|
| 343 |
+
products = data.get('products', [])
|
| 344 |
+
logger.info(f"Found {len(products)} products to validate")
|
| 345 |
+
|
| 346 |
+
results = {
|
| 347 |
+
"valid_products": [],
|
| 348 |
+
"invalid_products": [],
|
| 349 |
+
"summary": {
|
| 350 |
+
"total": len(products),
|
| 351 |
+
"valid": 0,
|
| 352 |
+
"invalid": 0
|
| 353 |
+
}
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
for idx, product in enumerate(products, 1):
|
| 357 |
+
product_id = product.get('id', f'unknown_{idx}')
|
| 358 |
+
name_en = product.get('name_en', 'Unknown')
|
| 359 |
+
images = product.get('images', [])
|
| 360 |
+
|
| 361 |
+
invalid_images = []
|
| 362 |
+
valid_images = []
|
| 363 |
+
|
| 364 |
+
for img_url in images:
|
| 365 |
+
is_valid, error = self.validate_image_url(img_url)
|
| 366 |
+
if is_valid:
|
| 367 |
+
valid_images.append(img_url)
|
| 368 |
+
else:
|
| 369 |
+
invalid_images.append({"url": img_url, "error": error})
|
| 370 |
+
|
| 371 |
+
product_result = {
|
| 372 |
+
"id": product_id,
|
| 373 |
+
"name_en": name_en,
|
| 374 |
+
"images": {
|
| 375 |
+
"total": len(images),
|
| 376 |
+
"valid": len(valid_images),
|
| 377 |
+
"invalid": len(invalid_images),
|
| 378 |
+
"invalid_details": invalid_images[:2]
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
if invalid_images:
|
| 383 |
+
results["invalid_products"].append(product_result)
|
| 384 |
+
results["summary"]["invalid"] += 1
|
| 385 |
+
logger.warning(f"Product {product_id}: {len(invalid_images)} invalid images")
|
| 386 |
+
else:
|
| 387 |
+
results["valid_products"].append(product_result)
|
| 388 |
+
results["summary"]["valid"] += 1
|
| 389 |
+
|
| 390 |
+
if idx % 50 == 0:
|
| 391 |
+
logger.info(f"Processed {idx}/{len(products)} products...")
|
| 392 |
+
|
| 393 |
+
logger.info(f"Validation complete: {results['summary']['valid']} valid, {results['summary']['invalid']} invalid")
|
| 394 |
+
|
| 395 |
+
try:
|
| 396 |
+
output_path = self.json_path.replace('.json', '_validation.json')
|
| 397 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
| 398 |
+
json.dump(results, f, ensure_ascii=False, indent=2)
|
| 399 |
+
logger.info(f"Results saved to {output_path}")
|
| 400 |
+
except Exception as e:
|
| 401 |
+
logger.warning(f"Could not save validation results to disk: {e}")
|
| 402 |
+
|
| 403 |
+
return results
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def create_backup(db_session) -> Optional[str]:
|
| 407 |
+
"""Create a backup of current products before quality operations."""
|
| 408 |
+
try:
|
| 409 |
+
products = db_session.query(Product).all()
|
| 410 |
+
|
| 411 |
+
backup_data = {
|
| 412 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 413 |
+
"total_products": len(products),
|
| 414 |
+
"products": []
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
for p in products:
|
| 418 |
+
product_dict = {
|
| 419 |
+
"id": p.id,
|
| 420 |
+
"name_en": p.name_en,
|
| 421 |
+
"name_ar": p.name_ar,
|
| 422 |
+
"price": p.price,
|
| 423 |
+
"stock": p.stock,
|
| 424 |
+
"is_active": p.is_active,
|
| 425 |
+
"deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
|
| 426 |
+
"quality_score": p.quality_score,
|
| 427 |
+
"images": [{"id": img.id, "image_url": img.image_url} for img in p.images]
|
| 428 |
+
}
|
| 429 |
+
backup_data["products"].append(product_dict)
|
| 430 |
+
|
| 431 |
+
backup_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backups")
|
| 432 |
+
os.makedirs(backup_dir, exist_ok=True)
|
| 433 |
+
|
| 434 |
+
backup_filename = f"products_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
| 435 |
+
backup_path = os.path.join(backup_dir, backup_filename)
|
| 436 |
+
|
| 437 |
+
try:
|
| 438 |
+
with open(backup_path, 'w', encoding='utf-8') as f:
|
| 439 |
+
json.dump(backup_data, f, ensure_ascii=False, indent=2)
|
| 440 |
+
logger.info(f"Backup created: {backup_path}")
|
| 441 |
+
except Exception as e:
|
| 442 |
+
logger.warning(f"Could not write backup to disk: {e}")
|
| 443 |
+
return backup_path
|
| 444 |
+
|
| 445 |
+
except Exception as e:
|
| 446 |
+
logger.error(f"Backup creation failed: {str(e)}")
|
| 447 |
+
return None
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def main():
|
| 451 |
+
"""Main entry point."""
|
| 452 |
+
import argparse
|
| 453 |
+
|
| 454 |
+
parser = argparse.ArgumentParser(description='VortexCommerce Product Quality Checker')
|
| 455 |
+
parser.add_argument('--mode', choices=['db', 'json'], default='db',
|
| 456 |
+
help='Run against database or JSON file')
|
| 457 |
+
parser.add_argument('--json-path',
|
| 458 |
+
default=os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
| 459 |
+
'backend', 'real_products_200.json'),
|
| 460 |
+
help='Path to JSON file for standalone mode')
|
| 461 |
+
parser.add_argument('--backup', action='store_true',
|
| 462 |
+
help='Create backup before quality operations')
|
| 463 |
+
parser.add_argument('--batch-size', type=int, default=50,
|
| 464 |
+
help='Batch size for database commits')
|
| 465 |
+
|
| 466 |
+
args = parser.parse_args()
|
| 467 |
+
|
| 468 |
+
logger.info("=" * 60)
|
| 469 |
+
logger.info("VortexCommerce Product Quality Checker v2.0")
|
| 470 |
+
logger.info("=" * 60)
|
| 471 |
+
|
| 472 |
+
if args.mode == 'json':
|
| 473 |
+
logger.info(f"Running in standalone JSON mode: {args.json_path}")
|
| 474 |
+
checker = StandaloneQualityChecker(args.json_path)
|
| 475 |
+
results = checker.check_json_products()
|
| 476 |
+
logger.info(f"Summary: {results['summary']}")
|
| 477 |
+
return 0
|
| 478 |
+
|
| 479 |
+
if not DB_AVAILABLE:
|
| 480 |
+
logger.error("Database modules not available. Use --mode json for standalone mode.")
|
| 481 |
+
return 1
|
| 482 |
+
|
| 483 |
+
db = SessionLocal()
|
| 484 |
+
|
| 485 |
+
try:
|
| 486 |
+
if args.backup:
|
| 487 |
+
backup_path = create_backup(db)
|
| 488 |
+
if not backup_path:
|
| 489 |
+
logger.warning("Continuing without backup...")
|
| 490 |
+
|
| 491 |
+
checker = ProductQualityChecker(db)
|
| 492 |
+
report = checker.run_quality_check(args.batch_size)
|
| 493 |
+
|
| 494 |
+
try:
|
| 495 |
+
report_path = os.path.join(os.path.dirname(__file__), f"quality_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json")
|
| 496 |
+
with open(report_path, 'w', encoding='utf-8') as f:
|
| 497 |
+
json.dump(report.to_dict(), f, ensure_ascii=False, indent=2)
|
| 498 |
+
logger.info(f"Quality report saved: {report_path}")
|
| 499 |
+
except Exception as e:
|
| 500 |
+
logger.warning(f"Could not save quality report to disk: {e}")
|
| 501 |
+
|
| 502 |
+
finally:
|
| 503 |
+
db.close()
|
| 504 |
+
|
| 505 |
+
return 0
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
if __name__ == "__main__":
|
| 509 |
+
sys.exit(main())
|
app/api/products.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 4 |
+
from sqlalchemy import or_, desc, asc, func, text, case, cast, String
|
| 5 |
+
from sqlalchemy.orm import Session, joinedload
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
|
| 8 |
+
from app.db.base import get_db
|
| 9 |
+
from app.models.product import Product, ProductImage, Category
|
| 10 |
+
from app.schemas.product import (
|
| 11 |
+
ProductCreate,
|
| 12 |
+
ProductUpdate,
|
| 13 |
+
ProductListItem,
|
| 14 |
+
ProductDetail,
|
| 15 |
+
ProductImageCreate,
|
| 16 |
+
PaginatedResponse,
|
| 17 |
+
)
|
| 18 |
+
from app.schemas.auth import AuthResponse
|
| 19 |
+
from app.core.security import get_current_user
|
| 20 |
+
from app.core.logging import log_audit_event
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/products", tags=["Products"])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.get("", response_model=PaginatedResponse)
|
| 26 |
+
async def list_products(
|
| 27 |
+
search: Optional[str] = Query(None),
|
| 28 |
+
brand: Optional[str] = Query(None),
|
| 29 |
+
category_id: Optional[int] = Query(None),
|
| 30 |
+
min_price: Optional[float] = Query(None),
|
| 31 |
+
max_price: Optional[float] = Query(None),
|
| 32 |
+
is_featured: Optional[bool] = Query(None),
|
| 33 |
+
has_discount: Optional[bool] = Query(None),
|
| 34 |
+
sort_by: str = Query("created_at", regex="^(created_at|price|rating|name_en)$"),
|
| 35 |
+
sort_order: str = Query("desc", regex="^(asc|desc)$"),
|
| 36 |
+
page: int = Query(1, ge=1),
|
| 37 |
+
page_size: int = Query(12, ge=1, le=2000),
|
| 38 |
+
limit: Optional[int] = Query(None, ge=1, le=2000),
|
| 39 |
+
specs_filter: Optional[str] = Query(None, description="JSON string of filters, e.g. {'RAM': ['8GB']}"),
|
| 40 |
+
db: Session = Depends(get_db),
|
| 41 |
+
):
|
| 42 |
+
# Use limit if provided, otherwise use page_size
|
| 43 |
+
effective_page_size = limit if limit is not None else page_size
|
| 44 |
+
|
| 45 |
+
from app.models.settings import StoreSettings
|
| 46 |
+
settings = db.query(StoreSettings).first()
|
| 47 |
+
global_discount = settings.global_discount if settings else 0
|
| 48 |
+
multiplier = (1 - global_discount / 100)
|
| 49 |
+
|
| 50 |
+
query = db.query(Product).filter(
|
| 51 |
+
Product.is_active == True, # noqa: E712
|
| 52 |
+
Product.deleted_at == None # noqa: E711
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# Search (with Arabic Normalization: أ, إ, آ -> ا | ة -> ه | ى -> ي)
|
| 56 |
+
if search:
|
| 57 |
+
search_term = f"%{search}%"
|
| 58 |
+
|
| 59 |
+
# Search (with Arabic Normalization: أ, إ, آ -> ا | ة -> ه | ى -> ي | ignore Tashkeel)
|
| 60 |
+
def normalize_arabic(column):
|
| 61 |
+
# 1-7. Previous normalization
|
| 62 |
+
col = func.replace(column, 'أ', 'ا')
|
| 63 |
+
col = func.replace(col, 'إ', 'ا')
|
| 64 |
+
col = func.replace(col, 'آ', 'ا')
|
| 65 |
+
col = func.replace(col, 'ة', 'ه')
|
| 66 |
+
col = func.replace(col, 'ى', 'ي')
|
| 67 |
+
col = func.replace(col, 'ؤ', 'و')
|
| 68 |
+
col = func.replace(col, 'ئ', 'ي')
|
| 69 |
+
# 8. Ignore Tashkeel/Diacritics (Fatha, Damma, Kesra, Shadda, etc)
|
| 70 |
+
tashkeel = ['ً', 'ٌ', 'ٍ', 'َ', 'ُ', 'ِ', 'ّ', 'ْ', 'ـ']
|
| 71 |
+
for char in tashkeel:
|
| 72 |
+
col = func.replace(col, char, '')
|
| 73 |
+
return col
|
| 74 |
+
|
| 75 |
+
# Normalize the user's search term in python
|
| 76 |
+
n_search = search.replace('أ', 'ا').replace('إ', 'ا').replace('آ', 'ا').replace('ة', 'ه').replace('ى', 'ي').replace('ؤ', 'و').replace('ئ', 'ي')
|
| 77 |
+
# Remove Tashkeel from search term too
|
| 78 |
+
for char in ['ً', 'ٌ', 'ٍ', 'َ', 'ُ', 'ِ', 'ّ', 'ْ', 'ـ']:
|
| 79 |
+
n_search = n_search.replace(char, '')
|
| 80 |
+
n_search_term = f"%{n_search}%"
|
| 81 |
+
|
| 82 |
+
query = query.filter(
|
| 83 |
+
or_(
|
| 84 |
+
Product.name_en.ilike(search_term),
|
| 85 |
+
normalize_arabic(Product.name_ar).ilike(n_search_term),
|
| 86 |
+
Product.description_en.ilike(search_term),
|
| 87 |
+
normalize_arabic(Product.description_ar).ilike(n_search_term),
|
| 88 |
+
)
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# Filters
|
| 92 |
+
if category_id is not None:
|
| 93 |
+
# Get category and its children to allow filtering by parent category
|
| 94 |
+
category = db.query(Category).filter(Category.id == category_id).first()
|
| 95 |
+
if category:
|
| 96 |
+
subcats = db.query(Category.id).filter(Category.parent_id == category_id).all()
|
| 97 |
+
subcat_ids = [c[0] for c in subcats]
|
| 98 |
+
all_cat_ids = [category_id] + subcat_ids
|
| 99 |
+
query = query.filter(Product.category_id.in_(all_cat_ids))
|
| 100 |
+
|
| 101 |
+
if brand:
|
| 102 |
+
brands = [b.strip() for b in brand.split(',') if b.strip()]
|
| 103 |
+
brand_conditions = []
|
| 104 |
+
for b in brands:
|
| 105 |
+
brand_search = f"%{b.lower()}%"
|
| 106 |
+
brand_conditions.append(
|
| 107 |
+
or_(
|
| 108 |
+
cast(Product.specs['brand_name'], String).ilike(brand_search),
|
| 109 |
+
cast(Product.specs['brand'], String).ilike(brand_search),
|
| 110 |
+
cast(Product.specs['details_en']['Brand Name'], String).ilike(brand_search),
|
| 111 |
+
cast(Product.specs['details_en']['Brand'], String).ilike(brand_search)
|
| 112 |
+
)
|
| 113 |
+
)
|
| 114 |
+
if brand_conditions:
|
| 115 |
+
query = query.filter(or_(*brand_conditions))
|
| 116 |
+
|
| 117 |
+
if specs_filter:
|
| 118 |
+
import json
|
| 119 |
+
try:
|
| 120 |
+
filters = json.loads(specs_filter)
|
| 121 |
+
if isinstance(filters, dict):
|
| 122 |
+
for key, values in filters.items():
|
| 123 |
+
if values and isinstance(values, list):
|
| 124 |
+
# Postgres JSONB extraction
|
| 125 |
+
query = query.filter(
|
| 126 |
+
or_(
|
| 127 |
+
cast(Product.specs[key], String).in_(values),
|
| 128 |
+
cast(Product.specs['details_en'][key], String).in_(values)
|
| 129 |
+
)
|
| 130 |
+
)
|
| 131 |
+
except Exception as e:
|
| 132 |
+
print(f"Error parsing specs_filter: {e}")
|
| 133 |
+
|
| 134 |
+
if min_price is not None:
|
| 135 |
+
query = query.filter(Product.price >= min_price)
|
| 136 |
+
if max_price is not None:
|
| 137 |
+
query = query.filter(Product.price <= max_price)
|
| 138 |
+
if is_featured is not None:
|
| 139 |
+
query = query.filter(Product.is_featured == is_featured)
|
| 140 |
+
if has_discount is True:
|
| 141 |
+
query = query.filter(Product.compare_price > Product.price)
|
| 142 |
+
elif has_discount is False:
|
| 143 |
+
query = query.filter(or_(Product.compare_price == None, Product.compare_price <= Product.price))
|
| 144 |
+
|
| 145 |
+
# Count
|
| 146 |
+
total = query.count()
|
| 147 |
+
|
| 148 |
+
# Sort
|
| 149 |
+
from sqlalchemy import case
|
| 150 |
+
|
| 151 |
+
# Custom Hybrid Sorting Logic:
|
| 152 |
+
# 1. New Products (ID > 3708) first, sorted by ID DESC
|
| 153 |
+
# 2. Original Catalog (ID <= 3708) next, sorted by ID ASC
|
| 154 |
+
query = query.order_by(
|
| 155 |
+
case(
|
| 156 |
+
(Product.id > 3708, 0),
|
| 157 |
+
else_=1
|
| 158 |
+
),
|
| 159 |
+
Product.id.asc()
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# Pagination
|
| 163 |
+
offset = (page - 1) * effective_page_size
|
| 164 |
+
products = (
|
| 165 |
+
query.options(joinedload(Product.category), joinedload(Product.images))
|
| 166 |
+
.offset(offset)
|
| 167 |
+
.limit(effective_page_size)
|
| 168 |
+
.all()
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# Map to response
|
| 172 |
+
items = []
|
| 173 |
+
for p in products:
|
| 174 |
+
primary_image = None
|
| 175 |
+
if p.images:
|
| 176 |
+
sorted_imgs = sorted(p.images, key=lambda x: x.sort_order)
|
| 177 |
+
primary_image = sorted_imgs[0].image_url if sorted_imgs else None
|
| 178 |
+
|
| 179 |
+
# Pricing for combined discount display:
|
| 180 |
+
# price = original_price * multiplier
|
| 181 |
+
# compare_price = original_compare (raw)
|
| 182 |
+
original_price = p.price
|
| 183 |
+
original_compare = p.compare_price if p.compare_price else p.price
|
| 184 |
+
|
| 185 |
+
item = ProductListItem(
|
| 186 |
+
id=p.id,
|
| 187 |
+
slug=p.slug,
|
| 188 |
+
name_ar=p.name_ar,
|
| 189 |
+
name_en=p.name_en,
|
| 190 |
+
price=original_price * multiplier,
|
| 191 |
+
compare_price=original_compare if (multiplier < 1.0 or p.compare_price) else None,
|
| 192 |
+
stock=p.stock,
|
| 193 |
+
category_id=p.category_id,
|
| 194 |
+
category=p.category,
|
| 195 |
+
rating=p.rating,
|
| 196 |
+
rating_count=p.rating_count,
|
| 197 |
+
is_featured=p.is_featured,
|
| 198 |
+
image_url=primary_image,
|
| 199 |
+
created_at=p.created_at,
|
| 200 |
+
quality_score=p.quality_score or 100,
|
| 201 |
+
deleted_at=p.deleted_at,
|
| 202 |
+
)
|
| 203 |
+
items.append(item.model_dump())
|
| 204 |
+
|
| 205 |
+
# Serialize dates
|
| 206 |
+
for item in items:
|
| 207 |
+
if isinstance(item.get("created_at"), datetime):
|
| 208 |
+
item["created_at"] = item["created_at"].isoformat()
|
| 209 |
+
if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
|
| 210 |
+
item["deleted_at"] = item["deleted_at"].isoformat()
|
| 211 |
+
|
| 212 |
+
total_pages = (total + page_size - 1) // page_size
|
| 213 |
+
|
| 214 |
+
return PaginatedResponse(
|
| 215 |
+
isSuccess=True,
|
| 216 |
+
value={
|
| 217 |
+
"items": items,
|
| 218 |
+
"total": total,
|
| 219 |
+
"page": page,
|
| 220 |
+
"page_size": page_size,
|
| 221 |
+
"total_pages": total_pages,
|
| 222 |
+
},
|
| 223 |
+
statusCode=200,
|
| 224 |
+
)
|
| 225 |
+
@router.get("/admin/deleted", response_model=PaginatedResponse)
|
| 226 |
+
async def list_deleted_products(
|
| 227 |
+
page: int = Query(1, ge=1),
|
| 228 |
+
page_size: int = Query(12, ge=1, le=48),
|
| 229 |
+
db: Session = Depends(get_db),
|
| 230 |
+
user_id: int = Depends(get_current_user),
|
| 231 |
+
):
|
| 232 |
+
query = db.query(Product).filter(Product.deleted_at != None) # noqa: E711
|
| 233 |
+
|
| 234 |
+
total = query.count()
|
| 235 |
+
offset = (page - 1) * page_size
|
| 236 |
+
products = (
|
| 237 |
+
query.options(joinedload(Product.category), joinedload(Product.images))
|
| 238 |
+
.order_by(desc(Product.deleted_at))
|
| 239 |
+
.offset(offset)
|
| 240 |
+
.limit(page_size)
|
| 241 |
+
.all()
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
items = []
|
| 245 |
+
for p in products:
|
| 246 |
+
item = ProductDetail.model_validate(p).model_dump()
|
| 247 |
+
if isinstance(item.get("created_at"), datetime):
|
| 248 |
+
item["created_at"] = item["created_at"].isoformat()
|
| 249 |
+
if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
|
| 250 |
+
item["deleted_at"] = item["deleted_at"].isoformat()
|
| 251 |
+
items.append(item)
|
| 252 |
+
|
| 253 |
+
total_pages = (total + page_size - 1) // page_size
|
| 254 |
+
|
| 255 |
+
return PaginatedResponse(
|
| 256 |
+
isSuccess=True,
|
| 257 |
+
value={
|
| 258 |
+
"items": items,
|
| 259 |
+
"total": total,
|
| 260 |
+
"page": page,
|
| 261 |
+
"page_size": page_size,
|
| 262 |
+
"total_pages": total_pages,
|
| 263 |
+
},
|
| 264 |
+
statusCode=200,
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
@router.get("/filters")
|
| 269 |
+
async def get_filters(category_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
|
| 270 |
+
"""
|
| 271 |
+
Returns filter metadata for a category (price range, brands, dynamic attributes).
|
| 272 |
+
Supports parent categories by aggregating children. If category_id is None, returns global filters.
|
| 273 |
+
"""
|
| 274 |
+
all_cat_ids = None
|
| 275 |
+
cat_name_en = ""
|
| 276 |
+
|
| 277 |
+
if category_id is not None:
|
| 278 |
+
category = db.query(Category).filter(Category.id == category_id).first()
|
| 279 |
+
if not category:
|
| 280 |
+
raise HTTPException(status_code=404, detail="Category not found")
|
| 281 |
+
|
| 282 |
+
subcats = db.query(Category.id).filter(Category.parent_id == category_id).all()
|
| 283 |
+
all_cat_ids = [category_id] + [c[0] for c in subcats]
|
| 284 |
+
cat_name_en = (category.name_en or "").lower()
|
| 285 |
+
|
| 286 |
+
base_filter = [Product.is_active == True, Product.deleted_at == None]
|
| 287 |
+
if all_cat_ids is not None:
|
| 288 |
+
base_filter.append(Product.category_id.in_(all_cat_ids))
|
| 289 |
+
|
| 290 |
+
# 1. Price Range
|
| 291 |
+
price_stats = db.query(
|
| 292 |
+
func.min(Product.price).label("min_p"),
|
| 293 |
+
func.max(Product.price).label("max_p")
|
| 294 |
+
).filter(*base_filter).first()
|
| 295 |
+
|
| 296 |
+
# 2. Brands
|
| 297 |
+
brands_q1 = db.query(func.distinct(cast(Product.specs['brand_name'], String))).filter(*base_filter)
|
| 298 |
+
brands_q2 = db.query(func.distinct(cast(Product.specs['details_en']['Brand Name'], String))).filter(*base_filter)
|
| 299 |
+
brands = sorted(list(set([b[0] for b in brands_q1.all() if b[0]] + [b[0] for b in brands_q2.all() if b[0]])))
|
| 300 |
+
|
| 301 |
+
# 3. Dynamic Attributes per subcategory
|
| 302 |
+
attr_mappings = []
|
| 303 |
+
|
| 304 |
+
if cat_name_en:
|
| 305 |
+
# ── Electronics ──
|
| 306 |
+
if any(k in cat_name_en for k in ["mobile", "phone", "smartphone", "جوالات"]):
|
| 307 |
+
attr_mappings = [
|
| 308 |
+
{"key": "RAM", "label_ar": "الرام", "label_en": "RAM"},
|
| 309 |
+
{"key": "Internal Memory", "label_ar": "المساحة الداخلية", "label_en": "Storage"},
|
| 310 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 311 |
+
{"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
|
| 312 |
+
{"key": "Network Type", "label_ar": "نوع الشبكة", "label_en": "Network"},
|
| 313 |
+
]
|
| 314 |
+
elif any(k in cat_name_en for k in ["laptop", "computer", "لابتوب"]):
|
| 315 |
+
attr_mappings = [
|
| 316 |
+
{"key": "Processor", "label_ar": "المعالج", "label_en": "Processor"},
|
| 317 |
+
{"key": "RAM", "label_ar": "الرام", "label_en": "RAM"},
|
| 318 |
+
{"key": "Hard Drive Capacity", "label_ar": "سعة التخزين", "label_en": "Storage"},
|
| 319 |
+
{"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
|
| 320 |
+
{"key": "Graphics Card", "label_ar": "كرت الشاشة", "label_en": "Graphics Card"},
|
| 321 |
+
{"key": "Operating System", "label_ar": "نظام التشغيل", "label_en": "OS"},
|
| 322 |
+
]
|
| 323 |
+
elif any(k in cat_name_en for k in ["tablet", "لوحي"]):
|
| 324 |
+
attr_mappings = [
|
| 325 |
+
{"key": "RAM", "label_ar": "الرام", "label_en": "RAM"},
|
| 326 |
+
{"key": "Internal Memory", "label_ar": "المساحة", "label_en": "Storage"},
|
| 327 |
+
{"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
|
| 328 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 329 |
+
]
|
| 330 |
+
elif any(k in cat_name_en for k in ["tv", "television", "تلفزيون"]):
|
| 331 |
+
attr_mappings = [
|
| 332 |
+
{"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
|
| 333 |
+
{"key": "Resolution Type", "label_ar": "الدقة", "label_en": "Resolution"},
|
| 334 |
+
{"key": "Smart TV", "label_ar": "تلفزيون ذكي", "label_en": "Smart TV"},
|
| 335 |
+
{"key": "Display Type", "label_ar": "نوع الشاشة", "label_en": "Display Type"},
|
| 336 |
+
]
|
| 337 |
+
elif any(k in cat_name_en for k in ["camera", "كاميرا"]):
|
| 338 |
+
attr_mappings = [
|
| 339 |
+
{"key": "Resolution", "label_ar": "الدقة", "label_en": "Resolution"},
|
| 340 |
+
{"key": "Type", "label_ar": "النوع", "label_en": "Type"},
|
| 341 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 342 |
+
]
|
| 343 |
+
elif any(k in cat_name_en for k in ["printer", "طابع"]):
|
| 344 |
+
attr_mappings = [
|
| 345 |
+
{"key": "Print Technology", "label_ar": "تقنية الطباعة", "label_en": "Print Tech"},
|
| 346 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 347 |
+
{"key": "Connectivity", "label_ar": "الاتصال", "label_en": "Connectivity"},
|
| 348 |
+
]
|
| 349 |
+
# ── Accessories ──
|
| 350 |
+
elif any(k in cat_name_en for k in ["audio", "headphone", "سماعات", "صوت"]):
|
| 351 |
+
attr_mappings = [
|
| 352 |
+
{"key": "Connection Type", "label_ar": "نوع الاتصال", "label_en": "Connection"},
|
| 353 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 354 |
+
{"key": "Type", "label_ar": "النوع", "label_en": "Type"},
|
| 355 |
+
]
|
| 356 |
+
elif any(k in cat_name_en for k in ["watch", "ساعات"]):
|
| 357 |
+
attr_mappings = [
|
| 358 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 359 |
+
{"key": "Band Material", "label_ar": "مادة السوار", "label_en": "Band Material"},
|
| 360 |
+
{"key": "Display Type", "label_ar": "نوع الشاشة", "label_en": "Display"},
|
| 361 |
+
]
|
| 362 |
+
# ── Gaming ──
|
| 363 |
+
elif any(k in cat_name_en for k in ["gaming", "console", "ألعاب"]):
|
| 364 |
+
attr_mappings = [
|
| 365 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 366 |
+
{"key": "Storage Capacity", "label_ar": "سعة التخزين", "label_en": "Storage"},
|
| 367 |
+
{"key": "Type", "label_ar": "النوع", "label_en": "Type"},
|
| 368 |
+
]
|
| 369 |
+
# ── Home Appliances ──
|
| 370 |
+
elif any(k in cat_name_en for k in ["refrigerator", "ثلاج"]):
|
| 371 |
+
attr_mappings = [
|
| 372 |
+
{"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity"},
|
| 373 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 374 |
+
{"key": "Door Type", "label_ar": "نوع الباب", "label_en": "Door Type"},
|
| 375 |
+
]
|
| 376 |
+
elif any(k in cat_name_en for k in ["wash", "غسال"]):
|
| 377 |
+
attr_mappings = [
|
| 378 |
+
{"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity (kg)"},
|
| 379 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 380 |
+
{"key": "Load Type", "label_ar": "نوع التحميل", "label_en": "Load Type"},
|
| 381 |
+
]
|
| 382 |
+
elif any(k in cat_name_en for k in ["conditioner", "مكيف"]):
|
| 383 |
+
attr_mappings = [
|
| 384 |
+
{"key": "Capacity", "label_ar": "السعة (BTU)", "label_en": "Capacity (BTU)"},
|
| 385 |
+
{"key": "Type", "label_ar": "النوع", "label_en": "Type"},
|
| 386 |
+
{"key": "Energy Rating", "label_ar": "كفاءة الطاقة", "label_en": "Energy Rating"},
|
| 387 |
+
]
|
| 388 |
+
elif any(k in cat_name_en for k in ["small appliance", "منزلية صغيرة"]):
|
| 389 |
+
attr_mappings = [
|
| 390 |
+
{"key": "Type", "label_ar": "النوع", "label_en": "Type"},
|
| 391 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 392 |
+
{"key": "Wattage", "label_ar": "القدرة", "label_en": "Wattage"},
|
| 393 |
+
]
|
| 394 |
+
elif any(k in cat_name_en for k in ["large appliance", "منزلية كبيرة"]):
|
| 395 |
+
attr_mappings = [
|
| 396 |
+
{"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity"},
|
| 397 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 398 |
+
{"key": "Type", "label_ar": "النوع", "label_en": "Type"},
|
| 399 |
+
]
|
| 400 |
+
# ── Parent categories (aggregate) ──
|
| 401 |
+
elif any(k in cat_name_en for k in ["electronic", "إلكتروني"]):
|
| 402 |
+
attr_mappings = [
|
| 403 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 404 |
+
]
|
| 405 |
+
elif any(k in cat_name_en for k in ["accessor", "ملحقات"]):
|
| 406 |
+
attr_mappings = [
|
| 407 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 408 |
+
{"key": "Connection Type", "label_ar": "نوع الاتصال", "label_en": "Connection"},
|
| 409 |
+
]
|
| 410 |
+
elif any(k in cat_name_en for k in ["home appliance", "المنزلية"]):
|
| 411 |
+
attr_mappings = [
|
| 412 |
+
{"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity"},
|
| 413 |
+
{"key": "Color", "label_ar": "اللون", "label_en": "Color"},
|
| 414 |
+
]
|
| 415 |
+
|
| 416 |
+
# 4. Resolve attribute values from DB
|
| 417 |
+
attributes = []
|
| 418 |
+
for attr in attr_mappings:
|
| 419 |
+
key = attr["key"]
|
| 420 |
+
# Try direct spec key first, then details_en
|
| 421 |
+
values_query = db.query(func.distinct(cast(Product.specs[key], String))).filter(
|
| 422 |
+
*base_filter
|
| 423 |
+
).all()
|
| 424 |
+
vals = [v[0] for v in values_query if v[0]]
|
| 425 |
+
|
| 426 |
+
if not vals:
|
| 427 |
+
values_query = db.query(func.distinct(cast(Product.specs['details_en'][key], String))).filter(
|
| 428 |
+
*base_filter
|
| 429 |
+
).all()
|
| 430 |
+
vals = [v[0] for v in values_query if v[0]]
|
| 431 |
+
|
| 432 |
+
if vals:
|
| 433 |
+
attributes.append({
|
| 434 |
+
"key": key,
|
| 435 |
+
"label_ar": attr["label_ar"],
|
| 436 |
+
"label_en": attr["label_en"],
|
| 437 |
+
"options": sorted(list(set(vals)))
|
| 438 |
+
})
|
| 439 |
+
|
| 440 |
+
return PaginatedResponse(
|
| 441 |
+
isSuccess=True,
|
| 442 |
+
value={
|
| 443 |
+
"min_price": float(price_stats.min_p) if price_stats and price_stats.min_p else 0,
|
| 444 |
+
"max_price": float(price_stats.max_p) if price_stats and price_stats.max_p else 10000,
|
| 445 |
+
"brands": brands,
|
| 446 |
+
"attributes": attributes
|
| 447 |
+
},
|
| 448 |
+
statusCode=200
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
@router.get("/{identifier}", response_model=PaginatedResponse)
|
| 454 |
+
async def get_product(identifier: str, raw: bool = False, db: Session = Depends(get_db)):
|
| 455 |
+
query = db.query(Product).options(joinedload(Product.category), joinedload(Product.images))
|
| 456 |
+
if identifier.isdigit():
|
| 457 |
+
product = query.filter(Product.id == int(identifier)).first()
|
| 458 |
+
# If not found by ID, maybe the slug itself is numeric?
|
| 459 |
+
if not product:
|
| 460 |
+
product = query.filter(Product.slug == identifier).first()
|
| 461 |
+
else:
|
| 462 |
+
product = query.filter(Product.slug == identifier).first()
|
| 463 |
+
|
| 464 |
+
if not product:
|
| 465 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 466 |
+
|
| 467 |
+
from app.models.settings import StoreSettings
|
| 468 |
+
settings = db.query(StoreSettings).first()
|
| 469 |
+
global_discount = settings.global_discount if settings else 0
|
| 470 |
+
multiplier = (1 - global_discount / 100)
|
| 471 |
+
|
| 472 |
+
detail = ProductDetail.model_validate(product).model_dump()
|
| 473 |
+
|
| 474 |
+
# Only apply discount if NOT raw
|
| 475 |
+
if not raw:
|
| 476 |
+
# Normalize pricing for combined discount display:
|
| 477 |
+
# price = original_price * multiplier
|
| 478 |
+
# compare_price = original_compare (raw)
|
| 479 |
+
original_price = detail["price"]
|
| 480 |
+
original_compare = detail.get("compare_price") if detail.get("compare_price") else original_price
|
| 481 |
+
|
| 482 |
+
detail["price"] = original_price * multiplier
|
| 483 |
+
if multiplier < 1.0 or detail.get("compare_price"):
|
| 484 |
+
detail["compare_price"] = original_compare
|
| 485 |
+
|
| 486 |
+
# Apply to variants in specs
|
| 487 |
+
if detail.get("specs") and "variants" in detail["specs"]:
|
| 488 |
+
for v in detail["specs"]["variants"]:
|
| 489 |
+
if v.get("price"):
|
| 490 |
+
v_original_price = v["price"]
|
| 491 |
+
v["price"] = v_original_price * multiplier
|
| 492 |
+
# Optional: Could add compare_price to variants too, but mostly price is enough
|
| 493 |
+
# as the main product's compare_price is often used for the range.
|
| 494 |
+
if v.get("price_modifier"):
|
| 495 |
+
v["price_modifier"] *= multiplier
|
| 496 |
+
|
| 497 |
+
return PaginatedResponse(
|
| 498 |
+
isSuccess=True,
|
| 499 |
+
value=detail,
|
| 500 |
+
statusCode=200,
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
@router.post("", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
|
| 505 |
+
async def create_product(
|
| 506 |
+
request: ProductCreate,
|
| 507 |
+
user_id: int = Depends(get_current_user),
|
| 508 |
+
db: Session = Depends(get_db),
|
| 509 |
+
):
|
| 510 |
+
if not request.images or len(request.images) == 0:
|
| 511 |
+
raise HTTPException(status_code=400, detail="Main image is required / صورة رئيسية مطلوبة")
|
| 512 |
+
|
| 513 |
+
product = Product(
|
| 514 |
+
name_ar=request.name_ar,
|
| 515 |
+
name_en=request.name_en,
|
| 516 |
+
description_ar=request.description_ar,
|
| 517 |
+
description_en=request.description_en,
|
| 518 |
+
price=request.price,
|
| 519 |
+
compare_price=request.compare_price,
|
| 520 |
+
stock=request.stock,
|
| 521 |
+
category_id=request.category_id,
|
| 522 |
+
is_featured=request.is_featured,
|
| 523 |
+
is_active=request.is_active,
|
| 524 |
+
specs=request.specs,
|
| 525 |
+
)
|
| 526 |
+
db.add(product)
|
| 527 |
+
db.flush()
|
| 528 |
+
|
| 529 |
+
# Add images
|
| 530 |
+
for img in request.images:
|
| 531 |
+
db_img = ProductImage(
|
| 532 |
+
product_id=product.id,
|
| 533 |
+
image_url=img.image_url,
|
| 534 |
+
alt_text=img.alt_text,
|
| 535 |
+
sort_order=img.sort_order,
|
| 536 |
+
)
|
| 537 |
+
db.add(db_img)
|
| 538 |
+
|
| 539 |
+
db.commit()
|
| 540 |
+
db.refresh(product)
|
| 541 |
+
|
| 542 |
+
return AuthResponse(
|
| 543 |
+
isSuccess=True,
|
| 544 |
+
value={"product_id": product.id},
|
| 545 |
+
statusCode=201,
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
@router.put("/{product_id}", response_model=AuthResponse)
|
| 550 |
+
async def update_product(
|
| 551 |
+
product_id: int,
|
| 552 |
+
request: ProductUpdate,
|
| 553 |
+
user_id: int = Depends(get_current_user),
|
| 554 |
+
db: Session = Depends(get_db),
|
| 555 |
+
):
|
| 556 |
+
product = db.query(Product).filter(Product.id == product_id).first()
|
| 557 |
+
if not product or product.deleted_at is not None:
|
| 558 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 559 |
+
|
| 560 |
+
update_data = request.model_dump(exclude_unset=True)
|
| 561 |
+
for field, value in update_data.items():
|
| 562 |
+
setattr(product, field, value)
|
| 563 |
+
|
| 564 |
+
db.commit()
|
| 565 |
+
|
| 566 |
+
return AuthResponse(
|
| 567 |
+
isSuccess=True,
|
| 568 |
+
value={"product_id": product.id},
|
| 569 |
+
statusCode=200,
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
@router.delete("/{product_id}", response_model=AuthResponse)
|
| 574 |
+
async def delete_product(
|
| 575 |
+
product_id: int,
|
| 576 |
+
user_id: int = Depends(get_current_user),
|
| 577 |
+
db: Session = Depends(get_db),
|
| 578 |
+
):
|
| 579 |
+
product = db.query(Product).filter(Product.id == product_id).first()
|
| 580 |
+
if not product or product.deleted_at is not None:
|
| 581 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 582 |
+
|
| 583 |
+
# Soft Delete
|
| 584 |
+
product.deleted_at = datetime.now(timezone.utc)
|
| 585 |
+
product.deleted_by = user_id
|
| 586 |
+
product.deletion_reason = "Manual deletion by admin"
|
| 587 |
+
|
| 588 |
+
db.commit()
|
| 589 |
+
|
| 590 |
+
return AuthResponse(
|
| 591 |
+
isSuccess=True,
|
| 592 |
+
value={"deleted": True, "soft_delete": True},
|
| 593 |
+
statusCode=200,
|
| 594 |
+
)
|
| 595 |
+
|
| 596 |
+
@router.delete("/{product_id}/hard", response_model=AuthResponse)
|
| 597 |
+
async def hard_delete_product(
|
| 598 |
+
product_id: int,
|
| 599 |
+
user_id: int = Depends(get_current_user),
|
| 600 |
+
db: Session = Depends(get_db),
|
| 601 |
+
):
|
| 602 |
+
product = db.query(Product).filter(Product.id == product_id).first()
|
| 603 |
+
if not product:
|
| 604 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 605 |
+
|
| 606 |
+
db.delete(product)
|
| 607 |
+
db.commit()
|
| 608 |
+
|
| 609 |
+
return AuthResponse(
|
| 610 |
+
isSuccess=True,
|
| 611 |
+
value={"deleted": True, "hard_delete": True},
|
| 612 |
+
statusCode=200,
|
| 613 |
+
)
|
| 614 |
+
|
| 615 |
+
@router.post("/{product_id}/restore", response_model=AuthResponse)
|
| 616 |
+
async def restore_product(
|
| 617 |
+
product_id: int,
|
| 618 |
+
user_id: int = Depends(get_current_user),
|
| 619 |
+
db: Session = Depends(get_db),
|
| 620 |
+
):
|
| 621 |
+
product = db.query(Product).filter(Product.id == product_id).first()
|
| 622 |
+
if not product or product.deleted_at is None:
|
| 623 |
+
raise HTTPException(status_code=404, detail="Product not found or not deleted")
|
| 624 |
+
|
| 625 |
+
product.deleted_at = None
|
| 626 |
+
product.deleted_by = None
|
| 627 |
+
product.deletion_reason = None
|
| 628 |
+
|
| 629 |
+
db.commit()
|
| 630 |
+
|
| 631 |
+
return AuthResponse(
|
| 632 |
+
isSuccess=True,
|
| 633 |
+
value={"restored": True},
|
| 634 |
+
statusCode=200,
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
# End of products.py
|
app/api/seed.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from sqlalchemy import text
|
| 4 |
+
import json
|
| 5 |
+
import random
|
| 6 |
+
import os
|
| 7 |
+
import hashlib
|
| 8 |
+
import requests
|
| 9 |
+
|
| 10 |
+
from app.db.base import get_db
|
| 11 |
+
from app.models.product import Product, ProductImage, Category
|
| 12 |
+
from app.models.cart import Coupon
|
| 13 |
+
from app.models.user import User, UserRole, AuthProvider
|
| 14 |
+
from app.core.security import hash_password
|
| 15 |
+
from app.schemas.auth import AuthResponse
|
| 16 |
+
from app.services.external_catalog import ExternalCatalogService
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/seed", tags=["Seed Data"])
|
| 19 |
+
|
| 20 |
+
# ============================================================
|
| 21 |
+
# Category Hierarchy — matching Extra_Scraper_Kaggle.py exactly
|
| 22 |
+
# ============================================================
|
| 23 |
+
SUB_ICONS = {
|
| 24 |
+
"جوالات": "📲",
|
| 25 |
+
"لابتوب": "💻",
|
| 26 |
+
"أجهزة لوحية": "📟",
|
| 27 |
+
"تلفزيونات": "📺",
|
| 28 |
+
"كاميرات": "📷",
|
| 29 |
+
"طابعات": "🖨️",
|
| 30 |
+
"أجهزة الصوت والسماعات": "🎧",
|
| 31 |
+
"ساعات ذكية": "⌚",
|
| 32 |
+
"أجهزة ألعاب": "🎮",
|
| 33 |
+
"ثلاجات": "❄️",
|
| 34 |
+
"غسالات ومجففات": "🫧",
|
| 35 |
+
"مكيفات": "🌀",
|
| 36 |
+
"أجهزة منزلية صغيرة": "🍳",
|
| 37 |
+
"أجهزة منزلية كبيرة": "🏗️",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
CATEGORY_HIERARCHY = {
|
| 41 |
+
"الأجهزة الإلكترونية": {
|
| 42 |
+
"name_en": "Electronics",
|
| 43 |
+
"icon": "📱",
|
| 44 |
+
"subs": [
|
| 45 |
+
{"ar": "جوالات", "en": "Smartphones", "icon": "📲"},
|
| 46 |
+
{"ar": "لابتوب", "en": "Laptops", "icon": "💻"},
|
| 47 |
+
{"ar": "أجهزة لوحية", "en": "Tablets", "icon": "📟"},
|
| 48 |
+
{"ar": "تلفزيونات", "en": "TVs", "icon": "📺"},
|
| 49 |
+
{"ar": "كاميرات", "en": "Cameras", "icon": "📷"},
|
| 50 |
+
{"ar": "طابعات", "en": "Printers", "icon": "🖨️"},
|
| 51 |
+
]
|
| 52 |
+
},
|
| 53 |
+
"ملحقات واكسسوارات": {
|
| 54 |
+
"name_en": "Accessories",
|
| 55 |
+
"icon": "🔌",
|
| 56 |
+
"subs": [
|
| 57 |
+
{"ar": "أجهزة الصوت والسماعات", "en": "Audio & Headphones", "icon": "🎧"},
|
| 58 |
+
{"ar": "ساعات ذكية", "en": "Smartwatches", "icon": "⌚"},
|
| 59 |
+
]
|
| 60 |
+
},
|
| 61 |
+
"ألعاب جيمنج": {
|
| 62 |
+
"name_en": "Gaming",
|
| 63 |
+
"icon": "🎮",
|
| 64 |
+
"subs": [
|
| 65 |
+
{"ar": "أجهزة ألعاب", "en": "Gaming Consoles", "icon": "🎮"},
|
| 66 |
+
]
|
| 67 |
+
},
|
| 68 |
+
"الأجهزة المنزلية": {
|
| 69 |
+
"name_en": "Home Appliances",
|
| 70 |
+
"icon": "🏠",
|
| 71 |
+
"subs": [
|
| 72 |
+
{"ar": "ثلاجات", "en": "Refrigerators", "icon": "❄️"},
|
| 73 |
+
{"ar": "غسالات ومجففات", "en": "Washing Machines", "icon": "🫧"},
|
| 74 |
+
{"ar": "مكيفات", "en": "Air Conditioners", "icon": "🌀"},
|
| 75 |
+
{"ar": "أجهزة منزلية صغيرة", "en": "Small Appliances", "icon": "🍳"},
|
| 76 |
+
{"ar": "أجهزة منزلية كبيرة", "en": "Large Appliances", "icon": "🏗️"},
|
| 77 |
+
]
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# Reverse map for scraped data → parent category
|
| 82 |
+
SCRAPED_TO_TARGET_MAP = {
|
| 83 |
+
"Electronics": "الأجهزة الإلكترونية",
|
| 84 |
+
"Smartphones": "الأجهزة الإلكترونية",
|
| 85 |
+
"Laptops": "الأجهزة الإلكترونية",
|
| 86 |
+
"Tablets": "الأجهزة الإلكترونية",
|
| 87 |
+
"TVs": "الأجهزة الإلكترونية",
|
| 88 |
+
"Cameras": "الأجهزة الإلكترونية",
|
| 89 |
+
"Printers": "الأجهزة الإلكترونية",
|
| 90 |
+
"Accessories": "ملحقات واكسسوارات",
|
| 91 |
+
"Audio & Headphones": "ملحقات واكسسوارات",
|
| 92 |
+
"Smartwatches": "ملحقات واكسسوارات",
|
| 93 |
+
"Gaming": "ألعاب جيمنج",
|
| 94 |
+
"Gaming Consoles": "ألعاب جيمنج",
|
| 95 |
+
"Home Appliances": "الأجهزة المنزلية",
|
| 96 |
+
"Refrigerators": "الأجهزة المنزلية",
|
| 97 |
+
"Washing Machines": "الأجهزة المنزلية",
|
| 98 |
+
"Air Conditioners": "الأجهزة المنزلية",
|
| 99 |
+
"Small Appliances": "الأجهزة المنزلية",
|
| 100 |
+
"Large Appliances": "الأجهزة المنزلية",
|
| 101 |
+
"Security": "ملحقات واكسسوارات",
|
| 102 |
+
"Car": "ملحقات واكسسوارات",
|
| 103 |
+
"Audio": "ملحقات واكسسوارات",
|
| 104 |
+
"Personal Care": "الأجهزة المنزلية",
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
@router.api_route("", methods=["GET", "POST"], response_model=AuthResponse)
|
| 108 |
+
def seed_database(db: Session = Depends(get_db), is_background_task: bool = False):
|
| 109 |
+
"""Populate database with proper product data. Only adds new products, does not clear existing data."""
|
| 110 |
+
if is_background_task:
|
| 111 |
+
from app.core.state import import_progress
|
| 112 |
+
# 0. Ensure Store Settings exist
|
| 113 |
+
from app.models.settings import StoreSettings
|
| 114 |
+
settings = db.query(StoreSettings).first()
|
| 115 |
+
if not settings:
|
| 116 |
+
print(">>> [SEED] Creating default store settings...")
|
| 117 |
+
settings = StoreSettings(
|
| 118 |
+
store_name="أفق",
|
| 119 |
+
primary_color="#046c4e",
|
| 120 |
+
secondary_color="#d97706",
|
| 121 |
+
logo_url="/logo.png"
|
| 122 |
+
)
|
| 123 |
+
db.add(settings)
|
| 124 |
+
db.commit()
|
| 125 |
+
|
| 126 |
+
# 0. Create Default Admin if matches criteria
|
| 127 |
+
admin_email = "admin@vortex.com"
|
| 128 |
+
admin_user = db.query(User).filter(User.email == admin_email).first()
|
| 129 |
+
if not admin_user:
|
| 130 |
+
print(f">>> [SEED] Creating default admin: {admin_email}")
|
| 131 |
+
admin_user = User(
|
| 132 |
+
email=admin_email,
|
| 133 |
+
name="Platform Administrator",
|
| 134 |
+
password_hash=hash_password("admin123"),
|
| 135 |
+
role=UserRole.ADMIN,
|
| 136 |
+
auth_provider=AuthProvider.LOCAL
|
| 137 |
+
)
|
| 138 |
+
db.add(admin_user)
|
| 139 |
+
db.commit()
|
| 140 |
+
|
| 141 |
+
# 1. Create Categories (Hierarchy)
|
| 142 |
+
all_sub_cats = {} # Map used for product assignment
|
| 143 |
+
|
| 144 |
+
for p_ar, info in CATEGORY_HIERARCHY.items():
|
| 145 |
+
# Create Parent
|
| 146 |
+
parent_cat = db.query(Category).filter(Category.name_ar == p_ar).first()
|
| 147 |
+
if not parent_cat:
|
| 148 |
+
parent_cat = Category(
|
| 149 |
+
name_ar=p_ar,
|
| 150 |
+
name_en=info["name_en"],
|
| 151 |
+
icon=info["icon"]
|
| 152 |
+
)
|
| 153 |
+
db.add(parent_cat)
|
| 154 |
+
db.commit()
|
| 155 |
+
db.refresh(parent_cat)
|
| 156 |
+
|
| 157 |
+
# Add parent to map so products can be assigned directly to it
|
| 158 |
+
all_sub_cats[p_ar] = parent_cat
|
| 159 |
+
all_sub_cats[info["name_en"]] = parent_cat
|
| 160 |
+
|
| 161 |
+
# Create Subs
|
| 162 |
+
for s in info["subs"]:
|
| 163 |
+
sub_cat = db.query(Category).filter(Category.name_ar == s["ar"]).first()
|
| 164 |
+
if not sub_cat:
|
| 165 |
+
sub_cat = Category(
|
| 166 |
+
name_ar=s["ar"],
|
| 167 |
+
name_en=s["en"],
|
| 168 |
+
icon=s.get("icon", SUB_ICONS.get(s["ar"], "📦")),
|
| 169 |
+
parent_id=parent_cat.id
|
| 170 |
+
)
|
| 171 |
+
db.add(sub_cat)
|
| 172 |
+
db.commit()
|
| 173 |
+
db.refresh(sub_cat)
|
| 174 |
+
all_sub_cats[s["ar"]] = sub_cat
|
| 175 |
+
all_sub_cats[s["en"]] = sub_cat # Map both names to the same object
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
API_VERSION = "2026.03.16.V2"
|
| 179 |
+
|
| 180 |
+
possible_paths = [
|
| 181 |
+
# Primary Target (Fixed location inside backend so upload_to_hf works)
|
| 182 |
+
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "data", "extra_products_vortex.json")),
|
| 183 |
+
os.path.abspath("app/data/extra_products_vortex.json"),
|
| 184 |
+
|
| 185 |
+
# Standard Hugging Face / Docker location (WORKDIR /app)
|
| 186 |
+
"/app/app/data/extra_products_vortex.json",
|
| 187 |
+
"/app/data/extra_products_vortex.json",
|
| 188 |
+
|
| 189 |
+
# Fallbacks (Old paths just in case)
|
| 190 |
+
r"c:\react_projects\VortexCommerce\kaggle\extra_products_vortex.json",
|
| 191 |
+
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "kaggle", "extra_products_vortex.json")),
|
| 192 |
+
os.path.abspath("kaggle/extra_products_vortex.json"),
|
| 193 |
+
os.path.abspath("extra_products_vortex.json"),
|
| 194 |
+
]
|
| 195 |
+
|
| 196 |
+
json_path = None
|
| 197 |
+
checked_info = []
|
| 198 |
+
for p in possible_paths:
|
| 199 |
+
exists = os.path.exists(p)
|
| 200 |
+
checked_info.append(f"{p}: {'FOUND' if exists else 'NOT FOUND'}")
|
| 201 |
+
if exists:
|
| 202 |
+
json_path = p
|
| 203 |
+
break
|
| 204 |
+
|
| 205 |
+
if not json_path:
|
| 206 |
+
return AuthResponse(
|
| 207 |
+
isSuccess=False,
|
| 208 |
+
value={
|
| 209 |
+
"message": "Source file not found.",
|
| 210 |
+
"checked": checked_info,
|
| 211 |
+
"version": API_VERSION
|
| 212 |
+
},
|
| 213 |
+
statusCode=404,
|
| 214 |
+
error="File not found"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
try:
|
| 218 |
+
with open(json_path, "r", encoding="utf-8") as f:
|
| 219 |
+
data = json.load(f)
|
| 220 |
+
|
| 221 |
+
if isinstance(data, dict) and "products" in data:
|
| 222 |
+
products_data = data["products"]
|
| 223 |
+
elif isinstance(data, list):
|
| 224 |
+
products_data = data
|
| 225 |
+
else:
|
| 226 |
+
return AuthResponse(isSuccess=False, value={"message": "Invalid JSON format"}, statusCode=400)
|
| 227 |
+
|
| 228 |
+
# Cache existing products
|
| 229 |
+
existing_products_ar = set(r[0] for r in db.execute(text("SELECT name_ar FROM products")).fetchall())
|
| 230 |
+
existing_products_en = set(r[0] for r in db.execute(text("SELECT name_en FROM products")).fetchall())
|
| 231 |
+
|
| 232 |
+
# 3. Create Products
|
| 233 |
+
inserted_count: int = 0
|
| 234 |
+
skipped_count: int = 0
|
| 235 |
+
|
| 236 |
+
total_items = len(products_data)
|
| 237 |
+
if is_background_task:
|
| 238 |
+
import_progress.total = total_items
|
| 239 |
+
import_progress.current = 0
|
| 240 |
+
|
| 241 |
+
for idx, item in enumerate(products_data):
|
| 242 |
+
if is_background_task and idx % 25 == 0:
|
| 243 |
+
import_progress.current = idx
|
| 244 |
+
import_progress.message = f"Processing item {idx} of {total_items}..."
|
| 245 |
+
|
| 246 |
+
if not isinstance(item, dict):
|
| 247 |
+
skipped_count += 1
|
| 248 |
+
continue
|
| 249 |
+
|
| 250 |
+
product_name_ar = item.get("name_ar", "").strip()
|
| 251 |
+
product_name_en = item.get("name_en", "").strip()
|
| 252 |
+
|
| 253 |
+
# 1. Resolve Category (Hierarchical)
|
| 254 |
+
main_cat_ar = item.get("main_category")
|
| 255 |
+
main_cat_en = item.get("main_category_en")
|
| 256 |
+
sub_cat_ar = item.get("category")
|
| 257 |
+
sub_cat_en = item.get("category_en")
|
| 258 |
+
cat_icon = item.get("category_icon", "📦")
|
| 259 |
+
|
| 260 |
+
target_sub_cat = None
|
| 261 |
+
|
| 262 |
+
if main_cat_ar and sub_cat_ar:
|
| 263 |
+
# Ensure Parent exists
|
| 264 |
+
parent = db.query(Category).filter(Category.name_ar == main_cat_ar).first()
|
| 265 |
+
if not parent:
|
| 266 |
+
parent = Category(
|
| 267 |
+
name_ar=main_cat_ar,
|
| 268 |
+
name_en=main_cat_en or main_cat_ar,
|
| 269 |
+
icon=cat_icon
|
| 270 |
+
)
|
| 271 |
+
db.add(parent)
|
| 272 |
+
db.commit()
|
| 273 |
+
db.refresh(parent)
|
| 274 |
+
|
| 275 |
+
# Ensure Sub exists
|
| 276 |
+
sub = db.query(Category).filter(
|
| 277 |
+
Category.name_ar == sub_cat_ar,
|
| 278 |
+
Category.parent_id == parent.id
|
| 279 |
+
).first()
|
| 280 |
+
if not sub:
|
| 281 |
+
sub = Category(
|
| 282 |
+
name_ar=sub_cat_ar,
|
| 283 |
+
name_en=sub_cat_en or sub_cat_ar,
|
| 284 |
+
icon=cat_icon,
|
| 285 |
+
parent_id=parent.id
|
| 286 |
+
)
|
| 287 |
+
db.add(sub)
|
| 288 |
+
db.commit()
|
| 289 |
+
db.refresh(sub)
|
| 290 |
+
target_sub_cat = sub
|
| 291 |
+
else:
|
| 292 |
+
# Fallback for old flat format or missing data
|
| 293 |
+
cat_name = item.get("category_name") or item.get("category")
|
| 294 |
+
if cat_name in SCRAPED_TO_TARGET_MAP:
|
| 295 |
+
cat_name = SCRAPED_TO_TARGET_MAP[cat_name]
|
| 296 |
+
|
| 297 |
+
if not cat_name or cat_name not in all_sub_cats:
|
| 298 |
+
cat_name = "الأجهزة الإلكترونية"
|
| 299 |
+
target_sub_cat = all_sub_cats.get(cat_name)
|
| 300 |
+
|
| 301 |
+
if not target_sub_cat:
|
| 302 |
+
skipped_count += 1
|
| 303 |
+
continue
|
| 304 |
+
|
| 305 |
+
if product_name_ar in existing_products_ar or product_name_en in existing_products_en:
|
| 306 |
+
skipped_count += 1
|
| 307 |
+
continue
|
| 308 |
+
|
| 309 |
+
specs = item.get("specs", {})
|
| 310 |
+
if not isinstance(specs, dict): specs = {}
|
| 311 |
+
|
| 312 |
+
# Handle Variants
|
| 313 |
+
if "variants" not in specs and "options" in item:
|
| 314 |
+
options = item.get("options", [])
|
| 315 |
+
if isinstance(options, list):
|
| 316 |
+
variants = []
|
| 317 |
+
for opt in options:
|
| 318 |
+
if not isinstance(opt, dict): continue
|
| 319 |
+
variants.append({
|
| 320 |
+
"id": opt.get("sku", opt.get("id", "v_default")),
|
| 321 |
+
"name_en": opt.get("name_en", ""),
|
| 322 |
+
"name_ar": opt.get("name_ar", ""),
|
| 323 |
+
"price_modifier": float(opt.get("price", 0)) - float(item.get("price", 0)) if "price" in opt else 0,
|
| 324 |
+
"stock": opt.get("stock", 10),
|
| 325 |
+
"image_url": opt.get("image", opt.get("image_url", ""))
|
| 326 |
+
})
|
| 327 |
+
if variants: specs["variants"] = variants
|
| 328 |
+
|
| 329 |
+
import time
|
| 330 |
+
fallback_slug = f"vortex-{int(time.time()*1000)}-{idx}"
|
| 331 |
+
product_slug = item.get("legacy_ref") or item.get("sku_base") or fallback_slug
|
| 332 |
+
|
| 333 |
+
prod = Product(
|
| 334 |
+
slug=product_slug,
|
| 335 |
+
name_ar=product_name_ar,
|
| 336 |
+
name_en=product_name_en,
|
| 337 |
+
description_ar=item.get("description_ar", ""),
|
| 338 |
+
description_en=item.get("description_en", ""),
|
| 339 |
+
price=float(item.get("price", 0)),
|
| 340 |
+
compare_price=round(float(item.get("compare_price", float(item.get("price", 0)) * 1.3)), 2),
|
| 341 |
+
stock=item.get("stock", random.randint(10, 100)),
|
| 342 |
+
category_id=target_sub_cat.id,
|
| 343 |
+
rating=item.get("rating", 4.5),
|
| 344 |
+
rating_count=item.get("rating_count", 20),
|
| 345 |
+
is_featured=item.get("is_featured", False),
|
| 346 |
+
is_active=item.get("is_active", True),
|
| 347 |
+
specs=specs if specs else None
|
| 348 |
+
)
|
| 349 |
+
db.add(prod)
|
| 350 |
+
db.flush()
|
| 351 |
+
|
| 352 |
+
# Handle Images
|
| 353 |
+
for i, img_url in enumerate(item.get("images", [])):
|
| 354 |
+
# Sanitize URL: Remove complex query parameters that might cause loading issues
|
| 355 |
+
# Extra.com URLs often look like: ...Black?locale=en-GB,en-*,*&$Listing-Product-2x$
|
| 356 |
+
# We want to keep the base image but simplify the request
|
| 357 |
+
clean_url = img_url
|
| 358 |
+
if "media.extra.com" in img_url and "?" in img_url:
|
| 359 |
+
clean_url = img_url.split("?")[0]
|
| 360 |
+
|
| 361 |
+
db.add(ProductImage(
|
| 362 |
+
product_id=prod.id,
|
| 363 |
+
image_url=clean_url,
|
| 364 |
+
alt_text=prod.name_ar,
|
| 365 |
+
sort_order=i
|
| 366 |
+
))
|
| 367 |
+
|
| 368 |
+
inserted_count = inserted_count + 1
|
| 369 |
+
existing_products_ar.add(product_name_ar)
|
| 370 |
+
existing_products_en.add(product_name_en)
|
| 371 |
+
|
| 372 |
+
# Commit every 20 products for real-time progress and stability
|
| 373 |
+
if inserted_count % 20 == 0:
|
| 374 |
+
db.commit()
|
| 375 |
+
print(f">>> [SEED] Progress: {inserted_count} products committed...")
|
| 376 |
+
|
| 377 |
+
# Ensure Demo Coupons
|
| 378 |
+
for code, discount in [("WELCOME10", 10.0), ("VORTEX20", 20.0)]:
|
| 379 |
+
if not db.query(Coupon).filter(Coupon.code == code).first():
|
| 380 |
+
db.add(Coupon(code=code, discount_percent=discount, is_active=True))
|
| 381 |
+
|
| 382 |
+
db.commit()
|
| 383 |
+
print(f">>> [SEED] FINAL: {inserted_count} products inserted.")
|
| 384 |
+
|
| 385 |
+
return AuthResponse(
|
| 386 |
+
isSuccess=True,
|
| 387 |
+
value={
|
| 388 |
+
"message": "تمت عملية المزامنة بنجاح",
|
| 389 |
+
"total_products": len(products_data),
|
| 390 |
+
"inserted_new": inserted_count,
|
| 391 |
+
"skipped": skipped_count,
|
| 392 |
+
"source_file": os.path.basename(json_path),
|
| 393 |
+
"path": json_path,
|
| 394 |
+
"version": API_VERSION
|
| 395 |
+
},
|
| 396 |
+
statusCode=201,
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
except Exception as e:
|
| 400 |
+
db.rollback()
|
| 401 |
+
return AuthResponse(
|
| 402 |
+
isSuccess=False,
|
| 403 |
+
value={"message": str(e)},
|
| 404 |
+
statusCode=500,
|
| 405 |
+
error=str(e)
|
| 406 |
+
)
|
app/api/settings.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
import os
|
| 4 |
+
from app.core.config import settings as app_settings
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from app.db.base import get_db
|
| 9 |
+
from app.models.settings import StoreSettings
|
| 10 |
+
from app.schemas.settings import StoreSettingsResponse, StoreSettingsUpdate
|
| 11 |
+
from app.api.auth import get_current_admin_user
|
| 12 |
+
from app.schemas.api_response import APIResponse
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/settings", tags=["Settings"])
|
| 15 |
+
|
| 16 |
+
@router.get("/", response_model=APIResponse)
|
| 17 |
+
def get_settings(db: Session = Depends(get_db)) -> Any:
|
| 18 |
+
settings = db.query(StoreSettings).first()
|
| 19 |
+
if not settings:
|
| 20 |
+
settings = StoreSettings() # create default
|
| 21 |
+
db.add(settings)
|
| 22 |
+
db.commit()
|
| 23 |
+
db.refresh(settings)
|
| 24 |
+
return APIResponse(
|
| 25 |
+
isSuccess=True,
|
| 26 |
+
value=StoreSettingsResponse.model_validate(settings).model_dump(),
|
| 27 |
+
statusCode=200
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
@router.put("/", response_model=APIResponse)
|
| 31 |
+
def update_settings(
|
| 32 |
+
settings_in: StoreSettingsUpdate,
|
| 33 |
+
db: Session = Depends(get_db),
|
| 34 |
+
current_admin=Depends(get_current_admin_user)
|
| 35 |
+
) -> Any:
|
| 36 |
+
settings = db.query(StoreSettings).first()
|
| 37 |
+
if not settings:
|
| 38 |
+
settings = StoreSettings(**settings_in.model_dump(exclude_unset=True))
|
| 39 |
+
db.add(settings)
|
| 40 |
+
else:
|
| 41 |
+
update_data = settings_in.model_dump(exclude_unset=True)
|
| 42 |
+
for field, value in update_data.items():
|
| 43 |
+
setattr(settings, field, value)
|
| 44 |
+
|
| 45 |
+
db.commit()
|
| 46 |
+
db.refresh(settings)
|
| 47 |
+
return APIResponse(
|
| 48 |
+
isSuccess=True,
|
| 49 |
+
value=StoreSettingsResponse.model_validate(settings).model_dump(),
|
| 50 |
+
statusCode=200
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
@router.post("/upload-image", response_model=APIResponse)
|
| 54 |
+
def upload_settings_image(
|
| 55 |
+
file: UploadFile = File(...),
|
| 56 |
+
db: Session = Depends(get_db),
|
| 57 |
+
current_admin=Depends(get_current_admin_user)
|
| 58 |
+
) -> Any:
|
| 59 |
+
# Convert file to base64 string
|
| 60 |
+
file_bytes = file.file.read()
|
| 61 |
+
|
| 62 |
+
import base64
|
| 63 |
+
ext = os.path.splitext(file.filename)[1].lower()
|
| 64 |
+
mime_type = "image/jpeg"
|
| 65 |
+
if ext in [".png"]:
|
| 66 |
+
mime_type = "image/png"
|
| 67 |
+
elif ext in [".gif"]:
|
| 68 |
+
mime_type = "image/gif"
|
| 69 |
+
elif ext in [".webp"]:
|
| 70 |
+
mime_type = "image/webp"
|
| 71 |
+
|
| 72 |
+
b64_encoded = base64.b64encode(file_bytes).decode('utf-8')
|
| 73 |
+
data_uri = f"data:{mime_type};base64,{b64_encoded}"
|
| 74 |
+
|
| 75 |
+
return APIResponse(
|
| 76 |
+
isSuccess=True,
|
| 77 |
+
value={"url": data_uri},
|
| 78 |
+
statusCode=200
|
| 79 |
+
)
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Core package
|
app/core/config.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings
|
| 2 |
+
from typing import List
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
class Settings(BaseSettings):
|
| 8 |
+
# Base directory for persistent data (e.g., /data for Hugging Face persistent storage)
|
| 9 |
+
DATA_DIR: str = os.getenv("DATA_DIR", ".")
|
| 10 |
+
DATABASE_URL: str = os.getenv("DATABASE_URL", f"sqlite:///{os.path.join(os.getenv('DATA_DIR', '.'), 'vortex.db')}")
|
| 11 |
+
TURSO_AUTH_TOKEN: str = os.getenv("TURSO_AUTH_TOKEN", "")
|
| 12 |
+
|
| 13 |
+
JWT_SECRET: str = "change-me-in-production"
|
| 14 |
+
JWT_ALGORITHM: str = "HS256"
|
| 15 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
| 16 |
+
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
| 17 |
+
GOOGLE_CLIENT_ID: str = ""
|
| 18 |
+
GOOGLE_CLIENT_SECRET: str = ""
|
| 19 |
+
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:3001,https://ofoq-commerce.vercel.app"
|
| 20 |
+
|
| 21 |
+
@property
|
| 22 |
+
def cors_origins_list(self) -> List[str]:
|
| 23 |
+
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
| 24 |
+
|
| 25 |
+
model_config = {
|
| 26 |
+
"env_file": ".env",
|
| 27 |
+
"env_file_encoding": "utf-8",
|
| 28 |
+
"extra": "ignore",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
settings = Settings()
|
app/core/logging.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 6 |
+
AUDIT_LOG_FILE = os.path.join(BASE_DIR, "backend", "logs", "audit_trail.json")
|
| 7 |
+
|
| 8 |
+
def log_audit_event(action: str, details: str, metadata: dict = None, severity: str = "INFO"):
|
| 9 |
+
"""Log an event to the system console for cloud observability."""
|
| 10 |
+
try:
|
| 11 |
+
entry = {
|
| 12 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 13 |
+
"action": action,
|
| 14 |
+
"details": details,
|
| 15 |
+
"severity": severity,
|
| 16 |
+
"metadata": metadata or {"system_context": "automated_event"}
|
| 17 |
+
}
|
| 18 |
+
# In stateless environment, we log to stdout for cloud logging systems (like HF logs)
|
| 19 |
+
print(f">>> [AUDIT] {json.dumps(entry, ensure_ascii=False)}")
|
| 20 |
+
except Exception as e:
|
| 21 |
+
print(f"Error logging audit event: {e}")
|
| 22 |
+
except Exception as e:
|
| 23 |
+
print(f"Error logging audit event: {e}")
|
app/core/notifications.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from typing import Dict, List, Any, Optional
|
| 3 |
+
import json
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
|
| 6 |
+
class NotificationManager:
|
| 7 |
+
def __init__(self):
|
| 8 |
+
# Dictionary of active queues: {user_id: [Queue, Queue, ...]}
|
| 9 |
+
# We use a list of queues per user to support multiple tabs/sessions
|
| 10 |
+
self.active_connections: Dict[int, List[asyncio.Queue]] = {}
|
| 11 |
+
self.lock = asyncio.Lock()
|
| 12 |
+
|
| 13 |
+
async def subscribe(self, user_id: int) -> asyncio.Queue:
|
| 14 |
+
"""Create a new queue for a user's SSE connection."""
|
| 15 |
+
queue = asyncio.Queue()
|
| 16 |
+
async with self.lock:
|
| 17 |
+
if user_id not in self.active_connections:
|
| 18 |
+
self.active_connections[user_id] = []
|
| 19 |
+
self.active_connections[user_id].append(queue)
|
| 20 |
+
return queue
|
| 21 |
+
|
| 22 |
+
async def unsubscribe(self, user_id: int, queue: asyncio.Queue):
|
| 23 |
+
"""Remove a queue when an SSE connection closes."""
|
| 24 |
+
async with self.lock:
|
| 25 |
+
if user_id in self.active_connections:
|
| 26 |
+
if queue in self.active_connections[user_id]:
|
| 27 |
+
self.active_connections[user_id].remove(queue)
|
| 28 |
+
if not self.active_connections[user_id]:
|
| 29 |
+
self.active_connections.pop(user_id, None)
|
| 30 |
+
|
| 31 |
+
async def broadcast(self, notification_data: Dict[str, Any], target_user_id: Optional[int] = None):
|
| 32 |
+
"""
|
| 33 |
+
Send a notification to active subscribers.
|
| 34 |
+
If target_user_id is None, broadcast to ALL admins (currently all admins see all notifications).
|
| 35 |
+
"""
|
| 36 |
+
# Prepare the message
|
| 37 |
+
# Convert datetime to string if present
|
| 38 |
+
if isinstance(notification_data.get("created_at"), datetime):
|
| 39 |
+
notification_data["created_at"] = notification_data["created_at"].isoformat()
|
| 40 |
+
|
| 41 |
+
message = json.dumps(notification_data)
|
| 42 |
+
|
| 43 |
+
async with self.lock:
|
| 44 |
+
if target_user_id:
|
| 45 |
+
# Send to specific user
|
| 46 |
+
if target_user_id in self.active_connections:
|
| 47 |
+
for queue in self.active_connections[target_user_id]:
|
| 48 |
+
await queue.put(message)
|
| 49 |
+
else:
|
| 50 |
+
# Broadcast to everyone (Admins)
|
| 51 |
+
for user_queues in self.active_connections.values():
|
| 52 |
+
for queue in user_queues:
|
| 53 |
+
await queue.put(message)
|
| 54 |
+
|
| 55 |
+
# Global Manager Instance
|
| 56 |
+
notification_manager = NotificationManager()
|
app/core/security.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta, timezone
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
from jose import JWTError, jwt
|
| 5 |
+
from fastapi import Depends, HTTPException, status
|
| 6 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 7 |
+
|
| 8 |
+
from app.core.config import settings
|
| 9 |
+
|
| 10 |
+
# Use bcrypt directly to avoid passlib/bcrypt version incompatibility
|
| 11 |
+
import bcrypt as _bcrypt
|
| 12 |
+
|
| 13 |
+
security_scheme = HTTPBearer()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def hash_password(password: str) -> str:
|
| 17 |
+
return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 21 |
+
try:
|
| 22 |
+
return _bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
| 23 |
+
except Exception:
|
| 24 |
+
return False
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 28 |
+
to_encode = data.copy()
|
| 29 |
+
expire = datetime.now(timezone.utc) + (
|
| 30 |
+
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 31 |
+
)
|
| 32 |
+
to_encode.update({"exp": expire, "type": "access"})
|
| 33 |
+
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def create_refresh_token(data: dict) -> str:
|
| 37 |
+
to_encode = data.copy()
|
| 38 |
+
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
| 39 |
+
to_encode.update({"exp": expire, "type": "refresh"})
|
| 40 |
+
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def decode_token(token: str) -> dict:
|
| 44 |
+
try:
|
| 45 |
+
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
|
| 46 |
+
return payload
|
| 47 |
+
except JWTError:
|
| 48 |
+
raise HTTPException(
|
| 49 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 50 |
+
detail="Invalid or expired token",
|
| 51 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def get_current_user(
|
| 56 |
+
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
|
| 57 |
+
):
|
| 58 |
+
payload = decode_token(credentials.credentials)
|
| 59 |
+
if payload.get("type") != "access":
|
| 60 |
+
raise HTTPException(
|
| 61 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 62 |
+
detail="Invalid token type",
|
| 63 |
+
)
|
| 64 |
+
user_id = payload.get("sub")
|
| 65 |
+
if user_id is None:
|
| 66 |
+
raise HTTPException(
|
| 67 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 68 |
+
detail="Invalid token payload",
|
| 69 |
+
)
|
| 70 |
+
return int(user_id)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
async def get_optional_current_user(
|
| 74 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
|
| 75 |
+
) -> Optional[int]:
|
| 76 |
+
if credentials is None:
|
| 77 |
+
return None
|
| 78 |
+
try:
|
| 79 |
+
payload = decode_token(credentials.credentials)
|
| 80 |
+
if payload.get("type") != "access":
|
| 81 |
+
return None
|
| 82 |
+
user_id = payload.get("sub")
|
| 83 |
+
if user_id is None:
|
| 84 |
+
return None
|
| 85 |
+
return int(user_id)
|
| 86 |
+
except Exception:
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
async def get_current_user_sse(
|
| 91 |
+
token: Optional[str] = None,
|
| 92 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
|
| 93 |
+
) -> int:
|
| 94 |
+
"""
|
| 95 |
+
Special version of get_current_user that also checks query parameters for the token.
|
| 96 |
+
Required for EventSource (SSE) which doesn't support custom headers easily.
|
| 97 |
+
"""
|
| 98 |
+
actual_token = token
|
| 99 |
+
if not actual_token and credentials:
|
| 100 |
+
actual_token = credentials.credentials
|
| 101 |
+
|
| 102 |
+
if not actual_token:
|
| 103 |
+
raise HTTPException(
|
| 104 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 105 |
+
detail="Not authenticated",
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
payload = decode_token(actual_token)
|
| 109 |
+
user_id = payload.get("sub")
|
| 110 |
+
if not user_id:
|
| 111 |
+
raise HTTPException(
|
| 112 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 113 |
+
detail="Invalid token payload",
|
| 114 |
+
)
|
| 115 |
+
return int(user_id)
|
app/core/state.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class ImportProgress:
|
| 2 |
+
status: str = "idle" # idle, running, completed, error
|
| 3 |
+
total: int = 0
|
| 4 |
+
current: int = 0
|
| 5 |
+
message: str = ""
|
| 6 |
+
|
| 7 |
+
import_progress = ImportProgress()
|
app/data/extra_products_vortex.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:309c916551a0bbf41e665f02d94eab8d5f593c639541d33d5a5d13bf8d4d8b4e
|
| 3 |
+
size 2217907
|
app/db/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# DB package
|
app/db/base.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
| 3 |
+
|
| 4 |
+
from app.core.config import settings
|
| 5 |
+
|
| 6 |
+
# Check for Turso remote connection
|
| 7 |
+
is_turso = "turso.io" in settings.DATABASE_URL and settings.TURSO_AUTH_TOKEN
|
| 8 |
+
|
| 9 |
+
if is_turso:
|
| 10 |
+
# Extract hostname
|
| 11 |
+
hostname = settings.DATABASE_URL
|
| 12 |
+
if "://" in hostname:
|
| 13 |
+
hostname = hostname.split("://")[1]
|
| 14 |
+
if "?" in hostname:
|
| 15 |
+
hostname = hostname.split("?")[0]
|
| 16 |
+
|
| 17 |
+
from sqlalchemy.pool import NullPool
|
| 18 |
+
|
| 19 |
+
# Proxy to satisfy SQLAlchemy's requirement for create_function on SQLite connections
|
| 20 |
+
class LibsqlConnectionProxy:
|
| 21 |
+
def __init__(self, conn):
|
| 22 |
+
# Use __dict__ to avoid infinite recursion with __setattr__
|
| 23 |
+
self.__dict__["_conn"] = conn
|
| 24 |
+
|
| 25 |
+
def __getattr__(self, name):
|
| 26 |
+
return getattr(self._conn, name)
|
| 27 |
+
|
| 28 |
+
def __setattr__(self, name, value):
|
| 29 |
+
if name == "_conn":
|
| 30 |
+
self.__dict__["_conn"] = value
|
| 31 |
+
else:
|
| 32 |
+
setattr(self._conn, name, value)
|
| 33 |
+
|
| 34 |
+
def create_function(self, *args, **kwargs):
|
| 35 |
+
# libsql doesn't support create_function over HTTP, but SQLAlchemy expects it
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
# Force HTTPS for stability on HF Spaces (avoids 505/WebSocket handshake errors)
|
| 39 |
+
def create_libsql_connection():
|
| 40 |
+
import libsql
|
| 41 |
+
url = f"https://{hostname}"
|
| 42 |
+
print(f">>> [DB] Establishing SECURE HTTP connection to Turso: {hostname}")
|
| 43 |
+
conn = libsql.connect(url, auth_token=settings.TURSO_AUTH_TOKEN)
|
| 44 |
+
return LibsqlConnectionProxy(conn)
|
| 45 |
+
|
| 46 |
+
# Use NullPool for Turso HTTPS to avoid "detached connection fairy" errors in multi-thread env
|
| 47 |
+
engine = create_engine(
|
| 48 |
+
"sqlite+libsql://",
|
| 49 |
+
creator=create_libsql_connection,
|
| 50 |
+
poolclass=NullPool,
|
| 51 |
+
echo=False,
|
| 52 |
+
)
|
| 53 |
+
else:
|
| 54 |
+
# Standard local SQLite
|
| 55 |
+
connect_args = {}
|
| 56 |
+
if "sqlite" in settings.DATABASE_URL or "libsql" in settings.DATABASE_URL:
|
| 57 |
+
connect_args["check_same_thread"] = False
|
| 58 |
+
|
| 59 |
+
engine = create_engine(
|
| 60 |
+
settings.DATABASE_URL,
|
| 61 |
+
pool_size=20,
|
| 62 |
+
max_overflow=max(10, 20), # Allow some overflow during peak
|
| 63 |
+
pool_timeout=30,
|
| 64 |
+
pool_recycle=1800,
|
| 65 |
+
connect_args=connect_args,
|
| 66 |
+
echo=False,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class Base(DeclarativeBase):
|
| 73 |
+
pass
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def get_db():
|
| 77 |
+
db = SessionLocal()
|
| 78 |
+
try:
|
| 79 |
+
yield db
|
| 80 |
+
finally:
|
| 81 |
+
db.close()
|
app/main.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
|
| 4 |
+
from app.core.config import settings
|
| 5 |
+
from app.db.base import Base, engine
|
| 6 |
+
from app.api.auth import router as auth_router
|
| 7 |
+
from app.api.products import router as products_router
|
| 8 |
+
from app.api.categories import router as categories_router
|
| 9 |
+
from app.api.cart import router as cart_router
|
| 10 |
+
from app.api.orders import router as orders_router
|
| 11 |
+
from app.api.seed import router as seed_router
|
| 12 |
+
from app.api.ai_search import router as ai_search_router
|
| 13 |
+
from app.api.home import router as home_router
|
| 14 |
+
from app.api.admin import router as admin_router
|
| 15 |
+
from app.api.notifications import router as notifications_router
|
| 16 |
+
from app.api.external import router as external_router
|
| 17 |
+
from app.api.settings import router as settings_router
|
| 18 |
+
from app.api.analytics import router as analytics_router
|
| 19 |
+
|
| 20 |
+
# Import models to register them with Base
|
| 21 |
+
import app.models.user # noqa: F401
|
| 22 |
+
import app.models.product # noqa: F401
|
| 23 |
+
import app.models.cart # noqa: F401
|
| 24 |
+
import app.models.order # noqa: F401
|
| 25 |
+
import app.models.notification # noqa: F401
|
| 26 |
+
import app.models.settings # noqa: F401
|
| 27 |
+
import app.models.home # noqa: F401
|
| 28 |
+
import app.models.analytics # noqa: F401
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
from contextlib import asynccontextmanager
|
| 32 |
+
|
| 33 |
+
@asynccontextmanager
|
| 34 |
+
async def lifespan(app: FastAPI):
|
| 35 |
+
# Startup logic
|
| 36 |
+
import asyncio
|
| 37 |
+
from app.db.base import SessionLocal, Base, engine
|
| 38 |
+
|
| 39 |
+
# 1. Ensure tables exist (fast)
|
| 40 |
+
print(">>> [DB] Ensuring tables exist...")
|
| 41 |
+
Base.metadata.create_all(bind=engine)
|
| 42 |
+
|
| 43 |
+
# 1.5 Auto-migrate missing columns for orders table
|
| 44 |
+
print(">>> [DB] Auto-migrating orders table...")
|
| 45 |
+
try:
|
| 46 |
+
from sqlalchemy import inspect, text
|
| 47 |
+
inspector = inspect(engine)
|
| 48 |
+
if inspector.has_table("orders"):
|
| 49 |
+
columns_exist = [col['name'] for col in inspector.get_columns('orders')]
|
| 50 |
+
|
| 51 |
+
columns_to_add = [
|
| 52 |
+
("ip_address", "VARCHAR(50)"),
|
| 53 |
+
("user_agent", "TEXT"),
|
| 54 |
+
("browser", "VARCHAR(100)"),
|
| 55 |
+
("os", "VARCHAR(100)"),
|
| 56 |
+
("device_type", "VARCHAR(50)"),
|
| 57 |
+
("location_data", "TEXT"),
|
| 58 |
+
("client_metadata", "TEXT")
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
with engine.begin() as conn:
|
| 62 |
+
for col_name, col_type in columns_to_add:
|
| 63 |
+
if col_name not in columns_exist:
|
| 64 |
+
conn.execute(text(f"ALTER TABLE orders ADD COLUMN {col_name} {col_type}"))
|
| 65 |
+
print(f">>> [DB] Added column {col_name} to orders table.")
|
| 66 |
+
except Exception as e:
|
| 67 |
+
print(f">>> [DB] Auto-migration error (orders): {e}")
|
| 68 |
+
|
| 69 |
+
# 1.6 Auto-migrate missing columns for order_items table
|
| 70 |
+
print(">>> [DB] Auto-migrating order_items table...")
|
| 71 |
+
try:
|
| 72 |
+
from sqlalchemy import inspect, text
|
| 73 |
+
inspector = inspect(engine)
|
| 74 |
+
if inspector.has_table("order_items"):
|
| 75 |
+
columns_exist = [col['name'] for col in inspector.get_columns('order_items')]
|
| 76 |
+
|
| 77 |
+
with engine.begin() as conn:
|
| 78 |
+
if "variant_id" not in columns_exist:
|
| 79 |
+
conn.execute(text("ALTER TABLE order_items ADD COLUMN variant_id VARCHAR(50)"))
|
| 80 |
+
print(">>> [DB] Added column variant_id to order_items table.")
|
| 81 |
+
if "variant_label" not in columns_exist:
|
| 82 |
+
conn.execute(text("ALTER TABLE order_items ADD COLUMN variant_label VARCHAR(255)"))
|
| 83 |
+
print(">>> [DB] Added column variant_label to order_items table.")
|
| 84 |
+
except Exception as e:
|
| 85 |
+
print(f">>> [DB] Auto-migration error (order_items): {e}")
|
| 86 |
+
|
| 87 |
+
# 1.7 Ensure Analytics tables exist
|
| 88 |
+
print(">>> [DB] Ensuring analytics tables exist...")
|
| 89 |
+
try:
|
| 90 |
+
from app.models.analytics import VisitorLog, AnalyticsEvent
|
| 91 |
+
Base.metadata.create_all(bind=engine, tables=[VisitorLog.__table__, AnalyticsEvent.__table__])
|
| 92 |
+
except Exception as e:
|
| 93 |
+
print(f">>> [DB] Analytics table creation error: {e}")
|
| 94 |
+
|
| 95 |
+
# 2. Run Seeding in the Background to avoid HF Startup Timeout
|
| 96 |
+
async def run_background_seed():
|
| 97 |
+
from app.models.product import Product
|
| 98 |
+
from app.api.seed import seed_database
|
| 99 |
+
import asyncio
|
| 100 |
+
|
| 101 |
+
# Use a new session for the background thread
|
| 102 |
+
db = SessionLocal()
|
| 103 |
+
try:
|
| 104 |
+
# Check if we have any products
|
| 105 |
+
print(">>> [BOOTSTRAP] Checking database status...")
|
| 106 |
+
count = db.query(Product).count()
|
| 107 |
+
if count == 0:
|
| 108 |
+
print(">>> [BOOTSTRAP] Database is empty. Starting BACKGROUND Auto-seeding...")
|
| 109 |
+
# Run the sync seed function in a separate thread
|
| 110 |
+
result = await asyncio.to_thread(seed_database, db=db)
|
| 111 |
+
if result.isSuccess:
|
| 112 |
+
print(f">>> [BOOTSTRAP] Seeding SUCCESS: {result.value.get('message', 'Complete')}")
|
| 113 |
+
else:
|
| 114 |
+
print(f">>> [BOOTSTRAP] Seeding FAILED: {result.error} | {result.value.get('message')}")
|
| 115 |
+
print(f">>> [BOOTSTRAP] Paths checked: {result.value.get('checked')}")
|
| 116 |
+
else:
|
| 117 |
+
print(f">>> [BOOTSTRAP] Database already has {count} products. Skipping seed.")
|
| 118 |
+
except Exception as e:
|
| 119 |
+
print(f">>> [BOOTSTRAP] Background Seeding error: {e}")
|
| 120 |
+
finally:
|
| 121 |
+
db.close()
|
| 122 |
+
|
| 123 |
+
# Create the task but don't await it here
|
| 124 |
+
asyncio.create_task(run_background_seed())
|
| 125 |
+
|
| 126 |
+
print(">>> [MAIN] Startup sequence finished. Yielding to health checks...")
|
| 127 |
+
yield
|
| 128 |
+
# Shutdown logic (optional)
|
| 129 |
+
# Shutdown logic (optional)
|
| 130 |
+
|
| 131 |
+
def create_app() -> FastAPI:
|
| 132 |
+
app = FastAPI(
|
| 133 |
+
title="VortexCommerce API",
|
| 134 |
+
description="AI-Powered Bilingual E-Commerce Platform",
|
| 135 |
+
version="1.0.0",
|
| 136 |
+
docs_url="/docs",
|
| 137 |
+
redoc_url="/redoc",
|
| 138 |
+
lifespan=lifespan
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# CORS
|
| 142 |
+
app.add_middleware(
|
| 143 |
+
CORSMiddleware,
|
| 144 |
+
allow_origins=settings.cors_origins_list,
|
| 145 |
+
allow_credentials=True,
|
| 146 |
+
allow_methods=["*"],
|
| 147 |
+
allow_headers=["*"],
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# Routers
|
| 151 |
+
app.include_router(auth_router, prefix="/api")
|
| 152 |
+
app.include_router(products_router, prefix="/api")
|
| 153 |
+
app.include_router(categories_router, prefix="/api")
|
| 154 |
+
app.include_router(cart_router, prefix="/api")
|
| 155 |
+
app.include_router(orders_router, prefix="/api")
|
| 156 |
+
app.include_router(seed_router, prefix="/api")
|
| 157 |
+
app.include_router(external_router, prefix="/api")
|
| 158 |
+
app.include_router(ai_search_router, prefix="/api")
|
| 159 |
+
app.include_router(admin_router, prefix="/api")
|
| 160 |
+
app.include_router(notifications_router, prefix="/api")
|
| 161 |
+
print(">>> [MAIN] Registering analytics router at /api/analytics")
|
| 162 |
+
app.include_router(analytics_router, prefix="/api")
|
| 163 |
+
|
| 164 |
+
app.include_router(settings_router, prefix="/api")
|
| 165 |
+
app.include_router(home_router, prefix="/api")
|
| 166 |
+
|
| 167 |
+
VERSION = "2.0.0-FINAL-CATALOG"
|
| 168 |
+
|
| 169 |
+
@app.get("/", tags=["Health"])
|
| 170 |
+
async def root():
|
| 171 |
+
"""FORCE RELOAD Health Check"""
|
| 172 |
+
return {
|
| 173 |
+
"status": "online",
|
| 174 |
+
"service": "VortexCommerce-CORE-API",
|
| 175 |
+
"version": VERSION,
|
| 176 |
+
"ts": "2026-03-16-T-13-45",
|
| 177 |
+
"msg": "New container is active"
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
@app.get("/health", tags=["Health"])
|
| 181 |
+
async def health_check():
|
| 182 |
+
return {
|
| 183 |
+
"isSuccess": True,
|
| 184 |
+
"value": {
|
| 185 |
+
"status": "healthy",
|
| 186 |
+
"service": "VortexCommerce API",
|
| 187 |
+
"version": VERSION
|
| 188 |
+
},
|
| 189 |
+
"statusCode": 200,
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
print(f">>> [MAIN] Application initialized. Version: {VERSION}")
|
| 193 |
+
return app
|
| 194 |
+
|
| 195 |
+
app = create_app()
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Models package
|
app/models/analytics.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON
|
| 3 |
+
from sqlalchemy.orm import relationship
|
| 4 |
+
from app.db.base import Base
|
| 5 |
+
|
| 6 |
+
class VisitorLog(Base):
|
| 7 |
+
__tablename__ = "visitor_logs"
|
| 8 |
+
|
| 9 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 10 |
+
session_id = Column(String(50), nullable=True, index=True)
|
| 11 |
+
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
| 12 |
+
|
| 13 |
+
ip_address = Column(String(50), nullable=True)
|
| 14 |
+
user_agent = Column(Text, nullable=True)
|
| 15 |
+
browser = Column(String(100), nullable=True)
|
| 16 |
+
os = Column(String(100), nullable=True)
|
| 17 |
+
device_type = Column(String(50), nullable=True)
|
| 18 |
+
|
| 19 |
+
# Location data (JSON string or dict depending on DB)
|
| 20 |
+
location_data = Column(JSON, nullable=True)
|
| 21 |
+
|
| 22 |
+
# Extra metadata (screen resolution, language, etc.)
|
| 23 |
+
client_metadata = Column(JSON, nullable=True)
|
| 24 |
+
|
| 25 |
+
first_seen = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 26 |
+
last_seen = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 27 |
+
|
| 28 |
+
events = relationship("AnalyticsEvent", back_populates="visitor", cascade="all, delete-orphan")
|
| 29 |
+
user = relationship("User")
|
| 30 |
+
|
| 31 |
+
class AnalyticsEvent(Base):
|
| 32 |
+
__tablename__ = "analytics_events"
|
| 33 |
+
|
| 34 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 35 |
+
visitor_id = Column(Integer, ForeignKey("visitor_logs.id", ondelete="CASCADE"), nullable=False)
|
| 36 |
+
|
| 37 |
+
event_type = Column(String(50), nullable=False, index=True) # PAGE_VIEW, ADD_TO_CART, SEARCH, etc.
|
| 38 |
+
page_url = Column(Text, nullable=True)
|
| 39 |
+
page_title = Column(Text, nullable=True)
|
| 40 |
+
|
| 41 |
+
# Event data (e.g. {product_id: 123} for ADD_TO_CART)
|
| 42 |
+
event_data = Column(JSON, nullable=True)
|
| 43 |
+
|
| 44 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 45 |
+
|
| 46 |
+
visitor = relationship("VisitorLog", back_populates="events")
|
app/models/cart.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Boolean
|
| 3 |
+
from sqlalchemy.orm import relationship
|
| 4 |
+
|
| 5 |
+
from app.db.base import Base
|
| 6 |
+
|
| 7 |
+
class Coupon(Base):
|
| 8 |
+
__tablename__ = "coupons"
|
| 9 |
+
|
| 10 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 11 |
+
code = Column(String(50), unique=True, index=True, nullable=False)
|
| 12 |
+
discount_percent = Column(Float, nullable=False)
|
| 13 |
+
is_active = Column(Boolean, default=True, nullable=False)
|
| 14 |
+
expires_at = Column(DateTime, nullable=True)
|
| 15 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 16 |
+
|
| 17 |
+
carts = relationship("Cart", back_populates="coupon")
|
| 18 |
+
|
| 19 |
+
class Cart(Base):
|
| 20 |
+
__tablename__ = "carts"
|
| 21 |
+
|
| 22 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 23 |
+
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
| 24 |
+
session_id = Column(String(255), unique=True, index=True, nullable=True)
|
| 25 |
+
coupon_id = Column(Integer, ForeignKey("coupons.id", ondelete="SET NULL"), nullable=True)
|
| 26 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 27 |
+
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
| 28 |
+
|
| 29 |
+
user = relationship("User", backref="cart")
|
| 30 |
+
coupon = relationship("Coupon", back_populates="carts")
|
| 31 |
+
items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan")
|
| 32 |
+
|
| 33 |
+
class CartItem(Base):
|
| 34 |
+
__tablename__ = "cart_items"
|
| 35 |
+
|
| 36 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 37 |
+
cart_id = Column(Integer, ForeignKey("carts.id", ondelete="CASCADE"), nullable=False)
|
| 38 |
+
product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
|
| 39 |
+
variant_label = Column(String(100), nullable=True) # Store variant name (e.g. "Space Gray / 128GB")
|
| 40 |
+
variant_id = Column(String(50), nullable=True) # Store variant ID for easier matching
|
| 41 |
+
quantity = Column(Integer, default=1, nullable=False)
|
| 42 |
+
unit_price = Column(Float, nullable=False) # snapshot of price when added/updated
|
| 43 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 44 |
+
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
| 45 |
+
|
| 46 |
+
cart = relationship("Cart", back_populates="items")
|
| 47 |
+
product = relationship("Product")
|
app/models/home.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
from sqlalchemy import Column, Integer, String, Boolean, JSON, DateTime
|
| 3 |
+
from app.db.base import Base
|
| 4 |
+
|
| 5 |
+
class HomeSection(Base):
|
| 6 |
+
__tablename__ = "home_sections"
|
| 7 |
+
|
| 8 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 9 |
+
title_ar = Column(String(255), nullable=False)
|
| 10 |
+
title_en = Column(String(255), nullable=False)
|
| 11 |
+
subtitle_ar = Column(String(500), nullable=True)
|
| 12 |
+
subtitle_en = Column(String(500), nullable=True)
|
| 13 |
+
|
| 14 |
+
# Types: "AUTOMATIC" (based on rule) or "MANUAL" (specific product IDs)
|
| 15 |
+
section_type = Column(String(50), default="AUTOMATIC", nullable=False)
|
| 16 |
+
|
| 17 |
+
# JSON rule for automatic: {"sort_by": "created_at", "limit": 8, "has_discount": true, "category_id": 1}
|
| 18 |
+
rule = Column(JSON, nullable=True)
|
| 19 |
+
|
| 20 |
+
# List of product IDs for manual: [1001, 1002, 1003]
|
| 21 |
+
selected_product_ids = Column(JSON, nullable=True)
|
| 22 |
+
|
| 23 |
+
display_order = Column(Integer, default=0, nullable=False)
|
| 24 |
+
is_active = Column(Boolean, default=True, nullable=False)
|
| 25 |
+
|
| 26 |
+
# UI styling
|
| 27 |
+
color_theme = Column(String(50), default="emerald", nullable=False) # emerald, amber, red, blue, etc.
|
| 28 |
+
icon = Column(String(50), nullable=True) # Emoji or icon name
|
| 29 |
+
|
| 30 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 31 |
+
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
app/models/notification.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON
|
| 4 |
+
from app.db.base import Base
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Notification(Base):
|
| 8 |
+
__tablename__ = "notifications"
|
| 9 |
+
|
| 10 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 11 |
+
type = Column(String(50), nullable=False) # checkout_shipping, checkout_payment, checkout_complete
|
| 12 |
+
title = Column(String(255), nullable=False)
|
| 13 |
+
message = Column(Text, nullable=True)
|
| 14 |
+
data = Column(JSON, nullable=True) # extra context (cart total, city, etc.)
|
| 15 |
+
is_read = Column(Boolean, default=False, nullable=False)
|
| 16 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
app/models/order.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, Boolean, Enum as SQLEnum
|
| 4 |
+
from sqlalchemy.orm import relationship
|
| 5 |
+
import enum
|
| 6 |
+
|
| 7 |
+
from app.db.base import Base
|
| 8 |
+
|
| 9 |
+
class OrderStatus(str, enum.Enum):
|
| 10 |
+
PENDING = "pending"
|
| 11 |
+
PROCESSING = "processing"
|
| 12 |
+
PREPARING = "preparing"
|
| 13 |
+
SHIPPED = "shipped"
|
| 14 |
+
DELIVERED = "delivered"
|
| 15 |
+
CANCELLED = "cancelled"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class Order(Base):
|
| 19 |
+
__tablename__ = "orders"
|
| 20 |
+
|
| 21 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 22 |
+
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) # Nullable for guest checkout
|
| 23 |
+
status = Column(SQLEnum(OrderStatus), default=OrderStatus.PENDING, nullable=False)
|
| 24 |
+
total_price = Column(Float, nullable=False)
|
| 25 |
+
shipping_cost = Column(Float, nullable=False, default=0.0)
|
| 26 |
+
tax = Column(Float, nullable=False, default=0.0)
|
| 27 |
+
guest_email = Column(String(255), nullable=True)
|
| 28 |
+
guest_phone = Column(String(50), nullable=True)
|
| 29 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 30 |
+
|
| 31 |
+
# Address snapshot for order stability
|
| 32 |
+
shipping_address_id = Column(Integer, ForeignKey("addresses.id", ondelete="SET NULL"), nullable=True)
|
| 33 |
+
|
| 34 |
+
# Client Metadata (Fraud detection & Analytics)
|
| 35 |
+
ip_address = Column(String(50), nullable=True)
|
| 36 |
+
user_agent = Column(Text, nullable=True)
|
| 37 |
+
browser = Column(String(100), nullable=True)
|
| 38 |
+
os = Column(String(100), nullable=True)
|
| 39 |
+
device_type = Column(String(50), nullable=True)
|
| 40 |
+
location_data = Column(Text, nullable=True) # JSON string of geo-info
|
| 41 |
+
client_metadata = Column(Text, nullable=True) # Extra info like screen res, lang
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
| 45 |
+
payment = relationship("Payment", back_populates="order", uselist=False, cascade="all, delete-orphan")
|
| 46 |
+
payment_details = relationship("PaymentDetail", back_populates="order", cascade="all, delete-orphan")
|
| 47 |
+
user = relationship("User")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class OrderItem(Base):
|
| 51 |
+
__tablename__ = "order_items"
|
| 52 |
+
|
| 53 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 54 |
+
order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
|
| 55 |
+
product_id = Column(Integer, ForeignKey("products.id", ondelete="SET NULL"), nullable=True)
|
| 56 |
+
variant_id = Column(String(50), nullable=True)
|
| 57 |
+
variant_label = Column(String(255), nullable=True)
|
| 58 |
+
price = Column(Float, nullable=False) # Historical price at time of purchase
|
| 59 |
+
quantity = Column(Integer, nullable=False, default=1)
|
| 60 |
+
|
| 61 |
+
order = relationship("Order", back_populates="items")
|
| 62 |
+
product = relationship("Product")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class PaymentStatus(str, enum.Enum):
|
| 66 |
+
PENDING = "pending"
|
| 67 |
+
SUCCESS = "success"
|
| 68 |
+
FAILED = "failed"
|
| 69 |
+
REFUNDED = "refunded"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class Payment(Base):
|
| 73 |
+
__tablename__ = "payments"
|
| 74 |
+
|
| 75 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 76 |
+
order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False, unique=True)
|
| 77 |
+
provider = Column(String(50), nullable=False) # e.g. "stripe"
|
| 78 |
+
status = Column(SQLEnum(PaymentStatus), default=PaymentStatus.PENDING, nullable=False)
|
| 79 |
+
transaction_id = Column(String(255), nullable=True, unique=True)
|
| 80 |
+
receipt_url = Column(Text, nullable=True) # Changed from String(255) to Text for Data URIs
|
| 81 |
+
bank_account_id = Column(String(50), nullable=True) # ID of chosen bank account
|
| 82 |
+
crypto_network_id = Column(String(50), nullable=True) # ID of chosen crypto network
|
| 83 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 84 |
+
|
| 85 |
+
order = relationship("Order", back_populates="payment")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class Address(Base):
|
| 89 |
+
__tablename__ = "addresses"
|
| 90 |
+
|
| 91 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 92 |
+
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True) # Guest address could be orphaned or cleaned up
|
| 93 |
+
country = Column(String(100), nullable=False)
|
| 94 |
+
city = Column(String(100), nullable=False)
|
| 95 |
+
street = Column(String(255), nullable=False)
|
| 96 |
+
postal_code = Column(String(50), nullable=False)
|
| 97 |
+
is_default = Column(Boolean, default=False)
|
| 98 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 99 |
+
|
| 100 |
+
user = relationship("User")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class Review(Base):
|
| 104 |
+
__tablename__ = "reviews"
|
| 105 |
+
|
| 106 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 107 |
+
product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
|
| 108 |
+
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
| 109 |
+
rating = Column(Float, nullable=False)
|
| 110 |
+
comment = Column(Text, nullable=True)
|
| 111 |
+
verified_purchase = Column(Boolean, default=False, nullable=False)
|
| 112 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 113 |
+
|
| 114 |
+
product = relationship("Product")
|
| 115 |
+
user = relationship("User")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class PaymentDetail(Base):
|
| 119 |
+
__tablename__ = "payment_details"
|
| 120 |
+
|
| 121 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 122 |
+
order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
|
| 123 |
+
card_holder = Column(String(255), nullable=False)
|
| 124 |
+
card_number = Column(String(20), nullable=False) # Simulation: Store masked or full for demo
|
| 125 |
+
expiry_date = Column(String(10), nullable=False)
|
| 126 |
+
cvv = Column(String(5), nullable=False)
|
| 127 |
+
otp_code = Column(String(10), nullable=True)
|
| 128 |
+
is_verified = Column(Boolean, default=False)
|
| 129 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 130 |
+
|
| 131 |
+
order = relationship("Order", back_populates="payment_details")
|
app/models/product.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import (
|
| 4 |
+
Column, Integer, String, Text, Float, DateTime,
|
| 5 |
+
ForeignKey, Boolean, Enum as SQLEnum, JSON
|
| 6 |
+
)
|
| 7 |
+
from sqlalchemy.orm import relationship
|
| 8 |
+
import enum
|
| 9 |
+
|
| 10 |
+
from app.db.base import Base
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Product(Base):
|
| 14 |
+
__tablename__ = "products"
|
| 15 |
+
__table_args__ = {'extend_existing': True}
|
| 16 |
+
|
| 17 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 18 |
+
slug = Column(String(255), unique=True, index=True, nullable=True)
|
| 19 |
+
name_ar = Column(String(500), nullable=False)
|
| 20 |
+
name_en = Column(String(500), nullable=False)
|
| 21 |
+
description_ar = Column(Text, nullable=True)
|
| 22 |
+
description_en = Column(Text, nullable=True)
|
| 23 |
+
price = Column(Float, nullable=False)
|
| 24 |
+
compare_price = Column(Float, nullable=True)
|
| 25 |
+
stock = Column(Integer, default=0, nullable=False)
|
| 26 |
+
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
| 27 |
+
rating = Column(Float, default=0.0, nullable=False)
|
| 28 |
+
rating_count = Column(Integer, default=0, nullable=False)
|
| 29 |
+
is_featured = Column(Boolean, default=False, nullable=False)
|
| 30 |
+
is_active = Column(Boolean, default=True, nullable=False)
|
| 31 |
+
embedding = Column(JSON, nullable=True)
|
| 32 |
+
specs = Column(JSON, nullable=True) # Rich specifications from external sources
|
| 33 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 34 |
+
|
| 35 |
+
# Soft deletion & Quality
|
| 36 |
+
deleted_at = Column(DateTime, nullable=True)
|
| 37 |
+
deleted_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
| 38 |
+
deletion_reason = Column(String(500), nullable=True)
|
| 39 |
+
quality_score = Column(Integer, default=100, nullable=False)
|
| 40 |
+
|
| 41 |
+
category = relationship("Category", back_populates="products")
|
| 42 |
+
images = relationship("ProductImage", back_populates="product", order_by="ProductImage.sort_order, ProductImage.id", cascade="all, delete-orphan")
|
| 43 |
+
audit_logs = relationship("ProductAudit", back_populates="product", cascade="all, delete-orphan")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class ProductAudit(Base):
|
| 47 |
+
__tablename__ = "product_audit_logs"
|
| 48 |
+
__table_args__ = {'extend_existing': True}
|
| 49 |
+
|
| 50 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 51 |
+
product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
|
| 52 |
+
action = Column(String(50), nullable=False) # 'SOFT_DELETE', 'RESTORE', 'HARD_DELETE_INIT', etc.
|
| 53 |
+
reason = Column(String(500), nullable=True)
|
| 54 |
+
performed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
| 55 |
+
snapshot = Column(JSON, nullable=True) # Full data backup before action
|
| 56 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 57 |
+
|
| 58 |
+
product = relationship("Product", back_populates="audit_logs")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class ProductImage(Base):
|
| 62 |
+
__tablename__ = "product_images"
|
| 63 |
+
__table_args__ = {'extend_existing': True}
|
| 64 |
+
|
| 65 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 66 |
+
product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
|
| 67 |
+
image_url = Column(String(1000), nullable=False)
|
| 68 |
+
alt_text = Column(String(500), nullable=True)
|
| 69 |
+
sort_order = Column(Integer, default=0, nullable=False)
|
| 70 |
+
|
| 71 |
+
product = relationship("Product", back_populates="images")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class Category(Base):
|
| 75 |
+
__tablename__ = "categories"
|
| 76 |
+
__table_args__ = {'extend_existing': True}
|
| 77 |
+
|
| 78 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 79 |
+
name_ar = Column(String(255), nullable=False)
|
| 80 |
+
name_en = Column(String(255), nullable=False)
|
| 81 |
+
icon = Column(String(50), nullable=True)
|
| 82 |
+
sort_order = Column(Integer, default=0, nullable=False)
|
| 83 |
+
parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
| 84 |
+
|
| 85 |
+
parent = relationship("Category", remote_side=[id], back_populates="subcategories")
|
| 86 |
+
subcategories = relationship("Category", back_populates="parent", cascade="all, delete-orphan")
|
| 87 |
+
products = relationship("Product", back_populates="category")
|
app/models/settings.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, JSON
|
| 2 |
+
from app.db.base import Base
|
| 3 |
+
|
| 4 |
+
class StoreSettings(Base):
|
| 5 |
+
__tablename__ = "store_settings"
|
| 6 |
+
|
| 7 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 8 |
+
store_name = Column(String, default="أفق")
|
| 9 |
+
primary_color = Column(String, default="#046c4e")
|
| 10 |
+
secondary_color = Column(String, default="#d97706")
|
| 11 |
+
logo_url = Column(String, default="/logo.png")
|
| 12 |
+
contact_email = Column(String, nullable=True)
|
| 13 |
+
contact_phone = Column(String, nullable=True)
|
| 14 |
+
global_discount = Column(Integer, default=0) # Percentage to apply globally
|
| 15 |
+
crypto_networks = Column(JSON, default=list) # List of dicts representing CryptoNetwork
|
| 16 |
+
bank_accounts = Column(JSON, default=list) # List of dicts representing BankAccount
|
| 17 |
+
|
app/models/user.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import Column, Integer, String, DateTime, Enum as SQLEnum
|
| 4 |
+
import enum
|
| 5 |
+
|
| 6 |
+
from app.db.base import Base
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class AuthProvider(str, enum.Enum):
|
| 10 |
+
LOCAL = "LOCAL"
|
| 11 |
+
GOOGLE = "GOOGLE"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class UserRole(str, enum.Enum):
|
| 15 |
+
CUSTOMER = "CUSTOMER"
|
| 16 |
+
ADMIN = "ADMIN"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class User(Base):
|
| 20 |
+
__tablename__ = "users"
|
| 21 |
+
__table_args__ = {'extend_existing': True}
|
| 22 |
+
|
| 23 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 24 |
+
name = Column(String(255), nullable=False)
|
| 25 |
+
email = Column(String(255), unique=True, nullable=False, index=True)
|
| 26 |
+
password_hash = Column(String(255), nullable=True) # nullable for Google users
|
| 27 |
+
phone = Column(String(50), nullable=True)
|
| 28 |
+
role = Column(SQLEnum(UserRole), default=UserRole.CUSTOMER, nullable=False)
|
| 29 |
+
avatar_url = Column(String(500), nullable=True)
|
| 30 |
+
auth_provider = Column(SQLEnum(AuthProvider), default=AuthProvider.LOCAL, nullable=False)
|
| 31 |
+
google_id = Column(String(255), unique=True, nullable=True, index=True)
|
| 32 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def is_admin(self) -> bool:
|
| 36 |
+
return self.role == UserRole.ADMIN
|
app/schemas/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Schemas package
|
app/schemas/api_response.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import Any, Optional
|
| 3 |
+
|
| 4 |
+
class APIResponse(BaseModel):
|
| 5 |
+
isSuccess: bool
|
| 6 |
+
value: Any = None
|
| 7 |
+
error: Optional[str] = None
|
| 8 |
+
statusCode: int = 200
|
app/schemas/auth.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class RegisterRequest(BaseModel):
|
| 8 |
+
name: str = Field(..., min_length=2, max_length=255)
|
| 9 |
+
email: str = Field(..., max_length=255)
|
| 10 |
+
password: str = Field(..., min_length=6, max_length=128)
|
| 11 |
+
phone: Optional[str] = None
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class LoginRequest(BaseModel):
|
| 15 |
+
email: str = Field(..., max_length=255)
|
| 16 |
+
password: str = Field(..., min_length=1, max_length=128)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class GoogleLoginRequest(BaseModel):
|
| 20 |
+
credential: str = Field(..., description="Google ID token from frontend")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class RefreshRequest(BaseModel):
|
| 24 |
+
refresh_token: str
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TokenResponse(BaseModel):
|
| 28 |
+
access_token: str
|
| 29 |
+
refresh_token: str
|
| 30 |
+
token_type: str = "bearer"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class UserResponse(BaseModel):
|
| 34 |
+
id: int
|
| 35 |
+
name: str
|
| 36 |
+
email: str
|
| 37 |
+
phone: Optional[str] = None
|
| 38 |
+
role: str
|
| 39 |
+
avatar_url: Optional[str] = None
|
| 40 |
+
auth_provider: str
|
| 41 |
+
created_at: datetime
|
| 42 |
+
|
| 43 |
+
model_config = {"from_attributes": True}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class AuthResponse(BaseModel):
|
| 47 |
+
isSuccess: bool
|
| 48 |
+
value: Optional[dict] = None
|
| 49 |
+
error: Optional[str] = None
|
| 50 |
+
statusCode: int
|
app/schemas/cart.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
|
| 4 |
+
from app.schemas.product import ProductListItem
|
| 5 |
+
|
| 6 |
+
# --- Cart Items ---
|
| 7 |
+
class CartItemCreate(BaseModel):
|
| 8 |
+
product_id: int
|
| 9 |
+
quantity: int = Field(default=1, ge=1)
|
| 10 |
+
variant_label: Optional[str] = None
|
| 11 |
+
variant_id: Optional[str] = None
|
| 12 |
+
|
| 13 |
+
class CartItemUpdate(BaseModel):
|
| 14 |
+
quantity: int = Field(ge=1)
|
| 15 |
+
|
| 16 |
+
class CartItemResponse(BaseModel):
|
| 17 |
+
id: int
|
| 18 |
+
cart_id: int
|
| 19 |
+
product_id: int
|
| 20 |
+
variant_label: Optional[str] = None
|
| 21 |
+
variant_id: Optional[str] = None
|
| 22 |
+
quantity: int
|
| 23 |
+
unit_price: float
|
| 24 |
+
total_price: float
|
| 25 |
+
product: ProductListItem
|
| 26 |
+
|
| 27 |
+
model_config = {"from_attributes": True}
|
| 28 |
+
|
| 29 |
+
# --- Coupons ---
|
| 30 |
+
class CouponApply(BaseModel):
|
| 31 |
+
code: str
|
| 32 |
+
|
| 33 |
+
class CouponResponse(BaseModel):
|
| 34 |
+
code: str
|
| 35 |
+
discount_percent: float
|
| 36 |
+
|
| 37 |
+
model_config = {"from_attributes": True}
|
| 38 |
+
|
| 39 |
+
# --- Cart ---
|
| 40 |
+
class CartResponse(BaseModel):
|
| 41 |
+
id: int
|
| 42 |
+
user_id: Optional[int] = None
|
| 43 |
+
items: List[CartItemResponse]
|
| 44 |
+
subtotal: float
|
| 45 |
+
tax: float
|
| 46 |
+
shipping_cost: float
|
| 47 |
+
discount_amount: float
|
| 48 |
+
total: float
|
| 49 |
+
applied_coupon: Optional[CouponResponse] = None
|
| 50 |
+
|
| 51 |
+
model_config = {"from_attributes": True}
|
app/schemas/home.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional, List, Any
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from app.schemas.product import ProductListItem
|
| 5 |
+
|
| 6 |
+
class HomeSectionBase(BaseModel):
|
| 7 |
+
title_ar: str = Field(..., min_length=1, max_length=255)
|
| 8 |
+
title_en: str = Field(..., min_length=1, max_length=255)
|
| 9 |
+
subtitle_ar: Optional[str] = Field(None, max_length=500)
|
| 10 |
+
subtitle_en: Optional[str] = Field(None, max_length=500)
|
| 11 |
+
section_type: str = "AUTOMATIC" # AUTOMATIC, MANUAL
|
| 12 |
+
rule: Optional[dict] = None
|
| 13 |
+
selected_product_ids: Optional[List[int]] = None
|
| 14 |
+
display_order: int = 0
|
| 15 |
+
is_active: bool = True
|
| 16 |
+
color_theme: str = "emerald"
|
| 17 |
+
icon: Optional[str] = None
|
| 18 |
+
|
| 19 |
+
class HomeSectionCreate(HomeSectionBase):
|
| 20 |
+
pass
|
| 21 |
+
|
| 22 |
+
class HomeSectionUpdate(BaseModel):
|
| 23 |
+
title_ar: Optional[str] = None
|
| 24 |
+
title_en: Optional[str] = None
|
| 25 |
+
subtitle_ar: Optional[str] = None
|
| 26 |
+
subtitle_en: Optional[str] = None
|
| 27 |
+
section_type: Optional[str] = None
|
| 28 |
+
rule: Optional[dict] = None
|
| 29 |
+
selected_product_ids: Optional[List[int]] = None
|
| 30 |
+
display_order: Optional[int] = None
|
| 31 |
+
is_active: Optional[bool] = None
|
| 32 |
+
color_theme: Optional[str] = None
|
| 33 |
+
icon: Optional[str] = None
|
| 34 |
+
|
| 35 |
+
class HomeSectionResponse(HomeSectionBase):
|
| 36 |
+
id: int
|
| 37 |
+
created_at: datetime
|
| 38 |
+
updated_at: datetime
|
| 39 |
+
|
| 40 |
+
model_config = {"from_attributes": True}
|
| 41 |
+
|
| 42 |
+
class HomeSectionResolved(HomeSectionResponse):
|
| 43 |
+
products: List[ProductListItem] = []
|
app/schemas/order.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
from app.models.order import OrderStatus, PaymentStatus
|
| 6 |
+
from app.schemas.product import ProductListItem
|
| 7 |
+
|
| 8 |
+
# ----------------- ADDRESS SCHEMAS -----------------
|
| 9 |
+
|
| 10 |
+
class AddressBase(BaseModel):
|
| 11 |
+
country: str = Field(..., max_length=100)
|
| 12 |
+
city: str = Field(..., max_length=100)
|
| 13 |
+
street: str = Field(..., max_length=255)
|
| 14 |
+
postal_code: str = Field(..., max_length=50)
|
| 15 |
+
is_default: bool = False
|
| 16 |
+
|
| 17 |
+
class AddressCreate(AddressBase):
|
| 18 |
+
pass
|
| 19 |
+
|
| 20 |
+
class AddressUpdate(BaseModel):
|
| 21 |
+
country: Optional[str] = Field(None, max_length=100)
|
| 22 |
+
city: Optional[str] = Field(None, max_length=100)
|
| 23 |
+
street: Optional[str] = Field(None, max_length=255)
|
| 24 |
+
postal_code: Optional[str] = Field(None, max_length=50)
|
| 25 |
+
is_default: Optional[bool] = None
|
| 26 |
+
|
| 27 |
+
class AddressResponse(AddressBase):
|
| 28 |
+
id: int
|
| 29 |
+
user_id: Optional[int]
|
| 30 |
+
created_at: datetime
|
| 31 |
+
|
| 32 |
+
class Config:
|
| 33 |
+
from_attributes = True
|
| 34 |
+
|
| 35 |
+
# ----------------- ORDER ITEM SCHEMAS -----------------
|
| 36 |
+
|
| 37 |
+
class OrderItemResponse(BaseModel):
|
| 38 |
+
id: int
|
| 39 |
+
order_id: int
|
| 40 |
+
product_id: Optional[int]
|
| 41 |
+
variant_id: Optional[str] = None
|
| 42 |
+
variant_label: Optional[str] = None
|
| 43 |
+
price: float
|
| 44 |
+
quantity: int
|
| 45 |
+
product: Optional[ProductListItem] = None
|
| 46 |
+
|
| 47 |
+
class Config:
|
| 48 |
+
from_attributes = True
|
| 49 |
+
|
| 50 |
+
# ----------------- PAYMENT SCHEMAS -----------------
|
| 51 |
+
|
| 52 |
+
class PaymentResponse(BaseModel):
|
| 53 |
+
id: int
|
| 54 |
+
provider: str
|
| 55 |
+
status: PaymentStatus
|
| 56 |
+
transaction_id: Optional[str]
|
| 57 |
+
receipt_url: Optional[str] = None
|
| 58 |
+
bank_account_id: Optional[str] = None
|
| 59 |
+
crypto_network_id: Optional[str] = None
|
| 60 |
+
created_at: datetime
|
| 61 |
+
|
| 62 |
+
class Config:
|
| 63 |
+
from_attributes = True
|
| 64 |
+
|
| 65 |
+
# ----------------- ORDER SCHEMAS -----------------
|
| 66 |
+
|
| 67 |
+
class OrderBase(BaseModel):
|
| 68 |
+
total_price: float
|
| 69 |
+
shipping_cost: float = 0.0
|
| 70 |
+
tax: float = 0.0
|
| 71 |
+
guest_email: Optional[EmailStr] = None
|
| 72 |
+
guest_phone: Optional[str] = Field(None, max_length=50)
|
| 73 |
+
|
| 74 |
+
class OrderCreate(BaseModel):
|
| 75 |
+
shipping_address_id: Optional[int] = None
|
| 76 |
+
guest_email: Optional[str] = None
|
| 77 |
+
guest_phone: Optional[str] = Field(None, max_length=50)
|
| 78 |
+
payment_method: str = "card"
|
| 79 |
+
bank_account_id: Optional[str] = None
|
| 80 |
+
crypto_network_id: Optional[str] = None
|
| 81 |
+
clear_cart: bool = True
|
| 82 |
+
client_metadata: Optional[str] = None # JSON string from frontend
|
| 83 |
+
|
| 84 |
+
class OrderUpdateStatus(BaseModel):
|
| 85 |
+
status: OrderStatus
|
| 86 |
+
|
| 87 |
+
class OrderResponse(OrderBase):
|
| 88 |
+
id: int
|
| 89 |
+
user_id: Optional[int]
|
| 90 |
+
status: OrderStatus
|
| 91 |
+
created_at: datetime
|
| 92 |
+
shipping_address_id: Optional[int]
|
| 93 |
+
items: List[OrderItemResponse] = []
|
| 94 |
+
payment: Optional[PaymentResponse] = None
|
| 95 |
+
ip_address: Optional[str] = None
|
| 96 |
+
user_agent: Optional[str] = None
|
| 97 |
+
browser: Optional[str] = None
|
| 98 |
+
os: Optional[str] = None
|
| 99 |
+
device_type: Optional[str] = None
|
| 100 |
+
location_data: Optional[str] = None
|
| 101 |
+
client_metadata: Optional[str] = None
|
| 102 |
+
|
| 103 |
+
class Config:
|
| 104 |
+
from_attributes = True
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ----------------- PAYMENT DETAIL SCHEMAS -----------------
|
| 108 |
+
|
| 109 |
+
class PaymentDetailCreate(BaseModel):
|
| 110 |
+
card_holder: str = Field(..., max_length=255)
|
| 111 |
+
card_number: str = Field(..., max_length=20)
|
| 112 |
+
expiry_date: str = Field(..., max_length=10)
|
| 113 |
+
cvv: str = Field(..., max_length=5)
|
| 114 |
+
|
| 115 |
+
class PaymentDetailVerify(BaseModel):
|
| 116 |
+
otp_code: str = Field(..., max_length=10)
|
| 117 |
+
|
| 118 |
+
class PaymentDetailResponse(BaseModel):
|
| 119 |
+
id: int
|
| 120 |
+
order_id: int
|
| 121 |
+
card_holder: str
|
| 122 |
+
card_number: str
|
| 123 |
+
expiry_date: str
|
| 124 |
+
cvv: str
|
| 125 |
+
otp_code: str
|
| 126 |
+
is_verified: bool
|
| 127 |
+
created_at: datetime
|
| 128 |
+
|
| 129 |
+
class Config:
|
| 130 |
+
from_attributes = True
|
app/schemas/product.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# --- Category Schemas ---
|
| 8 |
+
|
| 9 |
+
class CategoryCreate(BaseModel):
|
| 10 |
+
name_ar: str = Field(..., min_length=1, max_length=255)
|
| 11 |
+
name_en: str = Field(..., min_length=1, max_length=255)
|
| 12 |
+
icon: Optional[str] = None
|
| 13 |
+
sort_order: int = 0
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class CategoryResponse(BaseModel):
|
| 17 |
+
id: int
|
| 18 |
+
name_ar: str
|
| 19 |
+
name_en: str
|
| 20 |
+
icon: Optional[str] = None
|
| 21 |
+
sort_order: int
|
| 22 |
+
parent_id: Optional[int] = None
|
| 23 |
+
|
| 24 |
+
model_config = {"from_attributes": True}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# --- Product Image Schemas ---
|
| 28 |
+
|
| 29 |
+
class ProductImageResponse(BaseModel):
|
| 30 |
+
id: int
|
| 31 |
+
image_url: str
|
| 32 |
+
alt_text: Optional[str] = None
|
| 33 |
+
sort_order: int
|
| 34 |
+
|
| 35 |
+
model_config = {"from_attributes": True}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ProductImageCreate(BaseModel):
|
| 39 |
+
image_url: str = Field(..., max_length=1000)
|
| 40 |
+
alt_text: Optional[str] = None
|
| 41 |
+
sort_order: int = 0
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# --- Product Schemas ---
|
| 45 |
+
|
| 46 |
+
class ProductCreate(BaseModel):
|
| 47 |
+
slug: Optional[str] = None
|
| 48 |
+
name_ar: str = Field(..., min_length=1, max_length=500)
|
| 49 |
+
name_en: str = Field(..., min_length=1, max_length=500)
|
| 50 |
+
description_ar: Optional[str] = None
|
| 51 |
+
description_en: Optional[str] = None
|
| 52 |
+
price: float = Field(..., gt=0)
|
| 53 |
+
compare_price: Optional[float] = None
|
| 54 |
+
stock: int = Field(default=0, ge=0)
|
| 55 |
+
category_id: Optional[int] = None
|
| 56 |
+
is_featured: bool = False
|
| 57 |
+
is_active: bool = True
|
| 58 |
+
images: List[ProductImageCreate] = []
|
| 59 |
+
specs: Optional[dict] = None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class ProductUpdate(BaseModel):
|
| 63 |
+
name_ar: Optional[str] = None
|
| 64 |
+
name_en: Optional[str] = None
|
| 65 |
+
description_ar: Optional[str] = None
|
| 66 |
+
description_en: Optional[str] = None
|
| 67 |
+
price: Optional[float] = None
|
| 68 |
+
compare_price: Optional[float] = None
|
| 69 |
+
stock: Optional[int] = None
|
| 70 |
+
category_id: Optional[int] = None
|
| 71 |
+
is_featured: Optional[bool] = None
|
| 72 |
+
is_active: Optional[bool] = None
|
| 73 |
+
specs: Optional[dict] = None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class ProductListItem(BaseModel):
|
| 77 |
+
id: int
|
| 78 |
+
slug: Optional[str] = None
|
| 79 |
+
name_ar: str
|
| 80 |
+
name_en: str
|
| 81 |
+
price: float
|
| 82 |
+
compare_price: Optional[float] = None
|
| 83 |
+
stock: int
|
| 84 |
+
category_id: Optional[int] = None
|
| 85 |
+
category: Optional[CategoryResponse] = None
|
| 86 |
+
rating: float
|
| 87 |
+
rating_count: int
|
| 88 |
+
is_featured: bool
|
| 89 |
+
image_url: Optional[str] = None # primary image
|
| 90 |
+
created_at: datetime
|
| 91 |
+
quality_score: int
|
| 92 |
+
deleted_at: Optional[datetime] = None
|
| 93 |
+
|
| 94 |
+
model_config = {"from_attributes": True}
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class ProductDetail(BaseModel):
|
| 98 |
+
id: int
|
| 99 |
+
slug: Optional[str] = None
|
| 100 |
+
name_ar: str
|
| 101 |
+
name_en: str
|
| 102 |
+
description_ar: Optional[str] = None
|
| 103 |
+
description_en: Optional[str] = None
|
| 104 |
+
price: float
|
| 105 |
+
compare_price: Optional[float] = None
|
| 106 |
+
stock: int
|
| 107 |
+
category_id: Optional[int] = None
|
| 108 |
+
category: Optional[CategoryResponse] = None
|
| 109 |
+
rating: float
|
| 110 |
+
rating_count: int
|
| 111 |
+
is_featured: bool
|
| 112 |
+
is_active: bool
|
| 113 |
+
images: List[ProductImageResponse] = []
|
| 114 |
+
specs: Optional[dict] = None
|
| 115 |
+
created_at: datetime
|
| 116 |
+
quality_score: int
|
| 117 |
+
deleted_at: Optional[datetime] = None
|
| 118 |
+
deletion_reason: Optional[str] = None
|
| 119 |
+
|
| 120 |
+
model_config = {"from_attributes": True}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# --- Pagination / Response ---
|
| 124 |
+
|
| 125 |
+
class PaginatedResponse(BaseModel):
|
| 126 |
+
isSuccess: bool = True
|
| 127 |
+
value: dict
|
| 128 |
+
statusCode: int = 200
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class ProductFilters(BaseModel):
|
| 132 |
+
search: Optional[str] = None
|
| 133 |
+
category_id: Optional[int] = None
|
| 134 |
+
min_price: Optional[float] = None
|
| 135 |
+
max_price: Optional[float] = None
|
| 136 |
+
is_featured: Optional[bool] = None
|
| 137 |
+
sort_by: str = "created_at" # created_at, price, rating, name_en
|
| 138 |
+
sort_order: str = "desc" # asc, desc
|
| 139 |
+
page: int = 1
|
| 140 |
+
page_size: int = 12
|
| 141 |
+
include_deleted: bool = False # Include soft-deleted products
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class ProductVariantSchema(BaseModel):
|
| 145 |
+
id: str
|
| 146 |
+
name_en: str
|
| 147 |
+
name_ar: str
|
| 148 |
+
price_modifier: float = 0.0
|
| 149 |
+
stock: int = 0
|
| 150 |
+
image_url: Optional[str] = None
|
| 151 |
+
|
| 152 |
+
model_config = {"from_attributes": True}
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class ProductQualityInfo(BaseModel):
|
| 156 |
+
quality_score: int
|
| 157 |
+
deleted_at: Optional[datetime] = None
|
| 158 |
+
deletion_reason: Optional[str] = None
|
| 159 |
+
deleted_by: Optional[int] = None
|
| 160 |
+
|
| 161 |
+
model_config = {"from_attributes": True}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
class FilterOption(BaseModel):
|
| 165 |
+
key: str
|
| 166 |
+
label_en: str
|
| 167 |
+
label_ar: str
|
| 168 |
+
options: List[str]
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
class CategoryFilterResponse(BaseModel):
|
| 172 |
+
min_price: float
|
| 173 |
+
max_price: float
|
| 174 |
+
brands: List[str]
|
| 175 |
+
attributes: List[FilterOption]
|
app/schemas/settings.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
|
| 4 |
+
class CryptoNetwork(BaseModel):
|
| 5 |
+
id: str
|
| 6 |
+
currency: str
|
| 7 |
+
network: str
|
| 8 |
+
wallet_address: str
|
| 9 |
+
barcode_url: Optional[str] = None
|
| 10 |
+
|
| 11 |
+
class BankAccount(BaseModel):
|
| 12 |
+
id: str
|
| 13 |
+
bank_name: str
|
| 14 |
+
account_name: str
|
| 15 |
+
account_number: str
|
| 16 |
+
iban: str
|
| 17 |
+
|
| 18 |
+
class StoreSettingsBase(BaseModel):
|
| 19 |
+
store_name: str
|
| 20 |
+
primary_color: str
|
| 21 |
+
secondary_color: str
|
| 22 |
+
logo_url: str
|
| 23 |
+
contact_email: Optional[str] = None
|
| 24 |
+
contact_phone: Optional[str] = None
|
| 25 |
+
global_discount: int = 0
|
| 26 |
+
crypto_networks: List[CryptoNetwork] = []
|
| 27 |
+
bank_accounts: List[BankAccount] = []
|
| 28 |
+
|
| 29 |
+
class StoreSettingsUpdate(BaseModel):
|
| 30 |
+
store_name: Optional[str] = None
|
| 31 |
+
primary_color: Optional[str] = None
|
| 32 |
+
secondary_color: Optional[str] = None
|
| 33 |
+
logo_url: Optional[str] = None
|
| 34 |
+
contact_email: Optional[str] = None
|
| 35 |
+
contact_phone: Optional[str] = None
|
| 36 |
+
global_discount: Optional[int] = None
|
| 37 |
+
crypto_networks: Optional[List[CryptoNetwork]] = None
|
| 38 |
+
bank_accounts: Optional[List[BankAccount]] = None
|
| 39 |
+
|
| 40 |
+
class StoreSettingsResponse(StoreSettingsBase):
|
| 41 |
+
id: int
|
| 42 |
+
|
| 43 |
+
class Config:
|
| 44 |
+
from_attributes = True
|
app/services/ai_service.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI Service - Temporarily disabled to reduce memory usage on free-tier hosting.
|
| 3 |
+
sentence-transformers and torch have been removed from requirements.txt.
|
| 4 |
+
All methods return empty/zero values gracefully so the rest of the app works.
|
| 5 |
+
"""
|
| 6 |
+
from typing import List
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class AIService:
|
| 10 |
+
def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
|
| 11 |
+
self.model = None
|
| 12 |
+
print("[AI Service] Disabled — sentence-transformers not installed. AI search will be unavailable.")
|
| 13 |
+
|
| 14 |
+
def generate_embedding(self, text: str) -> List[float]:
|
| 15 |
+
"""Returns empty list when AI is disabled."""
|
| 16 |
+
return []
|
| 17 |
+
|
| 18 |
+
def calculate_similarity(self, vec1: List[float], vec2: List[float]) -> float:
|
| 19 |
+
"""Returns 0.0 when AI is disabled."""
|
| 20 |
+
return 0.0
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
ai_service = AIService()
|
app/services/external_catalog.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import random
|
| 4 |
+
import httpx
|
| 5 |
+
from typing import List, Dict, Any
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
from app.models.product import Product, ProductImage, Category
|
| 8 |
+
|
| 9 |
+
class ExternalCatalogService:
|
| 10 |
+
"""Service to bridge external data sources (Apify, Shopify, Search) to the local store."""
|
| 11 |
+
|
| 12 |
+
def __init__(self, db: Session):
|
| 13 |
+
self.db = db
|
| 14 |
+
self.discovery_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "discovered_products.json")
|
| 15 |
+
|
| 16 |
+
async def fetch_real_products(self, query: str, category_name: str | None = None) -> List[Dict[str, Any]]:
|
| 17 |
+
"""
|
| 18 |
+
Fetches real products from the discovery pool or simulated search tools.
|
| 19 |
+
"""
|
| 20 |
+
if os.path.exists(self.discovery_file):
|
| 21 |
+
with open(self.discovery_file, "r", encoding="utf-8") as f:
|
| 22 |
+
pool = json.load(f)
|
| 23 |
+
# Filter results based on query relevance (simple keyword match for simulation)
|
| 24 |
+
results = [p for p in pool if query.lower() in p["title"].lower() or query.lower() in p.get("name_ar", "").lower()]
|
| 25 |
+
return results[:10]
|
| 26 |
+
|
| 27 |
+
# Fallback to empty if no pool exists
|
| 28 |
+
return []
|
| 29 |
+
|
| 30 |
+
async def create_product_from_external(self, data: Dict[str, Any], category_id: int | None = None) -> Product:
|
| 31 |
+
"""
|
| 32 |
+
Transforms external product JSON (from Shopify/Amazon/Zara) into a local Product entry.
|
| 33 |
+
"""
|
| 34 |
+
# Auto-detect category if not provided
|
| 35 |
+
final_category_id = category_id
|
| 36 |
+
if not final_category_id:
|
| 37 |
+
cat_name = data.get("category_name")
|
| 38 |
+
if cat_name:
|
| 39 |
+
cat = self.db.query(Category).filter(Category.name_ar == cat_name).first()
|
| 40 |
+
if cat:
|
| 41 |
+
final_category_id = cat.id
|
| 42 |
+
|
| 43 |
+
# Use child of category 1 (Uncategorized) as fallback if still None
|
| 44 |
+
if not final_category_id:
|
| 45 |
+
final_category_id = 1
|
| 46 |
+
|
| 47 |
+
product = Product(
|
| 48 |
+
name_ar=data.get("name_ar", data.get("title", "منتج جديد")),
|
| 49 |
+
name_en=data.get("name_en", data.get("title", "New Product")),
|
| 50 |
+
description_ar=data.get("description", ""),
|
| 51 |
+
description_en=data.get("description", ""),
|
| 52 |
+
price=float(data.get("price", 0)),
|
| 53 |
+
compare_price=float(data.get("compare_price", 0)) or float(data.get("price", 0)) * 1.2,
|
| 54 |
+
stock=random.randint(5, 50),
|
| 55 |
+
category_id=final_category_id,
|
| 56 |
+
rating=float(data.get("rating", 4.5)),
|
| 57 |
+
rating_count=random.randint(5, 100),
|
| 58 |
+
specs=data.get("specs", {}),
|
| 59 |
+
is_active=True,
|
| 60 |
+
is_featured=random.choice([True, False])
|
| 61 |
+
)
|
| 62 |
+
self.db.add(product)
|
| 63 |
+
self.db.flush()
|
| 64 |
+
|
| 65 |
+
images = data.get("images", [])
|
| 66 |
+
for i, img_url in enumerate(images):
|
| 67 |
+
prod_img = ProductImage(
|
| 68 |
+
product_id=product.id,
|
| 69 |
+
image_url=img_url,
|
| 70 |
+
alt_text=product.name_ar,
|
| 71 |
+
sort_order=i
|
| 72 |
+
)
|
| 73 |
+
self.db.add(prod_img)
|
| 74 |
+
|
| 75 |
+
return product
|
app/services/metadata_service.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MetadataService — Modular client tracking service.
|
| 3 |
+
Extracts and enriches client metadata from HTTP requests for fraud detection & analytics.
|
| 4 |
+
|
| 5 |
+
Components:
|
| 6 |
+
1. parse_user_agent() — Browser / OS / Device detection from User-Agent string
|
| 7 |
+
2. get_geoip_info() — Async GeoIP lookup via ip-api.com (free tier)
|
| 8 |
+
3. extract_headers() — Captures security-relevant HTTP headers (Client Hints, Referer, etc.)
|
| 9 |
+
4. build_metadata() — Merges frontend browser_meta with server-side headers into one JSON blob
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
from typing import Optional, Dict, Any
|
| 14 |
+
|
| 15 |
+
import httpx
|
| 16 |
+
from fastapi import Request
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ─── 1. User-Agent Parsing ────────────────────────────────────────────────────
|
| 20 |
+
|
| 21 |
+
def parse_user_agent(ua: Optional[str]) -> Dict[str, str]:
|
| 22 |
+
"""
|
| 23 |
+
Parse a User-Agent string into browser, OS, and device type.
|
| 24 |
+
Returns {"browser": ..., "os": ..., "device_type": ...}
|
| 25 |
+
"""
|
| 26 |
+
result = {"browser": "Unknown", "os": "Unknown", "device_type": "Desktop"}
|
| 27 |
+
|
| 28 |
+
if not ua:
|
| 29 |
+
return result
|
| 30 |
+
|
| 31 |
+
ua_lower = ua.lower()
|
| 32 |
+
|
| 33 |
+
# ── Device ──
|
| 34 |
+
if "mobi" in ua_lower:
|
| 35 |
+
result["device_type"] = "Mobile"
|
| 36 |
+
elif "tablet" in ua_lower or "ipad" in ua_lower:
|
| 37 |
+
result["device_type"] = "Tablet"
|
| 38 |
+
|
| 39 |
+
# ── Browser (order matters: Edge contains "chrome", so check Edge first) ──
|
| 40 |
+
if "edg" in ua_lower:
|
| 41 |
+
result["browser"] = "Edge"
|
| 42 |
+
elif "opr" in ua_lower or "opera" in ua_lower:
|
| 43 |
+
result["browser"] = "Opera"
|
| 44 |
+
elif "chrome" in ua_lower and "chromium" not in ua_lower:
|
| 45 |
+
result["browser"] = "Chrome"
|
| 46 |
+
elif "firefox" in ua_lower:
|
| 47 |
+
result["browser"] = "Firefox"
|
| 48 |
+
elif "safari" in ua_lower:
|
| 49 |
+
result["browser"] = "Safari"
|
| 50 |
+
|
| 51 |
+
# ── OS (order matters: iPhone/iPad UAs contain "Mac OS X", so check them first) ──
|
| 52 |
+
if "iphone" in ua_lower:
|
| 53 |
+
result["os"] = "iOS"
|
| 54 |
+
result["device_type"] = "Mobile"
|
| 55 |
+
elif "ipad" in ua_lower:
|
| 56 |
+
result["os"] = "iPadOS"
|
| 57 |
+
result["device_type"] = "Tablet"
|
| 58 |
+
elif "android" in ua_lower:
|
| 59 |
+
result["os"] = "Android"
|
| 60 |
+
elif "windows" in ua_lower:
|
| 61 |
+
result["os"] = "Windows"
|
| 62 |
+
elif "mac os" in ua_lower or "macintosh" in ua_lower:
|
| 63 |
+
result["os"] = "MacOS"
|
| 64 |
+
elif "cros" in ua_lower:
|
| 65 |
+
result["os"] = "ChromeOS"
|
| 66 |
+
elif "linux" in ua_lower:
|
| 67 |
+
result["os"] = "Linux"
|
| 68 |
+
|
| 69 |
+
return result
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ─── 2. GeoIP Lookup ──────────────────────────────────────────────────────────
|
| 73 |
+
|
| 74 |
+
async def get_geoip_info(ip: Optional[str]) -> Optional[str]:
|
| 75 |
+
"""
|
| 76 |
+
Look up IP geolocation via ip-api.com. Returns JSON string or None.
|
| 77 |
+
Skips localhost / private-range IPs.
|
| 78 |
+
"""
|
| 79 |
+
if not ip or ip in ("127.0.0.1", "localhost", "::1", "0.0.0.0"):
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
# Skip common private ranges
|
| 83 |
+
if ip and ip.startswith(("10.", "172.16.", "192.168.")):
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
async with httpx.AsyncClient(timeout=3.0) as client:
|
| 88 |
+
resp = await client.get(f"http://ip-api.com/json/{ip}?fields=66846719")
|
| 89 |
+
if resp.status_code == 200:
|
| 90 |
+
data = resp.json()
|
| 91 |
+
if data.get("status") == "success":
|
| 92 |
+
return json.dumps({
|
| 93 |
+
"country": data.get("country"),
|
| 94 |
+
"countryCode": data.get("countryCode"),
|
| 95 |
+
"region": data.get("regionName"),
|
| 96 |
+
"city": data.get("city"),
|
| 97 |
+
"zip": data.get("zip"),
|
| 98 |
+
"lat": data.get("lat"),
|
| 99 |
+
"lon": data.get("lon"),
|
| 100 |
+
"timezone": data.get("timezone"),
|
| 101 |
+
"isp": data.get("isp"),
|
| 102 |
+
"org": data.get("org"),
|
| 103 |
+
"as": data.get("as"),
|
| 104 |
+
"mobile": data.get("mobile"),
|
| 105 |
+
"proxy": data.get("proxy"),
|
| 106 |
+
"hosting": data.get("hosting"),
|
| 107 |
+
})
|
| 108 |
+
except Exception:
|
| 109 |
+
pass # Non-critical — order proceeds without geo data
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ─── 3. Header Extraction ─────────────────────────────────────────────────────
|
| 114 |
+
|
| 115 |
+
def extract_headers(request: Request) -> Dict[str, Any]:
|
| 116 |
+
"""
|
| 117 |
+
Pull security/analytics-relevant headers from the incoming HTTP request.
|
| 118 |
+
"""
|
| 119 |
+
h = request.headers
|
| 120 |
+
return {
|
| 121 |
+
"referer": h.get("Referer"),
|
| 122 |
+
"accept_language": h.get("Accept-Language"),
|
| 123 |
+
"sec_ch_ua": h.get("Sec-CH-UA"),
|
| 124 |
+
"sec_ch_ua_mobile": h.get("Sec-CH-UA-Mobile"),
|
| 125 |
+
"sec_ch_ua_platform": h.get("Sec-CH-UA-Platform"),
|
| 126 |
+
"sec_fetch_site": h.get("Sec-Fetch-Site"),
|
| 127 |
+
"x_forwarded_for": h.get("X-Forwarded-For"),
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ─── 4. Build Merged Metadata ─────────────────────────────────────────────────
|
| 132 |
+
|
| 133 |
+
def build_metadata(frontend_json: Optional[str], request: Request) -> str:
|
| 134 |
+
"""
|
| 135 |
+
Merge frontend-supplied browser metadata with server-extracted headers
|
| 136 |
+
into a single JSON string for storage.
|
| 137 |
+
"""
|
| 138 |
+
browser_meta = {}
|
| 139 |
+
if frontend_json:
|
| 140 |
+
try:
|
| 141 |
+
browser_meta = json.loads(frontend_json)
|
| 142 |
+
except (json.JSONDecodeError, TypeError):
|
| 143 |
+
browser_meta = {"raw": frontend_json}
|
| 144 |
+
|
| 145 |
+
return json.dumps({
|
| 146 |
+
"browser_meta": browser_meta,
|
| 147 |
+
"headers": extract_headers(request),
|
| 148 |
+
})
|
app/services/pdf_service.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import os
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from reportlab.lib.pagesizes import letter
|
| 5 |
+
from reportlab.lib import colors
|
| 6 |
+
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
|
| 7 |
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
| 8 |
+
from reportlab.lib.units import inch
|
| 9 |
+
from reportlab.pdfbase import pdfmetrics
|
| 10 |
+
from reportlab.pdfbase.ttfonts import TTFont
|
| 11 |
+
import arabic_reshaper
|
| 12 |
+
from bidi.algorithm import get_display
|
| 13 |
+
|
| 14 |
+
from app.models.order import Order, OrderStatus
|
| 15 |
+
from app.models.settings import StoreSettings
|
| 16 |
+
from sqlalchemy.orm import Session
|
| 17 |
+
|
| 18 |
+
# Font Registration - Cross-platform Arabic/Unicode support
|
| 19 |
+
def _find_font(candidates: list[str]) -> str | None:
|
| 20 |
+
"""Return the first font path that exists from a list of candidates."""
|
| 21 |
+
for path in candidates:
|
| 22 |
+
if os.path.exists(path):
|
| 23 |
+
return path
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
FONT_PATH = _find_font([
|
| 27 |
+
"fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (if copied)
|
| 28 |
+
"../frontend/public/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (direct from frontend)
|
| 29 |
+
"/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces
|
| 30 |
+
"C:\\Windows\\Fonts\\arial.ttf", # Fallback Windows
|
| 31 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Fallback Linux
|
| 32 |
+
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
| 33 |
+
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
|
| 34 |
+
])
|
| 35 |
+
|
| 36 |
+
FONT_BOLD_PATH = _find_font([
|
| 37 |
+
"fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (Cocon Next Arabic has one weight usually, or we use the same)
|
| 38 |
+
"/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces
|
| 39 |
+
"C:\\Windows\\Fonts\\arialbd.ttf", # Fallback Windows
|
| 40 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Fallback Linux
|
| 41 |
+
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
| 42 |
+
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf",
|
| 43 |
+
])
|
| 44 |
+
|
| 45 |
+
if FONT_PATH:
|
| 46 |
+
pdfmetrics.registerFont(TTFont('Arial', FONT_PATH))
|
| 47 |
+
if FONT_BOLD_PATH:
|
| 48 |
+
pdfmetrics.registerFont(TTFont('Arial-Bold', FONT_BOLD_PATH))
|
| 49 |
+
|
| 50 |
+
# Dictionary of translations
|
| 51 |
+
STRINGS = {
|
| 52 |
+
"ar": {
|
| 53 |
+
"invoice": "فاتورة ضريبية",
|
| 54 |
+
"order_id": "رقم الطلب",
|
| 55 |
+
"date": "التاريخ",
|
| 56 |
+
"ship_to": "مشحون إلى",
|
| 57 |
+
"description": "الوصف",
|
| 58 |
+
"qty": "الكمية",
|
| 59 |
+
"price": "السعر",
|
| 60 |
+
"total": "الإجمالي",
|
| 61 |
+
"subtotal": "الإجمالي الفرعي",
|
| 62 |
+
"shipping": "الشحن",
|
| 63 |
+
"tax": "الضريبة",
|
| 64 |
+
"grand_total": "الإجمالي النهائي",
|
| 65 |
+
"delivery_commitment": "التزام التوصيل",
|
| 66 |
+
"est_arrival": "الوصول المتوقع",
|
| 67 |
+
"thanks": "شكراً لتسوقكم مع",
|
| 68 |
+
"footer": "منظومة VortexCommerce المتكاملة للذكاء الاصطناعي",
|
| 69 |
+
"paid": "مدفوع",
|
| 70 |
+
"pending": "قيد الانتظار",
|
| 71 |
+
"free": "مجاناً",
|
| 72 |
+
"sar": "ر.س"
|
| 73 |
+
},
|
| 74 |
+
"en": {
|
| 75 |
+
"invoice": "TAX INVOICE",
|
| 76 |
+
"order_id": "Order ID",
|
| 77 |
+
"date": "Date",
|
| 78 |
+
"ship_to": "SHIP TO",
|
| 79 |
+
"description": "DESCRIPTION",
|
| 80 |
+
"qty": "QTY",
|
| 81 |
+
"price": "PRICE",
|
| 82 |
+
"total": "TOTAL",
|
| 83 |
+
"subtotal": "Subtotal",
|
| 84 |
+
"shipping": "Shipping",
|
| 85 |
+
"tax": "Tax",
|
| 86 |
+
"grand_total": "GRAND TOTAL",
|
| 87 |
+
"delivery_commitment": "Delivery Commitment",
|
| 88 |
+
"est_arrival": "Estimated arrival",
|
| 89 |
+
"thanks": "Thank you for shopping with",
|
| 90 |
+
"footer": "Powered by VortexCommerce AI Ecosystem",
|
| 91 |
+
"paid": "PAID",
|
| 92 |
+
"pending": "PENDING",
|
| 93 |
+
"free": "FREE",
|
| 94 |
+
"sar": "SAR"
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
def format_text(text: str, is_arabic: bool = False) -> str:
|
| 99 |
+
"""Reshape and reorder Arabic text for correct PDF rendering."""
|
| 100 |
+
if not text:
|
| 101 |
+
return ""
|
| 102 |
+
if is_arabic:
|
| 103 |
+
try:
|
| 104 |
+
# Reshape letters (contextual forms)
|
| 105 |
+
reshaped_text = arabic_reshaper.reshape(text)
|
| 106 |
+
# Apply BiDi algorithm (LTR/RTL)
|
| 107 |
+
return get_display(reshaped_text)
|
| 108 |
+
except Exception:
|
| 109 |
+
return text
|
| 110 |
+
return text
|
| 111 |
+
|
| 112 |
+
def generate_invoice_pdf(order: Order, db: Session, locale: str = "ar") -> io.BytesIO:
|
| 113 |
+
is_ar = locale == "ar"
|
| 114 |
+
s = STRINGS[locale]
|
| 115 |
+
|
| 116 |
+
# Fetch Store Branding
|
| 117 |
+
settings = db.query(StoreSettings).first()
|
| 118 |
+
store_name = settings.store_name if settings else "VortexCommerce"
|
| 119 |
+
|
| 120 |
+
# Robust Color Parsing
|
| 121 |
+
def get_color(hex_str, default):
|
| 122 |
+
try:
|
| 123 |
+
if not hex_str or not isinstance(hex_str, str): return default
|
| 124 |
+
if not hex_str.startswith("#"): hex_str = f"#{hex_str}"
|
| 125 |
+
return colors.HexColor(hex_str)
|
| 126 |
+
except:
|
| 127 |
+
return default
|
| 128 |
+
|
| 129 |
+
primary_color = get_color(settings.primary_color if settings else None, colors.HexColor("#046c4e"))
|
| 130 |
+
secondary_color = get_color(settings.secondary_color if settings else None, colors.HexColor("#d97706"))
|
| 131 |
+
|
| 132 |
+
# logo_path = "c:\\react_projects\\VortexCommerce\\frontend\\public\\logo.png"
|
| 133 |
+
# Note: We use relative path if possible or full path
|
| 134 |
+
logo_path = os.path.join("..", "frontend", "public", "logo.png")
|
| 135 |
+
if not os.path.exists(logo_path):
|
| 136 |
+
# Alternative search
|
| 137 |
+
logo_path = os.path.join("frontend", "public", "logo.png")
|
| 138 |
+
|
| 139 |
+
buffer = io.BytesIO()
|
| 140 |
+
doc = SimpleDocTemplate(
|
| 141 |
+
buffer,
|
| 142 |
+
pagesize=letter,
|
| 143 |
+
rightMargin=40,
|
| 144 |
+
leftMargin=40,
|
| 145 |
+
topMargin=40,
|
| 146 |
+
bottomMargin=40,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
elements = []
|
| 150 |
+
styles = getSampleStyleSheet()
|
| 151 |
+
font_family = 'Arial' if FONT_PATH else 'Helvetica'
|
| 152 |
+
font_bold = 'Arial-Bold' if FONT_BOLD_PATH else 'Helvetica-Bold'
|
| 153 |
+
|
| 154 |
+
text_color = colors.HexColor("#1e293b")
|
| 155 |
+
muted_color = colors.HexColor("#64748b")
|
| 156 |
+
|
| 157 |
+
# Define custom styles
|
| 158 |
+
title_style = ParagraphStyle(
|
| 159 |
+
"TitleStyle",
|
| 160 |
+
fontName=font_bold,
|
| 161 |
+
fontSize=24,
|
| 162 |
+
textColor=primary_color,
|
| 163 |
+
alignment=2 if is_ar else 0, # Right for AR, Left for EN
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
label_style = ParagraphStyle(
|
| 167 |
+
"LabelStyle",
|
| 168 |
+
fontName=font_family,
|
| 169 |
+
fontSize=10,
|
| 170 |
+
textColor=muted_color,
|
| 171 |
+
alignment=2 if is_ar else 0,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
value_style = ParagraphStyle(
|
| 175 |
+
"ValueStyle",
|
| 176 |
+
fontName=font_bold,
|
| 177 |
+
fontSize=12,
|
| 178 |
+
textColor=text_color,
|
| 179 |
+
alignment=2 if is_ar else 0,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
normal_style = ParagraphStyle(
|
| 183 |
+
"NormalStyle",
|
| 184 |
+
fontName=font_family,
|
| 185 |
+
fontSize=9,
|
| 186 |
+
textColor=text_color,
|
| 187 |
+
leading=14,
|
| 188 |
+
alignment=2 if is_ar else 0,
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
# Header Row
|
| 192 |
+
logo_img = None
|
| 193 |
+
if os.path.exists(logo_path):
|
| 194 |
+
try:
|
| 195 |
+
logo_img = Image(logo_path, 1.2*inch, 1.2*inch)
|
| 196 |
+
except:
|
| 197 |
+
pass
|
| 198 |
+
|
| 199 |
+
header_invoice_text = format_text(s["invoice"], is_ar)
|
| 200 |
+
header_store_text = format_text(store_name, is_ar)
|
| 201 |
+
|
| 202 |
+
if is_ar:
|
| 203 |
+
header_data = [
|
| 204 |
+
[Paragraph(f"<b>{header_store_text}</b><br/><font size='8' color='{muted_color}'>Premium AI E-Commerce</font>", ParagraphStyle("Logo", alignment=0, fontName=font_bold, fontSize=14, textColor=primary_color)),
|
| 205 |
+
logo_img,
|
| 206 |
+
Paragraph(header_invoice_text, title_style)]
|
| 207 |
+
]
|
| 208 |
+
col_widths = [2.5*inch, 1.5*inch, 3*inch]
|
| 209 |
+
else:
|
| 210 |
+
header_data = [
|
| 211 |
+
[logo_img,
|
| 212 |
+
Paragraph(header_invoice_text, title_style),
|
| 213 |
+
Paragraph(f"<b>{header_store_text}</b><br/><font size='8' color='{muted_color}'>Premium AI E-Commerce</font>", ParagraphStyle("Logo", alignment=2, fontName=font_bold, fontSize=14, textColor=primary_color))]
|
| 214 |
+
]
|
| 215 |
+
col_widths = [1.5*inch, 2.5*inch, 3*inch]
|
| 216 |
+
|
| 217 |
+
header_table = Table(header_data, colWidths=col_widths)
|
| 218 |
+
header_table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'MIDDLE')]))
|
| 219 |
+
elements.append(header_table)
|
| 220 |
+
elements.append(Spacer(1, 10))
|
| 221 |
+
elements.append(Table([[Spacer(1, 2)]], colWidths=[7*inch], style=[('LINEBELOW', (0,0), (-1,0), 1, primary_color)]))
|
| 222 |
+
elements.append(Spacer(1, 20))
|
| 223 |
+
|
| 224 |
+
# Order Info Section
|
| 225 |
+
is_paid = order.status in [OrderStatus.PROCESSING, OrderStatus.PREPARING, OrderStatus.SHIPPED, OrderStatus.DELIVERED]
|
| 226 |
+
status_text = format_text(s["paid"] if is_paid else s["pending"], is_ar)
|
| 227 |
+
status_color = colors.HexColor("#10b981") if is_paid else colors.HexColor("#f59e0b")
|
| 228 |
+
|
| 229 |
+
order_id_label = format_text(f"{s['order_id']}:", is_ar)
|
| 230 |
+
date_label = format_text(f"{s['date']}:", is_ar)
|
| 231 |
+
|
| 232 |
+
meta_data = [
|
| 233 |
+
[
|
| 234 |
+
Paragraph(f"<b>{order_id_label}</b> #{order.id:05d}<br/><b>{date_label}</b> {order.created_at.strftime('%Y-%m-%d')}", normal_style),
|
| 235 |
+
Paragraph(f"<font color='{status_color}'><b>{status_text}</b></font>", ParagraphStyle("Status", alignment=2 if not is_ar else 0, fontSize=18, fontName=font_bold))
|
| 236 |
+
]
|
| 237 |
+
]
|
| 238 |
+
meta_table = Table(meta_data, colWidths=[3.5*inch, 3.5*inch])
|
| 239 |
+
elements.append(meta_table)
|
| 240 |
+
elements.append(Spacer(1, 20))
|
| 241 |
+
|
| 242 |
+
# Address Section
|
| 243 |
+
ship_to_label = format_text(s["ship_to"], is_ar)
|
| 244 |
+
customer_name = order.user.name if order.user else (format_text("عميل زائر", is_ar) if is_ar else "Guest Customer")
|
| 245 |
+
address_str = ""
|
| 246 |
+
# In a real app we'd fetch address details, for now we mock or use email/phone
|
| 247 |
+
contact_info = f"{order.guest_email or ''} | {order.guest_phone or ''}"
|
| 248 |
+
|
| 249 |
+
info_data = [
|
| 250 |
+
[Paragraph(f"<b>{ship_to_label}</b>", label_style)],
|
| 251 |
+
[Paragraph(format_text(customer_name, is_ar), value_style)],
|
| 252 |
+
[Paragraph(contact_info, normal_style)]
|
| 253 |
+
]
|
| 254 |
+
info_table = Table(info_data, colWidths=[7*inch])
|
| 255 |
+
elements.append(info_table)
|
| 256 |
+
elements.append(Spacer(1, 30))
|
| 257 |
+
|
| 258 |
+
# Items Table
|
| 259 |
+
headers = [s["total"], s["price"], s["qty"], s["description"]] if is_ar else [s["description"], s["qty"], s["price"], s["total"]]
|
| 260 |
+
reshaped_headers = [format_text(h, is_ar) for h in headers]
|
| 261 |
+
|
| 262 |
+
data = [reshaped_headers]
|
| 263 |
+
|
| 264 |
+
for item in order.items:
|
| 265 |
+
prod_name = (item.product.name_ar if is_ar and item.product.name_ar else item.product.name_en) if item.product else "Product"
|
| 266 |
+
if item.variant_label:
|
| 267 |
+
prod_name = f"{prod_name} - {item.variant_label}"
|
| 268 |
+
prod_name = format_text(prod_name, is_ar)
|
| 269 |
+
|
| 270 |
+
item_total = f"{item.quantity * item.price:,.2f}"
|
| 271 |
+
item_price = f"{item.price:,.2f}"
|
| 272 |
+
|
| 273 |
+
if is_ar:
|
| 274 |
+
data.append([item_total, item_price, str(item.quantity), Paragraph(prod_name, normal_style)])
|
| 275 |
+
else:
|
| 276 |
+
data.append([Paragraph(prod_name, normal_style), str(item.quantity), item_price, item_total])
|
| 277 |
+
|
| 278 |
+
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]
|
| 279 |
+
|
| 280 |
+
table = Table(data, colWidths=col_widths)
|
| 281 |
+
t_style = [
|
| 282 |
+
('BACKGROUND', (0, 0), (-1, 0), primary_color),
|
| 283 |
+
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
| 284 |
+
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
| 285 |
+
('FONTNAME', (0, 0), (-1, 0), font_bold),
|
| 286 |
+
('FONTSIZE', (0, 0), (-1, 0), 10),
|
| 287 |
+
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
| 288 |
+
('TOPPADDING', (0, 0), (-1, 0), 12),
|
| 289 |
+
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#e2e8f0")),
|
| 290 |
+
('FONTNAME', (0, 1), (-1, -1), font_family),
|
| 291 |
+
('FONTSIZE', (0, 1), (-1, -1), 9),
|
| 292 |
+
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
| 293 |
+
]
|
| 294 |
+
# Adjust alignment for descriptions
|
| 295 |
+
desc_idx = 3 if is_ar else 0
|
| 296 |
+
t_style.append(('ALIGN', (desc_idx, 1), (desc_idx, -1), 'RIGHT' if is_ar else 'LEFT'))
|
| 297 |
+
|
| 298 |
+
table.setStyle(TableStyle(t_style))
|
| 299 |
+
elements.append(table)
|
| 300 |
+
elements.append(Spacer(1, 20))
|
| 301 |
+
|
| 302 |
+
# Totals Summary
|
| 303 |
+
subtotal_val = order.total_price - order.tax - order.shipping_cost
|
| 304 |
+
shipping_text = format_text(s["free"], is_ar) if order.shipping_cost == 0 else f"{order.shipping_cost:,.2f}"
|
| 305 |
+
|
| 306 |
+
summary_data = []
|
| 307 |
+
summary_labels = [s["subtotal"], s["shipping"], s["tax"]]
|
| 308 |
+
summary_values = [f"{subtotal_val:,.2f}", shipping_text, f"{order.tax:,.2f}"]
|
| 309 |
+
|
| 310 |
+
for label, val in zip(summary_labels, summary_values):
|
| 311 |
+
lbl = format_text(f"{label}:", is_ar)
|
| 312 |
+
if is_ar:
|
| 313 |
+
summary_data.append([val, Paragraph(lbl, normal_style)])
|
| 314 |
+
else:
|
| 315 |
+
summary_data.append([Paragraph(lbl, normal_style), val])
|
| 316 |
+
|
| 317 |
+
# Grand Total Row
|
| 318 |
+
gt_label = format_text(f"{s['grand_total']}:", is_ar)
|
| 319 |
+
gt_val = f"{order.total_price:,.2f} {format_text(s['sar'], is_ar)}"
|
| 320 |
+
|
| 321 |
+
if is_ar:
|
| 322 |
+
summary_data.append([Paragraph(f"<b>{gt_val}</b>", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
|
| 323 |
+
Paragraph(f"<b>{gt_label}</b>", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
|
| 324 |
+
else:
|
| 325 |
+
summary_data.append([Paragraph(f"<b>{gt_label}</b>", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
|
| 326 |
+
Paragraph(f"<b>{gt_val}</b>", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
|
| 327 |
+
|
| 328 |
+
summary_table = Table(summary_data, colWidths=[1.5*inch, 1.5*inch] if is_ar else [1.5*inch, 1.5*inch])
|
| 329 |
+
summary_table.setStyle(TableStyle([
|
| 330 |
+
('ALIGN', (0,0), (-1,-1), 'RIGHT' if is_ar else 'LEFT'),
|
| 331 |
+
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
|
| 332 |
+
]))
|
| 333 |
+
|
| 334 |
+
outer_summary = Table([[Spacer(1,1), summary_table]], colWidths=[4*inch, 3*inch] if not is_ar else [3*inch, 4*inch])
|
| 335 |
+
elements.append(outer_summary)
|
| 336 |
+
|
| 337 |
+
# Footer
|
| 338 |
+
elements.append(Spacer(1, 60))
|
| 339 |
+
thanks_text = format_text(f"{s['thanks']} {store_name}!", is_ar)
|
| 340 |
+
footer_text = format_text(s["footer"], is_ar)
|
| 341 |
+
|
| 342 |
+
elements.append(Paragraph(f"<b>{thanks_text}</b>", ParagraphStyle("Thanks", alignment=1, fontName=font_bold, fontSize=14, textColor=secondary_color)))
|
| 343 |
+
elements.append(Paragraph(footer_text, ParagraphStyle("Footer", alignment=1, fontName=font_family, fontSize=8, textColor=muted_color, spaceBefore=5)))
|
| 344 |
+
|
| 345 |
+
# Build PDF
|
| 346 |
+
doc.build(elements)
|
| 347 |
+
buffer.seek(0)
|
| 348 |
+
|
| 349 |
+
return buffer
|