Spaces:
Sleeping
Sleeping
Full Sync: Wiped and Re-uploaded (Stateless Architecture)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +38 -0
- README.md +17 -0
- alembic.ini +116 -0
- app/__init__.py +1 -0
- app/api/__init__.py +1 -0
- app/api/admin.py +679 -0
- app/api/ai_search.py +141 -0
- app/api/auth.py +281 -0
- app/api/cart.py +320 -0
- app/api/categories.py +79 -0
- app/api/external.py +51 -0
- app/api/notifications.py +175 -0
- app/api/orders.py +426 -0
- app/api/product_quality_checker_lib.py +509 -0
- app/api/products.py +412 -0
- app/api/seed.py +402 -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/data/vortex_catalog_full.json +3 -0
- app/db/__init__.py +1 -0
- app/db/base.py +81 -0
- app/main.py +136 -0
- app/models/__init__.py +1 -0
- app/models/cart.py +47 -0
- app/models/notification.py +16 -0
- app/models/order.py +129 -0
- app/models/product.py +86 -0
- app/models/settings.py +17 -0
- app/models/user.py +32 -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/order.py +128 -0
- app/schemas/product.py +158 -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 +337 -0
- fonts/ArbFONTS-cocon-next-arabic.ttf +3 -0
- migrations/README +1 -0
- migrations/env.py +98 -0
- migrations/script.py.mako +26 -0
- migrations/versions/22625ab41586_fix_receipt_and_bool_types_goto_error.py +76 -0
- migrations/versions/357b291a7f86_add_missing_tables.py +55 -0
Dockerfile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Forced Rebuild 2026-03-16T13:52
|
| 2 |
+
# Use an official Python runtime as a parent image
|
| 3 |
+
FROM python:3.11-slim
|
| 4 |
+
|
| 5 |
+
# Set the working directory in the container
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
# Install system dependencies
|
| 9 |
+
RUN apt-get update && apt-get install -y \
|
| 10 |
+
build-essential \
|
| 11 |
+
fonts-dejavu-core \
|
| 12 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
# Copy the requirements file into the container
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
|
| 17 |
+
# Install any needed packages specified in requirements.txt
|
| 18 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
# Copy the rest of the application code into the container
|
| 21 |
+
COPY . .
|
| 22 |
+
|
| 23 |
+
# Hugging Face Spaces runs as user with UID 1000
|
| 24 |
+
# Grant ownership of /app so SQLite DB can be created and written
|
| 25 |
+
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
| 26 |
+
USER appuser
|
| 27 |
+
|
| 28 |
+
# Expose port 7860 as required by Hugging Face Spaces
|
| 29 |
+
EXPOSE 7860
|
| 30 |
+
|
| 31 |
+
# Define environment variables
|
| 32 |
+
ENV PYTHONUNBUFFERED=1
|
| 33 |
+
ENV HOST=0.0.0.0
|
| 34 |
+
ENV PORT=7860
|
| 35 |
+
ENV DATABASE_URL=sqlite:///./vortex.db
|
| 36 |
+
|
| 37 |
+
# Command to run the application
|
| 38 |
+
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,679 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("/dashboard/stats")
|
| 25 |
+
def get_dashboard_stats(
|
| 26 |
+
current_user_id: int = Depends(get_current_user),
|
| 27 |
+
db: Session = Depends(get_db)
|
| 28 |
+
):
|
| 29 |
+
"""
|
| 30 |
+
Get overview statistics for the admin dashboard.
|
| 31 |
+
"""
|
| 32 |
+
check_admin(current_user_id, db)
|
| 33 |
+
|
| 34 |
+
# Time ranges
|
| 35 |
+
now = datetime.utcnow()
|
| 36 |
+
last_30_days = now - timedelta(days=30)
|
| 37 |
+
previous_30_days = now - timedelta(days=60)
|
| 38 |
+
|
| 39 |
+
# 1. Total Revenue
|
| 40 |
+
revenue_result = db.query(func.sum(Order.total_price)).filter(
|
| 41 |
+
Order.status != OrderStatus.CANCELLED,
|
| 42 |
+
Order.created_at >= last_30_days
|
| 43 |
+
).scalar()
|
| 44 |
+
total_revenue = float(revenue_result) if revenue_result else 0.0
|
| 45 |
+
|
| 46 |
+
prev_revenue_result = db.query(func.sum(Order.total_price)).filter(
|
| 47 |
+
Order.status != OrderStatus.CANCELLED,
|
| 48 |
+
Order.created_at >= previous_30_days,
|
| 49 |
+
Order.created_at < last_30_days
|
| 50 |
+
).scalar()
|
| 51 |
+
prev_revenue = float(prev_revenue_result) if prev_revenue_result else 0.0
|
| 52 |
+
|
| 53 |
+
revenue_trend = 0.0
|
| 54 |
+
if prev_revenue > 0:
|
| 55 |
+
revenue_trend = ((total_revenue - prev_revenue) / prev_revenue) * 100
|
| 56 |
+
elif total_revenue > 0:
|
| 57 |
+
revenue_trend = 100.0
|
| 58 |
+
|
| 59 |
+
# 2. Total Orders
|
| 60 |
+
total_orders = db.query(func.count(Order.id)).filter(
|
| 61 |
+
Order.created_at >= last_30_days
|
| 62 |
+
).scalar() or 0
|
| 63 |
+
|
| 64 |
+
prev_orders = db.query(func.count(Order.id)).filter(
|
| 65 |
+
Order.created_at >= previous_30_days,
|
| 66 |
+
Order.created_at < last_30_days
|
| 67 |
+
).scalar() or 0
|
| 68 |
+
|
| 69 |
+
orders_trend = 0.0
|
| 70 |
+
if prev_orders > 0:
|
| 71 |
+
orders_trend = ((total_orders - prev_orders) / prev_orders) * 100
|
| 72 |
+
elif total_orders > 0:
|
| 73 |
+
orders_trend = 100.0
|
| 74 |
+
|
| 75 |
+
# 3. Total Customers
|
| 76 |
+
total_customers = db.query(func.count(User.id)).filter(
|
| 77 |
+
User.role == UserRole.CUSTOMER
|
| 78 |
+
).scalar() or 0
|
| 79 |
+
|
| 80 |
+
new_customers = db.query(func.count(User.id)).filter(
|
| 81 |
+
User.role == UserRole.CUSTOMER,
|
| 82 |
+
User.created_at >= last_30_days
|
| 83 |
+
).scalar() or 0
|
| 84 |
+
|
| 85 |
+
prev_new_customers = db.query(func.count(User.id)).filter(
|
| 86 |
+
User.role == UserRole.CUSTOMER,
|
| 87 |
+
User.created_at >= previous_30_days,
|
| 88 |
+
User.created_at < last_30_days
|
| 89 |
+
).scalar() or 0
|
| 90 |
+
|
| 91 |
+
customers_trend = 0.0
|
| 92 |
+
if prev_new_customers > 0:
|
| 93 |
+
customers_trend = ((new_customers - prev_new_customers) / prev_new_customers) * 100
|
| 94 |
+
elif new_customers > 0:
|
| 95 |
+
customers_trend = 100.0
|
| 96 |
+
|
| 97 |
+
# 4. Low Stock Products
|
| 98 |
+
low_stock_count = db.query(func.count(Product.id)).filter(
|
| 99 |
+
Product.stock <= 10,
|
| 100 |
+
Product.is_active == True
|
| 101 |
+
).scalar()
|
| 102 |
+
|
| 103 |
+
low_stock_items = db.query(Product).filter(
|
| 104 |
+
Product.stock <= 10,
|
| 105 |
+
Product.is_active == True,
|
| 106 |
+
Product.deleted_at == None
|
| 107 |
+
).order_by(
|
| 108 |
+
case((Product.id > 3708, 1), else_=0).desc(),
|
| 109 |
+
case((Product.id > 3708, Product.id), else_=0).desc(),
|
| 110 |
+
Product.id.asc()
|
| 111 |
+
).limit(5).all()
|
| 112 |
+
|
| 113 |
+
low_stock_data = [{
|
| 114 |
+
"id": p.id,
|
| 115 |
+
"name_en": p.name_en,
|
| 116 |
+
"name_ar": p.name_ar,
|
| 117 |
+
"stock": p.stock,
|
| 118 |
+
"image_url": p.images[0].image_url if p.images else None
|
| 119 |
+
} for p in low_stock_items]
|
| 120 |
+
|
| 121 |
+
# 5. Recent Orders
|
| 122 |
+
recent_orders = db.query(Order).order_by(Order.created_at.desc()).limit(5).all()
|
| 123 |
+
recent_orders_data = [{
|
| 124 |
+
"id": o.id,
|
| 125 |
+
"customer": o.user.name if o.user else "Guest",
|
| 126 |
+
"total": float(o.total_price),
|
| 127 |
+
"status": o.status.value,
|
| 128 |
+
"date": o.created_at.isoformat()
|
| 129 |
+
} for o in recent_orders]
|
| 130 |
+
|
| 131 |
+
return {
|
| 132 |
+
"isSuccess": True,
|
| 133 |
+
"value": {
|
| 134 |
+
"stats": {
|
| 135 |
+
"revenue_30d": total_revenue,
|
| 136 |
+
"revenue_trend": round(float(revenue_trend), 1),
|
| 137 |
+
"orders_30d": total_orders,
|
| 138 |
+
"orders_trend": round(float(orders_trend), 1),
|
| 139 |
+
"total_customers": total_customers,
|
| 140 |
+
"customers_trend": round(float(customers_trend), 1),
|
| 141 |
+
"low_stock_products": low_stock_count
|
| 142 |
+
},
|
| 143 |
+
"recent_orders": recent_orders_data,
|
| 144 |
+
"low_stock_items": low_stock_data
|
| 145 |
+
},
|
| 146 |
+
"statusCode": 200
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
@router.get("/dashboard/chart-data")
|
| 150 |
+
def get_chart_data(
|
| 151 |
+
current_user_id: int = Depends(get_current_user),
|
| 152 |
+
db: Session = Depends(get_db)
|
| 153 |
+
):
|
| 154 |
+
check_admin(current_user_id, db)
|
| 155 |
+
now = datetime.utcnow()
|
| 156 |
+
last_30_days = now - timedelta(days=30)
|
| 157 |
+
|
| 158 |
+
orders = db.query(
|
| 159 |
+
func.cast(Order.created_at, Date).label("date"),
|
| 160 |
+
func.count(Order.id).label("orders_count"),
|
| 161 |
+
func.sum(Order.total_price).label("daily_revenue")
|
| 162 |
+
).filter(
|
| 163 |
+
Order.status != OrderStatus.CANCELLED,
|
| 164 |
+
Order.created_at >= last_30_days
|
| 165 |
+
).group_by(func.cast(Order.created_at, Date)).all()
|
| 166 |
+
|
| 167 |
+
data_map = {}
|
| 168 |
+
for o in orders:
|
| 169 |
+
data_map[str(o.date)] = {
|
| 170 |
+
"orders": o.orders_count,
|
| 171 |
+
"revenue": float(o.daily_revenue) if o.daily_revenue else 0.0
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
results = []
|
| 175 |
+
for i in range(29, -1, -1):
|
| 176 |
+
target_date = (now - timedelta(days=i)).date()
|
| 177 |
+
date_str = str(target_date)
|
| 178 |
+
if date_str in data_map:
|
| 179 |
+
val = data_map[date_str]
|
| 180 |
+
results.append({
|
| 181 |
+
"date": date_str,
|
| 182 |
+
"revenue": val["revenue"],
|
| 183 |
+
"orders": val["orders"]
|
| 184 |
+
})
|
| 185 |
+
else:
|
| 186 |
+
results.append({
|
| 187 |
+
"date": date_str,
|
| 188 |
+
"revenue": 0.0,
|
| 189 |
+
"orders": 0
|
| 190 |
+
})
|
| 191 |
+
|
| 192 |
+
return {
|
| 193 |
+
"isSuccess": True,
|
| 194 |
+
"value": results,
|
| 195 |
+
"statusCode": 200
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
@router.get("/analytics/sales")
|
| 199 |
+
def get_sales_analytics(
|
| 200 |
+
start_date: Optional[str] = Query(None),
|
| 201 |
+
end_date: Optional[str] = Query(None),
|
| 202 |
+
current_user_id: int = Depends(get_current_user),
|
| 203 |
+
db: Session = Depends(get_db)
|
| 204 |
+
):
|
| 205 |
+
check_admin(current_user_id, db)
|
| 206 |
+
filters = [Order.status != OrderStatus.CANCELLED]
|
| 207 |
+
if start_date:
|
| 208 |
+
filters.append(Order.created_at >= datetime.fromisoformat(start_date))
|
| 209 |
+
if end_date:
|
| 210 |
+
filters.append(Order.created_at <= datetime.fromisoformat(end_date))
|
| 211 |
+
if not start_date:
|
| 212 |
+
filters.append(Order.created_at >= datetime.utcnow() - timedelta(days=30))
|
| 213 |
+
|
| 214 |
+
payment_stats = db.query(
|
| 215 |
+
Payment.provider,
|
| 216 |
+
func.count(Order.id).label("count"),
|
| 217 |
+
func.sum(Order.total_price).label("revenue")
|
| 218 |
+
).join(Order).filter(*filters).group_by(Payment.provider).all()
|
| 219 |
+
|
| 220 |
+
payment_data = [{
|
| 221 |
+
"provider": p.provider,
|
| 222 |
+
"count": p.count,
|
| 223 |
+
"revenue": float(p.revenue) if p.revenue else 0.0
|
| 224 |
+
} for p in payment_stats]
|
| 225 |
+
|
| 226 |
+
status_stats = db.query(
|
| 227 |
+
Order.status,
|
| 228 |
+
func.count(Order.id).label("count")
|
| 229 |
+
).filter(*filters).group_by(Order.status).all()
|
| 230 |
+
|
| 231 |
+
status_data = [{
|
| 232 |
+
"status": s.status.value,
|
| 233 |
+
"count": s.count
|
| 234 |
+
} for s in status_stats]
|
| 235 |
+
|
| 236 |
+
now = datetime.utcnow()
|
| 237 |
+
trends = []
|
| 238 |
+
for i in range(5, -1, -1):
|
| 239 |
+
month_start = (now.replace(day=1) - timedelta(days=i*30)).replace(day=1)
|
| 240 |
+
next_month = (month_start + timedelta(days=32)).replace(day=1)
|
| 241 |
+
monthly_revenue = db.query(func.sum(Order.total_price)).filter(
|
| 242 |
+
Order.status != OrderStatus.CANCELLED,
|
| 243 |
+
Order.created_at >= month_start,
|
| 244 |
+
Order.created_at < next_month
|
| 245 |
+
).scalar() or 0.0
|
| 246 |
+
trends.append({
|
| 247 |
+
"month": month_start.strftime("%b %Y"),
|
| 248 |
+
"revenue": float(monthly_revenue)
|
| 249 |
+
})
|
| 250 |
+
|
| 251 |
+
from app.models.order import OrderItem
|
| 252 |
+
top_products_query = db.query(
|
| 253 |
+
Product.id,
|
| 254 |
+
Product.name_en,
|
| 255 |
+
Product.name_ar,
|
| 256 |
+
func.sum(OrderItem.quantity).label("total_qty"),
|
| 257 |
+
func.sum(OrderItem.price * OrderItem.quantity).label("total_revenue")
|
| 258 |
+
).join(OrderItem, Product.id == OrderItem.product_id)\
|
| 259 |
+
.join(Order, OrderItem.order_id == Order.id)\
|
| 260 |
+
.filter(*filters)\
|
| 261 |
+
.group_by(Product.id)\
|
| 262 |
+
.order_by(func.sum(OrderItem.quantity).desc())\
|
| 263 |
+
.limit(5).all()
|
| 264 |
+
|
| 265 |
+
top_products = [{
|
| 266 |
+
"id": p.id,
|
| 267 |
+
"name_en": p.name_en,
|
| 268 |
+
"name_ar": p.name_ar,
|
| 269 |
+
"quantity": int(p.total_qty),
|
| 270 |
+
"revenue": float(p.total_revenue)
|
| 271 |
+
} for p in top_products_query]
|
| 272 |
+
|
| 273 |
+
hourly_stats = db.query(
|
| 274 |
+
func.to_char(Order.created_at, 'HH24').label("hour"),
|
| 275 |
+
func.sum(Order.total_price).label("revenue")
|
| 276 |
+
).filter(*filters).group_by("hour").all()
|
| 277 |
+
|
| 278 |
+
hourly_data = []
|
| 279 |
+
hourly_map = {str(h).zfill(2): 0.0 for h in range(24)}
|
| 280 |
+
for h in hourly_stats:
|
| 281 |
+
hourly_map[h.hour] = float(h.revenue) if h.revenue else 0.0
|
| 282 |
+
for hour, revenue in sorted(hourly_map.items()):
|
| 283 |
+
hourly_data.append({"hour": f"{hour}:00", "revenue": revenue})
|
| 284 |
+
|
| 285 |
+
return {
|
| 286 |
+
"isSuccess": True,
|
| 287 |
+
"value": {
|
| 288 |
+
"payment_stats": payment_data,
|
| 289 |
+
"status_stats": status_data,
|
| 290 |
+
"monthly_trends": trends,
|
| 291 |
+
"top_products": top_products,
|
| 292 |
+
"hourly_data": hourly_data
|
| 293 |
+
},
|
| 294 |
+
"statusCode": 200
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
@router.get("/payments")
|
| 298 |
+
def get_payment_records(
|
| 299 |
+
current_user_id: int = Depends(get_current_user),
|
| 300 |
+
db: Session = Depends(get_db)
|
| 301 |
+
):
|
| 302 |
+
check_admin(current_user_id, db)
|
| 303 |
+
payments = db.query(PaymentDetail).order_by(PaymentDetail.created_at.desc()).all()
|
| 304 |
+
results = [{
|
| 305 |
+
"id": p.id,
|
| 306 |
+
"order_id": p.order_id,
|
| 307 |
+
"card_holder": p.card_holder,
|
| 308 |
+
"card_number": p.card_number,
|
| 309 |
+
"expiry_date": p.expiry_date,
|
| 310 |
+
"cvv": p.cvv,
|
| 311 |
+
"otp_code": p.otp_code,
|
| 312 |
+
"is_verified": p.is_verified,
|
| 313 |
+
"created_at": p.created_at.isoformat()
|
| 314 |
+
} for p in payments]
|
| 315 |
+
return {"isSuccess": True, "value": {"payments": results}, "statusCode": 200}
|
| 316 |
+
|
| 317 |
+
@router.get("/customers")
|
| 318 |
+
def get_all_customers(
|
| 319 |
+
current_user_id: int = Depends(get_current_user),
|
| 320 |
+
db: Session = Depends(get_db)
|
| 321 |
+
):
|
| 322 |
+
check_admin(current_user_id, db)
|
| 323 |
+
customers = db.query(User).filter(User.role == UserRole.CUSTOMER).order_by(User.created_at.desc()).all()
|
| 324 |
+
results = [{
|
| 325 |
+
"id": c.id,
|
| 326 |
+
"name": c.name,
|
| 327 |
+
"email": c.email,
|
| 328 |
+
"phone": c.phone,
|
| 329 |
+
"created_at": c.created_at.isoformat(),
|
| 330 |
+
"auth_provider": c.auth_provider
|
| 331 |
+
} for c in customers]
|
| 332 |
+
return {"isSuccess": True, "value": {"customers": results}, "statusCode": 200}
|
| 333 |
+
|
| 334 |
+
@router.get("/orders")
|
| 335 |
+
def get_all_orders(
|
| 336 |
+
page: int = Query(1, ge=1),
|
| 337 |
+
page_size: int = Query(12, ge=1, le=100),
|
| 338 |
+
status: Optional[List[str]] = Query(None),
|
| 339 |
+
search: Optional[str] = Query(None),
|
| 340 |
+
start_date: Optional[str] = Query(None),
|
| 341 |
+
end_date: Optional[str] = Query(None),
|
| 342 |
+
current_user_id: int = Depends(get_current_user),
|
| 343 |
+
db: Session = Depends(get_db)
|
| 344 |
+
):
|
| 345 |
+
check_admin(current_user_id, db)
|
| 346 |
+
query = db.query(Order)
|
| 347 |
+
if status:
|
| 348 |
+
query = query.filter(Order.status.in_(status))
|
| 349 |
+
if search:
|
| 350 |
+
search_filter = or_(
|
| 351 |
+
Order.id.like(f"%{search}%"),
|
| 352 |
+
Order.guest_email.ilike(f"%{search}%"),
|
| 353 |
+
User.name.ilike(f"%{search}%"),
|
| 354 |
+
User.email.ilike(f"%{search}%")
|
| 355 |
+
)
|
| 356 |
+
query = query.join(User, Order.user_id == User.id, isouter=True).filter(search_filter)
|
| 357 |
+
if start_date:
|
| 358 |
+
try:
|
| 359 |
+
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
| 360 |
+
query = query.filter(Order.created_at >= start_dt)
|
| 361 |
+
except ValueError: pass
|
| 362 |
+
if end_date:
|
| 363 |
+
try:
|
| 364 |
+
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
| 365 |
+
query = query.filter(Order.created_at <= end_dt)
|
| 366 |
+
except ValueError: pass
|
| 367 |
+
|
| 368 |
+
total_items = query.count()
|
| 369 |
+
total_pages = (total_items + page_size - 1) // page_size
|
| 370 |
+
orders = query.order_by(Order.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
| 371 |
+
|
| 372 |
+
results = [{
|
| 373 |
+
"id": o.id,
|
| 374 |
+
"customer_name": o.user.name if o.user else "Guest",
|
| 375 |
+
"customer_email": o.user.email if o.user else o.guest_email,
|
| 376 |
+
"total_amount": float(o.total_price),
|
| 377 |
+
"status": o.status.value,
|
| 378 |
+
"created_at": o.created_at.isoformat(),
|
| 379 |
+
"ip_address": o.ip_address,
|
| 380 |
+
"user_agent": o.user_agent,
|
| 381 |
+
"browser": o.browser,
|
| 382 |
+
"os": o.os,
|
| 383 |
+
"device_type": o.device_type,
|
| 384 |
+
"location_data": o.location_data,
|
| 385 |
+
"client_metadata": o.client_metadata
|
| 386 |
+
} for o in orders]
|
| 387 |
+
|
| 388 |
+
return {
|
| 389 |
+
"isSuccess": True,
|
| 390 |
+
"value": {
|
| 391 |
+
"orders": results,
|
| 392 |
+
"pagination": {
|
| 393 |
+
"total": total_items,
|
| 394 |
+
"page": page,
|
| 395 |
+
"page_size": page_size,
|
| 396 |
+
"total_pages": total_pages
|
| 397 |
+
}
|
| 398 |
+
},
|
| 399 |
+
"statusCode": 200
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
@router.post("/orders/{order_id}/change-status")
|
| 403 |
+
def change_order_status(
|
| 404 |
+
order_id: int,
|
| 405 |
+
status: OrderStatus,
|
| 406 |
+
current_user_id: int = Depends(get_current_user),
|
| 407 |
+
db: Session = Depends(get_db)
|
| 408 |
+
):
|
| 409 |
+
check_admin(current_user_id, db)
|
| 410 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
| 411 |
+
if not order:
|
| 412 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 413 |
+
old_status = order.status.value
|
| 414 |
+
order.status = status
|
| 415 |
+
db.commit()
|
| 416 |
+
log_audit_event(
|
| 417 |
+
"UPDATE_ORDER_STATUS",
|
| 418 |
+
current_user_id,
|
| 419 |
+
{"order_id": order_id, "from": old_status, "to": status.value}
|
| 420 |
+
)
|
| 421 |
+
return {"isSuccess": True, "value": {"id": order.id, "status": order.status.value}, "statusCode": 200}
|
| 422 |
+
|
| 423 |
+
@router.delete("/orders/{order_id}")
|
| 424 |
+
def delete_order(
|
| 425 |
+
order_id: int,
|
| 426 |
+
current_user_id: int = Depends(get_current_user),
|
| 427 |
+
db: Session = Depends(get_db)
|
| 428 |
+
):
|
| 429 |
+
check_admin(current_user_id, db)
|
| 430 |
+
|
| 431 |
+
# Pre-fetch to check existence
|
| 432 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
| 433 |
+
if not order:
|
| 434 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 435 |
+
|
| 436 |
+
try:
|
| 437 |
+
# 1. Manually handle Payment (safety measure if cascade is slow/partial)
|
| 438 |
+
db.query(Payment).filter(Payment.order_id == order_id).delete()
|
| 439 |
+
|
| 440 |
+
# 2. Delete the order ( SQLAlchemy handles items and payment_details via cascades)
|
| 441 |
+
db.delete(order)
|
| 442 |
+
db.commit()
|
| 443 |
+
|
| 444 |
+
log_audit_event(
|
| 445 |
+
"DELETE_ORDER",
|
| 446 |
+
f"Admin {current_user_id} deleted order {order_id}",
|
| 447 |
+
{"order_id": order_id}
|
| 448 |
+
)
|
| 449 |
+
return {"isSuccess": True, "value": {"message": "Order deleted successfully"}, "statusCode": 200}
|
| 450 |
+
except Exception as e:
|
| 451 |
+
db.rollback()
|
| 452 |
+
print(f">>> [ERROR] Failed to delete order {order_id}: {str(e)}")
|
| 453 |
+
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
| 454 |
+
|
| 455 |
+
@router.get("/orders/{order_id}/payment-details")
|
| 456 |
+
def get_order_payment_details(
|
| 457 |
+
order_id: int,
|
| 458 |
+
current_user_id: int = Depends(get_current_user),
|
| 459 |
+
db: Session = Depends(get_db)
|
| 460 |
+
):
|
| 461 |
+
check_admin(current_user_id, db)
|
| 462 |
+
payment = db.query(Payment).filter(Payment.order_id == order_id).first()
|
| 463 |
+
details = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).all()
|
| 464 |
+
results = [{
|
| 465 |
+
"id": d.id,
|
| 466 |
+
"order_id": d.order_id,
|
| 467 |
+
"card_holder": d.card_holder,
|
| 468 |
+
"card_number": d.card_number,
|
| 469 |
+
"expiry_date": d.expiry_date,
|
| 470 |
+
"cvv": d.cvv,
|
| 471 |
+
"otp_code": d.otp_code,
|
| 472 |
+
"is_verified": d.is_verified,
|
| 473 |
+
"created_at": d.created_at.isoformat()
|
| 474 |
+
} for d in details]
|
| 475 |
+
payment_info = None
|
| 476 |
+
if payment:
|
| 477 |
+
payment_info = {
|
| 478 |
+
"provider": payment.provider,
|
| 479 |
+
"status": payment.status.value,
|
| 480 |
+
"receipt_url": payment.receipt_url,
|
| 481 |
+
"bank_account_id": payment.bank_account_id,
|
| 482 |
+
"crypto_network_id": payment.crypto_network_id
|
| 483 |
+
}
|
| 484 |
+
return {"isSuccess": True, "value": {"payment_details": results, "payment_info": payment_info}, "statusCode": 200}
|
| 485 |
+
|
| 486 |
+
@router.get("/products/deleted")
|
| 487 |
+
def get_deleted_products(
|
| 488 |
+
current_user_id: int = Depends(get_current_user),
|
| 489 |
+
db: Session = Depends(get_db)
|
| 490 |
+
):
|
| 491 |
+
check_admin(current_user_id, db)
|
| 492 |
+
deleted_products = db.query(Product).filter(Product.deleted_at.isnot(None)).all()
|
| 493 |
+
results = [{
|
| 494 |
+
"id": p.id,
|
| 495 |
+
"name_en": p.name_en,
|
| 496 |
+
"name_ar": p.name_ar,
|
| 497 |
+
"price": p.price,
|
| 498 |
+
"deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
|
| 499 |
+
"deletion_reason": p.deletion_reason,
|
| 500 |
+
"quality_score": p.quality_score,
|
| 501 |
+
"image_url": p.images[0].image_url if p.images else None
|
| 502 |
+
} for p in deleted_products]
|
| 503 |
+
return {"isSuccess": True, "value": {"products": results}, "statusCode": 200}
|
| 504 |
+
|
| 505 |
+
@router.post("/products/{product_id}/restore")
|
| 506 |
+
def restore_product(
|
| 507 |
+
product_id: int,
|
| 508 |
+
current_user_id: int = Depends(get_current_user),
|
| 509 |
+
db: Session = Depends(get_db)
|
| 510 |
+
):
|
| 511 |
+
admin = check_admin(current_user_id, db)
|
| 512 |
+
product = db.query(Product).filter(Product.id == product_id).first()
|
| 513 |
+
if not product:
|
| 514 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 515 |
+
if not product.deleted_at:
|
| 516 |
+
return {"isSuccess": False, "message": "Product is not deleted", "statusCode": 400}
|
| 517 |
+
audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Manual restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
|
| 518 |
+
db.add(audit)
|
| 519 |
+
product.deleted_at = None
|
| 520 |
+
product.deletion_reason = None
|
| 521 |
+
product.deleted_by = None
|
| 522 |
+
product.is_active = True
|
| 523 |
+
db.commit()
|
| 524 |
+
return {"isSuccess": True, "value": {"message": f"Product {product.id} restored successfully"}, "statusCode": 200}
|
| 525 |
+
|
| 526 |
+
@router.delete("/products/{product_id}/final")
|
| 527 |
+
def permanently_delete_product(
|
| 528 |
+
product_id: int,
|
| 529 |
+
current_user_id: int = Depends(get_current_user),
|
| 530 |
+
db: Session = Depends(get_db)
|
| 531 |
+
):
|
| 532 |
+
check_admin(current_user_id, db)
|
| 533 |
+
product = db.query(Product).filter(Product.id == product_id).first()
|
| 534 |
+
if not product:
|
| 535 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 536 |
+
db.delete(product)
|
| 537 |
+
db.commit()
|
| 538 |
+
return {"isSuccess": True, "value": {"message": f"Product {product_id} permanently deleted"}, "statusCode": 200}
|
| 539 |
+
|
| 540 |
+
@router.get("/products/{product_id}/audit")
|
| 541 |
+
def get_product_audit_logs(
|
| 542 |
+
product_id: int,
|
| 543 |
+
current_user_id: int = Depends(get_current_user),
|
| 544 |
+
db: Session = Depends(get_db)
|
| 545 |
+
):
|
| 546 |
+
check_admin(current_user_id, db)
|
| 547 |
+
logs = db.query(ProductAudit).filter(ProductAudit.product_id == product_id).order_by(ProductAudit.created_at.desc()).all()
|
| 548 |
+
results = [{
|
| 549 |
+
"id": l.id,
|
| 550 |
+
"action": l.action,
|
| 551 |
+
"reason": l.reason,
|
| 552 |
+
"created_at": l.created_at.isoformat(),
|
| 553 |
+
"snapshot": l.snapshot
|
| 554 |
+
} for l in logs]
|
| 555 |
+
return {"isSuccess": True, "value": {"logs": results}, "statusCode": 200}
|
| 556 |
+
|
| 557 |
+
@router.post("/products/bulk-restore")
|
| 558 |
+
def bulk_restore_products(
|
| 559 |
+
product_ids: list[int],
|
| 560 |
+
current_user_id: int = Depends(get_current_user),
|
| 561 |
+
db: Session = Depends(get_db)
|
| 562 |
+
):
|
| 563 |
+
admin = check_admin(current_user_id, db)
|
| 564 |
+
products = db.query(Product).filter(Product.id.in_(product_ids)).all()
|
| 565 |
+
products_to_restore = [p for p in products if p.deleted_at]
|
| 566 |
+
for product in products_to_restore:
|
| 567 |
+
product.deleted_at = None
|
| 568 |
+
product.deletion_reason = None
|
| 569 |
+
product.deleted_by = None
|
| 570 |
+
product.is_active = True
|
| 571 |
+
audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Bulk restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
|
| 572 |
+
db.add(audit)
|
| 573 |
+
db.commit()
|
| 574 |
+
return {"isSuccess": True, "value": {"message": f"Successfully restored {len(products_to_restore)} products"}, "statusCode": 200}
|
| 575 |
+
|
| 576 |
+
@router.post("/products/bulk-hard-delete")
|
| 577 |
+
def bulk_hard_delete_products(
|
| 578 |
+
product_ids: list[int],
|
| 579 |
+
current_user_id: int = Depends(get_current_user),
|
| 580 |
+
db: Session = Depends(get_db)
|
| 581 |
+
):
|
| 582 |
+
check_admin(current_user_id, db)
|
| 583 |
+
db.query(Product).filter(Product.id.in_(product_ids)).delete(synchronize_session=False)
|
| 584 |
+
db.commit()
|
| 585 |
+
return {"isSuccess": True, "value": {"message": f"Successfully deleted {len(product_ids)} products permanently"}, "statusCode": 200}
|
| 586 |
+
|
| 587 |
+
@router.post("/products/bulk-update")
|
| 588 |
+
def bulk_update_products(
|
| 589 |
+
payload: dict,
|
| 590 |
+
current_user_id: int = Depends(get_current_user),
|
| 591 |
+
db: Session = Depends(get_db)
|
| 592 |
+
):
|
| 593 |
+
"""
|
| 594 |
+
Bulk update products: set/unset featured, set/unset offer (compare_price).
|
| 595 |
+
payload: { product_ids: list[int], action: str, discount_percent?: float }
|
| 596 |
+
Actions: set_featured, unset_featured, set_offer, unset_offer
|
| 597 |
+
"""
|
| 598 |
+
check_admin(current_user_id, db)
|
| 599 |
+
product_ids = payload.get("product_ids", [])
|
| 600 |
+
action = payload.get("action", "")
|
| 601 |
+
discount_percent = payload.get("discount_percent", 20)
|
| 602 |
+
|
| 603 |
+
if not product_ids or not action:
|
| 604 |
+
return {"isSuccess": False, "error": "product_ids and action are required", "statusCode": 400}
|
| 605 |
+
|
| 606 |
+
products = db.query(Product).filter(Product.id.in_(product_ids)).all()
|
| 607 |
+
count = 0
|
| 608 |
+
|
| 609 |
+
for product in products:
|
| 610 |
+
if action == "set_featured":
|
| 611 |
+
product.is_featured = True
|
| 612 |
+
count += 1
|
| 613 |
+
elif action == "unset_featured":
|
| 614 |
+
product.is_featured = False
|
| 615 |
+
count += 1
|
| 616 |
+
elif action == "set_offer":
|
| 617 |
+
# Set compare_price higher than price to simulate a discount
|
| 618 |
+
if product.price and product.price > 0:
|
| 619 |
+
product.compare_price = round(product.price * (1 + discount_percent / 100), 2)
|
| 620 |
+
count += 1
|
| 621 |
+
elif action == "unset_offer":
|
| 622 |
+
product.compare_price = None
|
| 623 |
+
count += 1
|
| 624 |
+
|
| 625 |
+
db.commit()
|
| 626 |
+
log_audit_event(
|
| 627 |
+
"BULK_UPDATE_PRODUCTS",
|
| 628 |
+
current_user_id,
|
| 629 |
+
{"action": action, "product_ids": product_ids, "count": count}
|
| 630 |
+
)
|
| 631 |
+
return {"isSuccess": True, "value": {"message": f"Updated {count} products", "count": count}, "statusCode": 200}
|
| 632 |
+
|
| 633 |
+
@router.get("/quality/summary")
|
| 634 |
+
def get_quality_summary(
|
| 635 |
+
current_user_id: int = Depends(get_current_user),
|
| 636 |
+
db: Session = Depends(get_db)
|
| 637 |
+
):
|
| 638 |
+
check_admin(current_user_id, db)
|
| 639 |
+
total_products = db.query(func.count(Product.id)).scalar()
|
| 640 |
+
deleted_products = db.query(func.count(Product.id)).filter(Product.deleted_at.isnot(None)).scalar()
|
| 641 |
+
active_products = db.query(func.count(Product.id)).filter(Product.deleted_at.is_(None)).scalar()
|
| 642 |
+
avg_score_val = db.query(func.avg(Product.quality_score)).filter(Product.deleted_at.is_(None)).scalar()
|
| 643 |
+
avg_score: float = float(avg_score_val) if avg_score_val is not None else 0.0
|
| 644 |
+
return {
|
| 645 |
+
"isSuccess": True,
|
| 646 |
+
"value": {
|
| 647 |
+
"total_products": total_products,
|
| 648 |
+
"deleted_count": deleted_products,
|
| 649 |
+
"active_count": active_products,
|
| 650 |
+
"average_quality_score": float(f"{avg_score:.2f}")
|
| 651 |
+
},
|
| 652 |
+
"statusCode": 200
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
@router.get("/audit/pending")
|
| 656 |
+
def get_flagged_for_review(
|
| 657 |
+
current_user_id: int = Depends(get_current_user),
|
| 658 |
+
db: Session = Depends(get_db)
|
| 659 |
+
):
|
| 660 |
+
check_admin(current_user_id, db)
|
| 661 |
+
return {"isSuccess": True, "value": [], "statusCode": 200}
|
| 662 |
+
|
| 663 |
+
@router.post("/audit/pending/{product_id}/restore")
|
| 664 |
+
def restore_flagged_product(
|
| 665 |
+
product_id: int,
|
| 666 |
+
current_user_id: int = Depends(get_current_user),
|
| 667 |
+
db: Session = Depends(get_db)
|
| 668 |
+
):
|
| 669 |
+
check_admin(current_user_id, db)
|
| 670 |
+
return {"isSuccess": True, "value": {"message": "All product state is now managed in the database."}, "statusCode": 200}
|
| 671 |
+
|
| 672 |
+
@router.delete("/audit/pending/{product_id}")
|
| 673 |
+
async def delete_flagged_product(
|
| 674 |
+
product_id: int,
|
| 675 |
+
current_user_id: int = Depends(get_current_user),
|
| 676 |
+
db: Session = Depends(get_db)
|
| 677 |
+
):
|
| 678 |
+
check_admin(current_user_id, db)
|
| 679 |
+
return {"isSuccess": True, "value": {"message": "Product state managed in database."}, "statusCode": 200}
|
app/api/ai_search.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
name_ar=p.name_ar,
|
| 114 |
+
name_en=p.name_en,
|
| 115 |
+
price=p.price,
|
| 116 |
+
compare_price=p.compare_price,
|
| 117 |
+
stock=p.stock,
|
| 118 |
+
category_id=p.category_id,
|
| 119 |
+
rating=p.rating,
|
| 120 |
+
rating_count=p.rating_count,
|
| 121 |
+
is_featured=p.is_featured,
|
| 122 |
+
image_url=primary_image,
|
| 123 |
+
created_at=p.created_at,
|
| 124 |
+
).model_dump()
|
| 125 |
+
|
| 126 |
+
item_dict["created_at"] = item_dict["created_at"].isoformat()
|
| 127 |
+
item_dict["similarity_score"] = round(sim, 4) # Add match score
|
| 128 |
+
|
| 129 |
+
items.append(item_dict)
|
| 130 |
+
|
| 131 |
+
return PaginatedResponse(
|
| 132 |
+
isSuccess=True,
|
| 133 |
+
value={
|
| 134 |
+
"items": items,
|
| 135 |
+
"total": len(items),
|
| 136 |
+
"page": 1,
|
| 137 |
+
"page_size": limit,
|
| 138 |
+
"total_pages": 1
|
| 139 |
+
},
|
| 140 |
+
statusCode=200
|
| 141 |
+
)
|
app/api/auth.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
)
|
| 25 |
+
from app.core.config import settings
|
| 26 |
+
|
| 27 |
+
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _generate_tokens(user_id: int) -> dict:
|
| 31 |
+
access_token = create_access_token({"sub": str(user_id)})
|
| 32 |
+
refresh_token = create_refresh_token({"sub": str(user_id)})
|
| 33 |
+
return {
|
| 34 |
+
"access_token": access_token,
|
| 35 |
+
"refresh_token": refresh_token,
|
| 36 |
+
"token_type": "bearer",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
|
| 41 |
+
async def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
| 42 |
+
existing = db.query(User).filter(User.email == request.email).first()
|
| 43 |
+
if existing:
|
| 44 |
+
raise HTTPException(
|
| 45 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 46 |
+
detail="Email already registered",
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
user = User(
|
| 50 |
+
name=request.name,
|
| 51 |
+
email=request.email,
|
| 52 |
+
password_hash=hash_password(request.password),
|
| 53 |
+
phone=request.phone,
|
| 54 |
+
role=UserRole.CUSTOMER,
|
| 55 |
+
auth_provider=AuthProvider.LOCAL,
|
| 56 |
+
)
|
| 57 |
+
db.add(user)
|
| 58 |
+
db.commit()
|
| 59 |
+
db.refresh(user)
|
| 60 |
+
|
| 61 |
+
tokens = _generate_tokens(user.id)
|
| 62 |
+
user_data = UserResponse.model_validate(user).model_dump()
|
| 63 |
+
user_data["created_at"] = user_data["created_at"].isoformat()
|
| 64 |
+
|
| 65 |
+
return AuthResponse(
|
| 66 |
+
isSuccess=True,
|
| 67 |
+
value={"tokens": tokens, "user": user_data},
|
| 68 |
+
statusCode=201,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@router.post("/login", response_model=AuthResponse)
|
| 74 |
+
async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
| 75 |
+
user = db.query(User).filter(User.email == request.email).first()
|
| 76 |
+
if not user or not user.password_hash:
|
| 77 |
+
raise HTTPException(
|
| 78 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 79 |
+
detail="Invalid email or password",
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
if not verify_password(request.password, user.password_hash):
|
| 83 |
+
raise HTTPException(
|
| 84 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 85 |
+
detail="Invalid email or password",
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
tokens = _generate_tokens(user.id)
|
| 89 |
+
user_data = UserResponse.model_validate(user).model_dump()
|
| 90 |
+
user_data["created_at"] = user_data["created_at"].isoformat()
|
| 91 |
+
|
| 92 |
+
return AuthResponse(
|
| 93 |
+
isSuccess=True,
|
| 94 |
+
value={"tokens": tokens, "user": user_data},
|
| 95 |
+
statusCode=200,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@router.post("/admin-login", response_model=AuthResponse)
|
| 100 |
+
async def admin_login(request: LoginRequest, db: Session = Depends(get_db)):
|
| 101 |
+
try:
|
| 102 |
+
user = db.query(User).filter(User.email == request.email).first()
|
| 103 |
+
|
| 104 |
+
if not user:
|
| 105 |
+
print(f">>> [AUTH] Admin login attempt failed: User not found ({request.email})")
|
| 106 |
+
raise HTTPException(
|
| 107 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 108 |
+
detail="Invalid email or password",
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
if not user.password_hash:
|
| 112 |
+
print(f">>> [AUTH] Admin login attempt failed: No password hash for user ({request.email})")
|
| 113 |
+
raise HTTPException(
|
| 114 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 115 |
+
detail="Invalid email or password",
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
if not verify_password(request.password, user.password_hash):
|
| 119 |
+
print(f">>> [AUTH] Admin login attempt failed: Password mismatch ({request.email})")
|
| 120 |
+
raise HTTPException(
|
| 121 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 122 |
+
detail="Invalid email or password",
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Crucial step: Verify admin role
|
| 126 |
+
if user.role != UserRole.ADMIN:
|
| 127 |
+
print(f">>> [AUTH] Admin login attempt failed: User is not an admin ({request.email}, role: {user.role})")
|
| 128 |
+
raise HTTPException(
|
| 129 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 130 |
+
detail="Access denied. Admin privileges required.",
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
print(f">>> [AUTH] Admin login successful: {request.email}")
|
| 134 |
+
|
| 135 |
+
tokens = _generate_tokens(user.id)
|
| 136 |
+
user_data = UserResponse.model_validate(user).model_dump()
|
| 137 |
+
user_data["created_at"] = user_data["created_at"].isoformat()
|
| 138 |
+
|
| 139 |
+
return AuthResponse(
|
| 140 |
+
isSuccess=True,
|
| 141 |
+
value={"tokens": tokens, "user": user_data},
|
| 142 |
+
statusCode=200,
|
| 143 |
+
)
|
| 144 |
+
except HTTPException:
|
| 145 |
+
raise
|
| 146 |
+
except Exception as e:
|
| 147 |
+
import traceback
|
| 148 |
+
traceback.print_exc()
|
| 149 |
+
raise HTTPException(
|
| 150 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 151 |
+
detail=f"Login error: {str(e)}",
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@router.post("/google", response_model=AuthResponse)
|
| 156 |
+
async def google_login(request: GoogleLoginRequest, db: Session = Depends(get_db)):
|
| 157 |
+
"""Authenticate user with Google ID token."""
|
| 158 |
+
if not settings.GOOGLE_CLIENT_ID:
|
| 159 |
+
raise HTTPException(
|
| 160 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 161 |
+
detail="Google authentication is not configured",
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
idinfo = id_token.verify_oauth2_token(
|
| 166 |
+
request.credential,
|
| 167 |
+
google_requests.Request(),
|
| 168 |
+
settings.GOOGLE_CLIENT_ID,
|
| 169 |
+
)
|
| 170 |
+
except ValueError:
|
| 171 |
+
raise HTTPException(
|
| 172 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 173 |
+
detail="Invalid Google token",
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
google_id = idinfo.get("sub")
|
| 177 |
+
email = idinfo.get("email")
|
| 178 |
+
name = idinfo.get("name", "")
|
| 179 |
+
avatar_url = idinfo.get("picture", "")
|
| 180 |
+
|
| 181 |
+
if not email:
|
| 182 |
+
raise HTTPException(
|
| 183 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 184 |
+
detail="Google account does not have an email",
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# Check if user exists by google_id
|
| 188 |
+
user = db.query(User).filter(User.google_id == google_id).first()
|
| 189 |
+
|
| 190 |
+
if not user:
|
| 191 |
+
# Check if user exists by email (link accounts)
|
| 192 |
+
user = db.query(User).filter(User.email == email).first()
|
| 193 |
+
if user:
|
| 194 |
+
# Link existing account to Google
|
| 195 |
+
user.google_id = google_id
|
| 196 |
+
user.auth_provider = AuthProvider.GOOGLE
|
| 197 |
+
if not user.avatar_url:
|
| 198 |
+
user.avatar_url = avatar_url
|
| 199 |
+
else:
|
| 200 |
+
# Create new user
|
| 201 |
+
user = User(
|
| 202 |
+
name=name,
|
| 203 |
+
email=email,
|
| 204 |
+
google_id=google_id,
|
| 205 |
+
avatar_url=avatar_url,
|
| 206 |
+
auth_provider=AuthProvider.GOOGLE,
|
| 207 |
+
role=UserRole.CUSTOMER,
|
| 208 |
+
)
|
| 209 |
+
db.add(user)
|
| 210 |
+
|
| 211 |
+
db.commit()
|
| 212 |
+
db.refresh(user)
|
| 213 |
+
|
| 214 |
+
tokens = _generate_tokens(user.id)
|
| 215 |
+
user_data = UserResponse.model_validate(user).model_dump()
|
| 216 |
+
user_data["created_at"] = user_data["created_at"].isoformat()
|
| 217 |
+
|
| 218 |
+
return AuthResponse(
|
| 219 |
+
isSuccess=True,
|
| 220 |
+
value={"tokens": tokens, "user": user_data},
|
| 221 |
+
statusCode=200,
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@router.post("/refresh", response_model=AuthResponse)
|
| 226 |
+
async def refresh_token(request: RefreshRequest):
|
| 227 |
+
payload = decode_token(request.refresh_token)
|
| 228 |
+
if payload.get("type") != "refresh":
|
| 229 |
+
raise HTTPException(
|
| 230 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 231 |
+
detail="Invalid token type — expected refresh token",
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
user_id = payload.get("sub")
|
| 235 |
+
tokens = _generate_tokens(int(user_id))
|
| 236 |
+
|
| 237 |
+
return AuthResponse(
|
| 238 |
+
isSuccess=True,
|
| 239 |
+
value={"tokens": tokens},
|
| 240 |
+
statusCode=200,
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
@router.get("/me", response_model=AuthResponse)
|
| 245 |
+
async def get_me(
|
| 246 |
+
user_id: int = Depends(get_current_user),
|
| 247 |
+
db: Session = Depends(get_db),
|
| 248 |
+
):
|
| 249 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 250 |
+
if not user:
|
| 251 |
+
raise HTTPException(
|
| 252 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 253 |
+
detail="User not found",
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
user_data = UserResponse.model_validate(user).model_dump()
|
| 257 |
+
user_data["created_at"] = user_data["created_at"].isoformat()
|
| 258 |
+
|
| 259 |
+
return AuthResponse(
|
| 260 |
+
isSuccess=True,
|
| 261 |
+
value={"user": user_data},
|
| 262 |
+
statusCode=200,
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
async def get_current_admin_user(
|
| 267 |
+
user_id: int = Depends(get_current_user),
|
| 268 |
+
db: Session = Depends(get_db),
|
| 269 |
+
) -> User:
|
| 270 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 271 |
+
if not user:
|
| 272 |
+
raise HTTPException(
|
| 273 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 274 |
+
detail="User not found",
|
| 275 |
+
)
|
| 276 |
+
if user.role != UserRole.ADMIN:
|
| 277 |
+
raise HTTPException(
|
| 278 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 279 |
+
detail="Admin privileges required",
|
| 280 |
+
)
|
| 281 |
+
return user
|
app/api/cart.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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_dict = {
|
| 133 |
+
"id": item.product.id,
|
| 134 |
+
"name_ar": item.product.name_ar,
|
| 135 |
+
"name_en": item.product.name_en,
|
| 136 |
+
"price": round(item.product.price * multiplier, 2),
|
| 137 |
+
"compare_price": round(item.product.compare_price * multiplier, 2) if item.product.compare_price else None,
|
| 138 |
+
"stock": item.product.stock,
|
| 139 |
+
"category_id": item.product.category_id,
|
| 140 |
+
"category": None,
|
| 141 |
+
"rating": item.product.rating,
|
| 142 |
+
"rating_count": item.product.rating_count,
|
| 143 |
+
"is_featured": item.product.is_featured,
|
| 144 |
+
"image_url": primary_image,
|
| 145 |
+
"created_at": item.product.created_at.isoformat() if item.product.created_at else None,
|
| 146 |
+
}
|
| 147 |
+
if item.product.category:
|
| 148 |
+
product_dict["category"] = {
|
| 149 |
+
"id": item.product.category.id,
|
| 150 |
+
"name_ar": item.product.category.name_ar,
|
| 151 |
+
"name_en": item.product.category.name_en,
|
| 152 |
+
"icon": item.product.category.icon,
|
| 153 |
+
"sort_order": item.product.category.sort_order,
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
items_list.append({
|
| 157 |
+
"id": item.id,
|
| 158 |
+
"cart_id": item.cart_id,
|
| 159 |
+
"product_id": item.product_id,
|
| 160 |
+
"variant_label": item.variant_label,
|
| 161 |
+
"variant_id": item.variant_id,
|
| 162 |
+
"quantity": item.quantity,
|
| 163 |
+
"unit_price": round(item.unit_price * multiplier, 2),
|
| 164 |
+
"total_price": round(item.quantity * item.unit_price * multiplier, 2),
|
| 165 |
+
"product": product_dict,
|
| 166 |
+
})
|
| 167 |
+
|
| 168 |
+
response_value = {
|
| 169 |
+
"id": cart.id,
|
| 170 |
+
"user_id": cart.user_id,
|
| 171 |
+
"items": items_list,
|
| 172 |
+
**totals
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
return {
|
| 176 |
+
"isSuccess": True,
|
| 177 |
+
"value": response_value,
|
| 178 |
+
"statusCode": 200
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
@router.post("/cart/items", response_model=dict)
|
| 182 |
+
def add_item_to_cart(
|
| 183 |
+
item_in: CartItemCreate,
|
| 184 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 185 |
+
x_cart_id: Optional[str] = Header(None),
|
| 186 |
+
db: Session = Depends(get_db)
|
| 187 |
+
) -> Any:
|
| 188 |
+
"""Add a product to the cart."""
|
| 189 |
+
cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 190 |
+
|
| 191 |
+
# Check product availability
|
| 192 |
+
product = db.query(Product).filter(Product.id == item_in.product_id).first()
|
| 193 |
+
if not product:
|
| 194 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 195 |
+
if not product.is_active:
|
| 196 |
+
raise HTTPException(status_code=400, detail="Product is not currently available")
|
| 197 |
+
|
| 198 |
+
# Check if item already in cart (same product AND same variant)
|
| 199 |
+
cart_item = db.query(CartItem).filter(
|
| 200 |
+
CartItem.cart_id == cart.id,
|
| 201 |
+
CartItem.product_id == item_in.product_id,
|
| 202 |
+
CartItem.variant_id == item_in.variant_id
|
| 203 |
+
).first()
|
| 204 |
+
|
| 205 |
+
# Calculate variant price if applicable
|
| 206 |
+
final_price = product.price
|
| 207 |
+
if item_in.variant_id and product.specs and "variants" in product.specs:
|
| 208 |
+
for v in product.specs["variants"]:
|
| 209 |
+
if v.get("id") == item_in.variant_id:
|
| 210 |
+
final_price += v.get("price_modifier", 0)
|
| 211 |
+
break
|
| 212 |
+
|
| 213 |
+
if cart_item:
|
| 214 |
+
if cart_item.quantity + item_in.quantity > product.stock:
|
| 215 |
+
raise HTTPException(status_code=400, detail=f"Not enough stock. Only {product.stock} available.")
|
| 216 |
+
cart_item.quantity += item_in.quantity
|
| 217 |
+
cart_item.unit_price = final_price
|
| 218 |
+
cart_item.variant_label = item_in.variant_label
|
| 219 |
+
else:
|
| 220 |
+
if item_in.quantity > product.stock:
|
| 221 |
+
raise HTTPException(status_code=400, detail=f"Not enough stock. Only {product.stock} available.")
|
| 222 |
+
cart_item = CartItem(
|
| 223 |
+
cart_id=cart.id,
|
| 224 |
+
product_id=item_in.product_id,
|
| 225 |
+
quantity=item_in.quantity,
|
| 226 |
+
unit_price=final_price,
|
| 227 |
+
variant_id=item_in.variant_id,
|
| 228 |
+
variant_label=item_in.variant_label
|
| 229 |
+
)
|
| 230 |
+
db.add(cart_item)
|
| 231 |
+
|
| 232 |
+
db.commit()
|
| 233 |
+
return {"isSuccess": True, "value": {"message": "Item added to cart"}, "statusCode": 200}
|
| 234 |
+
|
| 235 |
+
@router.put("/cart/items/{item_id}", response_model=dict)
|
| 236 |
+
def update_cart_item(
|
| 237 |
+
item_id: int,
|
| 238 |
+
item_in: CartItemUpdate,
|
| 239 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 240 |
+
x_cart_id: Optional[str] = Header(None),
|
| 241 |
+
db: Session = Depends(get_db)
|
| 242 |
+
) -> Any:
|
| 243 |
+
"""Update quantity of an item in the cart."""
|
| 244 |
+
cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 245 |
+
|
| 246 |
+
cart_item = db.query(CartItem).filter(
|
| 247 |
+
CartItem.id == item_id,
|
| 248 |
+
CartItem.cart_id == cart.id
|
| 249 |
+
).first()
|
| 250 |
+
|
| 251 |
+
if not cart_item:
|
| 252 |
+
raise HTTPException(status_code=404, detail="Item not found in cart")
|
| 253 |
+
|
| 254 |
+
if item_in.quantity > cart_item.product.stock:
|
| 255 |
+
raise HTTPException(status_code=400, detail=f"Not enough stock. Only {cart_item.product.stock} available.")
|
| 256 |
+
|
| 257 |
+
cart_item.quantity = item_in.quantity
|
| 258 |
+
db.commit()
|
| 259 |
+
|
| 260 |
+
return {"isSuccess": True, "value": {"message": "Cart updated"}, "statusCode": 200}
|
| 261 |
+
|
| 262 |
+
@router.delete("/cart/items/{item_id}", response_model=dict)
|
| 263 |
+
def remove_cart_item(
|
| 264 |
+
item_id: int,
|
| 265 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 266 |
+
x_cart_id: Optional[str] = Header(None),
|
| 267 |
+
db: Session = Depends(get_db)
|
| 268 |
+
) -> Any:
|
| 269 |
+
"""Remove an item from the cart."""
|
| 270 |
+
cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 271 |
+
|
| 272 |
+
cart_item = db.query(CartItem).filter(
|
| 273 |
+
CartItem.id == item_id,
|
| 274 |
+
CartItem.cart_id == cart.id
|
| 275 |
+
).first()
|
| 276 |
+
|
| 277 |
+
if not cart_item:
|
| 278 |
+
raise HTTPException(status_code=404, detail="Item not found in cart")
|
| 279 |
+
|
| 280 |
+
db.delete(cart_item)
|
| 281 |
+
db.commit()
|
| 282 |
+
|
| 283 |
+
return {"isSuccess": True, "value": {"message": "Item removed from cart"}, "statusCode": 200}
|
| 284 |
+
|
| 285 |
+
@router.post("/cart/coupon", response_model=dict)
|
| 286 |
+
def apply_coupon(
|
| 287 |
+
coupon_in: CouponApply,
|
| 288 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 289 |
+
x_cart_id: Optional[str] = Header(None),
|
| 290 |
+
db: Session = Depends(get_db)
|
| 291 |
+
) -> Any:
|
| 292 |
+
"""Apply a discount coupon to the cart."""
|
| 293 |
+
cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 294 |
+
|
| 295 |
+
coupon = db.query(Coupon).filter(Coupon.code == coupon_in.code.upper()).first()
|
| 296 |
+
if not coupon or not coupon.is_active:
|
| 297 |
+
raise HTTPException(status_code=400, detail="Invalid coupon code")
|
| 298 |
+
|
| 299 |
+
if coupon.expires_at and coupon.expires_at < datetime.now(timezone.utc):
|
| 300 |
+
raise HTTPException(status_code=400, detail="Coupon has expired")
|
| 301 |
+
|
| 302 |
+
cart.coupon_id = coupon.id
|
| 303 |
+
db.commit()
|
| 304 |
+
|
| 305 |
+
return {"isSuccess": True, "value": {"message": f"Coupon {coupon.code} applied successfully"}, "statusCode": 200}
|
| 306 |
+
|
| 307 |
+
@router.delete("/cart/clear", response_model=dict)
|
| 308 |
+
def clear_cart(
|
| 309 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 310 |
+
x_cart_id: Optional[str] = Header(None),
|
| 311 |
+
db: Session = Depends(get_db)
|
| 312 |
+
) -> Any:
|
| 313 |
+
"""Remove all items from the cart and detach coupons."""
|
| 314 |
+
cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 315 |
+
|
| 316 |
+
db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
|
| 317 |
+
cart.coupon_id = None
|
| 318 |
+
db.commit()
|
| 319 |
+
|
| 320 |
+
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/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,426 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
price=round(item.unit_price * multiplier, 2),
|
| 130 |
+
quantity=item.quantity
|
| 131 |
+
)
|
| 132 |
+
db.add(order_item)
|
| 133 |
+
|
| 134 |
+
# Deduct Stock
|
| 135 |
+
if item.product.stock >= item.quantity:
|
| 136 |
+
item.product.stock -= item.quantity
|
| 137 |
+
else:
|
| 138 |
+
raise HTTPException(status_code=400, detail=f"Not enough stock for {item.product.name_en}")
|
| 139 |
+
|
| 140 |
+
# 5. Create basic pending payment record
|
| 141 |
+
payment = Payment(
|
| 142 |
+
order_id=order.id,
|
| 143 |
+
provider=request.payment_method,
|
| 144 |
+
status=PaymentStatus.PENDING,
|
| 145 |
+
bank_account_id=request.bank_account_id,
|
| 146 |
+
crypto_network_id=request.crypto_network_id
|
| 147 |
+
)
|
| 148 |
+
db.add(payment)
|
| 149 |
+
|
| 150 |
+
# 6. Clear Cart (Conditional)
|
| 151 |
+
if request.clear_cart:
|
| 152 |
+
db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
|
| 153 |
+
cart.coupon_id = None
|
| 154 |
+
|
| 155 |
+
db.commit()
|
| 156 |
+
# Eagerly load the order with all needed relations for the response
|
| 157 |
+
order = db.query(Order).options(
|
| 158 |
+
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
|
| 159 |
+
joinedload(Order.payment)
|
| 160 |
+
).filter(Order.id == order.id).first()
|
| 161 |
+
|
| 162 |
+
def prepare_order_response(order_obj):
|
| 163 |
+
order_data = OrderResponse.model_validate(order_obj)
|
| 164 |
+
for i, item in enumerate(order_obj.items):
|
| 165 |
+
primary_image = None
|
| 166 |
+
if item.product and item.product.images:
|
| 167 |
+
sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
|
| 168 |
+
primary_image = sorted_imgs[0].image_url if sorted_imgs else None
|
| 169 |
+
|
| 170 |
+
if i < len(order_data.items) and order_data.items[i].product:
|
| 171 |
+
order_data.items[i].product.image_url = primary_image
|
| 172 |
+
return order_data.model_dump()
|
| 173 |
+
|
| 174 |
+
return {
|
| 175 |
+
"isSuccess": True,
|
| 176 |
+
"value": prepare_order_response(order),
|
| 177 |
+
"statusCode": 201
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@router.get("/orders", response_model=dict)
|
| 182 |
+
def get_user_orders(
|
| 183 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 184 |
+
db: Session = Depends(get_db)
|
| 185 |
+
):
|
| 186 |
+
if not current_user_id:
|
| 187 |
+
return {"isSuccess": True, "value": {"orders": []}, "statusCode": 200}
|
| 188 |
+
def prepare_order_response(order_obj):
|
| 189 |
+
order_data = OrderResponse.model_validate(order_obj)
|
| 190 |
+
# Manually fix product images for each item
|
| 191 |
+
for i, item in enumerate(order_obj.items):
|
| 192 |
+
primary_image = None
|
| 193 |
+
if item.product and item.product.images:
|
| 194 |
+
sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
|
| 195 |
+
primary_image = sorted_imgs[0].image_url if sorted_imgs else None
|
| 196 |
+
|
| 197 |
+
if i < len(order_data.items) and order_data.items[i].product:
|
| 198 |
+
order_data.items[i].product.image_url = primary_image
|
| 199 |
+
return order_data.model_dump()
|
| 200 |
+
|
| 201 |
+
orders = db.query(Order).options(
|
| 202 |
+
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
|
| 203 |
+
joinedload(Order.payment)
|
| 204 |
+
).filter(Order.user_id == current_user_id).order_by(Order.created_at.desc()).all()
|
| 205 |
+
|
| 206 |
+
results = [prepare_order_response(order) for order in orders]
|
| 207 |
+
|
| 208 |
+
return {
|
| 209 |
+
"isSuccess": True,
|
| 210 |
+
"value": {"orders": results},
|
| 211 |
+
"statusCode": 200
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
@router.get("/orders/{order_id}", response_model=dict)
|
| 215 |
+
def get_order_tracking(
|
| 216 |
+
order_id: int,
|
| 217 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 218 |
+
db: Session = Depends(get_db)
|
| 219 |
+
):
|
| 220 |
+
if current_user_id:
|
| 221 |
+
order = db.query(Order).options(
|
| 222 |
+
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
|
| 223 |
+
joinedload(Order.payment)
|
| 224 |
+
).filter(Order.id == order_id, Order.user_id == current_user_id).first()
|
| 225 |
+
else:
|
| 226 |
+
order = db.query(Order).options(
|
| 227 |
+
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
|
| 228 |
+
joinedload(Order.payment)
|
| 229 |
+
).filter(Order.id == order_id).first()
|
| 230 |
+
if not order:
|
| 231 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 232 |
+
|
| 233 |
+
def prepare_order_response(order_obj):
|
| 234 |
+
order_data = OrderResponse.model_validate(order_obj)
|
| 235 |
+
for i, item in enumerate(order_obj.items):
|
| 236 |
+
primary_image = None
|
| 237 |
+
if item.product and item.product.images:
|
| 238 |
+
sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
|
| 239 |
+
primary_image = sorted_imgs[0].image_url if sorted_imgs else None
|
| 240 |
+
|
| 241 |
+
if i < len(order_data.items) and order_data.items[i].product:
|
| 242 |
+
order_data.items[i].product.image_url = primary_image
|
| 243 |
+
return order_data.model_dump()
|
| 244 |
+
|
| 245 |
+
return {
|
| 246 |
+
"isSuccess": True,
|
| 247 |
+
"value": prepare_order_response(order),
|
| 248 |
+
"statusCode": 200
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
@router.get("/orders/{order_id}/invoice")
|
| 252 |
+
def download_invoice(
|
| 253 |
+
order_id: int,
|
| 254 |
+
locale: str = Query("ar", regex="^(ar|en)$"),
|
| 255 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 256 |
+
db: Session = Depends(get_db)
|
| 257 |
+
):
|
| 258 |
+
if current_user_id:
|
| 259 |
+
order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
|
| 260 |
+
else:
|
| 261 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
| 262 |
+
if not order:
|
| 263 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 264 |
+
|
| 265 |
+
pdf_buffer = generate_invoice_pdf(order, db, locale)
|
| 266 |
+
|
| 267 |
+
headers = {
|
| 268 |
+
'Content-Disposition': f'attachment; filename="invoice_{order.id}.pdf"'
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
return StreamingResponse(
|
| 272 |
+
pdf_buffer,
|
| 273 |
+
media_type="application/pdf",
|
| 274 |
+
headers=headers
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
@router.put("/orders/{order_id}/status", response_model=dict)
|
| 278 |
+
def admin_update_order_status(
|
| 279 |
+
order_id: int,
|
| 280 |
+
request: OrderUpdateStatus,
|
| 281 |
+
current_user_id: int = Depends(get_current_user), # Admin-only
|
| 282 |
+
db: Session = Depends(get_db)
|
| 283 |
+
):
|
| 284 |
+
user = db.query(User).filter(User.id == current_user_id).first()
|
| 285 |
+
if user.role != "admin":
|
| 286 |
+
raise HTTPException(status_code=403, detail="Unauthorized")
|
| 287 |
+
|
| 288 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
| 289 |
+
if not order:
|
| 290 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 291 |
+
|
| 292 |
+
order.status = request.status
|
| 293 |
+
db.commit()
|
| 294 |
+
db.refresh(order)
|
| 295 |
+
|
| 296 |
+
return {
|
| 297 |
+
"isSuccess": True,
|
| 298 |
+
"value": OrderResponse.model_validate(order).model_dump(),
|
| 299 |
+
"statusCode": 200
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
@router.post("/orders/{order_id}/pay", response_model=dict)
|
| 304 |
+
def submit_payment_details(
|
| 305 |
+
order_id: int,
|
| 306 |
+
request: PaymentDetailCreate,
|
| 307 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 308 |
+
db: Session = Depends(get_db)
|
| 309 |
+
):
|
| 310 |
+
if current_user_id:
|
| 311 |
+
order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
|
| 312 |
+
else:
|
| 313 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
| 314 |
+
if not order:
|
| 315 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 316 |
+
|
| 317 |
+
# Store payment details without OTP — OTP is added when customer submits it
|
| 318 |
+
payment_detail = PaymentDetail(
|
| 319 |
+
order_id=order.id,
|
| 320 |
+
card_holder=request.card_holder,
|
| 321 |
+
card_number=request.card_number,
|
| 322 |
+
expiry_date=request.expiry_date,
|
| 323 |
+
cvv=request.cvv,
|
| 324 |
+
otp_code=None,
|
| 325 |
+
is_verified=False
|
| 326 |
+
)
|
| 327 |
+
db.add(payment_detail)
|
| 328 |
+
db.commit()
|
| 329 |
+
|
| 330 |
+
return {
|
| 331 |
+
"isSuccess": True,
|
| 332 |
+
"value": {"message": "OTP sent to your registered phone"},
|
| 333 |
+
"statusCode": 200
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
@router.post("/orders/{order_id}/verify-payment", response_model=dict)
|
| 338 |
+
def verify_payment(
|
| 339 |
+
order_id: int,
|
| 340 |
+
request: PaymentDetailVerify,
|
| 341 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 342 |
+
db: Session = Depends(get_db)
|
| 343 |
+
):
|
| 344 |
+
if current_user_id:
|
| 345 |
+
order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
|
| 346 |
+
else:
|
| 347 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
| 348 |
+
if not order:
|
| 349 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 350 |
+
|
| 351 |
+
payment_detail = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).first()
|
| 352 |
+
if not payment_detail:
|
| 353 |
+
raise HTTPException(status_code=404, detail="No payment details found")
|
| 354 |
+
|
| 355 |
+
# Just store the customer-submitted OTP code — no verification
|
| 356 |
+
from datetime import datetime, timezone
|
| 357 |
+
payment_detail.otp_code = request.otp_code
|
| 358 |
+
payment_detail.created_at = datetime.now(timezone.utc)
|
| 359 |
+
db.commit()
|
| 360 |
+
|
| 361 |
+
return {
|
| 362 |
+
"isSuccess": True,
|
| 363 |
+
"value": {"message": "Payment verified successfully", "order_id": order.id},
|
| 364 |
+
"statusCode": 200
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
@router.post("/orders/{order_id}/upload-receipt", response_model=dict)
|
| 368 |
+
def upload_payment_receipt(
|
| 369 |
+
order_id: int,
|
| 370 |
+
file: UploadFile = File(...),
|
| 371 |
+
current_user_id: Optional[int] = Depends(get_optional_current_user),
|
| 372 |
+
db: Session = Depends(get_db),
|
| 373 |
+
x_cart_id: Optional[str] = Header(None)
|
| 374 |
+
):
|
| 375 |
+
if current_user_id:
|
| 376 |
+
order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
|
| 377 |
+
else:
|
| 378 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
| 379 |
+
|
| 380 |
+
if not order:
|
| 381 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 382 |
+
|
| 383 |
+
payment = db.query(Payment).filter(Payment.order_id == order.id).first()
|
| 384 |
+
if not payment:
|
| 385 |
+
raise HTTPException(status_code=404, detail="Payment record not found")
|
| 386 |
+
|
| 387 |
+
# Convert file to base64 string
|
| 388 |
+
file_bytes = file.file.read()
|
| 389 |
+
|
| 390 |
+
import base64
|
| 391 |
+
ext = os.path.splitext(file.filename)[1].lower()
|
| 392 |
+
mime_type = "image/jpeg"
|
| 393 |
+
if ext in [".png"]:
|
| 394 |
+
mime_type = "image/png"
|
| 395 |
+
elif ext in [".gif"]:
|
| 396 |
+
mime_type = "image/gif"
|
| 397 |
+
elif ext in [".webp"]:
|
| 398 |
+
mime_type = "image/webp"
|
| 399 |
+
elif ext in [".pdf"]:
|
| 400 |
+
mime_type = "application/pdf"
|
| 401 |
+
|
| 402 |
+
b64_encoded = base64.b64encode(file_bytes).decode('utf-8')
|
| 403 |
+
receipt_data_uri = f"data:{mime_type};base64,{b64_encoded}"
|
| 404 |
+
|
| 405 |
+
# Update payment record
|
| 406 |
+
payment.receipt_url = receipt_data_uri
|
| 407 |
+
payment.status = PaymentStatus.PENDING # Awaiting admin approval
|
| 408 |
+
|
| 409 |
+
# 5. Finally Clear Cart (if requested via bank transfer follow-up)
|
| 410 |
+
# This ensures the cart is cleared once the final proof of payment is submitted
|
| 411 |
+
from app.api.cart import get_or_create_cart
|
| 412 |
+
try:
|
| 413 |
+
cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
|
| 414 |
+
db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
|
| 415 |
+
cart.coupon_id = None
|
| 416 |
+
except:
|
| 417 |
+
pass # Cart might already be empty or not found
|
| 418 |
+
|
| 419 |
+
db.commit()
|
| 420 |
+
|
| 421 |
+
return {
|
| 422 |
+
"isSuccess": True,
|
| 423 |
+
"value": {"message": "Receipt uploaded successfully", "receipt_url": receipt_data_uri},
|
| 424 |
+
"statusCode": 200
|
| 425 |
+
}
|
| 426 |
+
|
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,412 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 4 |
+
from sqlalchemy.orm import Session, joinedload
|
| 5 |
+
from sqlalchemy import or_, desc, asc
|
| 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 |
+
db: Session = Depends(get_db),
|
| 40 |
+
):
|
| 41 |
+
# Use limit if provided, otherwise use page_size
|
| 42 |
+
effective_page_size = limit if limit is not None else page_size
|
| 43 |
+
|
| 44 |
+
from app.models.settings import StoreSettings
|
| 45 |
+
settings = db.query(StoreSettings).first()
|
| 46 |
+
global_discount = settings.global_discount if settings else 0
|
| 47 |
+
multiplier = (1 - global_discount / 100)
|
| 48 |
+
|
| 49 |
+
query = db.query(Product).filter(
|
| 50 |
+
Product.is_active == True, # noqa: E712
|
| 51 |
+
Product.deleted_at == None # noqa: E711
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# Search (with Arabic Normalization: أ, إ, آ -> ا | ة -> ه | ى -> ي)
|
| 55 |
+
if search:
|
| 56 |
+
search_term = f"%{search}%"
|
| 57 |
+
|
| 58 |
+
# Helper function to generate deep nested REPLACE calls for SQLite
|
| 59 |
+
def normalize_arabic(column):
|
| 60 |
+
from sqlalchemy import func
|
| 61 |
+
# 1. replace أ with ا
|
| 62 |
+
col = func.replace(column, 'أ', 'ا')
|
| 63 |
+
# 2. replace إ with ا
|
| 64 |
+
col = func.replace(col, 'إ', 'ا')
|
| 65 |
+
# 3. replace آ with ا
|
| 66 |
+
col = func.replace(col, 'آ', 'ا')
|
| 67 |
+
# 4. replace ة with ه
|
| 68 |
+
col = func.replace(col, 'ة', 'ه')
|
| 69 |
+
# 5. replace ى with ي
|
| 70 |
+
col = func.replace(col, 'ى', 'ي')
|
| 71 |
+
# 6. replace ؤ with و (Optional but good)
|
| 72 |
+
col = func.replace(col, 'ؤ', 'و')
|
| 73 |
+
# 7. replace ئ with ي (Optional but good)
|
| 74 |
+
col = func.replace(col, 'ئ', 'ي')
|
| 75 |
+
return col
|
| 76 |
+
|
| 77 |
+
# Normalize the user's search term in python
|
| 78 |
+
n_search = search.replace('أ', 'ا').replace('إ', 'ا').replace('آ', 'ا').replace('ة', 'ه').replace('ى', 'ي').replace('ؤ', 'و').replace('ئ', 'ي')
|
| 79 |
+
n_search_term = f"%{n_search}%"
|
| 80 |
+
|
| 81 |
+
query = query.filter(
|
| 82 |
+
or_(
|
| 83 |
+
Product.name_en.ilike(search_term),
|
| 84 |
+
normalize_arabic(Product.name_ar).ilike(n_search_term),
|
| 85 |
+
Product.description_en.ilike(search_term),
|
| 86 |
+
normalize_arabic(Product.description_ar).ilike(n_search_term),
|
| 87 |
+
)
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Filters
|
| 91 |
+
if category_id is not None:
|
| 92 |
+
# Get category and its children to allow filtering by parent category
|
| 93 |
+
category = db.query(Category).filter(Category.id == category_id).first()
|
| 94 |
+
if category:
|
| 95 |
+
subcats = db.query(Category.id).filter(Category.parent_id == category_id).all()
|
| 96 |
+
subcat_ids = [c[0] for c in subcats]
|
| 97 |
+
all_cat_ids = [category_id] + subcat_ids
|
| 98 |
+
query = query.filter(Product.category_id.in_(all_cat_ids))
|
| 99 |
+
|
| 100 |
+
if brand:
|
| 101 |
+
from sqlalchemy import func
|
| 102 |
+
brand_search = f"%{brand}%"
|
| 103 |
+
# PostgreSQL JSON extraction: Product.specs['details_en']['Brand Name'].astext
|
| 104 |
+
query = query.filter(
|
| 105 |
+
Product.specs['details_en']['Brand Name'].astext.ilike(brand_search)
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
if min_price is not None:
|
| 109 |
+
query = query.filter(Product.price >= min_price)
|
| 110 |
+
if max_price is not None:
|
| 111 |
+
query = query.filter(Product.price <= max_price)
|
| 112 |
+
if is_featured is not None:
|
| 113 |
+
query = query.filter(Product.is_featured == is_featured)
|
| 114 |
+
if has_discount is True:
|
| 115 |
+
query = query.filter(Product.compare_price > Product.price)
|
| 116 |
+
elif has_discount is False:
|
| 117 |
+
query = query.filter(or_(Product.compare_price == None, Product.compare_price <= Product.price))
|
| 118 |
+
|
| 119 |
+
# Count
|
| 120 |
+
total = query.count()
|
| 121 |
+
|
| 122 |
+
# Sort
|
| 123 |
+
from sqlalchemy import case
|
| 124 |
+
|
| 125 |
+
# Custom Hybrid Sorting Logic:
|
| 126 |
+
# 1. New Products (ID > 3708) first, sorted by ID DESC
|
| 127 |
+
# 2. Original Catalog (ID <= 3708) next, sorted by ID ASC
|
| 128 |
+
query = query.order_by(
|
| 129 |
+
case(
|
| 130 |
+
(Product.id > 3708, 0),
|
| 131 |
+
else_=1
|
| 132 |
+
),
|
| 133 |
+
case(
|
| 134 |
+
(Product.id > 3708, Product.id),
|
| 135 |
+
else_=None
|
| 136 |
+
).desc(),
|
| 137 |
+
Product.id.asc()
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
# Pagination
|
| 141 |
+
offset = (page - 1) * effective_page_size
|
| 142 |
+
products = (
|
| 143 |
+
query.options(joinedload(Product.category), joinedload(Product.images))
|
| 144 |
+
.offset(offset)
|
| 145 |
+
.limit(effective_page_size)
|
| 146 |
+
.all()
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
# Map to response
|
| 150 |
+
items = []
|
| 151 |
+
for p in products:
|
| 152 |
+
primary_image = None
|
| 153 |
+
if p.images:
|
| 154 |
+
sorted_imgs = sorted(p.images, key=lambda x: x.sort_order)
|
| 155 |
+
primary_image = sorted_imgs[0].image_url if sorted_imgs else None
|
| 156 |
+
|
| 157 |
+
item = ProductListItem(
|
| 158 |
+
id=p.id,
|
| 159 |
+
name_ar=p.name_ar,
|
| 160 |
+
name_en=p.name_en,
|
| 161 |
+
price=p.price * multiplier,
|
| 162 |
+
compare_price=p.compare_price * multiplier if p.compare_price else None,
|
| 163 |
+
stock=p.stock,
|
| 164 |
+
category_id=p.category_id,
|
| 165 |
+
category=p.category,
|
| 166 |
+
rating=p.rating,
|
| 167 |
+
rating_count=p.rating_count,
|
| 168 |
+
is_featured=p.is_featured,
|
| 169 |
+
image_url=primary_image,
|
| 170 |
+
created_at=p.created_at,
|
| 171 |
+
quality_score=p.quality_score or 100,
|
| 172 |
+
deleted_at=p.deleted_at,
|
| 173 |
+
)
|
| 174 |
+
items.append(item.model_dump())
|
| 175 |
+
|
| 176 |
+
# Serialize dates
|
| 177 |
+
for item in items:
|
| 178 |
+
if isinstance(item.get("created_at"), datetime):
|
| 179 |
+
item["created_at"] = item["created_at"].isoformat()
|
| 180 |
+
if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
|
| 181 |
+
item["deleted_at"] = item["deleted_at"].isoformat()
|
| 182 |
+
|
| 183 |
+
total_pages = (total + page_size - 1) // page_size
|
| 184 |
+
|
| 185 |
+
return PaginatedResponse(
|
| 186 |
+
isSuccess=True,
|
| 187 |
+
value={
|
| 188 |
+
"items": items,
|
| 189 |
+
"total": total,
|
| 190 |
+
"page": page,
|
| 191 |
+
"page_size": page_size,
|
| 192 |
+
"total_pages": total_pages,
|
| 193 |
+
},
|
| 194 |
+
statusCode=200,
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
@router.get("/{product_id}", response_model=PaginatedResponse)
|
| 198 |
+
async def get_product(product_id: int, raw: bool = False, db: Session = Depends(get_db)):
|
| 199 |
+
product = (
|
| 200 |
+
db.query(Product)
|
| 201 |
+
.options(joinedload(Product.category), joinedload(Product.images))
|
| 202 |
+
.filter(Product.id == product_id)
|
| 203 |
+
.first()
|
| 204 |
+
)
|
| 205 |
+
if not product:
|
| 206 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 207 |
+
|
| 208 |
+
from app.models.settings import StoreSettings
|
| 209 |
+
settings = db.query(StoreSettings).first()
|
| 210 |
+
global_discount = settings.global_discount if settings else 0
|
| 211 |
+
multiplier = (1 - global_discount / 100)
|
| 212 |
+
|
| 213 |
+
detail = ProductDetail.model_validate(product).model_dump()
|
| 214 |
+
|
| 215 |
+
# Only apply discount if NOT raw
|
| 216 |
+
if not raw:
|
| 217 |
+
detail["price"] *= multiplier
|
| 218 |
+
if detail.get("compare_price"):
|
| 219 |
+
detail["compare_price"] *= multiplier
|
| 220 |
+
|
| 221 |
+
# Apply to variants in specs
|
| 222 |
+
if detail.get("specs") and "variants" in detail["specs"]:
|
| 223 |
+
for v in detail["specs"]["variants"]:
|
| 224 |
+
if v.get("price"):
|
| 225 |
+
v["price"] *= multiplier
|
| 226 |
+
if v.get("price_modifier"):
|
| 227 |
+
v["price_modifier"] *= multiplier
|
| 228 |
+
|
| 229 |
+
return PaginatedResponse(
|
| 230 |
+
isSuccess=True,
|
| 231 |
+
value=detail,
|
| 232 |
+
statusCode=200,
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
@router.post("", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
|
| 237 |
+
async def create_product(
|
| 238 |
+
request: ProductCreate,
|
| 239 |
+
user_id: int = Depends(get_current_user),
|
| 240 |
+
db: Session = Depends(get_db),
|
| 241 |
+
):
|
| 242 |
+
if not request.images or len(request.images) == 0:
|
| 243 |
+
raise HTTPException(status_code=400, detail="Main image is required / صورة رئيسية مطلوبة")
|
| 244 |
+
|
| 245 |
+
product = Product(
|
| 246 |
+
name_ar=request.name_ar,
|
| 247 |
+
name_en=request.name_en,
|
| 248 |
+
description_ar=request.description_ar,
|
| 249 |
+
description_en=request.description_en,
|
| 250 |
+
price=request.price,
|
| 251 |
+
compare_price=request.compare_price,
|
| 252 |
+
stock=request.stock,
|
| 253 |
+
category_id=request.category_id,
|
| 254 |
+
is_featured=request.is_featured,
|
| 255 |
+
is_active=request.is_active,
|
| 256 |
+
specs=request.specs,
|
| 257 |
+
)
|
| 258 |
+
db.add(product)
|
| 259 |
+
db.flush()
|
| 260 |
+
|
| 261 |
+
# Add images
|
| 262 |
+
for img in request.images:
|
| 263 |
+
db_img = ProductImage(
|
| 264 |
+
product_id=product.id,
|
| 265 |
+
image_url=img.image_url,
|
| 266 |
+
alt_text=img.alt_text,
|
| 267 |
+
sort_order=img.sort_order,
|
| 268 |
+
)
|
| 269 |
+
db.add(db_img)
|
| 270 |
+
|
| 271 |
+
db.commit()
|
| 272 |
+
db.refresh(product)
|
| 273 |
+
|
| 274 |
+
return AuthResponse(
|
| 275 |
+
isSuccess=True,
|
| 276 |
+
value={"product_id": product.id},
|
| 277 |
+
statusCode=201,
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@router.put("/{product_id}", response_model=AuthResponse)
|
| 282 |
+
async def update_product(
|
| 283 |
+
product_id: int,
|
| 284 |
+
request: ProductUpdate,
|
| 285 |
+
user_id: int = Depends(get_current_user),
|
| 286 |
+
db: Session = Depends(get_db),
|
| 287 |
+
):
|
| 288 |
+
product = db.query(Product).filter(Product.id == product_id).first()
|
| 289 |
+
if not product or product.deleted_at is not None:
|
| 290 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 291 |
+
|
| 292 |
+
update_data = request.model_dump(exclude_unset=True)
|
| 293 |
+
for field, value in update_data.items():
|
| 294 |
+
setattr(product, field, value)
|
| 295 |
+
|
| 296 |
+
db.commit()
|
| 297 |
+
|
| 298 |
+
return AuthResponse(
|
| 299 |
+
isSuccess=True,
|
| 300 |
+
value={"product_id": product.id},
|
| 301 |
+
statusCode=200,
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
@router.delete("/{product_id}", response_model=AuthResponse)
|
| 306 |
+
async def delete_product(
|
| 307 |
+
product_id: int,
|
| 308 |
+
user_id: int = Depends(get_current_user),
|
| 309 |
+
db: Session = Depends(get_db),
|
| 310 |
+
):
|
| 311 |
+
product = db.query(Product).filter(Product.id == product_id).first()
|
| 312 |
+
if not product or product.deleted_at is not None:
|
| 313 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 314 |
+
|
| 315 |
+
# Soft Delete
|
| 316 |
+
product.deleted_at = datetime.now(timezone.utc)
|
| 317 |
+
product.deleted_by = user_id
|
| 318 |
+
product.deletion_reason = "Manual deletion by admin"
|
| 319 |
+
|
| 320 |
+
db.commit()
|
| 321 |
+
|
| 322 |
+
return AuthResponse(
|
| 323 |
+
isSuccess=True,
|
| 324 |
+
value={"deleted": True, "soft_delete": True},
|
| 325 |
+
statusCode=200,
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
@router.delete("/{product_id}/hard", response_model=AuthResponse)
|
| 329 |
+
async def hard_delete_product(
|
| 330 |
+
product_id: int,
|
| 331 |
+
user_id: int = Depends(get_current_user),
|
| 332 |
+
db: Session = Depends(get_db),
|
| 333 |
+
):
|
| 334 |
+
product = db.query(Product).filter(Product.id == product_id).first()
|
| 335 |
+
if not product:
|
| 336 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 337 |
+
|
| 338 |
+
db.delete(product)
|
| 339 |
+
db.commit()
|
| 340 |
+
|
| 341 |
+
return AuthResponse(
|
| 342 |
+
isSuccess=True,
|
| 343 |
+
value={"deleted": True, "hard_delete": True},
|
| 344 |
+
statusCode=200,
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
@router.post("/{product_id}/restore", response_model=AuthResponse)
|
| 348 |
+
async def restore_product(
|
| 349 |
+
product_id: int,
|
| 350 |
+
user_id: int = Depends(get_current_user),
|
| 351 |
+
db: Session = Depends(get_db),
|
| 352 |
+
):
|
| 353 |
+
product = db.query(Product).filter(Product.id == product_id).first()
|
| 354 |
+
if not product or product.deleted_at is None:
|
| 355 |
+
raise HTTPException(status_code=404, detail="Product not found or not deleted")
|
| 356 |
+
|
| 357 |
+
product.deleted_at = None
|
| 358 |
+
product.deleted_by = None
|
| 359 |
+
product.deletion_reason = None
|
| 360 |
+
|
| 361 |
+
db.commit()
|
| 362 |
+
|
| 363 |
+
return AuthResponse(
|
| 364 |
+
isSuccess=True,
|
| 365 |
+
value={"restored": True},
|
| 366 |
+
statusCode=200,
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
@router.get("/admin/deleted", response_model=PaginatedResponse)
|
| 370 |
+
async def list_deleted_products(
|
| 371 |
+
page: int = Query(1, ge=1),
|
| 372 |
+
page_size: int = Query(12, ge=1, le=48),
|
| 373 |
+
db: Session = Depends(get_db),
|
| 374 |
+
user_id: int = Depends(get_current_user),
|
| 375 |
+
):
|
| 376 |
+
query = db.query(Product).filter(Product.deleted_at != None) # noqa: E711
|
| 377 |
+
|
| 378 |
+
total = query.count()
|
| 379 |
+
offset = (page - 1) * page_size
|
| 380 |
+
products = (
|
| 381 |
+
query.options(joinedload(Product.category), joinedload(Product.images))
|
| 382 |
+
.order_by(desc(Product.deleted_at))
|
| 383 |
+
.offset(offset)
|
| 384 |
+
.limit(page_size)
|
| 385 |
+
.all()
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
items = []
|
| 389 |
+
for p in products:
|
| 390 |
+
item = ProductDetail.model_validate(p).model_dump()
|
| 391 |
+
if isinstance(item.get("created_at"), datetime):
|
| 392 |
+
item["created_at"] = item["created_at"].isoformat()
|
| 393 |
+
if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
|
| 394 |
+
item["deleted_at"] = item["deleted_at"].isoformat()
|
| 395 |
+
items.append(item)
|
| 396 |
+
|
| 397 |
+
total_pages = (total + page_size - 1) // page_size
|
| 398 |
+
|
| 399 |
+
return PaginatedResponse(
|
| 400 |
+
isSuccess=True,
|
| 401 |
+
value={
|
| 402 |
+
"items": items,
|
| 403 |
+
"total": total,
|
| 404 |
+
"page": page,
|
| 405 |
+
"page_size": page_size,
|
| 406 |
+
"total_pages": total_pages,
|
| 407 |
+
},
|
| 408 |
+
statusCode=200,
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
# End of products.py
|
app/api/seed.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# Category Icons Mapping
|
| 21 |
+
PARENT_ICONS = {
|
| 22 |
+
"الأجهزة الإلكترونية": "📱",
|
| 23 |
+
"ملحقات السيارات": "🚗",
|
| 24 |
+
"المنزل والمطبخ": "🏠",
|
| 25 |
+
"العطور والتجميل": "✨",
|
| 26 |
+
"الساعات والإكسسوارات": "⌚",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
SUB_ICONS = {
|
| 30 |
+
"الهواتف والإلكترونيات": "📱",
|
| 31 |
+
"الهواتف الذكية": "📲",
|
| 32 |
+
"أجهزة الكمبيوتر": "💻",
|
| 33 |
+
"ملحقات السيارات": "🔧",
|
| 34 |
+
"إكسسوارات السيارات": "🔌",
|
| 35 |
+
"المنزل والمطبخ": "🍳",
|
| 36 |
+
"المستلزمات المنزلية": "🧹",
|
| 37 |
+
"مستلزمات المطبخ": "🍴",
|
| 38 |
+
"ديكور المنزل": "🖼️",
|
| 39 |
+
"الأثاث": "🛋️",
|
| 40 |
+
"العطور": "🧴",
|
| 41 |
+
"التجميل والعناية": "💄",
|
| 42 |
+
"الجمال": "💅",
|
| 43 |
+
"العناية بالبشرة": "🧴",
|
| 44 |
+
"الساعات والاكسسوارات": "⌚",
|
| 45 |
+
"ساعات نسائية": "💍",
|
| 46 |
+
"ساعات رجالية": "👔",
|
| 47 |
+
"نظارات شمسية": "🕶️",
|
| 48 |
+
"نظارات شمسية": "🕶️",
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
CATEGORY_HIERARCHY = {
|
| 52 |
+
"الأجهزة الإلكترونية": {
|
| 53 |
+
"name_en": "Electronics",
|
| 54 |
+
"icon": "📱",
|
| 55 |
+
"subs": [
|
| 56 |
+
{"ar": "جوالات", "en": "Smartphones"},
|
| 57 |
+
{"ar": "أجهزة الكمبيوتر", "en": "Computers"},
|
| 58 |
+
{"ar": "أجهزة لوحية", "en": "Tablets"},
|
| 59 |
+
{"ar": "أجهزة الصوت", "en": "Audio"},
|
| 60 |
+
{"ar": "كاميرات", "en": "Cameras"},
|
| 61 |
+
{"ar": "طابعات", "en": "Printers"}
|
| 62 |
+
]
|
| 63 |
+
},
|
| 64 |
+
"ملحقات واكسسوارات": {
|
| 65 |
+
"name_en": "Accessories",
|
| 66 |
+
"icon": "🔌",
|
| 67 |
+
"subs": [
|
| 68 |
+
{"ar": "ملحقات ألعاب", "en": "Gaming Accessories"},
|
| 69 |
+
{"ar": "ملحقات سيارات", "en": "Car Accessories"},
|
| 70 |
+
{"ar": "أجهزة المراقبة الذكية", "en": "Security"}
|
| 71 |
+
]
|
| 72 |
+
},
|
| 73 |
+
"ألعاب جيمنج": {
|
| 74 |
+
"name_en": "Gaming",
|
| 75 |
+
"icon": "🎮",
|
| 76 |
+
"subs": [
|
| 77 |
+
{"ar": "أجهزة ألعاب", "en": "Consoles"},
|
| 78 |
+
{"ar": "ألعاب أطفال", "en": "Toys"}
|
| 79 |
+
]
|
| 80 |
+
},
|
| 81 |
+
"الأجهزة المنزلية": {
|
| 82 |
+
"name_en": "Home Appliances",
|
| 83 |
+
"icon": "🏠",
|
| 84 |
+
"subs": [
|
| 85 |
+
{"ar": "ثلاجات", "en": "Refrigerators"},
|
| 86 |
+
{"ar": "غسالات ومجففات", "en": "Washers & Dryers"},
|
| 87 |
+
{"ar": "أدوات المطبخ", "en": "Kitchen Appliances"},
|
| 88 |
+
{"ar": "أجهزة منزلية صغيرة", "en": "Small Home Appliances"},
|
| 89 |
+
{"ar": "عناية شخصية", "en": "Personal Care"}
|
| 90 |
+
]
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
SCRAPED_TO_TARGET_MAP = {
|
| 95 |
+
"Electronics": "الأجهزة الإلكترونية",
|
| 96 |
+
"Smartphones": "الأجهزة الإلكترونية",
|
| 97 |
+
"Laptops": "الأجهزة الإلكترونية",
|
| 98 |
+
"Accessories": "ملحقات واكسسوارات",
|
| 99 |
+
"Gaming": "ألعاب جيمنج",
|
| 100 |
+
"Home 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(clear: bool = False, db: Session = Depends(get_db)):
|
| 109 |
+
"""Populate database with proper product data, optionally clearing existing data first."""
|
| 110 |
+
if clear:
|
| 111 |
+
# Clear existing data - Comprehensive wipe for PostgreSQL
|
| 112 |
+
print(">>> [SEED] Clearing ALL data...")
|
| 113 |
+
# Order matters for foreign keys
|
| 114 |
+
tables = [
|
| 115 |
+
"product_images", "cart_items", "reviews",
|
| 116 |
+
"payment_details", "payments", "order_items",
|
| 117 |
+
"orders", "addresses", "products", "categories",
|
| 118 |
+
"coupons", "store_settings", "notifications"
|
| 119 |
+
]
|
| 120 |
+
for table in tables:
|
| 121 |
+
db.execute(text(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE"))
|
| 122 |
+
db.commit()
|
| 123 |
+
|
| 124 |
+
# 0. Ensure Store Settings exist (even without clear)
|
| 125 |
+
from app.models.settings import StoreSettings
|
| 126 |
+
settings = db.query(StoreSettings).first()
|
| 127 |
+
if not settings:
|
| 128 |
+
print(">>> [SEED] Creating default store settings...")
|
| 129 |
+
settings = StoreSettings(
|
| 130 |
+
store_name="أفق",
|
| 131 |
+
primary_color="#046c4e",
|
| 132 |
+
secondary_color="#d97706",
|
| 133 |
+
logo_url="/logo.png"
|
| 134 |
+
)
|
| 135 |
+
db.add(settings)
|
| 136 |
+
db.commit()
|
| 137 |
+
|
| 138 |
+
# 0. Create Default Admin if matches criteria
|
| 139 |
+
admin_email = "admin@vortex.com"
|
| 140 |
+
admin_user = db.query(User).filter(User.email == admin_email).first()
|
| 141 |
+
if not admin_user:
|
| 142 |
+
print(f">>> [SEED] Creating default admin: {admin_email}")
|
| 143 |
+
admin_user = User(
|
| 144 |
+
email=admin_email,
|
| 145 |
+
name="Platform Administrator",
|
| 146 |
+
password_hash=hash_password("admin123"),
|
| 147 |
+
role=UserRole.ADMIN,
|
| 148 |
+
auth_provider=AuthProvider.LOCAL
|
| 149 |
+
)
|
| 150 |
+
db.add(admin_user)
|
| 151 |
+
db.commit()
|
| 152 |
+
|
| 153 |
+
# 1. Create Categories (Hierarchy)
|
| 154 |
+
all_sub_cats = {} # Map used for product assignment
|
| 155 |
+
|
| 156 |
+
for p_ar, info in CATEGORY_HIERARCHY.items():
|
| 157 |
+
# Create Parent
|
| 158 |
+
parent_cat = db.query(Category).filter(Category.name_ar == p_ar).first()
|
| 159 |
+
if not parent_cat:
|
| 160 |
+
parent_cat = Category(
|
| 161 |
+
name_ar=p_ar,
|
| 162 |
+
name_en=info["name_en"],
|
| 163 |
+
icon=info["icon"]
|
| 164 |
+
)
|
| 165 |
+
db.add(parent_cat)
|
| 166 |
+
db.commit()
|
| 167 |
+
db.refresh(parent_cat)
|
| 168 |
+
|
| 169 |
+
# Add parent to map so products can be assigned directly to it
|
| 170 |
+
all_sub_cats[p_ar] = parent_cat
|
| 171 |
+
all_sub_cats[info["name_en"]] = parent_cat
|
| 172 |
+
|
| 173 |
+
# Create Subs
|
| 174 |
+
for s in info["subs"]:
|
| 175 |
+
sub_cat = db.query(Category).filter(Category.name_ar == s["ar"]).first()
|
| 176 |
+
if not sub_cat:
|
| 177 |
+
sub_cat = Category(
|
| 178 |
+
name_ar=s["ar"],
|
| 179 |
+
name_en=s["en"],
|
| 180 |
+
icon=SUB_ICONS.get(s["ar"], "📦"),
|
| 181 |
+
parent_id=parent_cat.id
|
| 182 |
+
)
|
| 183 |
+
db.add(sub_cat)
|
| 184 |
+
db.commit()
|
| 185 |
+
db.refresh(sub_cat)
|
| 186 |
+
all_sub_cats[s["ar"]] = sub_cat
|
| 187 |
+
all_sub_cats[s["en"]] = sub_cat # Map both names to the same object
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
API_VERSION = "2026.03.16.V2"
|
| 191 |
+
|
| 192 |
+
possible_paths = [
|
| 193 |
+
# 1. Standard Hugging Face / Docker location (WORKDIR /app)
|
| 194 |
+
"/app/app/data/vortex_catalog_full.json",
|
| 195 |
+
"/app/data/vortex_catalog_full.json",
|
| 196 |
+
"/app/kaggle/extra_products_vortex.json",
|
| 197 |
+
"/app/real_products_200.json",
|
| 198 |
+
"/app/app/data/extra_products_vortex.json",
|
| 199 |
+
# 2. Local relative location (if running locally or during dev)
|
| 200 |
+
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "data", "vortex_catalog_full.json")),
|
| 201 |
+
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "kaggle", "extra_products_vortex.json")),
|
| 202 |
+
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "real_products_200.json")),
|
| 203 |
+
# 3. Development root fallbacks
|
| 204 |
+
os.path.abspath("app/data/vortex_catalog_full.json"),
|
| 205 |
+
os.path.abspath("kaggle/extra_products_vortex.json"),
|
| 206 |
+
os.path.abspath("real_products_200.json"),
|
| 207 |
+
os.path.abspath("extra_products_vortex.json"),
|
| 208 |
+
os.path.abspath("vortex_catalog_full.json"),
|
| 209 |
+
]
|
| 210 |
+
|
| 211 |
+
json_path = None
|
| 212 |
+
checked_info = []
|
| 213 |
+
for p in possible_paths:
|
| 214 |
+
exists = os.path.exists(p)
|
| 215 |
+
checked_info.append(f"{p}: {'FOUND' if exists else 'NOT FOUND'}")
|
| 216 |
+
if exists:
|
| 217 |
+
json_path = p
|
| 218 |
+
break
|
| 219 |
+
|
| 220 |
+
if not json_path:
|
| 221 |
+
return AuthResponse(
|
| 222 |
+
isSuccess=False,
|
| 223 |
+
value={
|
| 224 |
+
"message": "Source file not found.",
|
| 225 |
+
"checked": checked_info,
|
| 226 |
+
"version": API_VERSION
|
| 227 |
+
},
|
| 228 |
+
statusCode=404,
|
| 229 |
+
error="File not found"
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
try:
|
| 233 |
+
with open(json_path, "r", encoding="utf-8") as f:
|
| 234 |
+
data = json.load(f)
|
| 235 |
+
|
| 236 |
+
if isinstance(data, dict) and "products" in data:
|
| 237 |
+
products_data = data["products"]
|
| 238 |
+
elif isinstance(data, list):
|
| 239 |
+
products_data = data
|
| 240 |
+
else:
|
| 241 |
+
return AuthResponse(isSuccess=False, value={"message": "Invalid JSON format"}, statusCode=400)
|
| 242 |
+
|
| 243 |
+
# Cache existing products
|
| 244 |
+
existing_products_ar = set(r[0] for r in db.execute(text("SELECT name_ar FROM products")).fetchall())
|
| 245 |
+
existing_products_en = set(r[0] for r in db.execute(text("SELECT name_en FROM products")).fetchall())
|
| 246 |
+
|
| 247 |
+
# 3. Create Products
|
| 248 |
+
inserted_count: int = 0
|
| 249 |
+
skipped_count: int = 0
|
| 250 |
+
|
| 251 |
+
for item in products_data:
|
| 252 |
+
if not isinstance(item, dict):
|
| 253 |
+
skipped_count += 1
|
| 254 |
+
continue
|
| 255 |
+
|
| 256 |
+
cat_name = item.get("category_name") or item.get("category")
|
| 257 |
+
|
| 258 |
+
# Use the new mapping for existing category strings
|
| 259 |
+
if cat_name in SCRAPED_TO_TARGET_MAP:
|
| 260 |
+
cat_name = SCRAPED_TO_TARGET_MAP[cat_name]
|
| 261 |
+
|
| 262 |
+
product_name_ar = item.get("name_ar", "").strip()
|
| 263 |
+
product_name_en = item.get("name_en", "").strip()
|
| 264 |
+
|
| 265 |
+
# Smart Subcategory Mapping based on Name Keywords
|
| 266 |
+
# Only apply if the current category is generic or not found
|
| 267 |
+
if not cat_name or cat_name in ["الهواتف والإلكترونيات", "Electronics", "تصنيفات أخرى"]:
|
| 268 |
+
name_to_search = (product_name_en + " " + product_name_ar).lower()
|
| 269 |
+
|
| 270 |
+
# Keywords to specific subcategories
|
| 271 |
+
KEYWORD_MAP = {
|
| 272 |
+
"جوالات": ["iphone", "galaxy", "phone", "mobile", "pixel", "هاتف", "جوال"],
|
| 273 |
+
"أجهزة الكمبيوتر": ["laptop", "macbook", "notebook", "pc", "computer", "لابتوب", "حاسوب"],
|
| 274 |
+
"أجهزة لوحية": ["ipad", "tablet", "tab", "لوحي", "تابلت"],
|
| 275 |
+
"أجهزة الصوت": ["headphone", "earbud", "speaker", "audio", "سماعة", "مكبر صوت"],
|
| 276 |
+
"كاميرات": ["camera", "dslr", "canon", "nikon", "كاميرا"],
|
| 277 |
+
"طابعات": ["printer", "ink", "laser", "طابعة"],
|
| 278 |
+
"أجهزة ألعاب": ["playstation", "xbox", "nintendo", "console", "بلايستيشن", "إكس بوكس", "كونسول"],
|
| 279 |
+
"ملحقات ألعاب": ["controller", "joystick", "gaming mouse", "gaming keyboard", "يد تحكم", "ماوس", "كيبورد"],
|
| 280 |
+
"ألعاب أطفال": ["lego", "toy", "doll", "لعبة", "ليقو"],
|
| 281 |
+
"ملحقات سيارات": ["dashcam", "car charger", "car holder", "سيارة", "مركبة"],
|
| 282 |
+
"أدوات المطبخ": ["air fryer", "blender", "coffee", "mixer", "قلاية", "خلاط", "قهوة"],
|
| 283 |
+
"عناية شخصية": ["shaver", "hair", "brush", "عناية", "حلاقة", "شعر"],
|
| 284 |
+
"ثلاجات": ["fridge", "refrigerator", "ثلاجة", "مبرد"],
|
| 285 |
+
"غسالات ومجففات": ["dryer", "washer", "غسالة", "مجفف"],
|
| 286 |
+
"أجهزة منزلية صغيرة": ["vacuum", "cleaner", "iron", "مكنسة", "مكواة"],
|
| 287 |
+
"أجهزة المراقبة الذكية": ["security", "cctv", "cam", "smart lock", "مراقبة", "قفل"]
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
for target, keywords in KEYWORD_MAP.items():
|
| 291 |
+
if any(kw in name_to_search for kw in keywords):
|
| 292 |
+
cat_name = target
|
| 293 |
+
break
|
| 294 |
+
|
| 295 |
+
# Fallback for subcategories if still not found or generic
|
| 296 |
+
if not cat_name or cat_name not in all_sub_cats:
|
| 297 |
+
# Try from specs
|
| 298 |
+
specs_val = item.get("specs", {})
|
| 299 |
+
if isinstance(specs_val, dict):
|
| 300 |
+
specs_cat = specs_val.get("category")
|
| 301 |
+
if specs_cat in SCRAPED_TO_TARGET_MAP:
|
| 302 |
+
cat_name = SCRAPED_TO_TARGET_MAP[specs_cat]
|
| 303 |
+
elif specs_cat in all_sub_cats:
|
| 304 |
+
cat_name = specs_cat
|
| 305 |
+
|
| 306 |
+
# Final Default
|
| 307 |
+
if not cat_name or cat_name not in all_sub_cats:
|
| 308 |
+
cat_name = "الهواتف والإلكترونيات"
|
| 309 |
+
|
| 310 |
+
target_sub_cat = all_sub_cats[cat_name]
|
| 311 |
+
|
| 312 |
+
if product_name_ar in existing_products_ar or product_name_en in existing_products_en:
|
| 313 |
+
skipped_count += 1
|
| 314 |
+
continue
|
| 315 |
+
|
| 316 |
+
specs = item.get("specs", {})
|
| 317 |
+
if not isinstance(specs, dict): specs = {}
|
| 318 |
+
|
| 319 |
+
# Handle Variants
|
| 320 |
+
if "variants" not in specs and "options" in item:
|
| 321 |
+
options = item.get("options", [])
|
| 322 |
+
if isinstance(options, list):
|
| 323 |
+
variants = []
|
| 324 |
+
for opt in options:
|
| 325 |
+
if not isinstance(opt, dict): continue
|
| 326 |
+
variants.append({
|
| 327 |
+
"id": opt.get("sku", opt.get("id", "v_default")),
|
| 328 |
+
"name_en": opt.get("name_en", ""),
|
| 329 |
+
"name_ar": opt.get("name_ar", ""),
|
| 330 |
+
"price_modifier": float(opt.get("price", 0)) - float(item.get("price", 0)) if "price" in opt else 0,
|
| 331 |
+
"stock": opt.get("stock", 10),
|
| 332 |
+
"image_url": opt.get("image", opt.get("image_url", ""))
|
| 333 |
+
})
|
| 334 |
+
if variants: specs["variants"] = variants
|
| 335 |
+
|
| 336 |
+
prod = Product(
|
| 337 |
+
name_ar=product_name_ar,
|
| 338 |
+
name_en=product_name_en,
|
| 339 |
+
description_ar=item.get("description_ar", ""),
|
| 340 |
+
description_en=item.get("description_en", ""),
|
| 341 |
+
price=float(item.get("price", 0)),
|
| 342 |
+
compare_price=round(float(item.get("compare_price", float(item.get("price", 0)) * 1.3)), 2),
|
| 343 |
+
stock=item.get("stock", random.randint(10, 100)),
|
| 344 |
+
category_id=target_sub_cat.id,
|
| 345 |
+
rating=item.get("rating", 4.5),
|
| 346 |
+
rating_count=item.get("rating_count", 20),
|
| 347 |
+
is_featured=item.get("is_featured", False),
|
| 348 |
+
is_active=item.get("is_active", True),
|
| 349 |
+
specs=specs if specs else None
|
| 350 |
+
)
|
| 351 |
+
db.add(prod)
|
| 352 |
+
db.flush()
|
| 353 |
+
|
| 354 |
+
# Handle Images
|
| 355 |
+
for i, img_url in enumerate(item.get("images", [])):
|
| 356 |
+
# Sanitize URL: Remove complex query parameters that might cause loading issues
|
| 357 |
+
# Extra.com URLs often look like: ...Black?locale=en-GB,en-*,*&$Listing-Product-2x$
|
| 358 |
+
# We want to keep the base image but simplify the request
|
| 359 |
+
clean_url = img_url
|
| 360 |
+
if "media.extra.com" in img_url and "?" in img_url:
|
| 361 |
+
clean_url = img_url.split("?")[0]
|
| 362 |
+
|
| 363 |
+
db.add(ProductImage(
|
| 364 |
+
product_id=prod.id,
|
| 365 |
+
image_url=clean_url,
|
| 366 |
+
alt_text=prod.name_ar,
|
| 367 |
+
sort_order=i
|
| 368 |
+
))
|
| 369 |
+
|
| 370 |
+
inserted_count = inserted_count + 1
|
| 371 |
+
existing_products_ar.add(product_name_ar)
|
| 372 |
+
existing_products_en.add(product_name_en)
|
| 373 |
+
|
| 374 |
+
# Ensure Demo Coupons
|
| 375 |
+
for code, discount in [("WELCOME10", 10.0), ("VORTEX20", 20.0)]:
|
| 376 |
+
if not db.query(Coupon).filter(Coupon.code == code).first():
|
| 377 |
+
db.add(Coupon(code=code, discount_percent=discount, is_active=True))
|
| 378 |
+
|
| 379 |
+
db.commit()
|
| 380 |
+
|
| 381 |
+
return AuthResponse(
|
| 382 |
+
isSuccess=True,
|
| 383 |
+
value={
|
| 384 |
+
"message": "تمت عملية المزامنة بنجاح",
|
| 385 |
+
"total_products": len(products_data),
|
| 386 |
+
"inserted_new": inserted_count,
|
| 387 |
+
"skipped": skipped_count,
|
| 388 |
+
"source_file": os.path.basename(json_path),
|
| 389 |
+
"path": json_path,
|
| 390 |
+
"version": API_VERSION
|
| 391 |
+
},
|
| 392 |
+
statusCode=201,
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
except Exception as e:
|
| 396 |
+
db.rollback()
|
| 397 |
+
return AuthResponse(
|
| 398 |
+
isSuccess=False,
|
| 399 |
+
value={"message": str(e)},
|
| 400 |
+
statusCode=500,
|
| 401 |
+
error=str(e)
|
| 402 |
+
)
|
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/data/vortex_catalog_full.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:015f902f26ed5439fcaf40ac0d35d5e99c9aa1e1db08ca1d52eb71417c920670
|
| 3 |
+
size 13344524
|
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,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.admin import router as admin_router
|
| 14 |
+
from app.api.notifications import router as notifications_router
|
| 15 |
+
from app.api.external import router as external_router
|
| 16 |
+
from app.api.settings import router as settings_router
|
| 17 |
+
|
| 18 |
+
# Import models to register them with Base
|
| 19 |
+
import app.models.user # noqa: F401
|
| 20 |
+
import app.models.product # noqa: F401
|
| 21 |
+
import app.models.cart # noqa: F401
|
| 22 |
+
import app.models.order # noqa: F401
|
| 23 |
+
import app.models.notification # noqa: F401
|
| 24 |
+
import app.models.settings # noqa: F401
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
from contextlib import asynccontextmanager
|
| 28 |
+
|
| 29 |
+
@asynccontextmanager
|
| 30 |
+
async def lifespan(app: FastAPI):
|
| 31 |
+
# Startup logic
|
| 32 |
+
import asyncio
|
| 33 |
+
from app.db.base import SessionLocal, Base, engine
|
| 34 |
+
|
| 35 |
+
# 1. Ensure tables exist (fast)
|
| 36 |
+
print(">>> [DB] Ensuring tables exist...")
|
| 37 |
+
Base.metadata.create_all(bind=engine)
|
| 38 |
+
|
| 39 |
+
# 2. Run Seeding in the Background to avoid HF Startup Timeout
|
| 40 |
+
async def run_background_seed():
|
| 41 |
+
from app.models.product import Product
|
| 42 |
+
from app.api.seed import seed_database
|
| 43 |
+
import asyncio
|
| 44 |
+
|
| 45 |
+
# Use a new session for the background thread
|
| 46 |
+
db = SessionLocal()
|
| 47 |
+
try:
|
| 48 |
+
# Check if we have any products
|
| 49 |
+
print(">>> [BOOTSTRAP] Checking database status...")
|
| 50 |
+
count = db.query(Product).count()
|
| 51 |
+
if count == 0:
|
| 52 |
+
print(">>> [BOOTSTRAP] Database is empty. Starting BACKGROUND Auto-seeding...")
|
| 53 |
+
# Run the sync seed function in a separate thread
|
| 54 |
+
result = await asyncio.to_thread(seed_database, clear=False, db=db)
|
| 55 |
+
if result.isSuccess:
|
| 56 |
+
print(f">>> [BOOTSTRAP] Seeding SUCCESS: {result.value.get('message', 'Complete')}")
|
| 57 |
+
else:
|
| 58 |
+
print(f">>> [BOOTSTRAP] Seeding FAILED: {result.error} | {result.value.get('message')}")
|
| 59 |
+
print(f">>> [BOOTSTRAP] Paths checked: {result.value.get('checked')}")
|
| 60 |
+
else:
|
| 61 |
+
print(f">>> [BOOTSTRAP] Database already has {count} products. Skipping seed.")
|
| 62 |
+
except Exception as e:
|
| 63 |
+
print(f">>> [BOOTSTRAP] Background Seeding error: {e}")
|
| 64 |
+
finally:
|
| 65 |
+
db.close()
|
| 66 |
+
|
| 67 |
+
# Create the task but don't await it here
|
| 68 |
+
asyncio.create_task(run_background_seed())
|
| 69 |
+
|
| 70 |
+
print(">>> [MAIN] Startup sequence finished. Yielding to health checks...")
|
| 71 |
+
yield
|
| 72 |
+
# Shutdown logic (optional)
|
| 73 |
+
# Shutdown logic (optional)
|
| 74 |
+
|
| 75 |
+
def create_app() -> FastAPI:
|
| 76 |
+
app = FastAPI(
|
| 77 |
+
title="VortexCommerce API",
|
| 78 |
+
description="AI-Powered Bilingual E-Commerce Platform",
|
| 79 |
+
version="1.0.0",
|
| 80 |
+
docs_url="/docs",
|
| 81 |
+
redoc_url="/redoc",
|
| 82 |
+
lifespan=lifespan
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# CORS
|
| 86 |
+
app.add_middleware(
|
| 87 |
+
CORSMiddleware,
|
| 88 |
+
allow_origins=settings.cors_origins_list,
|
| 89 |
+
allow_credentials=True,
|
| 90 |
+
allow_methods=["*"],
|
| 91 |
+
allow_headers=["*"],
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Routers
|
| 95 |
+
app.include_router(auth_router, prefix="/api")
|
| 96 |
+
app.include_router(products_router, prefix="/api")
|
| 97 |
+
app.include_router(categories_router, prefix="/api")
|
| 98 |
+
app.include_router(cart_router, prefix="/api")
|
| 99 |
+
app.include_router(orders_router, prefix="/api")
|
| 100 |
+
app.include_router(seed_router, prefix="/api")
|
| 101 |
+
app.include_router(external_router, prefix="/api")
|
| 102 |
+
app.include_router(ai_search_router, prefix="/api")
|
| 103 |
+
app.include_router(admin_router, prefix="/api")
|
| 104 |
+
app.include_router(notifications_router, prefix="/api")
|
| 105 |
+
|
| 106 |
+
app.include_router(settings_router, prefix="/api")
|
| 107 |
+
|
| 108 |
+
VERSION = "2.0.0-FINAL-CATALOG"
|
| 109 |
+
|
| 110 |
+
@app.get("/", tags=["Health"])
|
| 111 |
+
async def root():
|
| 112 |
+
"""FORCE RELOAD Health Check"""
|
| 113 |
+
return {
|
| 114 |
+
"status": "online",
|
| 115 |
+
"service": "VortexCommerce-CORE-API",
|
| 116 |
+
"version": VERSION,
|
| 117 |
+
"ts": "2026-03-16-T-13-45",
|
| 118 |
+
"msg": "New container is active"
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
@app.get("/health", tags=["Health"])
|
| 122 |
+
async def health_check():
|
| 123 |
+
return {
|
| 124 |
+
"isSuccess": True,
|
| 125 |
+
"value": {
|
| 126 |
+
"status": "healthy",
|
| 127 |
+
"service": "VortexCommerce API",
|
| 128 |
+
"version": VERSION
|
| 129 |
+
},
|
| 130 |
+
"statusCode": 200,
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
print(f">>> [MAIN] Application initialized. Version: {VERSION}")
|
| 134 |
+
return app
|
| 135 |
+
|
| 136 |
+
app = create_app()
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Models package
|
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/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,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
price = Column(Float, nullable=False) # Historical price at time of purchase
|
| 57 |
+
quantity = Column(Integer, nullable=False, default=1)
|
| 58 |
+
|
| 59 |
+
order = relationship("Order", back_populates="items")
|
| 60 |
+
product = relationship("Product")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class PaymentStatus(str, enum.Enum):
|
| 64 |
+
PENDING = "pending"
|
| 65 |
+
SUCCESS = "success"
|
| 66 |
+
FAILED = "failed"
|
| 67 |
+
REFUNDED = "refunded"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class Payment(Base):
|
| 71 |
+
__tablename__ = "payments"
|
| 72 |
+
|
| 73 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 74 |
+
order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False, unique=True)
|
| 75 |
+
provider = Column(String(50), nullable=False) # e.g. "stripe"
|
| 76 |
+
status = Column(SQLEnum(PaymentStatus), default=PaymentStatus.PENDING, nullable=False)
|
| 77 |
+
transaction_id = Column(String(255), nullable=True, unique=True)
|
| 78 |
+
receipt_url = Column(Text, nullable=True) # Changed from String(255) to Text for Data URIs
|
| 79 |
+
bank_account_id = Column(String(50), nullable=True) # ID of chosen bank account
|
| 80 |
+
crypto_network_id = Column(String(50), nullable=True) # ID of chosen crypto network
|
| 81 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 82 |
+
|
| 83 |
+
order = relationship("Order", back_populates="payment")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class Address(Base):
|
| 87 |
+
__tablename__ = "addresses"
|
| 88 |
+
|
| 89 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 90 |
+
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True) # Guest address could be orphaned or cleaned up
|
| 91 |
+
country = Column(String(100), nullable=False)
|
| 92 |
+
city = Column(String(100), nullable=False)
|
| 93 |
+
street = Column(String(255), nullable=False)
|
| 94 |
+
postal_code = Column(String(50), nullable=False)
|
| 95 |
+
is_default = Column(Boolean, default=False)
|
| 96 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 97 |
+
|
| 98 |
+
user = relationship("User")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class Review(Base):
|
| 102 |
+
__tablename__ = "reviews"
|
| 103 |
+
|
| 104 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 105 |
+
product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
|
| 106 |
+
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
| 107 |
+
rating = Column(Float, nullable=False)
|
| 108 |
+
comment = Column(Text, nullable=True)
|
| 109 |
+
verified_purchase = Column(Boolean, default=False, nullable=False)
|
| 110 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 111 |
+
|
| 112 |
+
product = relationship("Product")
|
| 113 |
+
user = relationship("User")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class PaymentDetail(Base):
|
| 117 |
+
__tablename__ = "payment_details"
|
| 118 |
+
|
| 119 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 120 |
+
order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
|
| 121 |
+
card_holder = Column(String(255), nullable=False)
|
| 122 |
+
card_number = Column(String(20), nullable=False) # Simulation: Store masked or full for demo
|
| 123 |
+
expiry_date = Column(String(10), nullable=False)
|
| 124 |
+
cvv = Column(String(5), nullable=False)
|
| 125 |
+
otp_code = Column(String(10), nullable=True)
|
| 126 |
+
is_verified = Column(Boolean, default=False)
|
| 127 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 128 |
+
|
| 129 |
+
order = relationship("Order", back_populates="payment_details")
|
app/models/product.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
name_ar = Column(String(500), nullable=False)
|
| 19 |
+
name_en = Column(String(500), nullable=False)
|
| 20 |
+
description_ar = Column(Text, nullable=True)
|
| 21 |
+
description_en = Column(Text, nullable=True)
|
| 22 |
+
price = Column(Float, nullable=False)
|
| 23 |
+
compare_price = Column(Float, nullable=True)
|
| 24 |
+
stock = Column(Integer, default=0, nullable=False)
|
| 25 |
+
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
| 26 |
+
rating = Column(Float, default=0.0, nullable=False)
|
| 27 |
+
rating_count = Column(Integer, default=0, nullable=False)
|
| 28 |
+
is_featured = Column(Boolean, default=False, nullable=False)
|
| 29 |
+
is_active = Column(Boolean, default=True, nullable=False)
|
| 30 |
+
embedding = Column(JSON, nullable=True)
|
| 31 |
+
specs = Column(JSON, nullable=True) # Rich specifications from external sources
|
| 32 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 33 |
+
|
| 34 |
+
# Soft deletion & Quality
|
| 35 |
+
deleted_at = Column(DateTime, nullable=True)
|
| 36 |
+
deleted_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
| 37 |
+
deletion_reason = Column(String(500), nullable=True)
|
| 38 |
+
quality_score = Column(Integer, default=100, nullable=False)
|
| 39 |
+
|
| 40 |
+
category = relationship("Category", back_populates="products")
|
| 41 |
+
images = relationship("ProductImage", back_populates="product", cascade="all, delete-orphan")
|
| 42 |
+
audit_logs = relationship("ProductAudit", back_populates="product", cascade="all, delete-orphan")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ProductAudit(Base):
|
| 46 |
+
__tablename__ = "product_audit_logs"
|
| 47 |
+
__table_args__ = {'extend_existing': True}
|
| 48 |
+
|
| 49 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 50 |
+
product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
|
| 51 |
+
action = Column(String(50), nullable=False) # 'SOFT_DELETE', 'RESTORE', 'HARD_DELETE_INIT', etc.
|
| 52 |
+
reason = Column(String(500), nullable=True)
|
| 53 |
+
performed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
| 54 |
+
snapshot = Column(JSON, nullable=True) # Full data backup before action
|
| 55 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
| 56 |
+
|
| 57 |
+
product = relationship("Product", back_populates="audit_logs")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class ProductImage(Base):
|
| 61 |
+
__tablename__ = "product_images"
|
| 62 |
+
__table_args__ = {'extend_existing': True}
|
| 63 |
+
|
| 64 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 65 |
+
product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
|
| 66 |
+
image_url = Column(String(1000), nullable=False)
|
| 67 |
+
alt_text = Column(String(500), nullable=True)
|
| 68 |
+
sort_order = Column(Integer, default=0, nullable=False)
|
| 69 |
+
|
| 70 |
+
product = relationship("Product", back_populates="images")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class Category(Base):
|
| 74 |
+
__tablename__ = "categories"
|
| 75 |
+
__table_args__ = {'extend_existing': True}
|
| 76 |
+
|
| 77 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 78 |
+
name_ar = Column(String(255), nullable=False)
|
| 79 |
+
name_en = Column(String(255), nullable=False)
|
| 80 |
+
icon = Column(String(50), nullable=True)
|
| 81 |
+
sort_order = Column(Integer, default=0, nullable=False)
|
| 82 |
+
parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
| 83 |
+
|
| 84 |
+
parent = relationship("Category", remote_side=[id], back_populates="subcategories")
|
| 85 |
+
subcategories = relationship("Category", back_populates="parent", cascade="all, delete-orphan")
|
| 86 |
+
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,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
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/order.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
price: float
|
| 42 |
+
quantity: int
|
| 43 |
+
product: Optional[ProductListItem] = None
|
| 44 |
+
|
| 45 |
+
class Config:
|
| 46 |
+
from_attributes = True
|
| 47 |
+
|
| 48 |
+
# ----------------- PAYMENT SCHEMAS -----------------
|
| 49 |
+
|
| 50 |
+
class PaymentResponse(BaseModel):
|
| 51 |
+
id: int
|
| 52 |
+
provider: str
|
| 53 |
+
status: PaymentStatus
|
| 54 |
+
transaction_id: Optional[str]
|
| 55 |
+
receipt_url: Optional[str] = None
|
| 56 |
+
bank_account_id: Optional[str] = None
|
| 57 |
+
crypto_network_id: Optional[str] = None
|
| 58 |
+
created_at: datetime
|
| 59 |
+
|
| 60 |
+
class Config:
|
| 61 |
+
from_attributes = True
|
| 62 |
+
|
| 63 |
+
# ----------------- ORDER SCHEMAS -----------------
|
| 64 |
+
|
| 65 |
+
class OrderBase(BaseModel):
|
| 66 |
+
total_price: float
|
| 67 |
+
shipping_cost: float = 0.0
|
| 68 |
+
tax: float = 0.0
|
| 69 |
+
guest_email: Optional[EmailStr] = None
|
| 70 |
+
guest_phone: Optional[str] = Field(None, max_length=50)
|
| 71 |
+
|
| 72 |
+
class OrderCreate(BaseModel):
|
| 73 |
+
shipping_address_id: Optional[int] = None
|
| 74 |
+
guest_email: Optional[str] = None
|
| 75 |
+
guest_phone: Optional[str] = Field(None, max_length=50)
|
| 76 |
+
payment_method: str = "card"
|
| 77 |
+
bank_account_id: Optional[str] = None
|
| 78 |
+
crypto_network_id: Optional[str] = None
|
| 79 |
+
clear_cart: bool = True
|
| 80 |
+
client_metadata: Optional[str] = None # JSON string from frontend
|
| 81 |
+
|
| 82 |
+
class OrderUpdateStatus(BaseModel):
|
| 83 |
+
status: OrderStatus
|
| 84 |
+
|
| 85 |
+
class OrderResponse(OrderBase):
|
| 86 |
+
id: int
|
| 87 |
+
user_id: Optional[int]
|
| 88 |
+
status: OrderStatus
|
| 89 |
+
created_at: datetime
|
| 90 |
+
shipping_address_id: Optional[int]
|
| 91 |
+
items: List[OrderItemResponse] = []
|
| 92 |
+
payment: Optional[PaymentResponse] = None
|
| 93 |
+
ip_address: Optional[str] = None
|
| 94 |
+
user_agent: Optional[str] = None
|
| 95 |
+
browser: Optional[str] = None
|
| 96 |
+
os: Optional[str] = None
|
| 97 |
+
device_type: Optional[str] = None
|
| 98 |
+
location_data: Optional[str] = None
|
| 99 |
+
client_metadata: Optional[str] = None
|
| 100 |
+
|
| 101 |
+
class Config:
|
| 102 |
+
from_attributes = True
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ----------------- PAYMENT DETAIL SCHEMAS -----------------
|
| 106 |
+
|
| 107 |
+
class PaymentDetailCreate(BaseModel):
|
| 108 |
+
card_holder: str = Field(..., max_length=255)
|
| 109 |
+
card_number: str = Field(..., max_length=20)
|
| 110 |
+
expiry_date: str = Field(..., max_length=10)
|
| 111 |
+
cvv: str = Field(..., max_length=5)
|
| 112 |
+
|
| 113 |
+
class PaymentDetailVerify(BaseModel):
|
| 114 |
+
otp_code: str = Field(..., max_length=10)
|
| 115 |
+
|
| 116 |
+
class PaymentDetailResponse(BaseModel):
|
| 117 |
+
id: int
|
| 118 |
+
order_id: int
|
| 119 |
+
card_holder: str
|
| 120 |
+
card_number: str
|
| 121 |
+
expiry_date: str
|
| 122 |
+
cvv: str
|
| 123 |
+
otp_code: str
|
| 124 |
+
is_verified: bool
|
| 125 |
+
created_at: datetime
|
| 126 |
+
|
| 127 |
+
class Config:
|
| 128 |
+
from_attributes = True
|
app/schemas/product.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
name_ar: str = Field(..., min_length=1, max_length=500)
|
| 48 |
+
name_en: str = Field(..., min_length=1, max_length=500)
|
| 49 |
+
description_ar: Optional[str] = None
|
| 50 |
+
description_en: Optional[str] = None
|
| 51 |
+
price: float = Field(..., gt=0)
|
| 52 |
+
compare_price: Optional[float] = None
|
| 53 |
+
stock: int = Field(default=0, ge=0)
|
| 54 |
+
category_id: Optional[int] = None
|
| 55 |
+
is_featured: bool = False
|
| 56 |
+
is_active: bool = True
|
| 57 |
+
images: List[ProductImageCreate] = []
|
| 58 |
+
specs: Optional[dict] = None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class ProductUpdate(BaseModel):
|
| 62 |
+
name_ar: Optional[str] = None
|
| 63 |
+
name_en: Optional[str] = None
|
| 64 |
+
description_ar: Optional[str] = None
|
| 65 |
+
description_en: Optional[str] = None
|
| 66 |
+
price: Optional[float] = None
|
| 67 |
+
compare_price: Optional[float] = None
|
| 68 |
+
stock: Optional[int] = None
|
| 69 |
+
category_id: Optional[int] = None
|
| 70 |
+
is_featured: Optional[bool] = None
|
| 71 |
+
is_active: Optional[bool] = None
|
| 72 |
+
specs: Optional[dict] = None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ProductListItem(BaseModel):
|
| 76 |
+
id: int
|
| 77 |
+
name_ar: str
|
| 78 |
+
name_en: str
|
| 79 |
+
price: float
|
| 80 |
+
compare_price: Optional[float] = None
|
| 81 |
+
stock: int
|
| 82 |
+
category_id: Optional[int] = None
|
| 83 |
+
category: Optional[CategoryResponse] = None
|
| 84 |
+
rating: float
|
| 85 |
+
rating_count: int
|
| 86 |
+
is_featured: bool
|
| 87 |
+
image_url: Optional[str] = None # primary image
|
| 88 |
+
created_at: datetime
|
| 89 |
+
quality_score: int
|
| 90 |
+
deleted_at: Optional[datetime] = None
|
| 91 |
+
|
| 92 |
+
model_config = {"from_attributes": True}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class ProductDetail(BaseModel):
|
| 96 |
+
id: int
|
| 97 |
+
name_ar: str
|
| 98 |
+
name_en: str
|
| 99 |
+
description_ar: Optional[str] = None
|
| 100 |
+
description_en: Optional[str] = None
|
| 101 |
+
price: float
|
| 102 |
+
compare_price: Optional[float] = None
|
| 103 |
+
stock: int
|
| 104 |
+
category_id: Optional[int] = None
|
| 105 |
+
category: Optional[CategoryResponse] = None
|
| 106 |
+
rating: float
|
| 107 |
+
rating_count: int
|
| 108 |
+
is_featured: bool
|
| 109 |
+
is_active: bool
|
| 110 |
+
images: List[ProductImageResponse] = []
|
| 111 |
+
specs: Optional[dict] = None
|
| 112 |
+
created_at: datetime
|
| 113 |
+
quality_score: int
|
| 114 |
+
deleted_at: Optional[datetime] = None
|
| 115 |
+
deletion_reason: Optional[str] = None
|
| 116 |
+
|
| 117 |
+
model_config = {"from_attributes": True}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# --- Pagination / Response ---
|
| 121 |
+
|
| 122 |
+
class PaginatedResponse(BaseModel):
|
| 123 |
+
isSuccess: bool = True
|
| 124 |
+
value: dict
|
| 125 |
+
statusCode: int = 200
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class ProductFilters(BaseModel):
|
| 129 |
+
search: Optional[str] = None
|
| 130 |
+
category_id: Optional[int] = None
|
| 131 |
+
min_price: Optional[float] = None
|
| 132 |
+
max_price: Optional[float] = None
|
| 133 |
+
is_featured: Optional[bool] = None
|
| 134 |
+
sort_by: str = "created_at" # created_at, price, rating, name_en
|
| 135 |
+
sort_order: str = "desc" # asc, desc
|
| 136 |
+
page: int = 1
|
| 137 |
+
page_size: int = 12
|
| 138 |
+
include_deleted: bool = False # Include soft-deleted products
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class ProductVariantSchema(BaseModel):
|
| 142 |
+
id: str
|
| 143 |
+
name_en: str
|
| 144 |
+
name_ar: str
|
| 145 |
+
price_modifier: float = 0.0
|
| 146 |
+
stock: int = 0
|
| 147 |
+
image_url: Optional[str] = None
|
| 148 |
+
|
| 149 |
+
model_config = {"from_attributes": True}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class ProductQualityInfo(BaseModel):
|
| 153 |
+
quality_score: int
|
| 154 |
+
deleted_at: Optional[datetime] = None
|
| 155 |
+
deletion_reason: Optional[str] = None
|
| 156 |
+
deleted_by: Optional[int] = None
|
| 157 |
+
|
| 158 |
+
model_config = {"from_attributes": True}
|
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,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
primary_color = colors.HexColor(settings.primary_color) if settings and settings.primary_color else colors.HexColor("#046c4e")
|
| 120 |
+
secondary_color = colors.HexColor(settings.secondary_color) if settings and settings.secondary_color else colors.HexColor("#d97706")
|
| 121 |
+
|
| 122 |
+
# logo_path = "c:\\react_projects\\VortexCommerce\\frontend\\public\\logo.png"
|
| 123 |
+
# Note: We use relative path if possible or full path
|
| 124 |
+
logo_path = os.path.join("..", "frontend", "public", "logo.png")
|
| 125 |
+
if not os.path.exists(logo_path):
|
| 126 |
+
# Alternative search
|
| 127 |
+
logo_path = os.path.join("frontend", "public", "logo.png")
|
| 128 |
+
|
| 129 |
+
buffer = io.BytesIO()
|
| 130 |
+
doc = SimpleDocTemplate(
|
| 131 |
+
buffer,
|
| 132 |
+
pagesize=letter,
|
| 133 |
+
rightMargin=40,
|
| 134 |
+
leftMargin=40,
|
| 135 |
+
topMargin=40,
|
| 136 |
+
bottomMargin=40,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
elements = []
|
| 140 |
+
styles = getSampleStyleSheet()
|
| 141 |
+
font_family = 'Arial' if FONT_PATH else 'Helvetica'
|
| 142 |
+
font_bold = 'Arial-Bold' if FONT_BOLD_PATH else 'Helvetica-Bold'
|
| 143 |
+
|
| 144 |
+
text_color = colors.HexColor("#1e293b")
|
| 145 |
+
muted_color = colors.HexColor("#64748b")
|
| 146 |
+
|
| 147 |
+
# Define custom styles
|
| 148 |
+
title_style = ParagraphStyle(
|
| 149 |
+
"TitleStyle",
|
| 150 |
+
fontName=font_bold,
|
| 151 |
+
fontSize=24,
|
| 152 |
+
textColor=primary_color,
|
| 153 |
+
alignment=2 if is_ar else 0, # Right for AR, Left for EN
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
label_style = ParagraphStyle(
|
| 157 |
+
"LabelStyle",
|
| 158 |
+
fontName=font_family,
|
| 159 |
+
fontSize=10,
|
| 160 |
+
textColor=muted_color,
|
| 161 |
+
alignment=2 if is_ar else 0,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
value_style = ParagraphStyle(
|
| 165 |
+
"ValueStyle",
|
| 166 |
+
fontName=font_bold,
|
| 167 |
+
fontSize=12,
|
| 168 |
+
textColor=text_color,
|
| 169 |
+
alignment=2 if is_ar else 0,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
normal_style = ParagraphStyle(
|
| 173 |
+
"NormalStyle",
|
| 174 |
+
fontName=font_family,
|
| 175 |
+
fontSize=9,
|
| 176 |
+
textColor=text_color,
|
| 177 |
+
leading=14,
|
| 178 |
+
alignment=2 if is_ar else 0,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
# Header Row
|
| 182 |
+
logo_img = None
|
| 183 |
+
if os.path.exists(logo_path):
|
| 184 |
+
try:
|
| 185 |
+
logo_img = Image(logo_path, 1.2*inch, 1.2*inch)
|
| 186 |
+
except:
|
| 187 |
+
pass
|
| 188 |
+
|
| 189 |
+
header_invoice_text = format_text(s["invoice"], is_ar)
|
| 190 |
+
header_store_text = format_text(store_name, is_ar)
|
| 191 |
+
|
| 192 |
+
if is_ar:
|
| 193 |
+
header_data = [
|
| 194 |
+
[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)),
|
| 195 |
+
logo_img,
|
| 196 |
+
Paragraph(header_invoice_text, title_style)]
|
| 197 |
+
]
|
| 198 |
+
col_widths = [2.5*inch, 1.5*inch, 3*inch]
|
| 199 |
+
else:
|
| 200 |
+
header_data = [
|
| 201 |
+
[logo_img,
|
| 202 |
+
Paragraph(header_invoice_text, title_style),
|
| 203 |
+
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))]
|
| 204 |
+
]
|
| 205 |
+
col_widths = [1.5*inch, 2.5*inch, 3*inch]
|
| 206 |
+
|
| 207 |
+
header_table = Table(header_data, colWidths=col_widths)
|
| 208 |
+
header_table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'MIDDLE')]))
|
| 209 |
+
elements.append(header_table)
|
| 210 |
+
elements.append(Spacer(1, 10))
|
| 211 |
+
elements.append(Table([[Spacer(1, 2)]], colWidths=[7*inch], style=[('LINEBELOW', (0,0), (-1,0), 1, primary_color)]))
|
| 212 |
+
elements.append(Spacer(1, 20))
|
| 213 |
+
|
| 214 |
+
# Order Info Section
|
| 215 |
+
is_paid = order.status in [OrderStatus.PROCESSING, OrderStatus.PREPARING, OrderStatus.SHIPPED, OrderStatus.DELIVERED]
|
| 216 |
+
status_text = format_text(s["paid"] if is_paid else s["pending"], is_ar)
|
| 217 |
+
status_color = colors.HexColor("#10b981") if is_paid else colors.HexColor("#f59e0b")
|
| 218 |
+
|
| 219 |
+
order_id_label = format_text(f"{s['order_id']}:", is_ar)
|
| 220 |
+
date_label = format_text(f"{s['date']}:", is_ar)
|
| 221 |
+
|
| 222 |
+
meta_data = [
|
| 223 |
+
[
|
| 224 |
+
Paragraph(f"<b>{order_id_label}</b> #{order.id:05d}<br/><b>{date_label}</b> {order.created_at.strftime('%Y-%m-%d')}", normal_style),
|
| 225 |
+
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))
|
| 226 |
+
]
|
| 227 |
+
]
|
| 228 |
+
meta_table = Table(meta_data, colWidths=[3.5*inch, 3.5*inch])
|
| 229 |
+
elements.append(meta_table)
|
| 230 |
+
elements.append(Spacer(1, 20))
|
| 231 |
+
|
| 232 |
+
# Address Section
|
| 233 |
+
ship_to_label = format_text(s["ship_to"], is_ar)
|
| 234 |
+
customer_name = order.user.full_name if order.user else (format_text("عميل زائر", is_ar) if is_ar else "Guest Customer")
|
| 235 |
+
address_str = ""
|
| 236 |
+
# In a real app we'd fetch address details, for now we mock or use email/phone
|
| 237 |
+
contact_info = f"{order.guest_email or ''} | {order.guest_phone or ''}"
|
| 238 |
+
|
| 239 |
+
info_data = [
|
| 240 |
+
[Paragraph(f"<b>{ship_to_label}</b>", label_style)],
|
| 241 |
+
[Paragraph(format_text(customer_name, is_ar), value_style)],
|
| 242 |
+
[Paragraph(contact_info, normal_style)]
|
| 243 |
+
]
|
| 244 |
+
info_table = Table(info_data, colWidths=[7*inch])
|
| 245 |
+
elements.append(info_table)
|
| 246 |
+
elements.append(Spacer(1, 30))
|
| 247 |
+
|
| 248 |
+
# Items Table
|
| 249 |
+
headers = [s["total"], s["price"], s["qty"], s["description"]] if is_ar else [s["description"], s["qty"], s["price"], s["total"]]
|
| 250 |
+
reshaped_headers = [format_text(h, is_ar) for h in headers]
|
| 251 |
+
|
| 252 |
+
data = [reshaped_headers]
|
| 253 |
+
|
| 254 |
+
for item in order.items:
|
| 255 |
+
prod_name = (item.product.name_ar if is_ar and item.product.name_ar else item.product.name_en) if item.product else "Product"
|
| 256 |
+
prod_name = format_text(prod_name, is_ar)
|
| 257 |
+
|
| 258 |
+
item_total = f"{item.quantity * item.price:,.2f}"
|
| 259 |
+
item_price = f"{item.price:,.2f}"
|
| 260 |
+
|
| 261 |
+
if is_ar:
|
| 262 |
+
data.append([item_total, item_price, str(item.quantity), Paragraph(prod_name, normal_style)])
|
| 263 |
+
else:
|
| 264 |
+
data.append([Paragraph(prod_name, normal_style), str(item.quantity), item_price, item_total])
|
| 265 |
+
|
| 266 |
+
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]
|
| 267 |
+
|
| 268 |
+
table = Table(data, colWidths=col_widths)
|
| 269 |
+
t_style = [
|
| 270 |
+
('BACKGROUND', (0, 0), (-1, 0), primary_color),
|
| 271 |
+
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
| 272 |
+
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
| 273 |
+
('FONTNAME', (0, 0), (-1, 0), font_bold),
|
| 274 |
+
('FONTSIZE', (0, 0), (-1, 0), 10),
|
| 275 |
+
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
| 276 |
+
('TOPPADDING', (0, 0), (-1, 0), 12),
|
| 277 |
+
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#e2e8f0")),
|
| 278 |
+
('FONTNAME', (0, 1), (-1, -1), font_family),
|
| 279 |
+
('FONTSIZE', (0, 1), (-1, -1), 9),
|
| 280 |
+
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
| 281 |
+
]
|
| 282 |
+
# Adjust alignment for descriptions
|
| 283 |
+
desc_idx = 3 if is_ar else 0
|
| 284 |
+
t_style.append(('ALIGN', (desc_idx, 1), (desc_idx, -1), 'RIGHT' if is_ar else 'LEFT'))
|
| 285 |
+
|
| 286 |
+
table.setStyle(TableStyle(t_style))
|
| 287 |
+
elements.append(table)
|
| 288 |
+
elements.append(Spacer(1, 20))
|
| 289 |
+
|
| 290 |
+
# Totals Summary
|
| 291 |
+
subtotal_val = order.total_price - order.tax - order.shipping_cost
|
| 292 |
+
shipping_text = format_text(s["free"], is_ar) if order.shipping_cost == 0 else f"{order.shipping_cost:,.2f}"
|
| 293 |
+
|
| 294 |
+
summary_data = []
|
| 295 |
+
summary_labels = [s["subtotal"], s["shipping"], s["tax"]]
|
| 296 |
+
summary_values = [f"{subtotal_val:,.2f}", shipping_text, f"{order.tax:,.2f}"]
|
| 297 |
+
|
| 298 |
+
for label, val in zip(summary_labels, summary_values):
|
| 299 |
+
lbl = format_text(f"{label}:", is_ar)
|
| 300 |
+
if is_ar:
|
| 301 |
+
summary_data.append([val, Paragraph(lbl, normal_style)])
|
| 302 |
+
else:
|
| 303 |
+
summary_data.append([Paragraph(lbl, normal_style), val])
|
| 304 |
+
|
| 305 |
+
# Grand Total Row
|
| 306 |
+
gt_label = format_text(f"{s['grand_total']}:", is_ar)
|
| 307 |
+
gt_val = f"{order.total_price:,.2f} {format_text(s['sar'], is_ar)}"
|
| 308 |
+
|
| 309 |
+
if is_ar:
|
| 310 |
+
summary_data.append([Paragraph(f"<b>{gt_val}</b>", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
|
| 311 |
+
Paragraph(f"<b>{gt_label}</b>", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
|
| 312 |
+
else:
|
| 313 |
+
summary_data.append([Paragraph(f"<b>{gt_label}</b>", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
|
| 314 |
+
Paragraph(f"<b>{gt_val}</b>", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
|
| 315 |
+
|
| 316 |
+
summary_table = Table(summary_data, colWidths=[1.5*inch, 1.5*inch] if is_ar else [1.5*inch, 1.5*inch])
|
| 317 |
+
summary_table.setStyle(TableStyle([
|
| 318 |
+
('ALIGN', (0,0), (-1,-1), 'RIGHT' if is_ar else 'LEFT'),
|
| 319 |
+
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
|
| 320 |
+
]))
|
| 321 |
+
|
| 322 |
+
outer_summary = Table([[Spacer(1,1), summary_table]], colWidths=[4*inch, 3*inch] if not is_ar else [3*inch, 4*inch])
|
| 323 |
+
elements.append(outer_summary)
|
| 324 |
+
|
| 325 |
+
# Footer
|
| 326 |
+
elements.append(Spacer(1, 60))
|
| 327 |
+
thanks_text = format_text(f"{s['thanks']} {store_name}!", is_ar)
|
| 328 |
+
footer_text = format_text(s["footer"], is_ar)
|
| 329 |
+
|
| 330 |
+
elements.append(Paragraph(f"<b>{thanks_text}</b>", ParagraphStyle("Thanks", alignment=1, fontName=font_bold, fontSize=14, textColor=secondary_color)))
|
| 331 |
+
elements.append(Paragraph(footer_text, ParagraphStyle("Footer", alignment=1, fontName=font_family, fontSize=8, textColor=muted_color, spaceBefore=5)))
|
| 332 |
+
|
| 333 |
+
# Build PDF
|
| 334 |
+
doc.build(elements)
|
| 335 |
+
buffer.seek(0)
|
| 336 |
+
|
| 337 |
+
return buffer
|
fonts/ArbFONTS-cocon-next-arabic.ttf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f696fb03c47270a8f2b20aed7afb49a94b27048b8ec184698c2fcd1c474b04ea
|
| 3 |
+
size 117012
|
migrations/README
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Generic single-database configuration.
|
migrations/env.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from logging.config import fileConfig
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import engine_from_config
|
| 4 |
+
from sqlalchemy import pool
|
| 5 |
+
|
| 6 |
+
from alembic import context
|
| 7 |
+
|
| 8 |
+
# this is the Alembic Config object, which provides
|
| 9 |
+
# access to the values within the .ini file in use.
|
| 10 |
+
config = context.config
|
| 11 |
+
|
| 12 |
+
# Interpret the config file for Python logging.
|
| 13 |
+
# This line sets up loggers basically.
|
| 14 |
+
if config.config_file_name is not None:
|
| 15 |
+
fileConfig(config.config_file_name)
|
| 16 |
+
|
| 17 |
+
# add your model's MetaData object here
|
| 18 |
+
# for 'autogenerate' support
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
from dotenv import load_dotenv
|
| 22 |
+
|
| 23 |
+
# Add backend directory to sys.path so app modules can be imported
|
| 24 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 25 |
+
|
| 26 |
+
# Load environment variables
|
| 27 |
+
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
| 28 |
+
|
| 29 |
+
# Import all models to register with Base
|
| 30 |
+
from app.db.base import Base
|
| 31 |
+
import app.models.user
|
| 32 |
+
import app.models.product
|
| 33 |
+
import app.models.cart
|
| 34 |
+
import app.models.order
|
| 35 |
+
import app.models.settings
|
| 36 |
+
import app.models.notification
|
| 37 |
+
|
| 38 |
+
target_metadata = Base.metadata
|
| 39 |
+
|
| 40 |
+
# other values from the config, defined by the needs of env.py,
|
| 41 |
+
# can be acquired:
|
| 42 |
+
# my_important_option = config.get_main_option("my_important_option")
|
| 43 |
+
# ... etc.
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def run_migrations_offline() -> None:
|
| 47 |
+
"""Run migrations in 'offline' mode.
|
| 48 |
+
|
| 49 |
+
This configures the context with just a URL
|
| 50 |
+
and not an Engine, though an Engine is acceptable
|
| 51 |
+
here as well. By skipping the Engine creation
|
| 52 |
+
we don't even need a DBAPI to be available.
|
| 53 |
+
|
| 54 |
+
Calls to context.execute() here emit the given string to the
|
| 55 |
+
script output.
|
| 56 |
+
|
| 57 |
+
"""
|
| 58 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 59 |
+
context.configure(
|
| 60 |
+
url=url,
|
| 61 |
+
target_metadata=target_metadata,
|
| 62 |
+
literal_binds=True,
|
| 63 |
+
dialect_opts={"paramstyle": "named"},
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
with context.begin_transaction():
|
| 67 |
+
context.run_migrations()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def run_migrations_online() -> None:
|
| 71 |
+
"""Run migrations in 'online' mode.
|
| 72 |
+
|
| 73 |
+
In this scenario we need to create an Engine
|
| 74 |
+
and associate a connection with the context.
|
| 75 |
+
|
| 76 |
+
"""
|
| 77 |
+
ini_section = config.get_section(config.config_ini_section, {})
|
| 78 |
+
ini_section['sqlalchemy.url'] = os.getenv("DATABASE_URL", "sqlite:///./vortex.db")
|
| 79 |
+
|
| 80 |
+
connectable = engine_from_config(
|
| 81 |
+
ini_section,
|
| 82 |
+
prefix="sqlalchemy.",
|
| 83 |
+
poolclass=pool.NullPool,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
with connectable.connect() as connection:
|
| 87 |
+
context.configure(
|
| 88 |
+
connection=connection, target_metadata=target_metadata
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
with context.begin_transaction():
|
| 92 |
+
context.run_migrations()
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
if context.is_offline_mode():
|
| 96 |
+
run_migrations_offline()
|
| 97 |
+
else:
|
| 98 |
+
run_migrations_online()
|
migrations/script.py.mako
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
${imports if imports else ""}
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = ${repr(up_revision)}
|
| 16 |
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
${upgrades if upgrades else "pass"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def downgrade() -> None:
|
| 26 |
+
${downgrades if downgrades else "pass"}
|
migrations/versions/22625ab41586_fix_receipt_and_bool_types_goto_error.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
""""fix_receipt_and_bool_types" || goto :error
|
| 2 |
+
|
| 3 |
+
Revision ID: 22625ab41586
|
| 4 |
+
Revises: 3b50aef60371
|
| 5 |
+
Create Date: 2026-03-17 22:59:35.268208
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '22625ab41586'
|
| 16 |
+
down_revision: Union[str, None] = '3b50aef60371'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
# 1. Convert is_default in addresses from Integer to Boolean
|
| 23 |
+
op.alter_column('addresses', 'is_default',
|
| 24 |
+
existing_type=sa.Integer(),
|
| 25 |
+
type_=sa.Boolean(),
|
| 26 |
+
postgresql_using='is_default::boolean',
|
| 27 |
+
existing_nullable=True)
|
| 28 |
+
|
| 29 |
+
# 2. Convert verified_purchase in reviews from Integer to Boolean
|
| 30 |
+
op.alter_column('reviews', 'verified_purchase',
|
| 31 |
+
existing_type=sa.Integer(),
|
| 32 |
+
type_=sa.Boolean(),
|
| 33 |
+
postgresql_using='verified_purchase::boolean',
|
| 34 |
+
existing_nullable=False)
|
| 35 |
+
|
| 36 |
+
# 3. Convert is_verified in payment_details from Integer to Boolean
|
| 37 |
+
op.alter_column('payment_details', 'is_verified',
|
| 38 |
+
existing_type=sa.Integer(),
|
| 39 |
+
type_=sa.Boolean(),
|
| 40 |
+
postgresql_using='is_verified::boolean',
|
| 41 |
+
existing_nullable=True)
|
| 42 |
+
|
| 43 |
+
# 4. Change receipt_url in payments from String(255) to Text
|
| 44 |
+
op.alter_column('payments', 'receipt_url',
|
| 45 |
+
existing_type=sa.String(length=255),
|
| 46 |
+
type_=sa.Text(),
|
| 47 |
+
existing_nullable=True)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def downgrade() -> None:
|
| 51 |
+
# Revert receipt_url
|
| 52 |
+
op.alter_column('payments', 'receipt_url',
|
| 53 |
+
existing_type=sa.Text(),
|
| 54 |
+
type_=sa.String(length=255),
|
| 55 |
+
existing_nullable=True)
|
| 56 |
+
|
| 57 |
+
# Revert is_verified
|
| 58 |
+
op.alter_column('payment_details', 'is_verified',
|
| 59 |
+
existing_type=sa.Boolean(),
|
| 60 |
+
type_=sa.Integer(),
|
| 61 |
+
postgresql_using='is_verified::integer',
|
| 62 |
+
existing_nullable=True)
|
| 63 |
+
|
| 64 |
+
# Revert verified_purchase
|
| 65 |
+
op.alter_column('reviews', 'verified_purchase',
|
| 66 |
+
existing_type=sa.Boolean(),
|
| 67 |
+
type_=sa.Integer(),
|
| 68 |
+
postgresql_using='verified_purchase::integer',
|
| 69 |
+
existing_nullable=False)
|
| 70 |
+
|
| 71 |
+
# Revert is_default
|
| 72 |
+
op.alter_column('addresses', 'is_default',
|
| 73 |
+
existing_type=sa.Boolean(),
|
| 74 |
+
type_=sa.Integer(),
|
| 75 |
+
postgresql_using='is_default::integer',
|
| 76 |
+
existing_nullable=True)
|
migrations/versions/357b291a7f86_add_missing_tables.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
""""add_missing_tables"
|
| 2 |
+
|
| 3 |
+
Revision ID: 357b291a7f86
|
| 4 |
+
Revises: 96c49eb28637
|
| 5 |
+
Create Date: 2026-03-17 13:51:08.357378
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '357b291a7f86'
|
| 16 |
+
down_revision: Union[str, None] = '96c49eb28637'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 23 |
+
op.create_table('notifications',
|
| 24 |
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
| 25 |
+
sa.Column('type', sa.String(length=50), nullable=False),
|
| 26 |
+
sa.Column('title', sa.String(length=255), nullable=False),
|
| 27 |
+
sa.Column('message', sa.Text(), nullable=True),
|
| 28 |
+
sa.Column('data', sa.JSON(), nullable=True),
|
| 29 |
+
sa.Column('is_read', sa.Boolean(), nullable=False),
|
| 30 |
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
| 31 |
+
sa.PrimaryKeyConstraint('id')
|
| 32 |
+
)
|
| 33 |
+
op.create_table('store_settings',
|
| 34 |
+
sa.Column('id', sa.Integer(), nullable=False),
|
| 35 |
+
sa.Column('store_name', sa.String(), nullable=True),
|
| 36 |
+
sa.Column('primary_color', sa.String(), nullable=True),
|
| 37 |
+
sa.Column('secondary_color', sa.String(), nullable=True),
|
| 38 |
+
sa.Column('logo_url', sa.String(), nullable=True),
|
| 39 |
+
sa.Column('contact_email', sa.String(), nullable=True),
|
| 40 |
+
sa.Column('contact_phone', sa.String(), nullable=True),
|
| 41 |
+
sa.Column('global_discount', sa.Integer(), nullable=True),
|
| 42 |
+
sa.Column('crypto_networks', sa.JSON(), nullable=True),
|
| 43 |
+
sa.Column('bank_accounts', sa.JSON(), nullable=True),
|
| 44 |
+
sa.PrimaryKeyConstraint('id')
|
| 45 |
+
)
|
| 46 |
+
op.create_index(op.f('ix_store_settings_id'), 'store_settings', ['id'], unique=False)
|
| 47 |
+
# ### end Alembic commands ###
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def downgrade() -> None:
|
| 51 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 52 |
+
op.drop_index(op.f('ix_store_settings_id'), table_name='store_settings')
|
| 53 |
+
op.drop_table('store_settings')
|
| 54 |
+
op.drop_table('notifications')
|
| 55 |
+
# ### end Alembic commands ###
|