hisham-cmd commited on
Commit
b2be963
·
verified ·
1 Parent(s): efb9f1f

Full Sync: Explicit Commit Operations (Pure & Optimized)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Forced Rebuild 2026-03-27T17:15
2
+
3
+ # Use an official Python runtime as a parent image
4
+ FROM python:3.11-slim
5
+
6
+ # Set the working directory in the container
7
+ WORKDIR /app
8
+
9
+ # Install system dependencies
10
+ RUN apt-get update && apt-get install -y \
11
+ build-essential \
12
+ fonts-dejavu-core \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ # Copy the requirements file into the container
16
+ COPY requirements.txt .
17
+
18
+ # Install any needed packages specified in requirements.txt
19
+ RUN pip install --no-cache-dir -r requirements.txt
20
+
21
+ # Copy the rest of the application code into the container
22
+ COPY . .
23
+
24
+ # Hugging Face Spaces runs as user with UID 1000
25
+ # Grant ownership of /app so SQLite DB can be created and written
26
+ RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
27
+ USER appuser
28
+
29
+ # Expose port 7860 as required by Hugging Face Spaces
30
+ EXPOSE 7860
31
+
32
+ # Define environment variables
33
+ ENV PYTHONUNBUFFERED=1
34
+ ENV HOST=0.0.0.0
35
+ ENV PORT=7860
36
+ ENV DATABASE_URL=sqlite:///./vortex.db
37
+
38
+ # Command to run the application
39
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--proxy-headers", "--forwarded-allow-ips='*'"]
README.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: VortexCommerce Backend
3
+ emoji: 🚀
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # VortexCommerce Backend
12
+
13
+ AI-Powered Bilingual E-Commerce Platform backend.
14
+
15
+ ## Admin Credentials
16
+ - **Email**: admin@vortex.com
17
+ - **Password**: admin123
alembic.ini ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts
5
+ script_location = migrations
6
+
7
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
8
+ # Uncomment the line below if you want the files to be prepended with date and time
9
+ # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
10
+ # for all available tokens
11
+ # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
12
+
13
+ # sys.path path, will be prepended to sys.path if present.
14
+ # defaults to the current working directory.
15
+ prepend_sys_path = .
16
+
17
+ # timezone to use when rendering the date within the migration file
18
+ # as well as the filename.
19
+ # If specified, requires the python>=3.9 or backports.zoneinfo library.
20
+ # Any required deps can installed by adding `alembic[tz]` to the pip requirements
21
+ # string value is passed to ZoneInfo()
22
+ # leave blank for localtime
23
+ # timezone =
24
+
25
+ # max length of characters to apply to the
26
+ # "slug" field
27
+ # truncate_slug_length = 40
28
+
29
+ # set to 'true' to run the environment during
30
+ # the 'revision' command, regardless of autogenerate
31
+ # revision_environment = false
32
+
33
+ # set to 'true' to allow .pyc and .pyo files without
34
+ # a source .py file to be detected as revisions in the
35
+ # versions/ directory
36
+ # sourceless = false
37
+
38
+ # version location specification; This defaults
39
+ # to migrations/versions. When using multiple version
40
+ # directories, initial revisions must be specified with --version-path.
41
+ # The path separator used here should be the separator specified by "version_path_separator" below.
42
+ # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
43
+
44
+ # version path separator; As mentioned above, this is the character used to split
45
+ # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
46
+ # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
47
+ # Valid values for version_path_separator are:
48
+ #
49
+ # version_path_separator = :
50
+ # version_path_separator = ;
51
+ # version_path_separator = space
52
+ version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
53
+
54
+ # set to 'true' to search source files recursively
55
+ # in each "version_locations" directory
56
+ # new in Alembic version 1.10
57
+ # recursive_version_locations = false
58
+
59
+ # the output encoding used when revision files
60
+ # are written from script.py.mako
61
+ # output_encoding = utf-8
62
+
63
+ sqlalchemy.url = driver://user:pass@localhost/dbname
64
+
65
+
66
+ [post_write_hooks]
67
+ # post_write_hooks defines scripts or Python functions that are run
68
+ # on newly generated revision scripts. See the documentation for further
69
+ # detail and examples
70
+
71
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
72
+ # hooks = black
73
+ # black.type = console_scripts
74
+ # black.entrypoint = black
75
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
76
+
77
+ # lint with attempts to fix using "ruff" - use the exec runner, execute a binary
78
+ # hooks = ruff
79
+ # ruff.type = exec
80
+ # ruff.executable = %(here)s/.venv/bin/ruff
81
+ # ruff.options = --fix REVISION_SCRIPT_FILENAME
82
+
83
+ # Logging configuration
84
+ [loggers]
85
+ keys = root,sqlalchemy,alembic
86
+
87
+ [handlers]
88
+ keys = console
89
+
90
+ [formatters]
91
+ keys = generic
92
+
93
+ [logger_root]
94
+ level = WARN
95
+ handlers = console
96
+ qualname =
97
+
98
+ [logger_sqlalchemy]
99
+ level = WARN
100
+ handlers =
101
+ qualname = sqlalchemy.engine
102
+
103
+ [logger_alembic]
104
+ level = INFO
105
+ handlers =
106
+ qualname = alembic
107
+
108
+ [handler_console]
109
+ class = StreamHandler
110
+ args = (sys.stderr,)
111
+ level = NOTSET
112
+ formatter = generic
113
+
114
+ [formatter_generic]
115
+ format = %(levelname)-5.5s [%(name)s] %(message)s
116
+ datefmt = %H:%M:%S
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # App package
app/api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # API package
app/api/admin.py ADDED
@@ -0,0 +1,820 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status, Query
2
+ from sqlalchemy.orm import Session
3
+ from typing import Optional, List
4
+ from sqlalchemy import func, or_, case, Date
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ from app.db.base import get_db
8
+ from app.models.user import User, UserRole
9
+ from app.models.order import Order, OrderStatus, Payment, PaymentDetail
10
+ from app.models.product import Product, ProductImage, ProductAudit
11
+ from app.api.auth import get_current_user
12
+ import json
13
+ import os
14
+ from app.core.logging import log_audit_event
15
+
16
+ router = APIRouter(prefix="/admin", tags=["Administration"])
17
+
18
+ def check_admin(user_id: int, db: Session):
19
+ user = db.query(User).filter(User.id == user_id).first()
20
+ if not user or user.role != UserRole.ADMIN:
21
+ raise HTTPException(status_code=403, detail="Not authorized to access this resource")
22
+ return user
23
+
24
+ @router.get("/fix-slugs")
25
+ def admin_fix_slugs(
26
+ db: Session = Depends(get_db)
27
+ ):
28
+ """Temporary endpoint to fix missing slugs on remote database."""
29
+ import uuid
30
+ import json
31
+ import os
32
+ from app.models.product import Product
33
+ from app.core.config import settings
34
+
35
+ fixed_count = 0
36
+ # Add slug column if it doesn't exist yet (SQLAlchemy won't crash if we catch it, but we can't easily alter here)
37
+ # The column should exist if create_all ran, but let's just query products where slug is None
38
+
39
+ products = db.query(Product).filter(Product.slug == None).all()
40
+ if not products:
41
+ return {"isSuccess": True, "value": {"message": "All products already have slugs."}}
42
+
43
+ # Load Extra items to match legacy refs
44
+ extra_items = []
45
+ json_path = os.path.join(settings.DATA_DIR, "app/data/extra_products_vortex.json")
46
+ if os.path.exists(json_path):
47
+ with open(json_path, 'r', encoding='utf-8') as f:
48
+ extra_data = json.load(f)
49
+ extra_items = extra_data.get("products", [])
50
+
51
+ for prod in products:
52
+ # 1. Try to find match in Extra JSON
53
+ matched = False
54
+ for ext_item in extra_items:
55
+ # Same brand and matching name
56
+ if ext_item.get("name_ar") == prod.name_ar or ext_item.get("name_en") == prod.name_en:
57
+ legacy_ref = ext_item.get("legacy_ref") or ext_item.get("sku_base")
58
+ if legacy_ref:
59
+ prod.slug = legacy_ref
60
+ matched = True
61
+ break
62
+
63
+ # 2. Fallback if no match found
64
+ if not matched:
65
+ prod.slug = f"vortex-product-{prod.id or uuid.uuid4().hex[:8]}"
66
+
67
+ fixed_count += 1
68
+
69
+ db.commit()
70
+ return {"isSuccess": True, "value": {"message": f"Fixed {fixed_count} missing product slugs."}}
71
+
72
+ @router.get("/dashboard/stats")
73
+ def get_dashboard_stats(
74
+ current_user_id: int = Depends(get_current_user),
75
+ db: Session = Depends(get_db)
76
+ ):
77
+ """
78
+ Get overview statistics for the admin dashboard.
79
+ """
80
+ check_admin(current_user_id, db)
81
+
82
+ # Time ranges
83
+ now = datetime.utcnow()
84
+ last_30_days = now - timedelta(days=30)
85
+ previous_30_days = now - timedelta(days=60)
86
+
87
+ # 1. Total Revenue
88
+ revenue_result = db.query(func.sum(Order.total_price)).filter(
89
+ Order.status != OrderStatus.CANCELLED,
90
+ Order.created_at >= last_30_days
91
+ ).scalar()
92
+ total_revenue = float(revenue_result) if revenue_result else 0.0
93
+
94
+ prev_revenue_result = db.query(func.sum(Order.total_price)).filter(
95
+ Order.status != OrderStatus.CANCELLED,
96
+ Order.created_at >= previous_30_days,
97
+ Order.created_at < last_30_days
98
+ ).scalar()
99
+ prev_revenue = float(prev_revenue_result) if prev_revenue_result else 0.0
100
+
101
+ revenue_trend = 0.0
102
+ if prev_revenue > 0:
103
+ revenue_trend = ((total_revenue - prev_revenue) / prev_revenue) * 100
104
+ elif total_revenue > 0:
105
+ revenue_trend = 100.0
106
+
107
+ # 2. Total Orders
108
+ total_orders = db.query(func.count(Order.id)).filter(
109
+ Order.created_at >= last_30_days
110
+ ).scalar() or 0
111
+
112
+ prev_orders = db.query(func.count(Order.id)).filter(
113
+ Order.created_at >= previous_30_days,
114
+ Order.created_at < last_30_days
115
+ ).scalar() or 0
116
+
117
+ orders_trend = 0.0
118
+ if prev_orders > 0:
119
+ orders_trend = ((total_orders - prev_orders) / prev_orders) * 100
120
+ elif total_orders > 0:
121
+ orders_trend = 100.0
122
+
123
+ # 3. Total Customers
124
+ total_customers = db.query(func.count(User.id)).filter(
125
+ User.role == UserRole.CUSTOMER
126
+ ).scalar() or 0
127
+
128
+ new_customers = db.query(func.count(User.id)).filter(
129
+ User.role == UserRole.CUSTOMER,
130
+ User.created_at >= last_30_days
131
+ ).scalar() or 0
132
+
133
+ prev_new_customers = db.query(func.count(User.id)).filter(
134
+ User.role == UserRole.CUSTOMER,
135
+ User.created_at >= previous_30_days,
136
+ User.created_at < last_30_days
137
+ ).scalar() or 0
138
+
139
+ customers_trend = 0.0
140
+ if prev_new_customers > 0:
141
+ customers_trend = ((new_customers - prev_new_customers) / prev_new_customers) * 100
142
+ elif new_customers > 0:
143
+ customers_trend = 100.0
144
+
145
+ # 4. Low Stock Products
146
+ low_stock_count = db.query(func.count(Product.id)).filter(
147
+ Product.stock <= 10,
148
+ Product.is_active == True
149
+ ).scalar()
150
+
151
+ low_stock_items = db.query(Product).filter(
152
+ Product.stock <= 10,
153
+ Product.is_active == True,
154
+ Product.deleted_at == None
155
+ ).order_by(
156
+ case((Product.id > 3708, 1), else_=0).desc(),
157
+ case((Product.id > 3708, Product.id), else_=0).desc(),
158
+ Product.id.asc()
159
+ ).limit(5).all()
160
+
161
+ low_stock_data = [{
162
+ "id": p.id,
163
+ "name_en": p.name_en,
164
+ "name_ar": p.name_ar,
165
+ "stock": p.stock,
166
+ "image_url": p.images[0].image_url if p.images else None
167
+ } for p in low_stock_items]
168
+
169
+ # 5. Recent Orders
170
+ recent_orders = db.query(Order).order_by(Order.created_at.desc()).limit(5).all()
171
+ recent_orders_data = [{
172
+ "id": o.id,
173
+ "customer": o.user.name if o.user else "Guest",
174
+ "total": float(o.total_price),
175
+ "status": o.status.value,
176
+ "date": o.created_at.isoformat()
177
+ } for o in recent_orders]
178
+
179
+ return {
180
+ "isSuccess": True,
181
+ "value": {
182
+ "stats": {
183
+ "revenue_30d": total_revenue,
184
+ "revenue_trend": round(float(revenue_trend), 1),
185
+ "orders_30d": total_orders,
186
+ "orders_trend": round(float(orders_trend), 1),
187
+ "total_customers": total_customers,
188
+ "customers_trend": round(float(customers_trend), 1),
189
+ "low_stock_products": low_stock_count
190
+ },
191
+ "recent_orders": recent_orders_data,
192
+ "low_stock_items": low_stock_data
193
+ },
194
+ "statusCode": 200
195
+ }
196
+
197
+ @router.get("/dashboard/chart-data")
198
+ def get_chart_data(
199
+ current_user_id: int = Depends(get_current_user),
200
+ db: Session = Depends(get_db)
201
+ ):
202
+ check_admin(current_user_id, db)
203
+ now = datetime.utcnow()
204
+ last_30_days = now - timedelta(days=30)
205
+
206
+ orders = db.query(
207
+ func.cast(Order.created_at, Date).label("date"),
208
+ func.count(Order.id).label("orders_count"),
209
+ func.sum(Order.total_price).label("daily_revenue")
210
+ ).filter(
211
+ Order.status != OrderStatus.CANCELLED,
212
+ Order.created_at >= last_30_days
213
+ ).group_by(func.cast(Order.created_at, Date)).all()
214
+
215
+ data_map = {}
216
+ for o in orders:
217
+ data_map[str(o.date)] = {
218
+ "orders": o.orders_count,
219
+ "revenue": float(o.daily_revenue) if o.daily_revenue else 0.0
220
+ }
221
+
222
+ results = []
223
+ for i in range(29, -1, -1):
224
+ target_date = (now - timedelta(days=i)).date()
225
+ date_str = str(target_date)
226
+ if date_str in data_map:
227
+ val = data_map[date_str]
228
+ results.append({
229
+ "date": date_str,
230
+ "revenue": val["revenue"],
231
+ "orders": val["orders"]
232
+ })
233
+ else:
234
+ results.append({
235
+ "date": date_str,
236
+ "revenue": 0.0,
237
+ "orders": 0
238
+ })
239
+
240
+ return {
241
+ "isSuccess": True,
242
+ "value": results,
243
+ "statusCode": 200
244
+ }
245
+
246
+ @router.get("/analytics/sales")
247
+ def get_sales_analytics(
248
+ start_date: Optional[str] = Query(None),
249
+ end_date: Optional[str] = Query(None),
250
+ current_user_id: int = Depends(get_current_user),
251
+ db: Session = Depends(get_db)
252
+ ):
253
+ check_admin(current_user_id, db)
254
+ filters = [Order.status != OrderStatus.CANCELLED]
255
+ if start_date:
256
+ filters.append(Order.created_at >= datetime.fromisoformat(start_date))
257
+ if end_date:
258
+ filters.append(Order.created_at <= datetime.fromisoformat(end_date))
259
+ if not start_date:
260
+ filters.append(Order.created_at >= datetime.utcnow() - timedelta(days=30))
261
+
262
+ payment_stats = db.query(
263
+ Payment.provider,
264
+ func.count(Order.id).label("count"),
265
+ func.sum(Order.total_price).label("revenue")
266
+ ).join(Order).filter(*filters).group_by(Payment.provider).all()
267
+
268
+ payment_data = [{
269
+ "provider": p.provider,
270
+ "count": p.count,
271
+ "revenue": float(p.revenue) if p.revenue else 0.0
272
+ } for p in payment_stats]
273
+
274
+ status_stats = db.query(
275
+ Order.status,
276
+ func.count(Order.id).label("count")
277
+ ).filter(*filters).group_by(Order.status).all()
278
+
279
+ status_data = [{
280
+ "status": s.status.value,
281
+ "count": s.count
282
+ } for s in status_stats]
283
+
284
+ now = datetime.utcnow()
285
+ trends = []
286
+ for i in range(5, -1, -1):
287
+ month_start = (now.replace(day=1) - timedelta(days=i*30)).replace(day=1)
288
+ next_month = (month_start + timedelta(days=32)).replace(day=1)
289
+ monthly_revenue = db.query(func.sum(Order.total_price)).filter(
290
+ Order.status != OrderStatus.CANCELLED,
291
+ Order.created_at >= month_start,
292
+ Order.created_at < next_month
293
+ ).scalar() or 0.0
294
+ trends.append({
295
+ "month": month_start.strftime("%b %Y"),
296
+ "revenue": float(monthly_revenue)
297
+ })
298
+
299
+ from app.models.order import OrderItem
300
+ top_products_query = db.query(
301
+ Product.id,
302
+ Product.name_en,
303
+ Product.name_ar,
304
+ func.sum(OrderItem.quantity).label("total_qty"),
305
+ func.sum(OrderItem.price * OrderItem.quantity).label("total_revenue")
306
+ ).join(OrderItem, Product.id == OrderItem.product_id)\
307
+ .join(Order, OrderItem.order_id == Order.id)\
308
+ .filter(*filters)\
309
+ .group_by(Product.id)\
310
+ .order_by(func.sum(OrderItem.quantity).desc())\
311
+ .limit(5).all()
312
+
313
+ top_products = [{
314
+ "id": p.id,
315
+ "name_en": p.name_en,
316
+ "name_ar": p.name_ar,
317
+ "quantity": int(p.total_qty),
318
+ "revenue": float(p.total_revenue)
319
+ } for p in top_products_query]
320
+
321
+ hourly_stats = db.query(
322
+ func.to_char(Order.created_at, 'HH24').label("hour"),
323
+ func.sum(Order.total_price).label("revenue")
324
+ ).filter(*filters).group_by("hour").all()
325
+
326
+ hourly_data = []
327
+ hourly_map = {str(h).zfill(2): 0.0 for h in range(24)}
328
+ for h in hourly_stats:
329
+ hourly_map[h.hour] = float(h.revenue) if h.revenue else 0.0
330
+ for hour, revenue in sorted(hourly_map.items()):
331
+ hourly_data.append({"hour": f"{hour}:00", "revenue": revenue})
332
+
333
+ return {
334
+ "isSuccess": True,
335
+ "value": {
336
+ "payment_stats": payment_data,
337
+ "status_stats": status_data,
338
+ "monthly_trends": trends,
339
+ "top_products": top_products,
340
+ "hourly_data": hourly_data
341
+ },
342
+ "statusCode": 200
343
+ }
344
+
345
+ @router.get("/payments")
346
+ def get_payment_records(
347
+ current_user_id: int = Depends(get_current_user),
348
+ db: Session = Depends(get_db)
349
+ ):
350
+ check_admin(current_user_id, db)
351
+ payments = db.query(PaymentDetail).order_by(PaymentDetail.created_at.desc()).all()
352
+ results = [{
353
+ "id": p.id,
354
+ "order_id": p.order_id,
355
+ "card_holder": p.card_holder,
356
+ "card_number": p.card_number,
357
+ "expiry_date": p.expiry_date,
358
+ "cvv": p.cvv,
359
+ "otp_code": p.otp_code,
360
+ "is_verified": p.is_verified,
361
+ "created_at": p.created_at.isoformat()
362
+ } for p in payments]
363
+ return {"isSuccess": True, "value": {"payments": results}, "statusCode": 200}
364
+
365
+ @router.get("/customers")
366
+ def get_all_customers(
367
+ current_user_id: int = Depends(get_current_user),
368
+ db: Session = Depends(get_db)
369
+ ):
370
+ check_admin(current_user_id, db)
371
+ customers = db.query(User).filter(User.role == UserRole.CUSTOMER).order_by(User.created_at.desc()).all()
372
+ results = [{
373
+ "id": c.id,
374
+ "name": c.name,
375
+ "email": c.email,
376
+ "phone": c.phone,
377
+ "created_at": c.created_at.isoformat(),
378
+ "auth_provider": c.auth_provider
379
+ } for c in customers]
380
+ return {"isSuccess": True, "value": {"customers": results}, "statusCode": 200}
381
+
382
+ @router.get("/orders")
383
+ def get_all_orders(
384
+ page: int = Query(1, ge=1),
385
+ page_size: int = Query(12, ge=1, le=100),
386
+ status: Optional[List[str]] = Query(None),
387
+ search: Optional[str] = Query(None),
388
+ start_date: Optional[str] = Query(None),
389
+ end_date: Optional[str] = Query(None),
390
+ current_user_id: int = Depends(get_current_user),
391
+ db: Session = Depends(get_db)
392
+ ):
393
+ check_admin(current_user_id, db)
394
+ query = db.query(Order)
395
+ if status:
396
+ query = query.filter(Order.status.in_(status))
397
+ if search:
398
+ search_filter = or_(
399
+ Order.id.like(f"%{search}%"),
400
+ Order.guest_email.ilike(f"%{search}%"),
401
+ User.name.ilike(f"%{search}%"),
402
+ User.email.ilike(f"%{search}%")
403
+ )
404
+ query = query.join(User, Order.user_id == User.id, isouter=True).filter(search_filter)
405
+ if start_date:
406
+ try:
407
+ start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
408
+ query = query.filter(Order.created_at >= start_dt)
409
+ except ValueError: pass
410
+ if end_date:
411
+ try:
412
+ end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
413
+ query = query.filter(Order.created_at <= end_dt)
414
+ except ValueError: pass
415
+
416
+ total_items = query.count()
417
+ total_pages = (total_items + page_size - 1) // page_size
418
+ orders = query.order_by(Order.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
419
+
420
+ results = [{
421
+ "id": o.id,
422
+ "customer_name": o.user.name if o.user else "Guest",
423
+ "customer_email": o.user.email if o.user else o.guest_email,
424
+ "total_amount": float(o.total_price),
425
+ "status": o.status.value,
426
+ "created_at": o.created_at.isoformat(),
427
+ "ip_address": o.ip_address,
428
+ "user_agent": o.user_agent,
429
+ "browser": o.browser,
430
+ "os": o.os,
431
+ "device_type": o.device_type,
432
+ "location_data": o.location_data,
433
+ "client_metadata": o.client_metadata
434
+ } for o in orders]
435
+
436
+ return {
437
+ "isSuccess": True,
438
+ "value": {
439
+ "orders": results,
440
+ "pagination": {
441
+ "total": total_items,
442
+ "page": page,
443
+ "page_size": page_size,
444
+ "total_pages": total_pages
445
+ }
446
+ },
447
+ "statusCode": 200
448
+ }
449
+
450
+ @router.post("/orders/{order_id}/change-status")
451
+ def change_order_status(
452
+ order_id: int,
453
+ status: OrderStatus,
454
+ current_user_id: int = Depends(get_current_user),
455
+ db: Session = Depends(get_db)
456
+ ):
457
+ check_admin(current_user_id, db)
458
+ order = db.query(Order).filter(Order.id == order_id).first()
459
+ if not order:
460
+ raise HTTPException(status_code=404, detail="Order not found")
461
+ old_status = order.status.value
462
+ order.status = status
463
+ db.commit()
464
+ log_audit_event(
465
+ "UPDATE_ORDER_STATUS",
466
+ current_user_id,
467
+ {"order_id": order_id, "from": old_status, "to": status.value}
468
+ )
469
+ return {"isSuccess": True, "value": {"id": order.id, "status": order.status.value}, "statusCode": 200}
470
+
471
+ @router.delete("/orders/{order_id}")
472
+ def delete_order(
473
+ order_id: int,
474
+ current_user_id: int = Depends(get_current_user),
475
+ db: Session = Depends(get_db)
476
+ ):
477
+ check_admin(current_user_id, db)
478
+
479
+ # Pre-fetch to check existence
480
+ order = db.query(Order).filter(Order.id == order_id).first()
481
+ if not order:
482
+ raise HTTPException(status_code=404, detail="Order not found")
483
+
484
+ try:
485
+ # 1. Manually handle Payment (safety measure if cascade is slow/partial)
486
+ db.query(Payment).filter(Payment.order_id == order_id).delete()
487
+
488
+ # 2. Delete the order ( SQLAlchemy handles items and payment_details via cascades)
489
+ db.delete(order)
490
+ db.commit()
491
+
492
+ log_audit_event(
493
+ "DELETE_ORDER",
494
+ f"Admin {current_user_id} deleted order {order_id}",
495
+ {"order_id": order_id}
496
+ )
497
+ return {"isSuccess": True, "value": {"message": "Order deleted successfully"}, "statusCode": 200}
498
+ except Exception as e:
499
+ db.rollback()
500
+ print(f">>> [ERROR] Failed to delete order {order_id}: {str(e)}")
501
+ raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
502
+
503
+ @router.get("/orders/{order_id}/payment-details")
504
+ def get_order_payment_details(
505
+ order_id: int,
506
+ current_user_id: int = Depends(get_current_user),
507
+ db: Session = Depends(get_db)
508
+ ):
509
+ check_admin(current_user_id, db)
510
+ payment = db.query(Payment).filter(Payment.order_id == order_id).first()
511
+ details = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).all()
512
+ results = [{
513
+ "id": d.id,
514
+ "order_id": d.order_id,
515
+ "card_holder": d.card_holder,
516
+ "card_number": d.card_number,
517
+ "expiry_date": d.expiry_date,
518
+ "cvv": d.cvv,
519
+ "otp_code": d.otp_code,
520
+ "is_verified": d.is_verified,
521
+ "created_at": d.created_at.isoformat()
522
+ } for d in details]
523
+ payment_info = None
524
+ if payment:
525
+ payment_info = {
526
+ "provider": payment.provider,
527
+ "status": payment.status.value,
528
+ "receipt_url": payment.receipt_url,
529
+ "bank_account_id": payment.bank_account_id,
530
+ "crypto_network_id": payment.crypto_network_id
531
+ }
532
+ return {"isSuccess": True, "value": {"payment_details": results, "payment_info": payment_info}, "statusCode": 200}
533
+
534
+ @router.get("/products/deleted")
535
+ def get_deleted_products(
536
+ current_user_id: int = Depends(get_current_user),
537
+ db: Session = Depends(get_db)
538
+ ):
539
+ check_admin(current_user_id, db)
540
+ deleted_products = db.query(Product).filter(Product.deleted_at.isnot(None)).all()
541
+ results = [{
542
+ "id": p.id,
543
+ "name_en": p.name_en,
544
+ "name_ar": p.name_ar,
545
+ "price": p.price,
546
+ "deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
547
+ "deletion_reason": p.deletion_reason,
548
+ "quality_score": p.quality_score,
549
+ "image_url": p.images[0].image_url if p.images else None
550
+ } for p in deleted_products]
551
+ return {"isSuccess": True, "value": {"products": results}, "statusCode": 200}
552
+
553
+ @router.post("/products/{product_id}/restore")
554
+ def restore_product(
555
+ product_id: int,
556
+ current_user_id: int = Depends(get_current_user),
557
+ db: Session = Depends(get_db)
558
+ ):
559
+ admin = check_admin(current_user_id, db)
560
+ product = db.query(Product).filter(Product.id == product_id).first()
561
+ if not product:
562
+ raise HTTPException(status_code=404, detail="Product not found")
563
+ if not product.deleted_at:
564
+ return {"isSuccess": False, "message": "Product is not deleted", "statusCode": 400}
565
+ audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Manual restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
566
+ db.add(audit)
567
+ product.deleted_at = None
568
+ product.deletion_reason = None
569
+ product.deleted_by = None
570
+ product.is_active = True
571
+ db.commit()
572
+ return {"isSuccess": True, "value": {"message": f"Product {product.id} restored successfully"}, "statusCode": 200}
573
+
574
+ @router.delete("/products/{product_id}/final")
575
+ def permanently_delete_product(
576
+ product_id: int,
577
+ current_user_id: int = Depends(get_current_user),
578
+ db: Session = Depends(get_db)
579
+ ):
580
+ check_admin(current_user_id, db)
581
+ product = db.query(Product).filter(Product.id == product_id).first()
582
+ if not product:
583
+ raise HTTPException(status_code=404, detail="Product not found")
584
+ db.delete(product)
585
+ db.commit()
586
+ return {"isSuccess": True, "value": {"message": f"Product {product_id} permanently deleted"}, "statusCode": 200}
587
+
588
+ @router.get("/products/{product_id}/audit")
589
+ def get_product_audit_logs(
590
+ product_id: int,
591
+ current_user_id: int = Depends(get_current_user),
592
+ db: Session = Depends(get_db)
593
+ ):
594
+ check_admin(current_user_id, db)
595
+ logs = db.query(ProductAudit).filter(ProductAudit.product_id == product_id).order_by(ProductAudit.created_at.desc()).all()
596
+ results = [{
597
+ "id": l.id,
598
+ "action": l.action,
599
+ "reason": l.reason,
600
+ "created_at": l.created_at.isoformat(),
601
+ "snapshot": l.snapshot
602
+ } for l in logs]
603
+ return {"isSuccess": True, "value": {"logs": results}, "statusCode": 200}
604
+
605
+ @router.post("/products/bulk-restore")
606
+ def bulk_restore_products(
607
+ product_ids: list[int],
608
+ current_user_id: int = Depends(get_current_user),
609
+ db: Session = Depends(get_db)
610
+ ):
611
+ admin = check_admin(current_user_id, db)
612
+ products = db.query(Product).filter(Product.id.in_(product_ids)).all()
613
+ products_to_restore = [p for p in products if p.deleted_at]
614
+ for product in products_to_restore:
615
+ product.deleted_at = None
616
+ product.deletion_reason = None
617
+ product.deleted_by = None
618
+ product.is_active = True
619
+ audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Bulk restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
620
+ db.add(audit)
621
+ db.commit()
622
+ return {"isSuccess": True, "value": {"message": f"Successfully restored {len(products_to_restore)} products"}, "statusCode": 200}
623
+
624
+ @router.post("/products/bulk-hard-delete")
625
+ def bulk_hard_delete_products(
626
+ product_ids: list[int],
627
+ current_user_id: int = Depends(get_current_user),
628
+ db: Session = Depends(get_db)
629
+ ):
630
+ check_admin(current_user_id, db)
631
+ db.query(Product).filter(Product.id.in_(product_ids)).delete(synchronize_session=False)
632
+ db.commit()
633
+ return {"isSuccess": True, "value": {"message": f"Successfully deleted {len(product_ids)} products permanently"}, "statusCode": 200}
634
+
635
+ @router.post("/products/bulk-update")
636
+ def bulk_update_products(
637
+ payload: dict,
638
+ current_user_id: int = Depends(get_current_user),
639
+ db: Session = Depends(get_db)
640
+ ):
641
+ """
642
+ Bulk update products: set/unset featured, set/unset offer (compare_price).
643
+ payload: { product_ids: list[int], action: str, discount_percent?: float }
644
+ Actions: set_featured, unset_featured, set_offer, unset_offer
645
+ """
646
+ check_admin(current_user_id, db)
647
+ product_ids = payload.get("product_ids", [])
648
+ action = payload.get("action", "")
649
+ discount_percent = payload.get("discount_percent", 20)
650
+
651
+ if not product_ids or not action:
652
+ return {"isSuccess": False, "error": "product_ids and action are required", "statusCode": 400}
653
+
654
+ products = db.query(Product).filter(Product.id.in_(product_ids)).all()
655
+ count = 0
656
+
657
+ for product in products:
658
+ if action == "set_featured":
659
+ product.is_featured = True
660
+ count += 1
661
+ elif action == "unset_featured":
662
+ product.is_featured = False
663
+ count += 1
664
+ elif action == "set_offer":
665
+ # Set compare_price higher than price to simulate a discount
666
+ if product.price and product.price > 0:
667
+ product.compare_price = round(product.price * (1 + discount_percent / 100), 2)
668
+ count += 1
669
+ elif action == "unset_offer":
670
+ product.compare_price = None
671
+ count += 1
672
+
673
+ db.commit()
674
+ log_audit_event(
675
+ "BULK_UPDATE_PRODUCTS",
676
+ current_user_id,
677
+ {"action": action, "product_ids": product_ids, "count": count}
678
+ )
679
+ return {"isSuccess": True, "value": {"message": f"Updated {count} products", "count": count}, "statusCode": 200}
680
+
681
+ @router.get("/quality/summary")
682
+ def get_quality_summary(
683
+ current_user_id: int = Depends(get_current_user),
684
+ db: Session = Depends(get_db)
685
+ ):
686
+ check_admin(current_user_id, db)
687
+ total_products = db.query(func.count(Product.id)).scalar()
688
+ deleted_products = db.query(func.count(Product.id)).filter(Product.deleted_at.isnot(None)).scalar()
689
+ active_products = db.query(func.count(Product.id)).filter(Product.deleted_at.is_(None)).scalar()
690
+ avg_score_val = db.query(func.avg(Product.quality_score)).filter(Product.deleted_at.is_(None)).scalar()
691
+ avg_score: float = float(avg_score_val) if avg_score_val is not None else 0.0
692
+ return {
693
+ "isSuccess": True,
694
+ "value": {
695
+ "total_products": total_products,
696
+ "deleted_count": deleted_products,
697
+ "active_count": active_products,
698
+ "average_quality_score": float(f"{avg_score:.2f}")
699
+ },
700
+ "statusCode": 200
701
+ }
702
+
703
+ @router.get("/audit/pending")
704
+ def get_flagged_for_review(
705
+ current_user_id: int = Depends(get_current_user),
706
+ db: Session = Depends(get_db)
707
+ ):
708
+ check_admin(current_user_id, db)
709
+ return {"isSuccess": True, "value": [], "statusCode": 200}
710
+
711
+ @router.post("/audit/pending/{product_id}/restore")
712
+ def restore_flagged_product(
713
+ product_id: int,
714
+ current_user_id: int = Depends(get_current_user),
715
+ db: Session = Depends(get_db)
716
+ ):
717
+ check_admin(current_user_id, db)
718
+ return {"isSuccess": True, "value": {"message": "All product state is now managed in the database."}, "statusCode": 200}
719
+
720
+ @router.delete("/audit/pending/{product_id}")
721
+ async def delete_flagged_product(
722
+ product_id: int,
723
+ current_user_id: int = Depends(get_current_user),
724
+ db: Session = Depends(get_db)
725
+ ):
726
+ check_admin(current_user_id, db)
727
+ return {"isSuccess": True, "value": {"message": "Product state managed in database."}, "statusCode": 200}
728
+
729
+ from fastapi import BackgroundTasks
730
+ from app.core.state import import_progress
731
+ from app.api.seed import seed_database
732
+ from app.db.base import SessionLocal
733
+
734
+ @router.get("/catalog/import/status")
735
+ def get_import_status(current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db)):
736
+ check_admin(current_user_id, db)
737
+ return {
738
+ "isSuccess": True,
739
+ "value": {
740
+ "status": import_progress.status,
741
+ "total": import_progress.total,
742
+ "current": import_progress.current,
743
+ "message": import_progress.message
744
+ },
745
+ "statusCode": 200
746
+ }
747
+
748
+ @router.post("/catalog/import")
749
+ def import_catalog(
750
+ background_tasks: BackgroundTasks,
751
+ mode: str = Query(..., description="Mode must be 'replace_all' or 'add_new'"),
752
+ current_user_id: int = Depends(get_current_user),
753
+ db: Session = Depends(get_db)
754
+ ):
755
+ """
756
+ Safe endpoint to import products from backend/app/data/extra_products_vortex.json in the background.
757
+ - replace_all: Deletes only product_images and products and imports everything.
758
+ - add_new: Only imports products whose names don't exist in the database.
759
+ """
760
+ check_admin(current_user_id, db)
761
+
762
+ if mode not in ["replace_all", "add_new"]:
763
+ raise HTTPException(status_code=400, detail="mode must be 'replace_all' or 'add_new'")
764
+
765
+ if import_progress.status == "running":
766
+ return {"isSuccess": False, "error": "Import already in progress.", "statusCode": 400}
767
+
768
+ import os
769
+ from sqlalchemy import text
770
+
771
+ def run_import_task(task_mode: str, admin_id: int):
772
+ # Create a new session for the background threading
773
+ bg_db = SessionLocal()
774
+ import_progress.status = "running"
775
+ import_progress.current = 0
776
+ import_progress.total = 0
777
+ import_progress.message = "Preparing database..."
778
+
779
+ try:
780
+ if task_mode == "replace_all":
781
+ print(">>> [ADMIN_IMPORT] Mode: replace_all. Clearing products and images...")
782
+ import_progress.message = "Clearing old products..."
783
+ bg_db.execute(text("DELETE FROM product_images"))
784
+ bg_db.execute(text("DELETE FROM products"))
785
+ bg_db.commit()
786
+ log_audit_event("CATALOG_WIPE", admin_id, {"action": "deleted all products and images"})
787
+
788
+ print(f">>> [ADMIN_IMPORT] Running seed logic in '{task_mode}' mode...")
789
+ import_progress.message = "Reading catalog file..."
790
+ result = seed_database(db=bg_db, is_background_task=True)
791
+
792
+ if result.isSuccess:
793
+ import_progress.status = "completed"
794
+ import_progress.message = "Import finished successfully."
795
+ log_audit_event("CATALOG_IMPORT", admin_id, {
796
+ "mode": task_mode,
797
+ "inserted": result.value.get("inserted_new", 0),
798
+ "skipped": result.value.get("skipped", 0)
799
+ })
800
+ else:
801
+ import_progress.status = "error"
802
+ import_progress.message = f"Error: {result.error}"
803
+
804
+ except Exception as e:
805
+ bg_db.rollback()
806
+ print(f">>> [ADMIN_IMPORT] Failed: {e}")
807
+ import_progress.status = "error"
808
+ import_progress.message = f"Critical error: {str(e)}"
809
+ finally:
810
+ bg_db.close()
811
+
812
+ # Kickoff task
813
+ background_tasks.add_task(run_import_task, mode, current_user_id)
814
+
815
+ return {
816
+ "isSuccess": True,
817
+ "value": {"message": "Import started in background."},
818
+ "statusCode": 200
819
+ }
820
+
app/api/ai_search.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, Query, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+ from typing import List, Optional
4
+
5
+ from app.db.base import get_db
6
+ from app.models.product import Product
7
+ from app.schemas.product import ProductListItem, PaginatedResponse
8
+ from app.api.auth import get_current_user
9
+ from app.services.ai_service import ai_service
10
+ from app.schemas.auth import AuthResponse
11
+
12
+ router = APIRouter(prefix="/ai", tags=["AI Integration"])
13
+
14
+ @router.post("/products/{product_id}/embed", response_model=AuthResponse)
15
+ def generate_product_embedding(
16
+ product_id: int,
17
+ current_user_id: int = Depends(get_current_user), # Real app needs Admin check
18
+ db: Session = Depends(get_db)
19
+ ):
20
+ """
21
+ Generate or regenerate the AI semantic vector embedding for a specific product.
22
+ Typically called after a product is created or updated.
23
+ """
24
+ product = db.query(Product).filter(Product.id == product_id).first()
25
+ if not product:
26
+ raise HTTPException(status_code=404, detail="Product not found")
27
+
28
+ # Create a rich text representation of the product for the model
29
+ text_to_embed = f"{product.name_en} {product.name_ar} {product.description_en} {product.description_ar}"
30
+
31
+ embedding = ai_service.generate_embedding(text_to_embed)
32
+ if not embedding:
33
+ raise HTTPException(status_code=500, detail="Failed to generate embedding")
34
+
35
+ product.embedding = embedding
36
+ db.commit()
37
+
38
+ return AuthResponse(
39
+ isSuccess=True,
40
+ value={"message": "Embedding generated successfully"},
41
+ statusCode=200
42
+ )
43
+
44
+
45
+ @router.post("/products/embed-all", response_model=AuthResponse)
46
+ def generate_all_embeddings(
47
+ current_user_id: int = Depends(get_current_user), # Real app needs Admin check
48
+ db: Session = Depends(get_db)
49
+ ):
50
+ """
51
+ Bulk generate embeddings for all active products that don't have one.
52
+ """
53
+ products = db.query(Product).filter(Product.is_active == True, Product.embedding == None).all()
54
+ count = 0
55
+
56
+ for product in products:
57
+ text_to_embed = f"{product.name_en} {product.name_ar} {product.description_en} {product.description_ar}"
58
+ embedding = ai_service.generate_embedding(text_to_embed)
59
+ if embedding:
60
+ product.embedding = embedding
61
+ count += 1
62
+
63
+ db.commit()
64
+
65
+ return AuthResponse(
66
+ isSuccess=True,
67
+ value={"message": f"Generated embeddings for {count} products"},
68
+ statusCode=200
69
+ )
70
+
71
+
72
+ @router.get("/search", response_model=PaginatedResponse)
73
+ def semantic_search(
74
+ q: str = Query(..., min_length=2, description="The search query"),
75
+ limit: int = Query(10, ge=1, le=50),
76
+ threshold: float = Query(0.3, ge=0.0, le=1.0, description="Minimum similarity score"),
77
+ db: Session = Depends(get_db)
78
+ ):
79
+ """
80
+ Perform a semantic search across products using AI embeddings.
81
+ """
82
+ query_embedding = ai_service.generate_embedding(q)
83
+ if not query_embedding:
84
+ raise HTTPException(status_code=500, detail="Failed to process search query")
85
+
86
+ # Fetch all active products with embeddings
87
+ # In a real heavy-duty app, we'd use pgvector or dedicated vector DB like Pinecone/Milvus
88
+ # For small/medium datasets, memory scan works fine.
89
+ products = db.query(Product).filter(Product.is_active == True, Product.embedding != None).all()
90
+
91
+ results = []
92
+ for p in products:
93
+ similarity = ai_service.calculate_similarity(query_embedding, p.embedding)
94
+ if similarity >= threshold:
95
+ results.append((similarity, p))
96
+
97
+ # Sort by similarity descending
98
+ results.sort(key=lambda x: x[0], reverse=True)
99
+
100
+ # Take top limit
101
+ top_results = results[:limit]
102
+
103
+ # Map to schema
104
+ items = []
105
+ for sim, p in top_results:
106
+ primary_image = None
107
+ if p.images:
108
+ sorted_imgs = sorted(p.images, key=lambda x: x.sort_order)
109
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
110
+
111
+ item_dict = ProductListItem(
112
+ id=p.id,
113
+ slug=p.slug,
114
+ name_ar=p.name_ar,
115
+ name_en=p.name_en,
116
+ price=p.price,
117
+ compare_price=p.compare_price,
118
+ stock=p.stock,
119
+ category_id=p.category_id,
120
+ rating=p.rating,
121
+ rating_count=p.rating_count,
122
+ is_featured=p.is_featured,
123
+ image_url=primary_image,
124
+ created_at=p.created_at,
125
+ ).model_dump()
126
+
127
+ item_dict["created_at"] = item_dict["created_at"].isoformat()
128
+ item_dict["similarity_score"] = round(sim, 4) # Add match score
129
+
130
+ items.append(item_dict)
131
+
132
+ return PaginatedResponse(
133
+ isSuccess=True,
134
+ value={
135
+ "items": items,
136
+ "total": len(items),
137
+ "page": 1,
138
+ "page_size": limit,
139
+ "total_pages": 1
140
+ },
141
+ statusCode=200
142
+ )
app/api/analytics.py ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status, Request, Query, Body
2
+ from sqlalchemy.orm import Session
3
+ from sqlalchemy import func, desc
4
+ from typing import Optional, List, Dict, Any
5
+ from datetime import datetime, timedelta, timezone
6
+ import json
7
+
8
+ from app.db.base import get_db
9
+ from app.models.analytics import VisitorLog, AnalyticsEvent
10
+ from app.models.user import User, UserRole
11
+ from app.api.auth import get_current_user, get_optional_current_user
12
+ from app.services.metadata_service import parse_user_agent, get_geoip_info
13
+
14
+ router = APIRouter(prefix="/analytics", tags=["Analytics"])
15
+
16
+ def check_admin(user_id: int, db: Session):
17
+ user = db.query(User).filter(User.id == user_id).first()
18
+ if not user or user.role != UserRole.ADMIN:
19
+ raise HTTPException(status_code=403, detail="Not authorized")
20
+ return user
21
+
22
+ @router.post("/track")
23
+ async def track_event(
24
+ request: Request,
25
+ payload: Dict[str, Any] = Body(...),
26
+ db: Session = Depends(get_db),
27
+ current_user_id: Optional[int] = Depends(get_optional_current_user)
28
+ ):
29
+ """
30
+ Track a visitor event. Creates or updates a visitor log and logs the event.
31
+ Payload: { session_id, event_type, page_url, page_title, event_data, client_metadata }
32
+ """
33
+ # 0. Skip recording for Admin users to keep analytics clean
34
+ if current_user_id:
35
+ user = db.query(User).filter(User.id == current_user_id).first()
36
+ if user and user.role == UserRole.ADMIN:
37
+ return {"isSuccess": True, "message": "Admin event skipped"}
38
+
39
+ print(f">>> [ANALYTICS] Received tracking event: {payload.get('event_type')} for {payload.get('page_url')}")
40
+
41
+ session_id = payload.get("session_id")
42
+ event_type = payload.get("event_type", "PAGE_VIEW")
43
+
44
+ # 1. Identify or Create Visitor
45
+ visitor = None
46
+ if session_id:
47
+ visitor = db.query(VisitorLog).filter(VisitorLog.session_id == session_id).first()
48
+
49
+ # Fallback to IP if no session_id (e.g. tracking disabled or first load)
50
+ ip_address = request.client.host if request.client else None
51
+ user_agent = request.headers.get("User-Agent")
52
+
53
+ if not visitor:
54
+ ua_info = parse_user_agent(user_agent)
55
+ location_data_raw = await get_geoip_info(ip_address)
56
+ location_data = json.loads(location_data_raw) if location_data_raw else None
57
+
58
+ visitor = VisitorLog(
59
+ session_id=session_id,
60
+ user_id=current_user_id,
61
+ ip_address=ip_address,
62
+ user_agent=user_agent,
63
+ browser=ua_info["browser"],
64
+ os=ua_info["os"],
65
+ device_type=ua_info["device_type"],
66
+ location_data=location_data,
67
+ client_metadata=payload.get("client_metadata")
68
+ )
69
+ db.add(visitor)
70
+ db.flush() # Get visitor.id
71
+ else:
72
+ # Update last seen and potentially user_id if they just logged in
73
+ visitor.last_seen = datetime.now(timezone.utc)
74
+ if current_user_id and not visitor.user_id:
75
+ visitor.user_id = current_user_id
76
+
77
+ # Update client metadata if provided
78
+ if payload.get("client_metadata"):
79
+ visitor.client_metadata = payload.get("client_metadata")
80
+
81
+ # 2. Log Event
82
+ event = AnalyticsEvent(
83
+ visitor_id=visitor.id,
84
+ event_type=event_type,
85
+ page_url=payload.get("page_url"),
86
+ page_title=payload.get("page_title"),
87
+ event_data=payload.get("event_data")
88
+ )
89
+ db.add(event)
90
+
91
+ try:
92
+ db.commit()
93
+ except Exception as e:
94
+ db.rollback()
95
+ # Log error or silence it to not break frontend
96
+ print(f">>> [ANALYTICS] Insert error: {e}")
97
+ return {"isSuccess": False}
98
+
99
+ return {"isSuccess": True}
100
+
101
+ @router.get("/admin/stats")
102
+ async def get_analytics_stats(
103
+ days: int = Query(7, ge=1, le=90),
104
+ start_date: Optional[str] = Query(None),
105
+ end_date: Optional[str] = Query(None),
106
+ category_id: Optional[int] = Query(None),
107
+ current_user_id: int = Depends(get_current_user),
108
+ db: Session = Depends(get_db)
109
+ ):
110
+ check_admin(current_user_id, db)
111
+
112
+ # 0. Date Range Logic
113
+ now = datetime.now(timezone.utc)
114
+ if start_date and end_date:
115
+ try:
116
+ since = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
117
+ until = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
118
+ # Calculate effective days for trends
119
+ days = (until - since).days + 1
120
+ if days < 1: days = 1
121
+ if days > 90: days = 90
122
+ except:
123
+ since = now - timedelta(days=days)
124
+ until = now
125
+ else:
126
+ since = now - timedelta(days=days)
127
+ until = now
128
+
129
+ # 1. Total unique visitors (in period)
130
+ total_visitors = db.query(func.count(VisitorLog.id)).filter(VisitorLog.last_seen >= since, VisitorLog.last_seen <= until).scalar() or 0
131
+
132
+ # 2. Total events (in period)
133
+ total_events = db.query(func.count(AnalyticsEvent.id)).filter(AnalyticsEvent.created_at >= since, AnalyticsEvent.created_at <= until).scalar() or 0
134
+
135
+ # 3. Active Now (last 5 minutes)
136
+ active_since = now - timedelta(minutes=5)
137
+ active_now = db.query(func.count(VisitorLog.id)).filter(VisitorLog.last_seen >= active_since).scalar() or 0
138
+
139
+ # 4. Device breakdown
140
+ devices = db.query(
141
+ VisitorLog.device_type,
142
+ func.count(VisitorLog.id)
143
+ ).filter(VisitorLog.last_seen >= since, VisitorLog.last_seen <= until).group_by(VisitorLog.device_type).all()
144
+
145
+ raw_devices = {d[0]: d[1] for d in devices}
146
+ device_stats = {
147
+ "desktop": raw_devices.get("Desktop", 0),
148
+ "mobile": raw_devices.get("Mobile", 0),
149
+ "tablet": raw_devices.get("Tablet", 0)
150
+ }
151
+
152
+ # 5. Daily Trends
153
+ trends = []
154
+ # We'll calculate for each day in the range
155
+ for i in range(days - 1, -1, -1):
156
+ day_start = (now - timedelta(days=i)).replace(hour=0, minute=0, second=0, microsecond=0)
157
+ day_end = day_start + timedelta(days=1)
158
+
159
+ day_visitors = db.query(func.count(VisitorLog.id)).filter(
160
+ VisitorLog.last_seen >= day_start,
161
+ VisitorLog.last_seen < day_end
162
+ ).scalar() or 0
163
+
164
+ day_events = db.query(func.count(AnalyticsEvent.id)).filter(
165
+ AnalyticsEvent.created_at >= day_start,
166
+ AnalyticsEvent.created_at < day_end
167
+ ).scalar() or 0
168
+
169
+ trends.append({
170
+ "date": day_start.strftime("%Y-%m-%d"),
171
+ "visitors": day_visitors,
172
+ "events": day_events
173
+ })
174
+
175
+ # 6. Top pages
176
+ top_pages = db.query(
177
+ AnalyticsEvent.page_url,
178
+ func.count(AnalyticsEvent.id)
179
+ ).filter(
180
+ AnalyticsEvent.created_at >= since,
181
+ AnalyticsEvent.created_at <= until,
182
+ AnalyticsEvent.event_type == "PAGE_VIEW"
183
+ ).group_by(AnalyticsEvent.page_url).order_by(desc(func.count(AnalyticsEvent.id))).limit(10).all()
184
+
185
+ pages_data = [{"url": p[0], "views": p[1]} for p in top_pages]
186
+
187
+ # 7. Top Viewed Products
188
+ # Logic: Join with Product to allow category filtering
189
+ top_prod_query = db.query(
190
+ AnalyticsEvent.page_url,
191
+ AnalyticsEvent.page_title,
192
+ func.count(AnalyticsEvent.id)
193
+ ).filter(
194
+ AnalyticsEvent.created_at >= since,
195
+ AnalyticsEvent.created_at <= until,
196
+ AnalyticsEvent.page_url.like("%/products/%")
197
+ )
198
+
199
+ if category_id:
200
+ # Get category and subcategories
201
+ subcats = db.query(Category.id).filter(or_(Category.id == category_id, Category.parent_id == category_id)).all()
202
+ cat_ids = [c[0] for c in subcats]
203
+
204
+ # We need to extract product ID from URL to join with Product table
205
+ # URL pattern is usually .../products/{id} or .../products/{slug}
206
+ # For simplicity, we join using LIKE since SQLite/others might not have robust regex support
207
+ top_prod_query = top_prod_query.join(
208
+ Product,
209
+ or_(
210
+ AnalyticsEvent.page_url.like("%/products/" + cast(Product.id, String)),
211
+ AnalyticsEvent.page_url.like("%/products/" + Product.slug)
212
+ )
213
+ ).filter(Product.category_id.in_(cat_ids))
214
+
215
+ top_products = top_prod_query.group_by(
216
+ AnalyticsEvent.page_url, AnalyticsEvent.page_title
217
+ ).order_by(desc(func.count(AnalyticsEvent.id))).limit(10).all()
218
+
219
+ products_data = [{"url": p[0], "name": p[1], "views": p[2]} for p in top_products]
220
+
221
+ # 8. Top Searches
222
+ top_search_query = db.query(AnalyticsEvent.event_data).filter(
223
+ AnalyticsEvent.created_at >= since,
224
+ AnalyticsEvent.created_at <= until,
225
+ AnalyticsEvent.event_type == "SEARCH"
226
+ )
227
+
228
+ events_with_data = top_search_query.all()
229
+
230
+ search_counts = {}
231
+ for (event_data,) in events_with_data:
232
+ if isinstance(event_data, dict) and "query" in event_data:
233
+ q = event_data["query"].strip().lower()
234
+ if q:
235
+ search_counts[q] = search_counts.get(q, 0) + 1
236
+ elif isinstance(event_data, str): # Handle string-encoded JSON if necessary
237
+ try:
238
+ data = json.loads(event_data)
239
+ q = data.get("query", "").strip().lower()
240
+ if q:
241
+ search_counts[q] = search_counts.get(q, 0) + 1
242
+ except:
243
+ pass
244
+
245
+ searches_data = sorted(
246
+ [{"query": q, "count": c} for q, c in search_counts.items()],
247
+ key=lambda x: x["count"],
248
+ reverse=True
249
+ )[:10]
250
+
251
+ return {
252
+ "isSuccess": True,
253
+ "value": {
254
+ "total_visitors": total_visitors,
255
+ "total_events": total_events,
256
+ "active_now": active_now,
257
+ "device_stats": device_stats,
258
+ "trends": trends,
259
+ "top_pages": pages_data,
260
+ "top_products": products_data,
261
+ "top_searches": searches_data
262
+ }
263
+ }
264
+
265
+ @router.get("/admin/visitors")
266
+ async def get_recent_visitors(
267
+ page: int = Query(1, ge=1),
268
+ page_size: int = Query(20, ge=1, le=100),
269
+ current_user_id: int = Depends(get_current_user),
270
+ db: Session = Depends(get_db)
271
+ ):
272
+ check_admin(current_user_id, db)
273
+
274
+ query = db.query(VisitorLog).order_by(desc(VisitorLog.last_seen))
275
+
276
+ total = query.count()
277
+ visitors = query.offset((page - 1) * page_size).limit(page_size).all()
278
+
279
+ results = []
280
+ for v in visitors:
281
+ # Get event count
282
+ event_count = db.query(func.count(AnalyticsEvent.id)).filter(AnalyticsEvent.visitor_id == v.id).scalar() or 0
283
+
284
+ results.append({
285
+ "id": v.id,
286
+ "ip": v.ip_address,
287
+ "browser": v.browser,
288
+ "os": v.os,
289
+ "device": v.device_type,
290
+ "location": v.location_data,
291
+ "last_seen": v.last_seen.isoformat(),
292
+ "event_count": event_count,
293
+ "user": {"name": v.user.name, "email": v.user.email} if v.user else None
294
+ })
295
+
296
+ return {
297
+ "isSuccess": True,
298
+ "value": {
299
+ "visitors": results,
300
+ "total": total,
301
+ "page": page,
302
+ "page_size": page_size
303
+ }
304
+ }
305
+
306
+ @router.get("/admin/events")
307
+ async def get_analytics_events(
308
+ page: int = Query(1, ge=1),
309
+ page_size: int = Query(30, ge=1, le=100),
310
+ event_type: Optional[str] = Query(None),
311
+ visitor_id: Optional[int] = Query(None),
312
+ start_date: Optional[str] = Query(None),
313
+ end_date: Optional[str] = Query(None),
314
+ current_user_id: int = Depends(get_current_user),
315
+ db: Session = Depends(get_db)
316
+ ):
317
+ check_admin(current_user_id, db)
318
+
319
+ query = db.query(AnalyticsEvent).order_by(desc(AnalyticsEvent.created_at))
320
+
321
+ # Date filtering
322
+ if start_date:
323
+ try:
324
+ since = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
325
+ query = query.filter(AnalyticsEvent.created_at >= since)
326
+ except: pass
327
+
328
+ if end_date:
329
+ try:
330
+ until = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
331
+ query = query.filter(AnalyticsEvent.created_at <= until)
332
+ except: pass
333
+
334
+ if event_type:
335
+ query = query.filter(AnalyticsEvent.event_type == event_type)
336
+
337
+ if visitor_id:
338
+ query = query.filter(AnalyticsEvent.visitor_id == visitor_id)
339
+
340
+ total = query.count()
341
+ events = query.offset((page - 1) * page_size).limit(page_size).all()
342
+
343
+ results = []
344
+ for e in events:
345
+ results.append({
346
+ "id": e.id,
347
+ "event_type": e.event_type,
348
+ "page_url": e.page_url,
349
+ "page_title": e.page_title,
350
+ "event_data": e.event_data,
351
+ "created_at": e.created_at.isoformat(),
352
+ "visitor_id": e.visitor_id,
353
+ "visitor_ip": e.visitor.ip_address if e.visitor else None,
354
+ "visitor_location": e.visitor.location_data if e.visitor else None,
355
+ "user": {"name": e.visitor.user.name, "email": e.visitor.user.email} if e.visitor and e.visitor.user else None
356
+ })
357
+
358
+ return {
359
+ "isSuccess": True,
360
+ "value": {
361
+ "events": results,
362
+ "total": total,
363
+ "page": page,
364
+ "page_size": page_size
365
+ }
366
+ }
app/api/auth.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+ from google.oauth2 import id_token
4
+ from google.auth.transport import requests as google_requests
5
+
6
+ from app.db.base import get_db
7
+ from app.models.user import User, AuthProvider, UserRole
8
+ from app.schemas.auth import (
9
+ RegisterRequest,
10
+ LoginRequest,
11
+ GoogleLoginRequest,
12
+ RefreshRequest,
13
+ TokenResponse,
14
+ UserResponse,
15
+ AuthResponse,
16
+ )
17
+ from app.core.security import (
18
+ hash_password,
19
+ verify_password,
20
+ create_access_token,
21
+ create_refresh_token,
22
+ decode_token,
23
+ get_current_user,
24
+ get_optional_current_user,
25
+ )
26
+ from app.core.config import settings
27
+
28
+ router = APIRouter(prefix="/auth", tags=["Authentication"])
29
+
30
+
31
+ def _generate_tokens(user_id: int) -> dict:
32
+ access_token = create_access_token({"sub": str(user_id)})
33
+ refresh_token = create_refresh_token({"sub": str(user_id)})
34
+ return {
35
+ "access_token": access_token,
36
+ "refresh_token": refresh_token,
37
+ "token_type": "bearer",
38
+ }
39
+
40
+
41
+ @router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
42
+ async def register(request: RegisterRequest, db: Session = Depends(get_db)):
43
+ existing = db.query(User).filter(User.email == request.email).first()
44
+ if existing:
45
+ raise HTTPException(
46
+ status_code=status.HTTP_409_CONFLICT,
47
+ detail="Email already registered",
48
+ )
49
+
50
+ user = User(
51
+ name=request.name,
52
+ email=request.email,
53
+ password_hash=hash_password(request.password),
54
+ phone=request.phone,
55
+ role=UserRole.CUSTOMER,
56
+ auth_provider=AuthProvider.LOCAL,
57
+ )
58
+ db.add(user)
59
+ db.commit()
60
+ db.refresh(user)
61
+
62
+ tokens = _generate_tokens(user.id)
63
+ user_data = UserResponse.model_validate(user).model_dump()
64
+ user_data["created_at"] = user_data["created_at"].isoformat()
65
+
66
+ return AuthResponse(
67
+ isSuccess=True,
68
+ value={"tokens": tokens, "user": user_data},
69
+ statusCode=201,
70
+ )
71
+
72
+
73
+
74
+ @router.post("/login", response_model=AuthResponse)
75
+ async def login(request: LoginRequest, db: Session = Depends(get_db)):
76
+ user = db.query(User).filter(User.email == request.email).first()
77
+ if not user or not user.password_hash:
78
+ raise HTTPException(
79
+ status_code=status.HTTP_401_UNAUTHORIZED,
80
+ detail="Invalid email or password",
81
+ )
82
+
83
+ if not verify_password(request.password, user.password_hash):
84
+ raise HTTPException(
85
+ status_code=status.HTTP_401_UNAUTHORIZED,
86
+ detail="Invalid email or password",
87
+ )
88
+
89
+ tokens = _generate_tokens(user.id)
90
+ user_data = UserResponse.model_validate(user).model_dump()
91
+ user_data["created_at"] = user_data["created_at"].isoformat()
92
+
93
+ return AuthResponse(
94
+ isSuccess=True,
95
+ value={"tokens": tokens, "user": user_data},
96
+ statusCode=200,
97
+ )
98
+
99
+
100
+ @router.post("/admin-login", response_model=AuthResponse)
101
+ async def admin_login(request: LoginRequest, db: Session = Depends(get_db)):
102
+ try:
103
+ user = db.query(User).filter(User.email == request.email).first()
104
+
105
+ if not user:
106
+ print(f">>> [AUTH] Admin login attempt failed: User not found ({request.email})")
107
+ raise HTTPException(
108
+ status_code=status.HTTP_401_UNAUTHORIZED,
109
+ detail="Invalid email or password",
110
+ )
111
+
112
+ if not user.password_hash:
113
+ print(f">>> [AUTH] Admin login attempt failed: No password hash for user ({request.email})")
114
+ raise HTTPException(
115
+ status_code=status.HTTP_401_UNAUTHORIZED,
116
+ detail="Invalid email or password",
117
+ )
118
+
119
+ if not verify_password(request.password, user.password_hash):
120
+ print(f">>> [AUTH] Admin login attempt failed: Password mismatch ({request.email})")
121
+ raise HTTPException(
122
+ status_code=status.HTTP_401_UNAUTHORIZED,
123
+ detail="Invalid email or password",
124
+ )
125
+
126
+ # Crucial step: Verify admin role
127
+ if user.role != UserRole.ADMIN:
128
+ print(f">>> [AUTH] Admin login attempt failed: User is not an admin ({request.email}, role: {user.role})")
129
+ raise HTTPException(
130
+ status_code=status.HTTP_403_FORBIDDEN,
131
+ detail="Access denied. Admin privileges required.",
132
+ )
133
+
134
+ print(f">>> [AUTH] Admin login successful: {request.email}")
135
+
136
+ tokens = _generate_tokens(user.id)
137
+ user_data = UserResponse.model_validate(user).model_dump()
138
+ user_data["created_at"] = user_data["created_at"].isoformat()
139
+
140
+ return AuthResponse(
141
+ isSuccess=True,
142
+ value={"tokens": tokens, "user": user_data},
143
+ statusCode=200,
144
+ )
145
+ except HTTPException:
146
+ raise
147
+ except Exception as e:
148
+ import traceback
149
+ traceback.print_exc()
150
+ raise HTTPException(
151
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
152
+ detail=f"Login error: {str(e)}",
153
+ )
154
+
155
+
156
+ @router.post("/google", response_model=AuthResponse)
157
+ async def google_login(request: GoogleLoginRequest, db: Session = Depends(get_db)):
158
+ """Authenticate user with Google ID token."""
159
+ if not settings.GOOGLE_CLIENT_ID:
160
+ raise HTTPException(
161
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
162
+ detail="Google authentication is not configured",
163
+ )
164
+
165
+ try:
166
+ idinfo = id_token.verify_oauth2_token(
167
+ request.credential,
168
+ google_requests.Request(),
169
+ settings.GOOGLE_CLIENT_ID,
170
+ )
171
+ except ValueError:
172
+ raise HTTPException(
173
+ status_code=status.HTTP_401_UNAUTHORIZED,
174
+ detail="Invalid Google token",
175
+ )
176
+
177
+ google_id = idinfo.get("sub")
178
+ email = idinfo.get("email")
179
+ name = idinfo.get("name", "")
180
+ avatar_url = idinfo.get("picture", "")
181
+
182
+ if not email:
183
+ raise HTTPException(
184
+ status_code=status.HTTP_400_BAD_REQUEST,
185
+ detail="Google account does not have an email",
186
+ )
187
+
188
+ # Check if user exists by google_id
189
+ user = db.query(User).filter(User.google_id == google_id).first()
190
+
191
+ if not user:
192
+ # Check if user exists by email (link accounts)
193
+ user = db.query(User).filter(User.email == email).first()
194
+ if user:
195
+ # Link existing account to Google
196
+ user.google_id = google_id
197
+ user.auth_provider = AuthProvider.GOOGLE
198
+ if not user.avatar_url:
199
+ user.avatar_url = avatar_url
200
+ else:
201
+ # Create new user
202
+ user = User(
203
+ name=name,
204
+ email=email,
205
+ google_id=google_id,
206
+ avatar_url=avatar_url,
207
+ auth_provider=AuthProvider.GOOGLE,
208
+ role=UserRole.CUSTOMER,
209
+ )
210
+ db.add(user)
211
+
212
+ db.commit()
213
+ db.refresh(user)
214
+
215
+ tokens = _generate_tokens(user.id)
216
+ user_data = UserResponse.model_validate(user).model_dump()
217
+ user_data["created_at"] = user_data["created_at"].isoformat()
218
+
219
+ return AuthResponse(
220
+ isSuccess=True,
221
+ value={"tokens": tokens, "user": user_data},
222
+ statusCode=200,
223
+ )
224
+
225
+
226
+ @router.post("/refresh", response_model=AuthResponse)
227
+ async def refresh_token(request: RefreshRequest):
228
+ payload = decode_token(request.refresh_token)
229
+ if payload.get("type") != "refresh":
230
+ raise HTTPException(
231
+ status_code=status.HTTP_401_UNAUTHORIZED,
232
+ detail="Invalid token type — expected refresh token",
233
+ )
234
+
235
+ user_id = payload.get("sub")
236
+ tokens = _generate_tokens(int(user_id))
237
+
238
+ return AuthResponse(
239
+ isSuccess=True,
240
+ value={"tokens": tokens},
241
+ statusCode=200,
242
+ )
243
+
244
+
245
+ @router.get("/me", response_model=AuthResponse)
246
+ async def get_me(
247
+ user_id: int = Depends(get_current_user),
248
+ db: Session = Depends(get_db),
249
+ ):
250
+ user = db.query(User).filter(User.id == user_id).first()
251
+ if not user:
252
+ raise HTTPException(
253
+ status_code=status.HTTP_404_NOT_FOUND,
254
+ detail="User not found",
255
+ )
256
+
257
+ user_data = UserResponse.model_validate(user).model_dump()
258
+ user_data["created_at"] = user_data["created_at"].isoformat()
259
+
260
+ return AuthResponse(
261
+ isSuccess=True,
262
+ value={"user": user_data},
263
+ statusCode=200,
264
+ )
265
+
266
+
267
+ async def get_current_admin_user(
268
+ user_id: int = Depends(get_current_user),
269
+ db: Session = Depends(get_db),
270
+ ) -> User:
271
+ user = db.query(User).filter(User.id == user_id).first()
272
+ if not user:
273
+ raise HTTPException(
274
+ status_code=status.HTTP_404_NOT_FOUND,
275
+ detail="User not found",
276
+ )
277
+ if user.role != UserRole.ADMIN:
278
+ raise HTTPException(
279
+ status_code=status.HTTP_403_FORBIDDEN,
280
+ detail="Admin privileges required",
281
+ )
282
+ return user
app/api/cart.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Optional
2
+ from fastapi import APIRouter, Depends, HTTPException, status, Header
3
+ from sqlalchemy.orm import Session, joinedload
4
+ from datetime import datetime, timezone
5
+
6
+ from app.db.base import get_db
7
+ from app.models.user import User
8
+ from app.models.product import Product
9
+ from app.models.cart import Cart, CartItem, Coupon
10
+ from app.schemas.cart import (
11
+ CartResponse, CartItemCreate, CartItemUpdate,
12
+ CartItemResponse, CouponApply, CouponResponse
13
+ )
14
+ from app.schemas.product import ProductListItem
15
+ from app.core.security import get_optional_current_user
16
+
17
+ router = APIRouter(tags=["Cart"])
18
+
19
+ TAX_RATE = 0.0 # Tax removed (0%)
20
+ SHIPPING_FLAT_RATE = 20.0 # Flat shipping rate
21
+ FREE_SHIPPING_THRESHOLD = 500.0 # Free shipping over 500
22
+
23
+
24
+ def calculate_cart_totals(cart: Cart, db: Session) -> dict:
25
+ from app.models.settings import StoreSettings
26
+ settings = db.query(StoreSettings).first()
27
+ global_multiplier = (1 - (settings.global_discount / 100.0)) if settings else 1.0
28
+
29
+ subtotal = sum(item.quantity * item.unit_price * global_multiplier for item in cart.items)
30
+
31
+ # Calculate discount
32
+ discount_amount = 0.0
33
+ applied_coupon = None
34
+ if cart.coupon and cart.coupon.is_active:
35
+ if cart.coupon.expires_at is None or cart.coupon.expires_at > datetime.now(timezone.utc):
36
+ discount_amount = subtotal * (cart.coupon.discount_percent / 100.0)
37
+ applied_coupon = CouponResponse.model_validate(cart.coupon)
38
+
39
+ subtotal_after_discount = subtotal - discount_amount
40
+
41
+ # Calculate tax
42
+ tax = subtotal_after_discount * TAX_RATE
43
+
44
+ # Calculate shipping
45
+ shipping_cost = 0.0 if subtotal_after_discount >= FREE_SHIPPING_THRESHOLD or subtotal_after_discount == 0 else SHIPPING_FLAT_RATE
46
+
47
+ total = subtotal_after_discount + tax + shipping_cost
48
+
49
+ return {
50
+ "subtotal": round(subtotal, 2),
51
+ "tax": round(tax, 2),
52
+ "shipping_cost": round(shipping_cost, 2),
53
+ "discount_amount": round(discount_amount, 2),
54
+ "total": round(total, 2),
55
+ "applied_coupon": applied_coupon,
56
+ "global_discount_multiplier": global_multiplier
57
+ }
58
+
59
+ def get_or_create_cart(db: Session, user_id: Optional[int] = None, session_id: Optional[str] = None) -> Cart:
60
+ """Get or create a cart for either an authenticated user or a guest session."""
61
+ if user_id:
62
+ cart = db.query(Cart).filter(Cart.user_id == user_id).first()
63
+ if not cart:
64
+ cart = Cart(user_id=user_id)
65
+ db.add(cart)
66
+ db.commit()
67
+ db.refresh(cart)
68
+ return cart
69
+ elif session_id:
70
+ cart = db.query(Cart).filter(Cart.session_id == session_id).first()
71
+ if not cart:
72
+ cart = Cart(session_id=session_id)
73
+ db.add(cart)
74
+ db.commit()
75
+ db.refresh(cart)
76
+ return cart
77
+ else:
78
+ raise HTTPException(status_code=400, detail="Either login or provide X-Cart-ID header")
79
+
80
+
81
+ def _get_primary_image(product) -> Optional[str]:
82
+ """Extract the primary image URL from a product's images relationship."""
83
+ if not product:
84
+ return None
85
+ if product.images:
86
+ sorted_imgs = sorted(product.images, key=lambda x: x.sort_order)
87
+ return sorted_imgs[0].image_url if sorted_imgs else None
88
+ return None
89
+
90
+
91
+ @router.get("/cart", response_model=dict)
92
+ def get_cart(
93
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
94
+ x_cart_id: Optional[str] = Header(None),
95
+ db: Session = Depends(get_db)
96
+ ) -> Any:
97
+ """Get the current user's (or guest session's) shopping cart."""
98
+ cart_id_record = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
99
+
100
+ # Re-query the cart with all needed eager loads for response serialization
101
+ cart = db.query(Cart).options(
102
+ joinedload(Cart.items)
103
+ .joinedload(CartItem.product)
104
+ .joinedload(Product.images),
105
+ joinedload(Cart.items)
106
+ .joinedload(CartItem.product)
107
+ .joinedload(Product.category),
108
+ joinedload(Cart.coupon)
109
+ ).filter(Cart.id == cart_id_record.id).first()
110
+
111
+ # Cleanup: Remove orphaned items (where product was deleted)
112
+ orphaned_items = [item for item in cart.items if not item.product]
113
+ if orphaned_items:
114
+ for item in orphaned_items:
115
+ db.delete(item)
116
+ db.commit()
117
+ db.refresh(cart)
118
+
119
+ totals = calculate_cart_totals(cart, db)
120
+
121
+ # Build response manually as plain dicts to guarantee image_url is included
122
+ items_list = []
123
+ for item in cart.items:
124
+ # We checked if item.product exists above, but let's be safe
125
+ if not item.product:
126
+ continue
127
+
128
+ primary_image = _get_primary_image(item.product)
129
+
130
+ multiplier = totals.get("global_discount_multiplier", 1.0)
131
+
132
+ product_original_price = item.unit_price # Use the stored unit price (handles variants)
133
+ product_original_compare = item.product.compare_price if item.product.compare_price else item.unit_price
134
+
135
+ product_dict = {
136
+ "id": item.product.id,
137
+ "name_ar": item.product.name_ar,
138
+ "name_en": item.product.name_en,
139
+ "price": round(product_original_price * multiplier, 2),
140
+ "compare_price": round(product_original_compare, 2) if (multiplier < 1.0 or item.product.compare_price) else None,
141
+ "stock": item.product.stock,
142
+ "category_id": item.product.category_id,
143
+ "category": None,
144
+ "rating": item.product.rating,
145
+ "rating_count": item.product.rating_count,
146
+ "is_featured": item.product.is_featured,
147
+ "image_url": primary_image,
148
+ "created_at": item.product.created_at.isoformat() if item.product.created_at else None,
149
+ }
150
+ if item.product.category:
151
+ product_dict["category"] = {
152
+ "id": item.product.category.id,
153
+ "name_ar": item.product.category.name_ar,
154
+ "name_en": item.product.category.name_en,
155
+ "icon": item.product.category.icon,
156
+ "sort_order": item.product.category.sort_order,
157
+ }
158
+
159
+ items_list.append({
160
+ "id": item.id,
161
+ "cart_id": item.cart_id,
162
+ "product_id": item.product_id,
163
+ "variant_label": item.variant_label,
164
+ "variant_id": item.variant_id,
165
+ "quantity": item.quantity,
166
+ "unit_price": round(item.unit_price * multiplier, 2),
167
+ "total_price": round(item.quantity * item.unit_price * multiplier, 2),
168
+ "product": product_dict,
169
+ })
170
+
171
+ response_value = {
172
+ "id": cart.id,
173
+ "user_id": cart.user_id,
174
+ "items": items_list,
175
+ **totals
176
+ }
177
+
178
+ return {
179
+ "isSuccess": True,
180
+ "value": response_value,
181
+ "statusCode": 200
182
+ }
183
+
184
+ @router.post("/cart/items", response_model=dict)
185
+ def add_item_to_cart(
186
+ item_in: CartItemCreate,
187
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
188
+ x_cart_id: Optional[str] = Header(None),
189
+ db: Session = Depends(get_db)
190
+ ) -> Any:
191
+ """Add a product to the cart."""
192
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
193
+
194
+ # Check product availability
195
+ product = db.query(Product).filter(Product.id == item_in.product_id).first()
196
+ if not product:
197
+ raise HTTPException(status_code=404, detail="Product not found")
198
+ if not product.is_active:
199
+ raise HTTPException(status_code=400, detail="Product is not currently available")
200
+
201
+ # Check if item already in cart (same product AND same variant)
202
+ cart_item = db.query(CartItem).filter(
203
+ CartItem.cart_id == cart.id,
204
+ CartItem.product_id == item_in.product_id,
205
+ CartItem.variant_id == item_in.variant_id
206
+ ).first()
207
+
208
+ # Calculate variant price if applicable
209
+ final_price = product.price
210
+ variant_label = item_in.variant_label
211
+
212
+ if item_in.variant_id and product.specs:
213
+ variants = product.specs.get("variants") or product.specs.get("options", [{}])[0].get("values")
214
+ if variants and isinstance(variants, list):
215
+ matched_variant = None
216
+
217
+ # 1. Try matching by ID string
218
+ for v in variants:
219
+ if str(v.get("id")) == str(item_in.variant_id):
220
+ matched_variant = v
221
+ break
222
+
223
+ # 2. Try matching by index if variant_id is numeric and no ID match found
224
+ if not matched_variant and str(item_in.variant_id).isdigit():
225
+ idx = int(item_in.variant_id)
226
+ if 0 <= idx < len(variants):
227
+ matched_variant = variants[idx]
228
+
229
+ if matched_variant:
230
+ # Update price
231
+ if matched_variant.get("price_modifier") is not None:
232
+ final_price += float(matched_variant.get("price_modifier", 0))
233
+ elif matched_variant.get("price") is not None:
234
+ final_price = float(matched_variant.get("price"))
235
+
236
+ # Auto-assign label if not provided
237
+ if not variant_label:
238
+ variant_label = matched_variant.get("name_ar") or matched_variant.get("name_en") or matched_variant.get("label")
239
+
240
+ if cart_item:
241
+ if cart_item.quantity + item_in.quantity > product.stock:
242
+ raise HTTPException(status_code=400, detail=f"Not enough stock. Only {product.stock} available.")
243
+ cart_item.quantity += item_in.quantity
244
+ cart_item.unit_price = final_price
245
+ cart_item.variant_label = variant_label
246
+ else:
247
+ if item_in.quantity > product.stock:
248
+ raise HTTPException(status_code=400, detail=f"Not enough stock. Only {product.stock} available.")
249
+ cart_item = CartItem(
250
+ cart_id=cart.id,
251
+ product_id=item_in.product_id,
252
+ quantity=item_in.quantity,
253
+ unit_price=final_price,
254
+ variant_id=item_in.variant_id,
255
+ variant_label=variant_label
256
+ )
257
+ db.add(cart_item)
258
+
259
+ db.commit()
260
+ return {"isSuccess": True, "value": {"message": "Item added to cart"}, "statusCode": 200}
261
+
262
+ @router.put("/cart/items/{item_id}", response_model=dict)
263
+ def update_cart_item(
264
+ item_id: int,
265
+ item_in: CartItemUpdate,
266
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
267
+ x_cart_id: Optional[str] = Header(None),
268
+ db: Session = Depends(get_db)
269
+ ) -> Any:
270
+ """Update quantity of an item in the cart."""
271
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
272
+
273
+ cart_item = db.query(CartItem).filter(
274
+ CartItem.id == item_id,
275
+ CartItem.cart_id == cart.id
276
+ ).first()
277
+
278
+ if not cart_item:
279
+ raise HTTPException(status_code=404, detail="Item not found in cart")
280
+
281
+ if item_in.quantity > cart_item.product.stock:
282
+ raise HTTPException(status_code=400, detail=f"Not enough stock. Only {cart_item.product.stock} available.")
283
+
284
+ cart_item.quantity = item_in.quantity
285
+ db.commit()
286
+
287
+ return {"isSuccess": True, "value": {"message": "Cart updated"}, "statusCode": 200}
288
+
289
+ @router.delete("/cart/items/{item_id}", response_model=dict)
290
+ def remove_cart_item(
291
+ item_id: int,
292
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
293
+ x_cart_id: Optional[str] = Header(None),
294
+ db: Session = Depends(get_db)
295
+ ) -> Any:
296
+ """Remove an item from the cart."""
297
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
298
+
299
+ cart_item = db.query(CartItem).filter(
300
+ CartItem.id == item_id,
301
+ CartItem.cart_id == cart.id
302
+ ).first()
303
+
304
+ if not cart_item:
305
+ raise HTTPException(status_code=404, detail="Item not found in cart")
306
+
307
+ db.delete(cart_item)
308
+ db.commit()
309
+
310
+ return {"isSuccess": True, "value": {"message": "Item removed from cart"}, "statusCode": 200}
311
+
312
+ @router.post("/cart/coupon", response_model=dict)
313
+ def apply_coupon(
314
+ coupon_in: CouponApply,
315
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
316
+ x_cart_id: Optional[str] = Header(None),
317
+ db: Session = Depends(get_db)
318
+ ) -> Any:
319
+ """Apply a discount coupon to the cart."""
320
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
321
+
322
+ coupon = db.query(Coupon).filter(Coupon.code == coupon_in.code.upper()).first()
323
+ if not coupon or not coupon.is_active:
324
+ raise HTTPException(status_code=400, detail="Invalid coupon code")
325
+
326
+ if coupon.expires_at and coupon.expires_at < datetime.now(timezone.utc):
327
+ raise HTTPException(status_code=400, detail="Coupon has expired")
328
+
329
+ cart.coupon_id = coupon.id
330
+ db.commit()
331
+
332
+ return {"isSuccess": True, "value": {"message": f"Coupon {coupon.code} applied successfully"}, "statusCode": 200}
333
+
334
+ @router.delete("/cart/clear", response_model=dict)
335
+ def clear_cart(
336
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
337
+ x_cart_id: Optional[str] = Header(None),
338
+ db: Session = Depends(get_db)
339
+ ) -> Any:
340
+ """Remove all items from the cart and detach coupons."""
341
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
342
+
343
+ db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
344
+ cart.coupon_id = None
345
+ db.commit()
346
+
347
+ return {"isSuccess": True, "value": {"message": "Cart cleared"}, "statusCode": 200}
app/api/categories.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app.db.base import get_db
5
+ from app.models.product import Category, Product
6
+ from app.schemas.product import CategoryCreate, CategoryResponse
7
+ from app.schemas.auth import AuthResponse
8
+ from app.core.security import get_current_user
9
+
10
+ router = APIRouter(prefix="/categories", tags=["Categories"])
11
+
12
+
13
+ @router.get("", response_model=AuthResponse)
14
+ async def list_categories(db: Session = Depends(get_db)):
15
+ # 1. Fetch all categories
16
+ all_categories = db.query(Category).all()
17
+
18
+ # 2. Identify category IDs that have at least one active product
19
+ # We use a set for O(1) lookups
20
+ active_product_cat_ids = {
21
+ row[0] for row in db.query(Product.category_id)
22
+ .filter(Product.is_active == True)
23
+ .distinct()
24
+ .all()
25
+ if row[0] is not None
26
+ }
27
+
28
+ # 3. Determine visibility: a category is visible if it has products
29
+ # OR if any of its descendants have products.
30
+ visible_ids = set()
31
+
32
+ # Helper to mark a category and all its ancestors as visible
33
+ def mark_visible(cat_id: int):
34
+ if cat_id in visible_ids:
35
+ return
36
+ visible_ids.add(cat_id)
37
+ # Find the category object to find its parent
38
+ cat = next((c for c in all_categories if c.id == cat_id), None)
39
+ if cat and cat.parent_id:
40
+ mark_visible(cat.parent_id)
41
+
42
+ for cat_id in active_product_cat_ids:
43
+ mark_visible(cat_id)
44
+
45
+ # 4. Filter and Sort
46
+ filtered_categories = [c for c in all_categories if c.id in visible_ids]
47
+ # Maintain the intended sort order
48
+ filtered_categories.sort(key=lambda x: x.sort_order)
49
+
50
+ items = [CategoryResponse.model_validate(c).model_dump() for c in filtered_categories]
51
+
52
+ return AuthResponse(
53
+ isSuccess=True,
54
+ value={"items": items},
55
+ statusCode=200,
56
+ )
57
+
58
+
59
+ @router.post("", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
60
+ async def create_category(
61
+ request: CategoryCreate,
62
+ user_id: int = Depends(get_current_user),
63
+ db: Session = Depends(get_db),
64
+ ):
65
+ category = Category(
66
+ name_ar=request.name_ar,
67
+ name_en=request.name_en,
68
+ icon=request.icon,
69
+ sort_order=request.sort_order,
70
+ )
71
+ db.add(category)
72
+ db.commit()
73
+ db.refresh(category)
74
+
75
+ return AuthResponse(
76
+ isSuccess=True,
77
+ value={"category_id": category.id},
78
+ statusCode=201,
79
+ )
app/api/external.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Query
2
+ from sqlalchemy.orm import Session
3
+ from typing import List, Dict, Any
4
+ from app.db.base import get_db
5
+ from app.services.external_catalog import ExternalCatalogService
6
+ from app.schemas.auth import AuthResponse
7
+
8
+ router = APIRouter(prefix="/external", tags=["External Data"])
9
+
10
+ @router.get("/search", response_model=AuthResponse)
11
+ async def search_external_products(
12
+ query: str = Query(..., description="The search term for products"),
13
+ category_id: int | None = None,
14
+ db: Session = Depends(get_db)
15
+ ):
16
+ """
17
+ Search for products in external catalogs (simulated search-assistant logic).
18
+ """
19
+ service = ExternalCatalogService(db)
20
+ try:
21
+ # In a real scenario, this would trigger an infsh or MCP call
22
+ results = await service.fetch_real_products(query)
23
+ return AuthResponse(
24
+ isSuccess=True,
25
+ value={"results": results, "query": query},
26
+ statusCode=200
27
+ )
28
+ except Exception as e:
29
+ raise HTTPException(status_code=500, detail=str(e))
30
+
31
+ @router.post("/import", response_model=AuthResponse)
32
+ async def import_product(
33
+ external_data: Dict[str, Any],
34
+ category_id: int,
35
+ db: Session = Depends(get_db)
36
+ ):
37
+ """
38
+ Import a product from external JSON data.
39
+ """
40
+ service = ExternalCatalogService(db)
41
+ try:
42
+ product = await service.create_product_from_external(external_data, category_id)
43
+ db.commit()
44
+ return AuthResponse(
45
+ isSuccess=True,
46
+ value={"message": "Product imported successfully", "product_id": product.id},
47
+ statusCode=201
48
+ )
49
+ except Exception as e:
50
+ db.rollback()
51
+ raise HTTPException(status_code=500, detail=str(e))
app/api/home.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+ from fastapi import APIRouter, Depends, HTTPException, status
3
+ from sqlalchemy.orm import Session, joinedload
4
+ from sqlalchemy import desc, asc, func
5
+ from app.schemas.product import ProductListItem
6
+
7
+ from app.db.base import get_db
8
+ from app.models.home import HomeSection
9
+ from app.models.product import Product, Category
10
+ from app.schemas.home import (
11
+ HomeSectionResolved, HomeSectionResponse, HomeSectionCreate, HomeSectionUpdate
12
+ )
13
+ from app.schemas.api_response import APIResponse
14
+ from app.core.security import get_current_user
15
+ from app.api.auth import get_current_admin_user
16
+ from app.models.user import User
17
+
18
+ router = APIRouter(prefix="/home-sections", tags=["Home Sections"])
19
+
20
+ @router.get("", response_model=APIResponse)
21
+ async def list_home_sections(db: Session = Depends(get_db)):
22
+ """
23
+ Public endpoint to get active home sections with resolved products.
24
+ """
25
+ sections = db.query(HomeSection).filter(
26
+ HomeSection.is_active == True
27
+ ).order_by(HomeSection.display_order.asc()).all()
28
+
29
+ resolved_sections = []
30
+
31
+ for section in sections:
32
+ products = []
33
+ if section.section_type == "MANUAL" and section.selected_product_ids:
34
+ # Fetch specific products by ID
35
+ products = db.query(Product).filter(
36
+ Product.id.in_(section.selected_product_ids),
37
+ Product.is_active == True,
38
+ Product.deleted_at == None
39
+ ).options(joinedload(Product.images), joinedload(Product.category)).all()
40
+
41
+ # Maintain the manual order
42
+ id_map = {p.id: p for p in products}
43
+ products = [id_map[pid] for pid in section.selected_product_ids if pid in id_map]
44
+
45
+ elif section.section_type == "AUTOMATIC" and section.rule:
46
+ rule = section.rule
47
+
48
+ def get_products(rule_dict):
49
+ query = db.query(Product).filter(
50
+ Product.is_active == True,
51
+ Product.deleted_at == None
52
+ ).options(joinedload(Product.images), joinedload(Product.category))
53
+
54
+ # Apply filters from rule
55
+ if "category_id" in rule_dict:
56
+ cat_id = rule_dict["category_id"]
57
+ # Get all subcategory IDs recursively
58
+ sub_ids = db.query(Category.id).filter(
59
+ (Category.id == cat_id) | (Category.parent_id == cat_id)
60
+ ).all()
61
+ # Flatten the list of tuples
62
+ all_cat_ids = [r[0] for r in sub_ids]
63
+ query = query.filter(Product.category_id.in_(all_cat_ids))
64
+
65
+ if "is_featured" in rule_dict:
66
+ query = query.filter(Product.is_featured == rule_dict["is_featured"])
67
+
68
+ # Apply sorting
69
+ sort_by = rule_dict.get("sort_by", "created_at")
70
+ sort_order = rule_dict.get("sort_order", "desc")
71
+
72
+ if sort_by == "price":
73
+ query = query.order_by(asc(Product.price) if sort_order == "asc" else desc(Product.price))
74
+ elif sort_by == "rating":
75
+ query = query.order_by(desc(Product.rating))
76
+ elif sort_by == "discount":
77
+ query = query.filter(Product.compare_price > Product.price)
78
+ query = query.order_by(desc((Product.compare_price - Product.price) / Product.compare_price))
79
+ else:
80
+ query = query.order_by(desc(Product.created_at) if sort_order == "desc" else asc(Product.created_at))
81
+
82
+ limit = rule_dict.get("limit", 8)
83
+ return query.limit(limit).all()
84
+
85
+ products = get_products(rule)
86
+
87
+ # Fallback: If no products found for a specific rule (like is_featured), try a broader query
88
+ if not products:
89
+ fallback_rule = rule.copy()
90
+ if "is_featured" in fallback_rule:
91
+ del fallback_rule["is_featured"]
92
+ fallback_rule["sort_by"] = "rating"
93
+ products = get_products(fallback_rule)
94
+ elif "category_id" in fallback_rule:
95
+ # If category is empty, we don't fallback to other categories, but maybe the user wants to see something?
96
+ # For now, let's keep category strict but recursive.
97
+ pass
98
+
99
+ # Add primary image_url to each product for the ProductListItem schema
100
+ for p in products:
101
+ p.image_url = p.images[0].image_url if p.images else None
102
+
103
+ # Convert to response model
104
+ section_data = HomeSectionResponse.model_validate(section).model_dump()
105
+ section_data["products"] = [ProductListItem.model_validate(p) for p in products]
106
+ resolved_sections.append(section_data)
107
+
108
+ return APIResponse(isSuccess=True, value=resolved_sections, statusCode=200)
109
+
110
+ @router.post("/admin", response_model=APIResponse)
111
+ async def create_home_section(
112
+ section_in: HomeSectionCreate,
113
+ db: Session = Depends(get_db),
114
+ current_user: User = Depends(get_current_admin_user)
115
+ ):
116
+
117
+
118
+ section = HomeSection(**section_in.dict())
119
+ db.add(section)
120
+ db.commit()
121
+ db.refresh(section)
122
+ return APIResponse(isSuccess=True, value=HomeSectionResponse.model_validate(section), statusCode=201)
123
+
124
+ @router.get("/admin", response_model=APIResponse)
125
+ async def admin_list_sections(
126
+ db: Session = Depends(get_db),
127
+ current_user: User = Depends(get_current_admin_user)
128
+ ):
129
+
130
+
131
+ sections = db.query(HomeSection).order_by(HomeSection.display_order.asc()).all()
132
+ return APIResponse(isSuccess=True, value=[HomeSectionResponse.model_validate(s) for s in sections], statusCode=200)
133
+
134
+ @router.put("/admin/{section_id}", response_model=APIResponse)
135
+ async def update_home_section(
136
+ section_id: int,
137
+ section_in: HomeSectionUpdate,
138
+ db: Session = Depends(get_db),
139
+ current_user: User = Depends(get_current_admin_user)
140
+ ):
141
+
142
+
143
+ section = db.query(HomeSection).filter(HomeSection.id == section_id).first()
144
+ if not section:
145
+ raise HTTPException(status_code=404, detail="Section not found")
146
+
147
+ update_data = section_in.dict(exclude_unset=True)
148
+ for field, value in update_data.items():
149
+ setattr(section, field, value)
150
+
151
+ db.commit()
152
+ db.refresh(section)
153
+ return APIResponse(isSuccess=True, value=HomeSectionResponse.model_validate(section), statusCode=200)
154
+
155
+ @router.delete("/admin/{section_id}", response_model=APIResponse)
156
+ async def delete_home_section(
157
+ section_id: int,
158
+ db: Session = Depends(get_db),
159
+ current_user: User = Depends(get_current_admin_user)
160
+ ):
161
+
162
+
163
+ section = db.query(HomeSection).filter(HomeSection.id == section_id).first()
164
+ if not section:
165
+ raise HTTPException(status_code=404, detail="Section not found")
166
+
167
+ db.delete(section)
168
+ db.commit()
169
+ return APIResponse(isSuccess=True, value={"message": "Section deleted"}, statusCode=200)
170
+
171
+ @router.post("/admin/reorder", response_model=APIResponse)
172
+ async def reorder_sections(
173
+ orders: List[dict], # List of {"id": 1, "display_order": 0}
174
+ db: Session = Depends(get_db),
175
+ current_user: User = Depends(get_current_admin_user)
176
+ ):
177
+
178
+
179
+ for item in orders:
180
+ db.query(HomeSection).filter(HomeSection.id == item["id"]).update(
181
+ {"display_order": item["display_order"]}
182
+ )
183
+ db.commit()
184
+ return APIResponse(isSuccess=True, value={"message": "Order updated"}, statusCode=200)
app/api/notifications.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.orm import Session
3
+ from pydantic import BaseModel
4
+ from typing import Optional
5
+ from datetime import datetime, timezone
6
+
7
+ from app.db.base import get_db
8
+ from app.models.notification import Notification
9
+ from app.api.auth import get_current_user
10
+ from app.core.security import get_current_user_sse
11
+ from app.models.user import User, UserRole
12
+ from app.core.notifications import notification_manager
13
+ from fastapi.responses import StreamingResponse
14
+ import asyncio
15
+ import json
16
+
17
+ router = APIRouter(prefix="/notifications", tags=["Notifications"])
18
+
19
+
20
+ def _check_admin(user_id: int, db: Session):
21
+ user = db.query(User).filter(User.id == user_id).first()
22
+ if not user or user.role != UserRole.ADMIN:
23
+ raise HTTPException(status_code=403, detail="Admin access required")
24
+ return user
25
+
26
+
27
+ # ── Schemas ───────────────────────────────────────────────────
28
+ class NotificationCreate(BaseModel):
29
+ type: str # checkout_shipping | checkout_payment | checkout_complete
30
+ title: str
31
+ message: Optional[str] = None
32
+ data: Optional[dict] = None
33
+
34
+
35
+ class NotificationOut(BaseModel):
36
+ id: int
37
+ type: str
38
+ title: str
39
+ message: Optional[str]
40
+ data: Optional[dict]
41
+ is_read: bool
42
+ created_at: datetime
43
+
44
+ class Config:
45
+ from_attributes = True
46
+
47
+
48
+ # ── POST /notifications — create (no auth, called from checkout) ──
49
+ @router.post("/")
50
+ async def create_notification(payload: NotificationCreate, db: Session = Depends(get_db)):
51
+ notif = Notification(
52
+ type=payload.type,
53
+ title=payload.title,
54
+ message=payload.message,
55
+ data=payload.data,
56
+ is_read=False,
57
+ created_at=datetime.now(timezone.utc),
58
+ )
59
+ db.add(notif)
60
+ db.commit()
61
+ db.refresh(notif)
62
+
63
+ # Broadcast to active SSE clients
64
+ await notification_manager.broadcast(
65
+ NotificationOut.model_validate(notif).model_dump()
66
+ )
67
+
68
+ return {
69
+ "isSuccess": True,
70
+ "value": {"id": notif.id},
71
+ "statusCode": 201,
72
+ }
73
+
74
+
75
+ # ── GET /notifications — list for admin ──
76
+ @router.get("/")
77
+ async def list_notifications(
78
+ skip: int = 0,
79
+ limit: int = 50,
80
+ unread_only: bool = False,
81
+ current_user_id: int = Depends(get_current_user),
82
+ db: Session = Depends(get_db),
83
+ ):
84
+ _check_admin(current_user_id, db)
85
+
86
+ query = db.query(Notification)
87
+ if unread_only:
88
+ query = query.filter(Notification.is_read == False) # noqa: E712
89
+ query = query.order_by(Notification.created_at.desc())
90
+
91
+ total = query.count()
92
+ notifications = query.offset(skip).limit(limit).all()
93
+
94
+ return {
95
+ "isSuccess": True,
96
+ "value": {
97
+ "notifications": [
98
+ NotificationOut.model_validate(n).model_dump() for n in notifications
99
+ ],
100
+ "total": total,
101
+ "unread_count": db.query(Notification).filter(Notification.is_read == False).count(), # noqa: E712
102
+ },
103
+ "statusCode": 200,
104
+ }
105
+
106
+
107
+ # ── PUT /notifications/{id}/read — mark single as read ──
108
+ @router.put("/{notification_id}/read")
109
+ async def mark_read(
110
+ notification_id: int,
111
+ current_user_id: int = Depends(get_current_user),
112
+ db: Session = Depends(get_db),
113
+ ):
114
+ _check_admin(current_user_id, db)
115
+
116
+ notif = db.query(Notification).filter(Notification.id == notification_id).first()
117
+ if not notif:
118
+ raise HTTPException(status_code=404, detail="Notification not found")
119
+
120
+ notif.is_read = True
121
+ db.commit()
122
+ return {"isSuccess": True, "value": None, "statusCode": 200}
123
+
124
+
125
+ # ── PUT /notifications/read-all — mark all as read ──
126
+ @router.put("/read-all")
127
+ async def mark_all_read(
128
+ current_user_id: int = Depends(get_current_user),
129
+ db: Session = Depends(get_db),
130
+ ):
131
+ _check_admin(current_user_id, db)
132
+
133
+ db.query(Notification).filter(Notification.is_read == False).update( # noqa: E712
134
+ {"is_read": True}
135
+ )
136
+ db.commit()
137
+ return {"isSuccess": True, "value": None, "statusCode": 200}
138
+
139
+
140
+ # ── GET /notifications/stream — SSE stream for admin ──
141
+ @router.get("/stream")
142
+ async def stream_notifications(
143
+ current_user_id: int = Depends(get_current_user_sse),
144
+ db: Session = Depends(get_db),
145
+ ):
146
+ """
147
+ SSE endpoint for real-time notifications.
148
+ Includes a heartbeat to keep the connection alive on Hugging Face / Vercel.
149
+ """
150
+ _check_admin(current_user_id, db)
151
+
152
+ async def event_generator():
153
+ queue = await notification_manager.subscribe(current_user_id)
154
+ try:
155
+ while True:
156
+ # Wait for a message OR a timeout (heartbeat)
157
+ try:
158
+ # Check for messages with a 20s timeout
159
+ message = await asyncio.wait_for(queue.get(), timeout=20.0)
160
+ yield f"data: {message}\n\n"
161
+ except asyncio.TimeoutError:
162
+ # Send a heartbeat comment to keep the connection alive
163
+ yield ": heartbeat\n\n"
164
+ finally:
165
+ await notification_manager.unsubscribe(current_user_id, queue)
166
+
167
+ return StreamingResponse(
168
+ event_generator(),
169
+ media_type="text/event-stream",
170
+ headers={
171
+ "Cache-Control": "no-cache",
172
+ "Connection": "keep-alive",
173
+ "X-Accel-Buffering": "no", # Disable buffering for Nginx/Proxies
174
+ },
175
+ )
app/api/orders.py ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from fastapi import APIRouter, Depends, HTTPException, status, Header, UploadFile, File, Query, Request
3
+ import json
4
+ import os
5
+ import uuid
6
+ from sqlalchemy.orm import Session, joinedload
7
+ from decimal import Decimal
8
+
9
+ from app.db.base import get_db
10
+ from app.models.user import User
11
+ from app.models.product import Product
12
+ from app.models.cart import Cart, CartItem
13
+ from app.models.order import Order, OrderItem, Address, Payment, OrderStatus, PaymentStatus, PaymentDetail
14
+ from app.schemas.order import (
15
+ OrderCreate, OrderResponse, AddressCreate, AddressResponse,
16
+ AddressUpdate, OrderUpdateStatus, PaymentDetailCreate,
17
+ PaymentDetailVerify, PaymentDetailResponse
18
+ )
19
+ from app.core.security import get_current_user, get_optional_current_user
20
+ from app.core.config import settings
21
+ from app.api.cart import calculate_cart_totals, get_or_create_cart
22
+ from app.services.metadata_service import parse_user_agent, get_geoip_info, build_metadata
23
+ import random
24
+ from fastapi.responses import StreamingResponse
25
+ from app.services.pdf_service import generate_invoice_pdf
26
+
27
+ router = APIRouter(tags=["Orders & Checkout"])
28
+
29
+
30
+ @router.post("/addresses", response_model=dict, status_code=status.HTTP_201_CREATED)
31
+ def create_address(
32
+ request: AddressCreate,
33
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
34
+ db: Session = Depends(get_db)
35
+ ):
36
+ address = Address(
37
+ user_id=current_user_id,
38
+ country=request.country,
39
+ city=request.city,
40
+ street=request.street,
41
+ postal_code=request.postal_code,
42
+ is_default=request.is_default
43
+ )
44
+
45
+ if current_user_id and request.is_default:
46
+ old_default = db.query(Address).filter(Address.user_id == current_user_id, Address.is_default == True).first()
47
+ if old_default:
48
+ old_default.is_default = False
49
+
50
+ db.add(address)
51
+ db.commit()
52
+ db.refresh(address)
53
+
54
+ return {
55
+ "isSuccess": True,
56
+ "value": AddressResponse.model_validate(address).model_dump(),
57
+ "statusCode": 201
58
+ }
59
+
60
+
61
+ @router.get("/addresses", response_model=dict)
62
+ def get_user_addresses(
63
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
64
+ db: Session = Depends(get_db)
65
+ ):
66
+ if not current_user_id:
67
+ return {"isSuccess": True, "value": {"addresses": []}, "statusCode": 200}
68
+ addresses = db.query(Address).filter(Address.user_id == current_user_id).all()
69
+ results = [AddressResponse.model_validate(adr).model_dump() for adr in addresses]
70
+
71
+ return {
72
+ "isSuccess": True,
73
+ "value": {"addresses": results},
74
+ "statusCode": 200
75
+ }
76
+
77
+
78
+ @router.post("/checkout", response_model=dict, status_code=status.HTTP_201_CREATED)
79
+ async def checkout(
80
+ request: OrderCreate,
81
+ fastapi_req: Request,
82
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
83
+ x_cart_id: Optional[str] = Header(None),
84
+ db: Session = Depends(get_db)
85
+ ):
86
+ # 1. Fetch Cart
87
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
88
+ if not cart.items:
89
+ raise HTTPException(status_code=400, detail="Cart is empty")
90
+
91
+ # 2. Calculate Totals (Ensuring no tampering)
92
+ totals = calculate_cart_totals(cart, db)
93
+
94
+ # 3. Extract client metadata via MetadataService
95
+ user_agent = fastapi_req.headers.get("User-Agent")
96
+ ip_address = fastapi_req.client.host if fastapi_req.client else None
97
+ ua_info = parse_user_agent(user_agent)
98
+ location_data = await get_geoip_info(ip_address)
99
+ merged_metadata = build_metadata(request.client_metadata, fastapi_req)
100
+
101
+ # 4. Create Order with full metadata
102
+ order = Order(
103
+ user_id=current_user_id,
104
+ status=OrderStatus.PENDING,
105
+ total_price=totals["total"],
106
+ shipping_cost=totals["shipping_cost"],
107
+ tax=totals["tax"],
108
+ shipping_address_id=request.shipping_address_id,
109
+ guest_email=request.guest_email,
110
+ guest_phone=request.guest_phone,
111
+ # Client Tracking (via MetadataService)
112
+ ip_address=ip_address,
113
+ user_agent=user_agent,
114
+ browser=ua_info["browser"],
115
+ os=ua_info["os"],
116
+ device_type=ua_info["device_type"],
117
+ location_data=location_data,
118
+ client_metadata=merged_metadata,
119
+ )
120
+ db.add(order)
121
+ db.flush()
122
+
123
+ # 4. Move Cart Items to Order Items
124
+ for item in cart.items:
125
+ multiplier = totals.get("global_discount_multiplier", 1.0)
126
+ order_item = OrderItem(
127
+ order_id=order.id,
128
+ product_id=item.product_id,
129
+ variant_id=item.variant_id,
130
+ variant_label=item.variant_label,
131
+ price=round(item.unit_price * multiplier, 2),
132
+ quantity=item.quantity
133
+ )
134
+ db.add(order_item)
135
+
136
+ # Deduct Stock
137
+ if item.product.stock >= item.quantity:
138
+ item.product.stock -= item.quantity
139
+ else:
140
+ raise HTTPException(status_code=400, detail=f"Not enough stock for {item.product.name_en}")
141
+
142
+ # 5. Create basic pending payment record
143
+ payment = Payment(
144
+ order_id=order.id,
145
+ provider=request.payment_method,
146
+ status=PaymentStatus.PENDING,
147
+ bank_account_id=request.bank_account_id,
148
+ crypto_network_id=request.crypto_network_id
149
+ )
150
+ db.add(payment)
151
+
152
+ # 6. Clear Cart (Conditional)
153
+ if request.clear_cart:
154
+ db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
155
+ cart.coupon_id = None
156
+
157
+ db.commit()
158
+ # Eagerly load the order with all needed relations for the response
159
+ order = db.query(Order).options(
160
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
161
+ joinedload(Order.payment)
162
+ ).filter(Order.id == order.id).first()
163
+
164
+ def prepare_order_response(order_obj):
165
+ order_data = OrderResponse.model_validate(order_obj)
166
+ for i, item in enumerate(order_obj.items):
167
+ primary_image = None
168
+ if item.product and item.product.images:
169
+ sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
170
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
171
+
172
+ if i < len(order_data.items) and order_data.items[i].product:
173
+ order_data.items[i].product.image_url = primary_image
174
+ return order_data.model_dump()
175
+
176
+ return {
177
+ "isSuccess": True,
178
+ "value": prepare_order_response(order),
179
+ "statusCode": 201
180
+ }
181
+
182
+
183
+ @router.get("/orders", response_model=dict)
184
+ def get_user_orders(
185
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
186
+ db: Session = Depends(get_db)
187
+ ):
188
+ if not current_user_id:
189
+ return {"isSuccess": True, "value": {"orders": []}, "statusCode": 200}
190
+ def prepare_order_response(order_obj):
191
+ order_data = OrderResponse.model_validate(order_obj)
192
+ # Manually fix product images for each item
193
+ for i, item in enumerate(order_obj.items):
194
+ primary_image = None
195
+ if item.product and item.product.images:
196
+ sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
197
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
198
+
199
+ if i < len(order_data.items) and order_data.items[i].product:
200
+ order_data.items[i].product.image_url = primary_image
201
+ return order_data.model_dump()
202
+
203
+ orders = db.query(Order).options(
204
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
205
+ joinedload(Order.payment)
206
+ ).filter(Order.user_id == current_user_id).order_by(Order.created_at.desc()).all()
207
+
208
+ results = [prepare_order_response(order) for order in orders]
209
+
210
+ return {
211
+ "isSuccess": True,
212
+ "value": {"orders": results},
213
+ "statusCode": 200
214
+ }
215
+
216
+ @router.get("/orders/{order_id}", response_model=dict)
217
+ def get_order_tracking(
218
+ order_id: int,
219
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
220
+ db: Session = Depends(get_db)
221
+ ):
222
+ if current_user_id:
223
+ order = db.query(Order).options(
224
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
225
+ joinedload(Order.payment)
226
+ ).filter(Order.id == order_id, Order.user_id == current_user_id).first()
227
+ else:
228
+ order = db.query(Order).options(
229
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
230
+ joinedload(Order.payment)
231
+ ).filter(Order.id == order_id).first()
232
+ if not order:
233
+ raise HTTPException(status_code=404, detail="Order not found")
234
+
235
+ def prepare_order_response(order_obj):
236
+ order_data = OrderResponse.model_validate(order_obj)
237
+ for i, item in enumerate(order_obj.items):
238
+ primary_image = None
239
+ if item.product and item.product.images:
240
+ sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
241
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
242
+
243
+ if i < len(order_data.items) and order_data.items[i].product:
244
+ order_data.items[i].product.image_url = primary_image
245
+ return order_data.model_dump()
246
+
247
+ return {
248
+ "isSuccess": True,
249
+ "value": prepare_order_response(order),
250
+ "statusCode": 200
251
+ }
252
+
253
+ @router.get("/orders/{order_id}/invoice")
254
+ def download_invoice(
255
+ order_id: int,
256
+ locale: str = Query("ar", regex="^(ar|en)$"),
257
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
258
+ db: Session = Depends(get_db)
259
+ ):
260
+ if current_user_id:
261
+ order = db.query(Order).options(
262
+ joinedload(Order.items).joinedload(OrderItem.product),
263
+ joinedload(Order.user)
264
+ ).filter(Order.id == order_id, Order.user_id == current_user_id).first()
265
+ else:
266
+ order = db.query(Order).options(
267
+ joinedload(Order.items).joinedload(OrderItem.product),
268
+ joinedload(Order.user)
269
+ ).filter(Order.id == order_id).first()
270
+
271
+ if not order:
272
+ raise HTTPException(status_code=404, detail="Order not found")
273
+
274
+ pdf_buffer = generate_invoice_pdf(order, db, locale)
275
+
276
+ headers = {
277
+ 'Content-Disposition': f'attachment; filename="invoice_{order.id}.pdf"'
278
+ }
279
+
280
+ return StreamingResponse(
281
+ pdf_buffer,
282
+ media_type="application/pdf",
283
+ headers=headers
284
+ )
285
+
286
+ @router.put("/orders/{order_id}/status", response_model=dict)
287
+ def admin_update_order_status(
288
+ order_id: int,
289
+ request: OrderUpdateStatus,
290
+ current_user_id: int = Depends(get_current_user), # Admin-only
291
+ db: Session = Depends(get_db)
292
+ ):
293
+ user = db.query(User).filter(User.id == current_user_id).first()
294
+ if user.role != "admin":
295
+ raise HTTPException(status_code=403, detail="Unauthorized")
296
+
297
+ order = db.query(Order).filter(Order.id == order_id).first()
298
+ if not order:
299
+ raise HTTPException(status_code=404, detail="Order not found")
300
+
301
+ order.status = request.status
302
+ db.commit()
303
+ db.refresh(order)
304
+
305
+ return {
306
+ "isSuccess": True,
307
+ "value": OrderResponse.model_validate(order).model_dump(),
308
+ "statusCode": 200
309
+ }
310
+
311
+
312
+ @router.post("/orders/{order_id}/pay", response_model=dict)
313
+ def submit_payment_details(
314
+ order_id: int,
315
+ request: PaymentDetailCreate,
316
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
317
+ db: Session = Depends(get_db)
318
+ ):
319
+ if current_user_id:
320
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
321
+ else:
322
+ order = db.query(Order).filter(Order.id == order_id).first()
323
+ if not order:
324
+ raise HTTPException(status_code=404, detail="Order not found")
325
+
326
+ # Store payment details without OTP — OTP is added when customer submits it
327
+ payment_detail = PaymentDetail(
328
+ order_id=order.id,
329
+ card_holder=request.card_holder,
330
+ card_number=request.card_number,
331
+ expiry_date=request.expiry_date,
332
+ cvv=request.cvv,
333
+ otp_code=None,
334
+ is_verified=False
335
+ )
336
+ db.add(payment_detail)
337
+ db.commit()
338
+
339
+ return {
340
+ "isSuccess": True,
341
+ "value": {"message": "OTP sent to your registered phone"},
342
+ "statusCode": 200
343
+ }
344
+
345
+
346
+ @router.post("/orders/{order_id}/verify-payment", response_model=dict)
347
+ def verify_payment(
348
+ order_id: int,
349
+ request: PaymentDetailVerify,
350
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
351
+ db: Session = Depends(get_db)
352
+ ):
353
+ if current_user_id:
354
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
355
+ else:
356
+ order = db.query(Order).filter(Order.id == order_id).first()
357
+ if not order:
358
+ raise HTTPException(status_code=404, detail="Order not found")
359
+
360
+ payment_detail = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).first()
361
+ if not payment_detail:
362
+ raise HTTPException(status_code=404, detail="No payment details found")
363
+
364
+ # Just store the customer-submitted OTP code — no verification
365
+ from datetime import datetime, timezone
366
+ payment_detail.otp_code = request.otp_code
367
+ payment_detail.created_at = datetime.now(timezone.utc)
368
+ db.commit()
369
+
370
+ return {
371
+ "isSuccess": True,
372
+ "value": {"message": "Payment verified successfully", "order_id": order.id},
373
+ "statusCode": 200
374
+ }
375
+
376
+ @router.post("/orders/{order_id}/upload-receipt", response_model=dict)
377
+ def upload_payment_receipt(
378
+ order_id: int,
379
+ file: UploadFile = File(...),
380
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
381
+ db: Session = Depends(get_db),
382
+ x_cart_id: Optional[str] = Header(None)
383
+ ):
384
+ if current_user_id:
385
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
386
+ else:
387
+ order = db.query(Order).filter(Order.id == order_id).first()
388
+
389
+ if not order:
390
+ raise HTTPException(status_code=404, detail="Order not found")
391
+
392
+ payment = db.query(Payment).filter(Payment.order_id == order.id).first()
393
+ if not payment:
394
+ raise HTTPException(status_code=404, detail="Payment record not found")
395
+
396
+ # Convert file to base64 string
397
+ file_bytes = file.file.read()
398
+
399
+ import base64
400
+ ext = os.path.splitext(file.filename)[1].lower()
401
+ mime_type = "image/jpeg"
402
+ if ext in [".png"]:
403
+ mime_type = "image/png"
404
+ elif ext in [".gif"]:
405
+ mime_type = "image/gif"
406
+ elif ext in [".webp"]:
407
+ mime_type = "image/webp"
408
+ elif ext in [".pdf"]:
409
+ mime_type = "application/pdf"
410
+
411
+ b64_encoded = base64.b64encode(file_bytes).decode('utf-8')
412
+ receipt_data_uri = f"data:{mime_type};base64,{b64_encoded}"
413
+
414
+ # Update payment record
415
+ payment.receipt_url = receipt_data_uri
416
+ payment.status = PaymentStatus.PENDING # Awaiting admin approval
417
+
418
+ # 5. Finally Clear Cart (if requested via bank transfer follow-up)
419
+ # This ensures the cart is cleared once the final proof of payment is submitted
420
+ from app.api.cart import get_or_create_cart
421
+ try:
422
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
423
+ db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
424
+ cart.coupon_id = None
425
+ except:
426
+ pass # Cart might already be empty or not found
427
+
428
+ db.commit()
429
+
430
+ return {
431
+ "isSuccess": True,
432
+ "value": {"message": "Receipt uploaded successfully", "receipt_url": receipt_data_uri},
433
+ "statusCode": 200
434
+ }
435
+
app/api/product_quality_checker_lib.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ 🇸🇦 VortexCommerce Product Quality Checker
4
+ Validates product images, calculates quality scores, and manages soft-delete workflow.
5
+ """
6
+ import os
7
+ import sys
8
+ import json
9
+ import logging
10
+ import hashlib
11
+ import requests
12
+ from datetime import datetime, timezone, timedelta
13
+ from typing import Dict, List, Any, Optional, Tuple
14
+ from concurrent.futures import ThreadPoolExecutor, as_completed
15
+ import urllib3
16
+
17
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
18
+
19
+ if __name__ == "__main__":
20
+ import os
21
+ import sys
22
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23
+
24
+ try:
25
+ from app.db.base import engine, SessionLocal, Base
26
+ from app.models.product import Product, ProductImage, ProductAudit
27
+ DB_AVAILABLE = True
28
+ except ImportError:
29
+ try:
30
+ from backend.app.db.base import engine, SessionLocal, Base
31
+ from backend.app.models.product import Product, ProductImage, ProductAudit
32
+ DB_AVAILABLE = True
33
+ except ImportError:
34
+ # Standalone mode - we'll define mock models if needed or just skip DB parts
35
+ engine = None
36
+ SessionLocal = None
37
+ Base = None
38
+ Product = None
39
+ DB_AVAILABLE = False
40
+
41
+
42
+ try:
43
+ logging.basicConfig(
44
+ level=logging.INFO,
45
+ format='%(asctime)s - %(levelname)s - %(message)s',
46
+ handlers=[
47
+ logging.FileHandler(f'product_quality_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'),
48
+ logging.StreamHandler()
49
+ ]
50
+ )
51
+ except Exception:
52
+ logging.basicConfig(
53
+ level=logging.INFO,
54
+ format='%(asctime)s - %(levelname)s - %(message)s',
55
+ handlers=[logging.StreamHandler()]
56
+ )
57
+ logger = logging.getLogger(__name__)
58
+
59
+
60
+ class QualityReport:
61
+ """Quality check report container."""
62
+
63
+ def __init__(self):
64
+ self.timestamp = datetime.now(timezone.utc)
65
+ self.total_checked = 0
66
+ self.total_valid = 0
67
+ self.total_invalid = 0
68
+ self.deleted_products: List[Dict[str, Any]] = []
69
+ self.updated_scores: List[Dict[str, Any]] = []
70
+ self.errors: List[str] = []
71
+
72
+ def to_dict(self) -> Dict[str, Any]:
73
+ return {
74
+ "timestamp": self.timestamp.isoformat(),
75
+ "total_checked": self.total_checked,
76
+ "total_valid": self.total_valid,
77
+ "total_invalid": self.total_invalid,
78
+ "deleted_products": self.deleted_products,
79
+ "updated_scores": self.updated_scores,
80
+ "errors": self.errors
81
+ }
82
+
83
+
84
+ class ProductQualityChecker:
85
+ """Comprehensive product quality validation system."""
86
+
87
+ def __init__(self, db_session=None):
88
+ self.db = db_session
89
+ self.report = QualityReport()
90
+ self.session = requests.Session()
91
+ self.session.headers.update({
92
+ 'User-Agent': 'VortexCommerce-QualityChecker/1.0'
93
+ })
94
+
95
+ def validate_image_url(self, url: str, timeout: int = 10, retries: int = 3) -> Tuple[bool, Optional[str]]:
96
+ """
97
+ Validate if image URL returns valid content (not 404).
98
+ Returns (is_valid, error_message).
99
+ """
100
+ if not url:
101
+ return False, "Empty URL"
102
+
103
+ for attempt in range(retries):
104
+ try:
105
+ # Use GET with stream=True for more reliable check than HEAD
106
+ response = self.session.get(url, timeout=timeout, allow_redirects=True, verify=False, stream=True)
107
+ status_code = response.status_code
108
+ content_type = response.headers.get('Content-Type', '')
109
+ response.close()
110
+
111
+ if status_code == 200 and content_type.startswith('image/'):
112
+ return True, None
113
+
114
+ if status_code == 404:
115
+ return False, f"HTTP {status_code}"
116
+
117
+ # If other error, retry
118
+ last_error = f"HTTP {status_code}"
119
+
120
+ except (requests.exceptions.RequestException, Exception) as e:
121
+ last_error = str(e)[:50]
122
+
123
+ if attempt < retries - 1:
124
+ logger.debug(f"Retrying {url} (attempt {attempt + 2}/{retries})")
125
+
126
+ return False, last_error
127
+
128
+ def validate_product_images(self, product: 'Product') -> Tuple[bool, List[str]]:
129
+ """Validate all images for a product including variants."""
130
+ errors = []
131
+
132
+ # Check primary images
133
+ if not product.images:
134
+ errors.append("No primary images attached to product")
135
+ else:
136
+ for img in product.images:
137
+ is_valid, error = self.validate_image_url(img.image_url)
138
+ if not is_valid:
139
+ errors.append(f"Primary Image {img.id}: {error}")
140
+
141
+ # Check variant images if present in specs
142
+ if product.specs and isinstance(product.specs, dict) and 'variants' in product.specs:
143
+ variants = product.specs['variants']
144
+ for i, var in enumerate(variants):
145
+ img_url = var.get('image_url') or var.get('image')
146
+ if img_url:
147
+ is_valid, error = self.validate_image_url(img_url)
148
+ if not is_valid:
149
+ errors.append(f"Variant {i} Image: {error}")
150
+
151
+ return len(errors) == 0, errors
152
+
153
+ def calculate_quality_score(self, product: 'Product') -> int:
154
+ """
155
+ Calculates a weighted quality score (0-100):
156
+ - Name/Desc (30pts): EN/AR presence and length
157
+ - Images (30pts): Primary image validity and gallery count
158
+ - Variants (20pts): Nested options completeness
159
+ - Metadata (20pts): Specs, brand, category
160
+ """
161
+ score = 0
162
+
163
+ # 1. Names & Descriptions (30 pts)
164
+ if product.name_en and len(product.name_en) > 10: score += 7
165
+ if product.name_ar and len(product.name_ar) > 10: score += 8
166
+ if product.description_en and len(product.description_en) > 50: score += 7
167
+ if product.description_ar and len(product.description_ar) > 50: score += 8
168
+
169
+ # 2. Images (30 pts)
170
+ if product.images and len(product.images) > 0:
171
+ score += 15 # Has at least one image
172
+ if len(product.images) >= 3: score += 15 # Good gallery
173
+ elif len(product.images) == 2: score += 10
174
+
175
+ # 3. Variants/Options (20 pts)
176
+ has_options = False
177
+ if product.specs and isinstance(product.specs, dict):
178
+ options = product.specs.get('options')
179
+ if options and len(options) > 0:
180
+ has_options = True
181
+ score += 20
182
+
183
+ # 4. Metadata (20 pts)
184
+ if product.category_id: score += 5
185
+ if product.price > 0: score += 5
186
+ if product.stock > 0: score += 5
187
+ if product.specs and len(product.specs) > 2: score += 5
188
+
189
+ return min(100, score)
190
+
191
+ def perform_soft_delete(self, product: 'Product', reason: str, deleted_by: Optional[int] = None):
192
+ """
193
+ Soft-deletes a product and archives a snapshot for 30-day recovery.
194
+ """
195
+ try:
196
+ # Prepare recovery snapshot
197
+ snapshot = {
198
+ "name_en": product.name_en,
199
+ "name_ar": product.name_ar,
200
+ "price": product.price,
201
+ "stock": product.stock,
202
+ "specs": product.specs,
203
+ "images": [img.image_url for img in product.images],
204
+ "soft_deleted_at": datetime.now(timezone.utc).isoformat()
205
+ }
206
+
207
+ product.deleted_at = datetime.now(timezone.utc)
208
+ product.is_active = False
209
+ product.deletion_reason = reason
210
+ product.deleted_by = deleted_by
211
+
212
+ # Log audit trail
213
+ audit = ProductAudit(
214
+ product_id=product.id,
215
+ action="IMAGE_FAILURE_AUTO_DELETE",
216
+ reason=reason,
217
+ snapshot=snapshot,
218
+ performed_by=deleted_by
219
+ )
220
+ self.db.add(audit)
221
+
222
+ self.report.deleted_products.append({
223
+ "id": product.id,
224
+ "reason": reason
225
+ })
226
+ self.report.total_invalid += 1
227
+ logger.warning(f"🚨 AUTO-DELETED product {product.id} due to {reason}")
228
+ return True
229
+ except Exception as e:
230
+ logger.error(f"Failed to soft-delete {product.id}: {e}")
231
+ return False
232
+
233
+ def process_product(self, product: 'Product') -> bool:
234
+ """Process quality lifecycle for a single product."""
235
+ self.report.total_checked += 1
236
+
237
+ # 1. Critical Image Integrity Check (Phase 3 Requirement)
238
+ if not product.images:
239
+ return self.perform_soft_delete(product, "Missing all images")
240
+
241
+ for img in product.images:
242
+ is_valid, error = self.validate_image_url(img.image_url)
243
+ if not is_valid:
244
+ return self.perform_soft_delete(product, f"Image 404/Error: {error}")
245
+
246
+ # 2. Score Calculation
247
+ old_score = product.quality_score
248
+ new_score = self.calculate_quality_score(product)
249
+
250
+ if old_score != new_score:
251
+ product.quality_score = new_score
252
+ self.report.updated_scores.append({
253
+ "id": product.id,
254
+ "old": old_score,
255
+ "new": new_score
256
+ })
257
+
258
+ self.report.total_valid += 1
259
+ return True
260
+
261
+ def run_quality_check(self, batch_size: int = 50) -> QualityReport:
262
+ """Run full quality check on all active products."""
263
+ if not self.db:
264
+ logger.error("No database session available")
265
+ return self.report
266
+
267
+ logger.info("=" * 60)
268
+ logger.info("Starting Product Quality Check")
269
+ logger.info("=" * 60)
270
+
271
+ try:
272
+ products = self.db.query(Product).filter(
273
+ Product.is_active == True,
274
+ Product.deleted_at == None
275
+ ).all()
276
+
277
+ logger.info(f"Found {len(products)} active products to check")
278
+
279
+ for idx, product in enumerate(products, 1):
280
+ self.process_product(product)
281
+
282
+ if idx % batch_size == 0:
283
+ self.db.commit()
284
+ logger.info(f"Processed {idx}/{len(products)} products...")
285
+
286
+ self.db.commit()
287
+
288
+ except Exception as e:
289
+ logger.error(f"Quality check failed: {str(e)}")
290
+ self.db.rollback()
291
+ self.report.errors.append(f"Quality check failed: {str(e)}")
292
+
293
+ logger.info("=" * 60)
294
+ logger.info(f"Quality Check Complete")
295
+ logger.info(f" Total Checked: {self.report.total_checked}")
296
+ logger.info(f" Valid Products: {self.report.total_valid}")
297
+ logger.info(f" Invalid (Deleted): {self.report.total_invalid}")
298
+ logger.info(f" Score Updates: {len(self.report.updated_scores)}")
299
+ logger.info("=" * 60)
300
+
301
+ return self.report
302
+
303
+
304
+ class StandaloneQualityChecker:
305
+ """Run quality check on JSON file without database."""
306
+
307
+ def __init__(self, json_path: str):
308
+ self.json_path = json_path
309
+ self.report = QualityReport()
310
+ self.session = requests.Session()
311
+
312
+ def validate_image_url(self, url: str, timeout: int = 10) -> Tuple[bool, Optional[str]]:
313
+ """Validate single image URL."""
314
+ if not url:
315
+ return False, "Empty URL"
316
+
317
+ try:
318
+ response = self.session.head(url, timeout=timeout, allow_redirects=True, verify=False)
319
+
320
+ if response.status_code >= 400:
321
+ response = self.session.get(url, stream=True, timeout=timeout, verify=False)
322
+ response.close()
323
+
324
+ if response.status_code >= 400:
325
+ return False, f"HTTP {response.status_code}"
326
+
327
+ content_type = response.headers.get('Content-Type', '')
328
+ if not content_type.startswith('image/'):
329
+ return False, f"Invalid content type: {content_type}"
330
+
331
+ return True, None
332
+
333
+ except Exception as e:
334
+ return False, str(e)[:50]
335
+
336
+ def check_json_products(self) -> Dict[str, Any]:
337
+ """Check products from JSON file."""
338
+ logger.info(f"Loading products from {self.json_path}")
339
+
340
+ with open(self.json_path, 'r', encoding='utf-8') as f:
341
+ data = json.load(f)
342
+
343
+ products = data.get('products', [])
344
+ logger.info(f"Found {len(products)} products to validate")
345
+
346
+ results = {
347
+ "valid_products": [],
348
+ "invalid_products": [],
349
+ "summary": {
350
+ "total": len(products),
351
+ "valid": 0,
352
+ "invalid": 0
353
+ }
354
+ }
355
+
356
+ for idx, product in enumerate(products, 1):
357
+ product_id = product.get('id', f'unknown_{idx}')
358
+ name_en = product.get('name_en', 'Unknown')
359
+ images = product.get('images', [])
360
+
361
+ invalid_images = []
362
+ valid_images = []
363
+
364
+ for img_url in images:
365
+ is_valid, error = self.validate_image_url(img_url)
366
+ if is_valid:
367
+ valid_images.append(img_url)
368
+ else:
369
+ invalid_images.append({"url": img_url, "error": error})
370
+
371
+ product_result = {
372
+ "id": product_id,
373
+ "name_en": name_en,
374
+ "images": {
375
+ "total": len(images),
376
+ "valid": len(valid_images),
377
+ "invalid": len(invalid_images),
378
+ "invalid_details": invalid_images[:2]
379
+ }
380
+ }
381
+
382
+ if invalid_images:
383
+ results["invalid_products"].append(product_result)
384
+ results["summary"]["invalid"] += 1
385
+ logger.warning(f"Product {product_id}: {len(invalid_images)} invalid images")
386
+ else:
387
+ results["valid_products"].append(product_result)
388
+ results["summary"]["valid"] += 1
389
+
390
+ if idx % 50 == 0:
391
+ logger.info(f"Processed {idx}/{len(products)} products...")
392
+
393
+ logger.info(f"Validation complete: {results['summary']['valid']} valid, {results['summary']['invalid']} invalid")
394
+
395
+ try:
396
+ output_path = self.json_path.replace('.json', '_validation.json')
397
+ with open(output_path, 'w', encoding='utf-8') as f:
398
+ json.dump(results, f, ensure_ascii=False, indent=2)
399
+ logger.info(f"Results saved to {output_path}")
400
+ except Exception as e:
401
+ logger.warning(f"Could not save validation results to disk: {e}")
402
+
403
+ return results
404
+
405
+
406
+ def create_backup(db_session) -> Optional[str]:
407
+ """Create a backup of current products before quality operations."""
408
+ try:
409
+ products = db_session.query(Product).all()
410
+
411
+ backup_data = {
412
+ "timestamp": datetime.now(timezone.utc).isoformat(),
413
+ "total_products": len(products),
414
+ "products": []
415
+ }
416
+
417
+ for p in products:
418
+ product_dict = {
419
+ "id": p.id,
420
+ "name_en": p.name_en,
421
+ "name_ar": p.name_ar,
422
+ "price": p.price,
423
+ "stock": p.stock,
424
+ "is_active": p.is_active,
425
+ "deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
426
+ "quality_score": p.quality_score,
427
+ "images": [{"id": img.id, "image_url": img.image_url} for img in p.images]
428
+ }
429
+ backup_data["products"].append(product_dict)
430
+
431
+ backup_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backups")
432
+ os.makedirs(backup_dir, exist_ok=True)
433
+
434
+ backup_filename = f"products_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
435
+ backup_path = os.path.join(backup_dir, backup_filename)
436
+
437
+ try:
438
+ with open(backup_path, 'w', encoding='utf-8') as f:
439
+ json.dump(backup_data, f, ensure_ascii=False, indent=2)
440
+ logger.info(f"Backup created: {backup_path}")
441
+ except Exception as e:
442
+ logger.warning(f"Could not write backup to disk: {e}")
443
+ return backup_path
444
+
445
+ except Exception as e:
446
+ logger.error(f"Backup creation failed: {str(e)}")
447
+ return None
448
+
449
+
450
+ def main():
451
+ """Main entry point."""
452
+ import argparse
453
+
454
+ parser = argparse.ArgumentParser(description='VortexCommerce Product Quality Checker')
455
+ parser.add_argument('--mode', choices=['db', 'json'], default='db',
456
+ help='Run against database or JSON file')
457
+ parser.add_argument('--json-path',
458
+ default=os.path.join(os.path.dirname(os.path.dirname(__file__)),
459
+ 'backend', 'real_products_200.json'),
460
+ help='Path to JSON file for standalone mode')
461
+ parser.add_argument('--backup', action='store_true',
462
+ help='Create backup before quality operations')
463
+ parser.add_argument('--batch-size', type=int, default=50,
464
+ help='Batch size for database commits')
465
+
466
+ args = parser.parse_args()
467
+
468
+ logger.info("=" * 60)
469
+ logger.info("VortexCommerce Product Quality Checker v2.0")
470
+ logger.info("=" * 60)
471
+
472
+ if args.mode == 'json':
473
+ logger.info(f"Running in standalone JSON mode: {args.json_path}")
474
+ checker = StandaloneQualityChecker(args.json_path)
475
+ results = checker.check_json_products()
476
+ logger.info(f"Summary: {results['summary']}")
477
+ return 0
478
+
479
+ if not DB_AVAILABLE:
480
+ logger.error("Database modules not available. Use --mode json for standalone mode.")
481
+ return 1
482
+
483
+ db = SessionLocal()
484
+
485
+ try:
486
+ if args.backup:
487
+ backup_path = create_backup(db)
488
+ if not backup_path:
489
+ logger.warning("Continuing without backup...")
490
+
491
+ checker = ProductQualityChecker(db)
492
+ report = checker.run_quality_check(args.batch_size)
493
+
494
+ try:
495
+ report_path = os.path.join(os.path.dirname(__file__), f"quality_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json")
496
+ with open(report_path, 'w', encoding='utf-8') as f:
497
+ json.dump(report.to_dict(), f, ensure_ascii=False, indent=2)
498
+ logger.info(f"Quality report saved: {report_path}")
499
+ except Exception as e:
500
+ logger.warning(f"Could not save quality report to disk: {e}")
501
+
502
+ finally:
503
+ db.close()
504
+
505
+ return 0
506
+
507
+
508
+ if __name__ == "__main__":
509
+ sys.exit(main())
app/api/products.py ADDED
@@ -0,0 +1,641 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
4
+ from sqlalchemy import or_, desc, asc, func, text, case, cast, String
5
+ from sqlalchemy.orm import Session, joinedload
6
+ from datetime import datetime, timezone
7
+
8
+ from app.db.base import get_db
9
+ from app.models.product import Product, ProductImage, Category
10
+ from app.schemas.product import (
11
+ ProductCreate,
12
+ ProductUpdate,
13
+ ProductListItem,
14
+ ProductDetail,
15
+ ProductImageCreate,
16
+ PaginatedResponse,
17
+ )
18
+ from app.schemas.auth import AuthResponse
19
+ from app.core.security import get_current_user
20
+ from app.core.logging import log_audit_event
21
+
22
+ router = APIRouter(prefix="/products", tags=["Products"])
23
+
24
+
25
+ @router.get("", response_model=PaginatedResponse)
26
+ async def list_products(
27
+ search: Optional[str] = Query(None),
28
+ brand: Optional[str] = Query(None),
29
+ category_id: Optional[int] = Query(None),
30
+ min_price: Optional[float] = Query(None),
31
+ max_price: Optional[float] = Query(None),
32
+ is_featured: Optional[bool] = Query(None),
33
+ has_discount: Optional[bool] = Query(None),
34
+ sort_by: str = Query("created_at", regex="^(created_at|price|rating|name_en)$"),
35
+ sort_order: str = Query("desc", regex="^(asc|desc)$"),
36
+ page: int = Query(1, ge=1),
37
+ page_size: int = Query(12, ge=1, le=2000),
38
+ limit: Optional[int] = Query(None, ge=1, le=2000),
39
+ specs_filter: Optional[str] = Query(None, description="JSON string of filters, e.g. {'RAM': ['8GB']}"),
40
+ db: Session = Depends(get_db),
41
+ ):
42
+ # Use limit if provided, otherwise use page_size
43
+ effective_page_size = limit if limit is not None else page_size
44
+
45
+ from app.models.settings import StoreSettings
46
+ settings = db.query(StoreSettings).first()
47
+ global_discount = settings.global_discount if settings else 0
48
+ multiplier = (1 - global_discount / 100)
49
+
50
+ query = db.query(Product).filter(
51
+ Product.is_active == True, # noqa: E712
52
+ Product.deleted_at == None # noqa: E711
53
+ )
54
+
55
+ # Search (with Arabic Normalization: أ, إ, آ -> ا | ة -> ه | ى -> ي)
56
+ if search:
57
+ search_term = f"%{search}%"
58
+
59
+ # Search (with Arabic Normalization: أ, إ, آ -> ا | ة -> ه | ى -> ي | ignore Tashkeel)
60
+ def normalize_arabic(column):
61
+ # 1-7. Previous normalization
62
+ col = func.replace(column, 'أ', 'ا')
63
+ col = func.replace(col, 'إ', 'ا')
64
+ col = func.replace(col, 'آ', 'ا')
65
+ col = func.replace(col, 'ة', 'ه')
66
+ col = func.replace(col, 'ى', 'ي')
67
+ col = func.replace(col, 'ؤ', 'و')
68
+ col = func.replace(col, 'ئ', 'ي')
69
+ # 8. Ignore Tashkeel/Diacritics (Fatha, Damma, Kesra, Shadda, etc)
70
+ tashkeel = ['ً', 'ٌ', 'ٍ', 'َ', 'ُ', 'ِ', 'ّ', 'ْ', 'ـ']
71
+ for char in tashkeel:
72
+ col = func.replace(col, char, '')
73
+ return col
74
+
75
+ # Normalize the user's search term in python
76
+ n_search = search.replace('أ', 'ا').replace('إ', 'ا').replace('آ', 'ا').replace('ة', 'ه').replace('ى', 'ي').replace('ؤ', 'و').replace('ئ', 'ي')
77
+ # Remove Tashkeel from search term too
78
+ for char in ['ً', 'ٌ', 'ٍ', 'َ', 'ُ', 'ِ', 'ّ', 'ْ', 'ـ']:
79
+ n_search = n_search.replace(char, '')
80
+ n_search_term = f"%{n_search}%"
81
+
82
+ query = query.filter(
83
+ or_(
84
+ Product.name_en.ilike(search_term),
85
+ normalize_arabic(Product.name_ar).ilike(n_search_term),
86
+ Product.description_en.ilike(search_term),
87
+ normalize_arabic(Product.description_ar).ilike(n_search_term),
88
+ )
89
+ )
90
+
91
+ # Filters
92
+ if category_id is not None:
93
+ # Get category and its children to allow filtering by parent category
94
+ category = db.query(Category).filter(Category.id == category_id).first()
95
+ if category:
96
+ subcats = db.query(Category.id).filter(Category.parent_id == category_id).all()
97
+ subcat_ids = [c[0] for c in subcats]
98
+ all_cat_ids = [category_id] + subcat_ids
99
+ query = query.filter(Product.category_id.in_(all_cat_ids))
100
+
101
+ if brand:
102
+ brands = [b.strip() for b in brand.split(',') if b.strip()]
103
+ brand_conditions = []
104
+ for b in brands:
105
+ brand_search = f"%{b.lower()}%"
106
+ brand_conditions.append(
107
+ or_(
108
+ cast(Product.specs['brand_name'], String).ilike(brand_search),
109
+ cast(Product.specs['brand'], String).ilike(brand_search),
110
+ cast(Product.specs['details_en']['Brand Name'], String).ilike(brand_search),
111
+ cast(Product.specs['details_en']['Brand'], String).ilike(brand_search)
112
+ )
113
+ )
114
+ if brand_conditions:
115
+ query = query.filter(or_(*brand_conditions))
116
+
117
+ if specs_filter:
118
+ import json
119
+ try:
120
+ filters = json.loads(specs_filter)
121
+ if isinstance(filters, dict):
122
+ for key, values in filters.items():
123
+ if values and isinstance(values, list):
124
+ # Postgres JSONB extraction
125
+ query = query.filter(
126
+ or_(
127
+ cast(Product.specs[key], String).in_(values),
128
+ cast(Product.specs['details_en'][key], String).in_(values)
129
+ )
130
+ )
131
+ except Exception as e:
132
+ print(f"Error parsing specs_filter: {e}")
133
+
134
+ if min_price is not None:
135
+ query = query.filter(Product.price >= min_price)
136
+ if max_price is not None:
137
+ query = query.filter(Product.price <= max_price)
138
+ if is_featured is not None:
139
+ query = query.filter(Product.is_featured == is_featured)
140
+ if has_discount is True:
141
+ query = query.filter(Product.compare_price > Product.price)
142
+ elif has_discount is False:
143
+ query = query.filter(or_(Product.compare_price == None, Product.compare_price <= Product.price))
144
+
145
+ # Count
146
+ total = query.count()
147
+
148
+ # Sort
149
+ from sqlalchemy import case
150
+
151
+ # Custom Hybrid Sorting Logic:
152
+ # 1. New Products (ID > 3708) first, sorted by ID DESC
153
+ # 2. Original Catalog (ID <= 3708) next, sorted by ID ASC
154
+ query = query.order_by(
155
+ case(
156
+ (Product.id > 3708, 0),
157
+ else_=1
158
+ ),
159
+ Product.id.asc()
160
+ )
161
+
162
+ # Pagination
163
+ offset = (page - 1) * effective_page_size
164
+ products = (
165
+ query.options(joinedload(Product.category), joinedload(Product.images))
166
+ .offset(offset)
167
+ .limit(effective_page_size)
168
+ .all()
169
+ )
170
+
171
+ # Map to response
172
+ items = []
173
+ for p in products:
174
+ primary_image = None
175
+ if p.images:
176
+ sorted_imgs = sorted(p.images, key=lambda x: x.sort_order)
177
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
178
+
179
+ # Pricing for combined discount display:
180
+ # price = original_price * multiplier
181
+ # compare_price = original_compare (raw)
182
+ original_price = p.price
183
+ original_compare = p.compare_price if p.compare_price else p.price
184
+
185
+ item = ProductListItem(
186
+ id=p.id,
187
+ slug=p.slug,
188
+ name_ar=p.name_ar,
189
+ name_en=p.name_en,
190
+ price=original_price * multiplier,
191
+ compare_price=original_compare if (multiplier < 1.0 or p.compare_price) else None,
192
+ stock=p.stock,
193
+ category_id=p.category_id,
194
+ category=p.category,
195
+ rating=p.rating,
196
+ rating_count=p.rating_count,
197
+ is_featured=p.is_featured,
198
+ image_url=primary_image,
199
+ created_at=p.created_at,
200
+ quality_score=p.quality_score or 100,
201
+ deleted_at=p.deleted_at,
202
+ )
203
+ items.append(item.model_dump())
204
+
205
+ # Serialize dates
206
+ for item in items:
207
+ if isinstance(item.get("created_at"), datetime):
208
+ item["created_at"] = item["created_at"].isoformat()
209
+ if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
210
+ item["deleted_at"] = item["deleted_at"].isoformat()
211
+
212
+ total_pages = (total + page_size - 1) // page_size
213
+
214
+ return PaginatedResponse(
215
+ isSuccess=True,
216
+ value={
217
+ "items": items,
218
+ "total": total,
219
+ "page": page,
220
+ "page_size": page_size,
221
+ "total_pages": total_pages,
222
+ },
223
+ statusCode=200,
224
+ )
225
+ @router.get("/admin/deleted", response_model=PaginatedResponse)
226
+ async def list_deleted_products(
227
+ page: int = Query(1, ge=1),
228
+ page_size: int = Query(12, ge=1, le=48),
229
+ db: Session = Depends(get_db),
230
+ user_id: int = Depends(get_current_user),
231
+ ):
232
+ query = db.query(Product).filter(Product.deleted_at != None) # noqa: E711
233
+
234
+ total = query.count()
235
+ offset = (page - 1) * page_size
236
+ products = (
237
+ query.options(joinedload(Product.category), joinedload(Product.images))
238
+ .order_by(desc(Product.deleted_at))
239
+ .offset(offset)
240
+ .limit(page_size)
241
+ .all()
242
+ )
243
+
244
+ items = []
245
+ for p in products:
246
+ item = ProductDetail.model_validate(p).model_dump()
247
+ if isinstance(item.get("created_at"), datetime):
248
+ item["created_at"] = item["created_at"].isoformat()
249
+ if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
250
+ item["deleted_at"] = item["deleted_at"].isoformat()
251
+ items.append(item)
252
+
253
+ total_pages = (total + page_size - 1) // page_size
254
+
255
+ return PaginatedResponse(
256
+ isSuccess=True,
257
+ value={
258
+ "items": items,
259
+ "total": total,
260
+ "page": page,
261
+ "page_size": page_size,
262
+ "total_pages": total_pages,
263
+ },
264
+ statusCode=200,
265
+ )
266
+
267
+
268
+ @router.get("/filters")
269
+ async def get_filters(category_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
270
+ """
271
+ Returns filter metadata for a category (price range, brands, dynamic attributes).
272
+ Supports parent categories by aggregating children. If category_id is None, returns global filters.
273
+ """
274
+ all_cat_ids = None
275
+ cat_name_en = ""
276
+
277
+ if category_id is not None:
278
+ category = db.query(Category).filter(Category.id == category_id).first()
279
+ if not category:
280
+ raise HTTPException(status_code=404, detail="Category not found")
281
+
282
+ subcats = db.query(Category.id).filter(Category.parent_id == category_id).all()
283
+ all_cat_ids = [category_id] + [c[0] for c in subcats]
284
+ cat_name_en = (category.name_en or "").lower()
285
+
286
+ base_filter = [Product.is_active == True, Product.deleted_at == None]
287
+ if all_cat_ids is not None:
288
+ base_filter.append(Product.category_id.in_(all_cat_ids))
289
+
290
+ # 1. Price Range
291
+ price_stats = db.query(
292
+ func.min(Product.price).label("min_p"),
293
+ func.max(Product.price).label("max_p")
294
+ ).filter(*base_filter).first()
295
+
296
+ # 2. Brands
297
+ brands_q1 = db.query(func.distinct(cast(Product.specs['brand_name'], String))).filter(*base_filter)
298
+ brands_q2 = db.query(func.distinct(cast(Product.specs['details_en']['Brand Name'], String))).filter(*base_filter)
299
+ brands = sorted(list(set([b[0] for b in brands_q1.all() if b[0]] + [b[0] for b in brands_q2.all() if b[0]])))
300
+
301
+ # 3. Dynamic Attributes per subcategory
302
+ attr_mappings = []
303
+
304
+ if cat_name_en:
305
+ # ── Electronics ──
306
+ if any(k in cat_name_en for k in ["mobile", "phone", "smartphone", "جوالات"]):
307
+ attr_mappings = [
308
+ {"key": "RAM", "label_ar": "الرام", "label_en": "RAM"},
309
+ {"key": "Internal Memory", "label_ar": "المساحة الداخلية", "label_en": "Storage"},
310
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
311
+ {"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
312
+ {"key": "Network Type", "label_ar": "نوع الشبكة", "label_en": "Network"},
313
+ ]
314
+ elif any(k in cat_name_en for k in ["laptop", "computer", "لابتوب"]):
315
+ attr_mappings = [
316
+ {"key": "Processor", "label_ar": "المعالج", "label_en": "Processor"},
317
+ {"key": "RAM", "label_ar": "الرام", "label_en": "RAM"},
318
+ {"key": "Hard Drive Capacity", "label_ar": "سعة التخزين", "label_en": "Storage"},
319
+ {"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
320
+ {"key": "Graphics Card", "label_ar": "كرت الشاشة", "label_en": "Graphics Card"},
321
+ {"key": "Operating System", "label_ar": "نظام التشغيل", "label_en": "OS"},
322
+ ]
323
+ elif any(k in cat_name_en for k in ["tablet", "لوحي"]):
324
+ attr_mappings = [
325
+ {"key": "RAM", "label_ar": "الرام", "label_en": "RAM"},
326
+ {"key": "Internal Memory", "label_ar": "المساحة", "label_en": "Storage"},
327
+ {"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
328
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
329
+ ]
330
+ elif any(k in cat_name_en for k in ["tv", "television", "تلفزيون"]):
331
+ attr_mappings = [
332
+ {"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
333
+ {"key": "Resolution Type", "label_ar": "الدقة", "label_en": "Resolution"},
334
+ {"key": "Smart TV", "label_ar": "تلفزيون ذكي", "label_en": "Smart TV"},
335
+ {"key": "Display Type", "label_ar": "نوع الشاشة", "label_en": "Display Type"},
336
+ ]
337
+ elif any(k in cat_name_en for k in ["camera", "كاميرا"]):
338
+ attr_mappings = [
339
+ {"key": "Resolution", "label_ar": "الدقة", "label_en": "Resolution"},
340
+ {"key": "Type", "label_ar": "النوع", "label_en": "Type"},
341
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
342
+ ]
343
+ elif any(k in cat_name_en for k in ["printer", "طابع"]):
344
+ attr_mappings = [
345
+ {"key": "Print Technology", "label_ar": "تقنية الطباعة", "label_en": "Print Tech"},
346
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
347
+ {"key": "Connectivity", "label_ar": "الاتصال", "label_en": "Connectivity"},
348
+ ]
349
+ # ── Accessories ──
350
+ elif any(k in cat_name_en for k in ["audio", "headphone", "سماعات", "صوت"]):
351
+ attr_mappings = [
352
+ {"key": "Connection Type", "label_ar": "نوع الاتصال", "label_en": "Connection"},
353
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
354
+ {"key": "Type", "label_ar": "النوع", "label_en": "Type"},
355
+ ]
356
+ elif any(k in cat_name_en for k in ["watch", "ساعات"]):
357
+ attr_mappings = [
358
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
359
+ {"key": "Band Material", "label_ar": "مادة السوار", "label_en": "Band Material"},
360
+ {"key": "Display Type", "label_ar": "نوع الشاشة", "label_en": "Display"},
361
+ ]
362
+ # ── Gaming ──
363
+ elif any(k in cat_name_en for k in ["gaming", "console", "ألعاب"]):
364
+ attr_mappings = [
365
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
366
+ {"key": "Storage Capacity", "label_ar": "سعة التخزين", "label_en": "Storage"},
367
+ {"key": "Type", "label_ar": "النوع", "label_en": "Type"},
368
+ ]
369
+ # ── Home Appliances ──
370
+ elif any(k in cat_name_en for k in ["refrigerator", "ثلاج"]):
371
+ attr_mappings = [
372
+ {"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity"},
373
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
374
+ {"key": "Door Type", "label_ar": "نوع الباب", "label_en": "Door Type"},
375
+ ]
376
+ elif any(k in cat_name_en for k in ["wash", "غسال"]):
377
+ attr_mappings = [
378
+ {"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity (kg)"},
379
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
380
+ {"key": "Load Type", "label_ar": "نوع التحميل", "label_en": "Load Type"},
381
+ ]
382
+ elif any(k in cat_name_en for k in ["conditioner", "مكيف"]):
383
+ attr_mappings = [
384
+ {"key": "Capacity", "label_ar": "السعة (BTU)", "label_en": "Capacity (BTU)"},
385
+ {"key": "Type", "label_ar": "النوع", "label_en": "Type"},
386
+ {"key": "Energy Rating", "label_ar": "كفاءة الطاقة", "label_en": "Energy Rating"},
387
+ ]
388
+ elif any(k in cat_name_en for k in ["small appliance", "منزلية صغيرة"]):
389
+ attr_mappings = [
390
+ {"key": "Type", "label_ar": "النوع", "label_en": "Type"},
391
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
392
+ {"key": "Wattage", "label_ar": "القدرة", "label_en": "Wattage"},
393
+ ]
394
+ elif any(k in cat_name_en for k in ["large appliance", "منزلية كبيرة"]):
395
+ attr_mappings = [
396
+ {"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity"},
397
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
398
+ {"key": "Type", "label_ar": "النوع", "label_en": "Type"},
399
+ ]
400
+ # ── Parent categories (aggregate) ──
401
+ elif any(k in cat_name_en for k in ["electronic", "إلكتروني"]):
402
+ attr_mappings = [
403
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
404
+ ]
405
+ elif any(k in cat_name_en for k in ["accessor", "ملحقات"]):
406
+ attr_mappings = [
407
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
408
+ {"key": "Connection Type", "label_ar": "نوع الاتصال", "label_en": "Connection"},
409
+ ]
410
+ elif any(k in cat_name_en for k in ["home appliance", "المنزلية"]):
411
+ attr_mappings = [
412
+ {"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity"},
413
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
414
+ ]
415
+
416
+ # 4. Resolve attribute values from DB
417
+ attributes = []
418
+ for attr in attr_mappings:
419
+ key = attr["key"]
420
+ # Try direct spec key first, then details_en
421
+ values_query = db.query(func.distinct(cast(Product.specs[key], String))).filter(
422
+ *base_filter
423
+ ).all()
424
+ vals = [v[0] for v in values_query if v[0]]
425
+
426
+ if not vals:
427
+ values_query = db.query(func.distinct(cast(Product.specs['details_en'][key], String))).filter(
428
+ *base_filter
429
+ ).all()
430
+ vals = [v[0] for v in values_query if v[0]]
431
+
432
+ if vals:
433
+ attributes.append({
434
+ "key": key,
435
+ "label_ar": attr["label_ar"],
436
+ "label_en": attr["label_en"],
437
+ "options": sorted(list(set(vals)))
438
+ })
439
+
440
+ return PaginatedResponse(
441
+ isSuccess=True,
442
+ value={
443
+ "min_price": float(price_stats.min_p) if price_stats and price_stats.min_p else 0,
444
+ "max_price": float(price_stats.max_p) if price_stats and price_stats.max_p else 10000,
445
+ "brands": brands,
446
+ "attributes": attributes
447
+ },
448
+ statusCode=200
449
+ )
450
+
451
+
452
+
453
+ @router.get("/{identifier}", response_model=PaginatedResponse)
454
+ async def get_product(identifier: str, raw: bool = False, db: Session = Depends(get_db)):
455
+ query = db.query(Product).options(joinedload(Product.category), joinedload(Product.images))
456
+ if identifier.isdigit():
457
+ product = query.filter(Product.id == int(identifier)).first()
458
+ # If not found by ID, maybe the slug itself is numeric?
459
+ if not product:
460
+ product = query.filter(Product.slug == identifier).first()
461
+ else:
462
+ product = query.filter(Product.slug == identifier).first()
463
+
464
+ if not product:
465
+ raise HTTPException(status_code=404, detail="Product not found")
466
+
467
+ from app.models.settings import StoreSettings
468
+ settings = db.query(StoreSettings).first()
469
+ global_discount = settings.global_discount if settings else 0
470
+ multiplier = (1 - global_discount / 100)
471
+
472
+ detail = ProductDetail.model_validate(product).model_dump()
473
+
474
+ # Only apply discount if NOT raw
475
+ if not raw:
476
+ # Normalize pricing for combined discount display:
477
+ # price = original_price * multiplier
478
+ # compare_price = original_compare (raw)
479
+ original_price = detail["price"]
480
+ original_compare = detail.get("compare_price") if detail.get("compare_price") else original_price
481
+
482
+ detail["price"] = original_price * multiplier
483
+ if multiplier < 1.0 or detail.get("compare_price"):
484
+ detail["compare_price"] = original_compare
485
+
486
+ # Apply to variants in specs
487
+ if detail.get("specs") and "variants" in detail["specs"]:
488
+ for v in detail["specs"]["variants"]:
489
+ if v.get("price"):
490
+ v_original_price = v["price"]
491
+ v["price"] = v_original_price * multiplier
492
+ # Optional: Could add compare_price to variants too, but mostly price is enough
493
+ # as the main product's compare_price is often used for the range.
494
+ if v.get("price_modifier"):
495
+ v["price_modifier"] *= multiplier
496
+
497
+ return PaginatedResponse(
498
+ isSuccess=True,
499
+ value=detail,
500
+ statusCode=200,
501
+ )
502
+
503
+
504
+ @router.post("", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
505
+ async def create_product(
506
+ request: ProductCreate,
507
+ user_id: int = Depends(get_current_user),
508
+ db: Session = Depends(get_db),
509
+ ):
510
+ if not request.images or len(request.images) == 0:
511
+ raise HTTPException(status_code=400, detail="Main image is required / صورة رئيسية مطلوبة")
512
+
513
+ product = Product(
514
+ name_ar=request.name_ar,
515
+ name_en=request.name_en,
516
+ description_ar=request.description_ar,
517
+ description_en=request.description_en,
518
+ price=request.price,
519
+ compare_price=request.compare_price,
520
+ stock=request.stock,
521
+ category_id=request.category_id,
522
+ is_featured=request.is_featured,
523
+ is_active=request.is_active,
524
+ specs=request.specs,
525
+ )
526
+ db.add(product)
527
+ db.flush()
528
+
529
+ # Add images
530
+ for img in request.images:
531
+ db_img = ProductImage(
532
+ product_id=product.id,
533
+ image_url=img.image_url,
534
+ alt_text=img.alt_text,
535
+ sort_order=img.sort_order,
536
+ )
537
+ db.add(db_img)
538
+
539
+ db.commit()
540
+ db.refresh(product)
541
+
542
+ return AuthResponse(
543
+ isSuccess=True,
544
+ value={"product_id": product.id},
545
+ statusCode=201,
546
+ )
547
+
548
+
549
+ @router.put("/{product_id}", response_model=AuthResponse)
550
+ async def update_product(
551
+ product_id: int,
552
+ request: ProductUpdate,
553
+ user_id: int = Depends(get_current_user),
554
+ db: Session = Depends(get_db),
555
+ ):
556
+ product = db.query(Product).filter(Product.id == product_id).first()
557
+ if not product or product.deleted_at is not None:
558
+ raise HTTPException(status_code=404, detail="Product not found")
559
+
560
+ update_data = request.model_dump(exclude_unset=True)
561
+ for field, value in update_data.items():
562
+ setattr(product, field, value)
563
+
564
+ db.commit()
565
+
566
+ return AuthResponse(
567
+ isSuccess=True,
568
+ value={"product_id": product.id},
569
+ statusCode=200,
570
+ )
571
+
572
+
573
+ @router.delete("/{product_id}", response_model=AuthResponse)
574
+ async def delete_product(
575
+ product_id: int,
576
+ user_id: int = Depends(get_current_user),
577
+ db: Session = Depends(get_db),
578
+ ):
579
+ product = db.query(Product).filter(Product.id == product_id).first()
580
+ if not product or product.deleted_at is not None:
581
+ raise HTTPException(status_code=404, detail="Product not found")
582
+
583
+ # Soft Delete
584
+ product.deleted_at = datetime.now(timezone.utc)
585
+ product.deleted_by = user_id
586
+ product.deletion_reason = "Manual deletion by admin"
587
+
588
+ db.commit()
589
+
590
+ return AuthResponse(
591
+ isSuccess=True,
592
+ value={"deleted": True, "soft_delete": True},
593
+ statusCode=200,
594
+ )
595
+
596
+ @router.delete("/{product_id}/hard", response_model=AuthResponse)
597
+ async def hard_delete_product(
598
+ product_id: int,
599
+ user_id: int = Depends(get_current_user),
600
+ db: Session = Depends(get_db),
601
+ ):
602
+ product = db.query(Product).filter(Product.id == product_id).first()
603
+ if not product:
604
+ raise HTTPException(status_code=404, detail="Product not found")
605
+
606
+ db.delete(product)
607
+ db.commit()
608
+
609
+ return AuthResponse(
610
+ isSuccess=True,
611
+ value={"deleted": True, "hard_delete": True},
612
+ statusCode=200,
613
+ )
614
+
615
+ @router.post("/{product_id}/restore", response_model=AuthResponse)
616
+ async def restore_product(
617
+ product_id: int,
618
+ user_id: int = Depends(get_current_user),
619
+ db: Session = Depends(get_db),
620
+ ):
621
+ product = db.query(Product).filter(Product.id == product_id).first()
622
+ if not product or product.deleted_at is None:
623
+ raise HTTPException(status_code=404, detail="Product not found or not deleted")
624
+
625
+ product.deleted_at = None
626
+ product.deleted_by = None
627
+ product.deletion_reason = None
628
+
629
+ db.commit()
630
+
631
+ return AuthResponse(
632
+ isSuccess=True,
633
+ value={"restored": True},
634
+ statusCode=200,
635
+ )
636
+
637
+
638
+
639
+
640
+
641
+ # End of products.py
app/api/seed.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.orm import Session
3
+ from sqlalchemy import text
4
+ import json
5
+ import random
6
+ import os
7
+ import hashlib
8
+ import requests
9
+
10
+ from app.db.base import get_db
11
+ from app.models.product import Product, ProductImage, Category
12
+ from app.models.cart import Coupon
13
+ from app.models.user import User, UserRole, AuthProvider
14
+ from app.core.security import hash_password
15
+ from app.schemas.auth import AuthResponse
16
+ from app.services.external_catalog import ExternalCatalogService
17
+
18
+ router = APIRouter(prefix="/seed", tags=["Seed Data"])
19
+
20
+ # ============================================================
21
+ # Category Hierarchy — matching Extra_Scraper_Kaggle.py exactly
22
+ # ============================================================
23
+ SUB_ICONS = {
24
+ "جوالات": "📲",
25
+ "لابتوب": "💻",
26
+ "أجهزة لوحية": "📟",
27
+ "تلفزيونات": "📺",
28
+ "كاميرات": "📷",
29
+ "طابعات": "🖨️",
30
+ "أجهزة الصوت والسماعات": "🎧",
31
+ "ساعات ذكية": "⌚",
32
+ "أجهزة ألعاب": "🎮",
33
+ "ثلاجات": "❄️",
34
+ "غسالات ومجففات": "🫧",
35
+ "مكيفات": "🌀",
36
+ "أجهزة منزلية صغيرة": "🍳",
37
+ "أجهزة منزلية كبيرة": "🏗️",
38
+ }
39
+
40
+ CATEGORY_HIERARCHY = {
41
+ "الأجهزة الإلكترونية": {
42
+ "name_en": "Electronics",
43
+ "icon": "📱",
44
+ "subs": [
45
+ {"ar": "جوالات", "en": "Smartphones", "icon": "📲"},
46
+ {"ar": "لابتوب", "en": "Laptops", "icon": "💻"},
47
+ {"ar": "أجهزة لوحية", "en": "Tablets", "icon": "📟"},
48
+ {"ar": "تلفزيونات", "en": "TVs", "icon": "📺"},
49
+ {"ar": "كاميرات", "en": "Cameras", "icon": "📷"},
50
+ {"ar": "طابعات", "en": "Printers", "icon": "🖨️"},
51
+ ]
52
+ },
53
+ "ملحقات واكسسوارات": {
54
+ "name_en": "Accessories",
55
+ "icon": "🔌",
56
+ "subs": [
57
+ {"ar": "أجهزة الصوت والسماعات", "en": "Audio & Headphones", "icon": "🎧"},
58
+ {"ar": "ساعات ذكية", "en": "Smartwatches", "icon": "⌚"},
59
+ ]
60
+ },
61
+ "ألعاب جيمنج": {
62
+ "name_en": "Gaming",
63
+ "icon": "🎮",
64
+ "subs": [
65
+ {"ar": "أجهزة ألعاب", "en": "Gaming Consoles", "icon": "🎮"},
66
+ ]
67
+ },
68
+ "الأجهزة المنزلية": {
69
+ "name_en": "Home Appliances",
70
+ "icon": "🏠",
71
+ "subs": [
72
+ {"ar": "ثلاجات", "en": "Refrigerators", "icon": "❄️"},
73
+ {"ar": "غسالات ومجففات", "en": "Washing Machines", "icon": "🫧"},
74
+ {"ar": "مكيفات", "en": "Air Conditioners", "icon": "🌀"},
75
+ {"ar": "أجهزة منزلية صغيرة", "en": "Small Appliances", "icon": "🍳"},
76
+ {"ar": "أجهزة منزلية كبيرة", "en": "Large Appliances", "icon": "🏗️"},
77
+ ]
78
+ }
79
+ }
80
+
81
+ # Reverse map for scraped data → parent category
82
+ SCRAPED_TO_TARGET_MAP = {
83
+ "Electronics": "الأجهزة الإلكترونية",
84
+ "Smartphones": "الأجهزة الإلكترونية",
85
+ "Laptops": "الأجهزة الإلكترونية",
86
+ "Tablets": "الأجهزة الإلكترونية",
87
+ "TVs": "الأجهزة الإلكترونية",
88
+ "Cameras": "الأجهزة الإلكترونية",
89
+ "Printers": "الأجهزة الإلكترونية",
90
+ "Accessories": "ملحقات واكسسوارات",
91
+ "Audio & Headphones": "ملحقات واكسسوارات",
92
+ "Smartwatches": "ملحقات واكسسوارات",
93
+ "Gaming": "ألعاب جيمنج",
94
+ "Gaming Consoles": "ألعاب جيمنج",
95
+ "Home Appliances": "الأجهزة المنزلية",
96
+ "Refrigerators": "الأجهزة المنزلية",
97
+ "Washing Machines": "الأجهزة المنزلية",
98
+ "Air Conditioners": "الأجهزة المنزلية",
99
+ "Small Appliances": "الأجهزة المنزلية",
100
+ "Large Appliances": "الأجهزة المنزلية",
101
+ "Security": "ملحقات واكسسوارات",
102
+ "Car": "ملحقات واكسسوارات",
103
+ "Audio": "ملحقات واكسسوارات",
104
+ "Personal Care": "الأجهزة المنزلية",
105
+ }
106
+
107
+ @router.api_route("", methods=["GET", "POST"], response_model=AuthResponse)
108
+ def seed_database(db: Session = Depends(get_db), is_background_task: bool = False):
109
+ """Populate database with proper product data. Only adds new products, does not clear existing data."""
110
+ if is_background_task:
111
+ from app.core.state import import_progress
112
+ # 0. Ensure Store Settings exist
113
+ from app.models.settings import StoreSettings
114
+ settings = db.query(StoreSettings).first()
115
+ if not settings:
116
+ print(">>> [SEED] Creating default store settings...")
117
+ settings = StoreSettings(
118
+ store_name="أفق",
119
+ primary_color="#046c4e",
120
+ secondary_color="#d97706",
121
+ logo_url="/logo.png"
122
+ )
123
+ db.add(settings)
124
+ db.commit()
125
+
126
+ # 0. Create Default Admin if matches criteria
127
+ admin_email = "admin@vortex.com"
128
+ admin_user = db.query(User).filter(User.email == admin_email).first()
129
+ if not admin_user:
130
+ print(f">>> [SEED] Creating default admin: {admin_email}")
131
+ admin_user = User(
132
+ email=admin_email,
133
+ name="Platform Administrator",
134
+ password_hash=hash_password("admin123"),
135
+ role=UserRole.ADMIN,
136
+ auth_provider=AuthProvider.LOCAL
137
+ )
138
+ db.add(admin_user)
139
+ db.commit()
140
+
141
+ # 1. Create Categories (Hierarchy)
142
+ all_sub_cats = {} # Map used for product assignment
143
+
144
+ for p_ar, info in CATEGORY_HIERARCHY.items():
145
+ # Create Parent
146
+ parent_cat = db.query(Category).filter(Category.name_ar == p_ar).first()
147
+ if not parent_cat:
148
+ parent_cat = Category(
149
+ name_ar=p_ar,
150
+ name_en=info["name_en"],
151
+ icon=info["icon"]
152
+ )
153
+ db.add(parent_cat)
154
+ db.commit()
155
+ db.refresh(parent_cat)
156
+
157
+ # Add parent to map so products can be assigned directly to it
158
+ all_sub_cats[p_ar] = parent_cat
159
+ all_sub_cats[info["name_en"]] = parent_cat
160
+
161
+ # Create Subs
162
+ for s in info["subs"]:
163
+ sub_cat = db.query(Category).filter(Category.name_ar == s["ar"]).first()
164
+ if not sub_cat:
165
+ sub_cat = Category(
166
+ name_ar=s["ar"],
167
+ name_en=s["en"],
168
+ icon=s.get("icon", SUB_ICONS.get(s["ar"], "📦")),
169
+ parent_id=parent_cat.id
170
+ )
171
+ db.add(sub_cat)
172
+ db.commit()
173
+ db.refresh(sub_cat)
174
+ all_sub_cats[s["ar"]] = sub_cat
175
+ all_sub_cats[s["en"]] = sub_cat # Map both names to the same object
176
+
177
+
178
+ API_VERSION = "2026.03.16.V2"
179
+
180
+ possible_paths = [
181
+ # Primary Target (Fixed location inside backend so upload_to_hf works)
182
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "data", "extra_products_vortex.json")),
183
+ os.path.abspath("app/data/extra_products_vortex.json"),
184
+
185
+ # Standard Hugging Face / Docker location (WORKDIR /app)
186
+ "/app/app/data/extra_products_vortex.json",
187
+ "/app/data/extra_products_vortex.json",
188
+
189
+ # Fallbacks (Old paths just in case)
190
+ r"c:\react_projects\VortexCommerce\kaggle\extra_products_vortex.json",
191
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "kaggle", "extra_products_vortex.json")),
192
+ os.path.abspath("kaggle/extra_products_vortex.json"),
193
+ os.path.abspath("extra_products_vortex.json"),
194
+ ]
195
+
196
+ json_path = None
197
+ checked_info = []
198
+ for p in possible_paths:
199
+ exists = os.path.exists(p)
200
+ checked_info.append(f"{p}: {'FOUND' if exists else 'NOT FOUND'}")
201
+ if exists:
202
+ json_path = p
203
+ break
204
+
205
+ if not json_path:
206
+ return AuthResponse(
207
+ isSuccess=False,
208
+ value={
209
+ "message": "Source file not found.",
210
+ "checked": checked_info,
211
+ "version": API_VERSION
212
+ },
213
+ statusCode=404,
214
+ error="File not found"
215
+ )
216
+
217
+ try:
218
+ with open(json_path, "r", encoding="utf-8") as f:
219
+ data = json.load(f)
220
+
221
+ if isinstance(data, dict) and "products" in data:
222
+ products_data = data["products"]
223
+ elif isinstance(data, list):
224
+ products_data = data
225
+ else:
226
+ return AuthResponse(isSuccess=False, value={"message": "Invalid JSON format"}, statusCode=400)
227
+
228
+ # Cache existing products
229
+ existing_products_ar = set(r[0] for r in db.execute(text("SELECT name_ar FROM products")).fetchall())
230
+ existing_products_en = set(r[0] for r in db.execute(text("SELECT name_en FROM products")).fetchall())
231
+
232
+ # 3. Create Products
233
+ inserted_count: int = 0
234
+ skipped_count: int = 0
235
+
236
+ total_items = len(products_data)
237
+ if is_background_task:
238
+ import_progress.total = total_items
239
+ import_progress.current = 0
240
+
241
+ for idx, item in enumerate(products_data):
242
+ if is_background_task and idx % 25 == 0:
243
+ import_progress.current = idx
244
+ import_progress.message = f"Processing item {idx} of {total_items}..."
245
+
246
+ if not isinstance(item, dict):
247
+ skipped_count += 1
248
+ continue
249
+
250
+ product_name_ar = item.get("name_ar", "").strip()
251
+ product_name_en = item.get("name_en", "").strip()
252
+
253
+ # 1. Resolve Category (Hierarchical)
254
+ main_cat_ar = item.get("main_category")
255
+ main_cat_en = item.get("main_category_en")
256
+ sub_cat_ar = item.get("category")
257
+ sub_cat_en = item.get("category_en")
258
+ cat_icon = item.get("category_icon", "📦")
259
+
260
+ target_sub_cat = None
261
+
262
+ if main_cat_ar and sub_cat_ar:
263
+ # Ensure Parent exists
264
+ parent = db.query(Category).filter(Category.name_ar == main_cat_ar).first()
265
+ if not parent:
266
+ parent = Category(
267
+ name_ar=main_cat_ar,
268
+ name_en=main_cat_en or main_cat_ar,
269
+ icon=cat_icon
270
+ )
271
+ db.add(parent)
272
+ db.commit()
273
+ db.refresh(parent)
274
+
275
+ # Ensure Sub exists
276
+ sub = db.query(Category).filter(
277
+ Category.name_ar == sub_cat_ar,
278
+ Category.parent_id == parent.id
279
+ ).first()
280
+ if not sub:
281
+ sub = Category(
282
+ name_ar=sub_cat_ar,
283
+ name_en=sub_cat_en or sub_cat_ar,
284
+ icon=cat_icon,
285
+ parent_id=parent.id
286
+ )
287
+ db.add(sub)
288
+ db.commit()
289
+ db.refresh(sub)
290
+ target_sub_cat = sub
291
+ else:
292
+ # Fallback for old flat format or missing data
293
+ cat_name = item.get("category_name") or item.get("category")
294
+ if cat_name in SCRAPED_TO_TARGET_MAP:
295
+ cat_name = SCRAPED_TO_TARGET_MAP[cat_name]
296
+
297
+ if not cat_name or cat_name not in all_sub_cats:
298
+ cat_name = "الأجهزة الإلكترونية"
299
+ target_sub_cat = all_sub_cats.get(cat_name)
300
+
301
+ if not target_sub_cat:
302
+ skipped_count += 1
303
+ continue
304
+
305
+ if product_name_ar in existing_products_ar or product_name_en in existing_products_en:
306
+ skipped_count += 1
307
+ continue
308
+
309
+ specs = item.get("specs", {})
310
+ if not isinstance(specs, dict): specs = {}
311
+
312
+ # Handle Variants
313
+ if "variants" not in specs and "options" in item:
314
+ options = item.get("options", [])
315
+ if isinstance(options, list):
316
+ variants = []
317
+ for opt in options:
318
+ if not isinstance(opt, dict): continue
319
+ variants.append({
320
+ "id": opt.get("sku", opt.get("id", "v_default")),
321
+ "name_en": opt.get("name_en", ""),
322
+ "name_ar": opt.get("name_ar", ""),
323
+ "price_modifier": float(opt.get("price", 0)) - float(item.get("price", 0)) if "price" in opt else 0,
324
+ "stock": opt.get("stock", 10),
325
+ "image_url": opt.get("image", opt.get("image_url", ""))
326
+ })
327
+ if variants: specs["variants"] = variants
328
+
329
+ import time
330
+ fallback_slug = f"vortex-{int(time.time()*1000)}-{idx}"
331
+ product_slug = item.get("legacy_ref") or item.get("sku_base") or fallback_slug
332
+
333
+ prod = Product(
334
+ slug=product_slug,
335
+ name_ar=product_name_ar,
336
+ name_en=product_name_en,
337
+ description_ar=item.get("description_ar", ""),
338
+ description_en=item.get("description_en", ""),
339
+ price=float(item.get("price", 0)),
340
+ compare_price=round(float(item.get("compare_price", float(item.get("price", 0)) * 1.3)), 2),
341
+ stock=item.get("stock", random.randint(10, 100)),
342
+ category_id=target_sub_cat.id,
343
+ rating=item.get("rating", 4.5),
344
+ rating_count=item.get("rating_count", 20),
345
+ is_featured=item.get("is_featured", False),
346
+ is_active=item.get("is_active", True),
347
+ specs=specs if specs else None
348
+ )
349
+ db.add(prod)
350
+ db.flush()
351
+
352
+ # Handle Images
353
+ for i, img_url in enumerate(item.get("images", [])):
354
+ # Sanitize URL: Remove complex query parameters that might cause loading issues
355
+ # Extra.com URLs often look like: ...Black?locale=en-GB,en-*,*&$Listing-Product-2x$
356
+ # We want to keep the base image but simplify the request
357
+ clean_url = img_url
358
+ if "media.extra.com" in img_url and "?" in img_url:
359
+ clean_url = img_url.split("?")[0]
360
+
361
+ db.add(ProductImage(
362
+ product_id=prod.id,
363
+ image_url=clean_url,
364
+ alt_text=prod.name_ar,
365
+ sort_order=i
366
+ ))
367
+
368
+ inserted_count = inserted_count + 1
369
+ existing_products_ar.add(product_name_ar)
370
+ existing_products_en.add(product_name_en)
371
+
372
+ # Commit every 20 products for real-time progress and stability
373
+ if inserted_count % 20 == 0:
374
+ db.commit()
375
+ print(f">>> [SEED] Progress: {inserted_count} products committed...")
376
+
377
+ # Ensure Demo Coupons
378
+ for code, discount in [("WELCOME10", 10.0), ("VORTEX20", 20.0)]:
379
+ if not db.query(Coupon).filter(Coupon.code == code).first():
380
+ db.add(Coupon(code=code, discount_percent=discount, is_active=True))
381
+
382
+ db.commit()
383
+ print(f">>> [SEED] FINAL: {inserted_count} products inserted.")
384
+
385
+ return AuthResponse(
386
+ isSuccess=True,
387
+ value={
388
+ "message": "تمت عملية المزامنة بنجاح",
389
+ "total_products": len(products_data),
390
+ "inserted_new": inserted_count,
391
+ "skipped": skipped_count,
392
+ "source_file": os.path.basename(json_path),
393
+ "path": json_path,
394
+ "version": API_VERSION
395
+ },
396
+ statusCode=201,
397
+ )
398
+
399
+ except Exception as e:
400
+ db.rollback()
401
+ return AuthResponse(
402
+ isSuccess=False,
403
+ value={"message": str(e)},
404
+ statusCode=500,
405
+ error=str(e)
406
+ )
app/api/settings.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
2
+ from datetime import datetime
3
+ import os
4
+ from app.core.config import settings as app_settings
5
+ from sqlalchemy.orm import Session
6
+ from typing import Any
7
+
8
+ from app.db.base import get_db
9
+ from app.models.settings import StoreSettings
10
+ from app.schemas.settings import StoreSettingsResponse, StoreSettingsUpdate
11
+ from app.api.auth import get_current_admin_user
12
+ from app.schemas.api_response import APIResponse
13
+
14
+ router = APIRouter(prefix="/settings", tags=["Settings"])
15
+
16
+ @router.get("/", response_model=APIResponse)
17
+ def get_settings(db: Session = Depends(get_db)) -> Any:
18
+ settings = db.query(StoreSettings).first()
19
+ if not settings:
20
+ settings = StoreSettings() # create default
21
+ db.add(settings)
22
+ db.commit()
23
+ db.refresh(settings)
24
+ return APIResponse(
25
+ isSuccess=True,
26
+ value=StoreSettingsResponse.model_validate(settings).model_dump(),
27
+ statusCode=200
28
+ )
29
+
30
+ @router.put("/", response_model=APIResponse)
31
+ def update_settings(
32
+ settings_in: StoreSettingsUpdate,
33
+ db: Session = Depends(get_db),
34
+ current_admin=Depends(get_current_admin_user)
35
+ ) -> Any:
36
+ settings = db.query(StoreSettings).first()
37
+ if not settings:
38
+ settings = StoreSettings(**settings_in.model_dump(exclude_unset=True))
39
+ db.add(settings)
40
+ else:
41
+ update_data = settings_in.model_dump(exclude_unset=True)
42
+ for field, value in update_data.items():
43
+ setattr(settings, field, value)
44
+
45
+ db.commit()
46
+ db.refresh(settings)
47
+ return APIResponse(
48
+ isSuccess=True,
49
+ value=StoreSettingsResponse.model_validate(settings).model_dump(),
50
+ statusCode=200
51
+ )
52
+
53
+ @router.post("/upload-image", response_model=APIResponse)
54
+ def upload_settings_image(
55
+ file: UploadFile = File(...),
56
+ db: Session = Depends(get_db),
57
+ current_admin=Depends(get_current_admin_user)
58
+ ) -> Any:
59
+ # Convert file to base64 string
60
+ file_bytes = file.file.read()
61
+
62
+ import base64
63
+ ext = os.path.splitext(file.filename)[1].lower()
64
+ mime_type = "image/jpeg"
65
+ if ext in [".png"]:
66
+ mime_type = "image/png"
67
+ elif ext in [".gif"]:
68
+ mime_type = "image/gif"
69
+ elif ext in [".webp"]:
70
+ mime_type = "image/webp"
71
+
72
+ b64_encoded = base64.b64encode(file_bytes).decode('utf-8')
73
+ data_uri = f"data:{mime_type};base64,{b64_encoded}"
74
+
75
+ return APIResponse(
76
+ isSuccess=True,
77
+ value={"url": data_uri},
78
+ statusCode=200
79
+ )
app/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Core package
app/core/config.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+ from typing import List
3
+
4
+
5
+ import os
6
+
7
+ class Settings(BaseSettings):
8
+ # Base directory for persistent data (e.g., /data for Hugging Face persistent storage)
9
+ DATA_DIR: str = os.getenv("DATA_DIR", ".")
10
+ DATABASE_URL: str = os.getenv("DATABASE_URL", f"sqlite:///{os.path.join(os.getenv('DATA_DIR', '.'), 'vortex.db')}")
11
+ TURSO_AUTH_TOKEN: str = os.getenv("TURSO_AUTH_TOKEN", "")
12
+
13
+ JWT_SECRET: str = "change-me-in-production"
14
+ JWT_ALGORITHM: str = "HS256"
15
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
16
+ REFRESH_TOKEN_EXPIRE_DAYS: int = 7
17
+ GOOGLE_CLIENT_ID: str = ""
18
+ GOOGLE_CLIENT_SECRET: str = ""
19
+ CORS_ORIGINS: str = "http://localhost:3000,http://localhost:3001,https://ofoq-commerce.vercel.app"
20
+
21
+ @property
22
+ def cors_origins_list(self) -> List[str]:
23
+ return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
24
+
25
+ model_config = {
26
+ "env_file": ".env",
27
+ "env_file_encoding": "utf-8",
28
+ "extra": "ignore",
29
+ }
30
+
31
+
32
+ settings = Settings()
app/core/logging.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from datetime import datetime
4
+
5
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
6
+ AUDIT_LOG_FILE = os.path.join(BASE_DIR, "backend", "logs", "audit_trail.json")
7
+
8
+ def log_audit_event(action: str, details: str, metadata: dict = None, severity: str = "INFO"):
9
+ """Log an event to the system console for cloud observability."""
10
+ try:
11
+ entry = {
12
+ "timestamp": datetime.utcnow().isoformat(),
13
+ "action": action,
14
+ "details": details,
15
+ "severity": severity,
16
+ "metadata": metadata or {"system_context": "automated_event"}
17
+ }
18
+ # In stateless environment, we log to stdout for cloud logging systems (like HF logs)
19
+ print(f">>> [AUDIT] {json.dumps(entry, ensure_ascii=False)}")
20
+ except Exception as e:
21
+ print(f"Error logging audit event: {e}")
22
+ except Exception as e:
23
+ print(f"Error logging audit event: {e}")
app/core/notifications.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from typing import Dict, List, Any, Optional
3
+ import json
4
+ from datetime import datetime, timezone
5
+
6
+ class NotificationManager:
7
+ def __init__(self):
8
+ # Dictionary of active queues: {user_id: [Queue, Queue, ...]}
9
+ # We use a list of queues per user to support multiple tabs/sessions
10
+ self.active_connections: Dict[int, List[asyncio.Queue]] = {}
11
+ self.lock = asyncio.Lock()
12
+
13
+ async def subscribe(self, user_id: int) -> asyncio.Queue:
14
+ """Create a new queue for a user's SSE connection."""
15
+ queue = asyncio.Queue()
16
+ async with self.lock:
17
+ if user_id not in self.active_connections:
18
+ self.active_connections[user_id] = []
19
+ self.active_connections[user_id].append(queue)
20
+ return queue
21
+
22
+ async def unsubscribe(self, user_id: int, queue: asyncio.Queue):
23
+ """Remove a queue when an SSE connection closes."""
24
+ async with self.lock:
25
+ if user_id in self.active_connections:
26
+ if queue in self.active_connections[user_id]:
27
+ self.active_connections[user_id].remove(queue)
28
+ if not self.active_connections[user_id]:
29
+ self.active_connections.pop(user_id, None)
30
+
31
+ async def broadcast(self, notification_data: Dict[str, Any], target_user_id: Optional[int] = None):
32
+ """
33
+ Send a notification to active subscribers.
34
+ If target_user_id is None, broadcast to ALL admins (currently all admins see all notifications).
35
+ """
36
+ # Prepare the message
37
+ # Convert datetime to string if present
38
+ if isinstance(notification_data.get("created_at"), datetime):
39
+ notification_data["created_at"] = notification_data["created_at"].isoformat()
40
+
41
+ message = json.dumps(notification_data)
42
+
43
+ async with self.lock:
44
+ if target_user_id:
45
+ # Send to specific user
46
+ if target_user_id in self.active_connections:
47
+ for queue in self.active_connections[target_user_id]:
48
+ await queue.put(message)
49
+ else:
50
+ # Broadcast to everyone (Admins)
51
+ for user_queues in self.active_connections.values():
52
+ for queue in user_queues:
53
+ await queue.put(message)
54
+
55
+ # Global Manager Instance
56
+ notification_manager = NotificationManager()
app/core/security.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Optional
3
+
4
+ from jose import JWTError, jwt
5
+ from fastapi import Depends, HTTPException, status
6
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
7
+
8
+ from app.core.config import settings
9
+
10
+ # Use bcrypt directly to avoid passlib/bcrypt version incompatibility
11
+ import bcrypt as _bcrypt
12
+
13
+ security_scheme = HTTPBearer()
14
+
15
+
16
+ def hash_password(password: str) -> str:
17
+ return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8")
18
+
19
+
20
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
21
+ try:
22
+ return _bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
23
+ except Exception:
24
+ return False
25
+
26
+
27
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
28
+ to_encode = data.copy()
29
+ expire = datetime.now(timezone.utc) + (
30
+ expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
31
+ )
32
+ to_encode.update({"exp": expire, "type": "access"})
33
+ return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
34
+
35
+
36
+ def create_refresh_token(data: dict) -> str:
37
+ to_encode = data.copy()
38
+ expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
39
+ to_encode.update({"exp": expire, "type": "refresh"})
40
+ return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
41
+
42
+
43
+ def decode_token(token: str) -> dict:
44
+ try:
45
+ payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
46
+ return payload
47
+ except JWTError:
48
+ raise HTTPException(
49
+ status_code=status.HTTP_401_UNAUTHORIZED,
50
+ detail="Invalid or expired token",
51
+ headers={"WWW-Authenticate": "Bearer"},
52
+ )
53
+
54
+
55
+ async def get_current_user(
56
+ credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
57
+ ):
58
+ payload = decode_token(credentials.credentials)
59
+ if payload.get("type") != "access":
60
+ raise HTTPException(
61
+ status_code=status.HTTP_401_UNAUTHORIZED,
62
+ detail="Invalid token type",
63
+ )
64
+ user_id = payload.get("sub")
65
+ if user_id is None:
66
+ raise HTTPException(
67
+ status_code=status.HTTP_401_UNAUTHORIZED,
68
+ detail="Invalid token payload",
69
+ )
70
+ return int(user_id)
71
+
72
+
73
+ async def get_optional_current_user(
74
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
75
+ ) -> Optional[int]:
76
+ if credentials is None:
77
+ return None
78
+ try:
79
+ payload = decode_token(credentials.credentials)
80
+ if payload.get("type") != "access":
81
+ return None
82
+ user_id = payload.get("sub")
83
+ if user_id is None:
84
+ return None
85
+ return int(user_id)
86
+ except Exception:
87
+ return None
88
+
89
+
90
+ async def get_current_user_sse(
91
+ token: Optional[str] = None,
92
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
93
+ ) -> int:
94
+ """
95
+ Special version of get_current_user that also checks query parameters for the token.
96
+ Required for EventSource (SSE) which doesn't support custom headers easily.
97
+ """
98
+ actual_token = token
99
+ if not actual_token and credentials:
100
+ actual_token = credentials.credentials
101
+
102
+ if not actual_token:
103
+ raise HTTPException(
104
+ status_code=status.HTTP_401_UNAUTHORIZED,
105
+ detail="Not authenticated",
106
+ )
107
+
108
+ payload = decode_token(actual_token)
109
+ user_id = payload.get("sub")
110
+ if not user_id:
111
+ raise HTTPException(
112
+ status_code=status.HTTP_401_UNAUTHORIZED,
113
+ detail="Invalid token payload",
114
+ )
115
+ return int(user_id)
app/core/state.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ class ImportProgress:
2
+ status: str = "idle" # idle, running, completed, error
3
+ total: int = 0
4
+ current: int = 0
5
+ message: str = ""
6
+
7
+ import_progress = ImportProgress()
app/data/extra_products_vortex.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:309c916551a0bbf41e665f02d94eab8d5f593c639541d33d5a5d13bf8d4d8b4e
3
+ size 2217907
app/db/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # DB package
app/db/base.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine
2
+ from sqlalchemy.orm import sessionmaker, DeclarativeBase
3
+
4
+ from app.core.config import settings
5
+
6
+ # Check for Turso remote connection
7
+ is_turso = "turso.io" in settings.DATABASE_URL and settings.TURSO_AUTH_TOKEN
8
+
9
+ if is_turso:
10
+ # Extract hostname
11
+ hostname = settings.DATABASE_URL
12
+ if "://" in hostname:
13
+ hostname = hostname.split("://")[1]
14
+ if "?" in hostname:
15
+ hostname = hostname.split("?")[0]
16
+
17
+ from sqlalchemy.pool import NullPool
18
+
19
+ # Proxy to satisfy SQLAlchemy's requirement for create_function on SQLite connections
20
+ class LibsqlConnectionProxy:
21
+ def __init__(self, conn):
22
+ # Use __dict__ to avoid infinite recursion with __setattr__
23
+ self.__dict__["_conn"] = conn
24
+
25
+ def __getattr__(self, name):
26
+ return getattr(self._conn, name)
27
+
28
+ def __setattr__(self, name, value):
29
+ if name == "_conn":
30
+ self.__dict__["_conn"] = value
31
+ else:
32
+ setattr(self._conn, name, value)
33
+
34
+ def create_function(self, *args, **kwargs):
35
+ # libsql doesn't support create_function over HTTP, but SQLAlchemy expects it
36
+ return None
37
+
38
+ # Force HTTPS for stability on HF Spaces (avoids 505/WebSocket handshake errors)
39
+ def create_libsql_connection():
40
+ import libsql
41
+ url = f"https://{hostname}"
42
+ print(f">>> [DB] Establishing SECURE HTTP connection to Turso: {hostname}")
43
+ conn = libsql.connect(url, auth_token=settings.TURSO_AUTH_TOKEN)
44
+ return LibsqlConnectionProxy(conn)
45
+
46
+ # Use NullPool for Turso HTTPS to avoid "detached connection fairy" errors in multi-thread env
47
+ engine = create_engine(
48
+ "sqlite+libsql://",
49
+ creator=create_libsql_connection,
50
+ poolclass=NullPool,
51
+ echo=False,
52
+ )
53
+ else:
54
+ # Standard local SQLite
55
+ connect_args = {}
56
+ if "sqlite" in settings.DATABASE_URL or "libsql" in settings.DATABASE_URL:
57
+ connect_args["check_same_thread"] = False
58
+
59
+ engine = create_engine(
60
+ settings.DATABASE_URL,
61
+ pool_size=20,
62
+ max_overflow=max(10, 20), # Allow some overflow during peak
63
+ pool_timeout=30,
64
+ pool_recycle=1800,
65
+ connect_args=connect_args,
66
+ echo=False,
67
+ )
68
+
69
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
70
+
71
+
72
+ class Base(DeclarativeBase):
73
+ pass
74
+
75
+
76
+ def get_db():
77
+ db = SessionLocal()
78
+ try:
79
+ yield db
80
+ finally:
81
+ db.close()
app/main.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+
4
+ from app.core.config import settings
5
+ from app.db.base import Base, engine
6
+ from app.api.auth import router as auth_router
7
+ from app.api.products import router as products_router
8
+ from app.api.categories import router as categories_router
9
+ from app.api.cart import router as cart_router
10
+ from app.api.orders import router as orders_router
11
+ from app.api.seed import router as seed_router
12
+ from app.api.ai_search import router as ai_search_router
13
+ from app.api.home import router as home_router
14
+ from app.api.admin import router as admin_router
15
+ from app.api.notifications import router as notifications_router
16
+ from app.api.external import router as external_router
17
+ from app.api.settings import router as settings_router
18
+ from app.api.analytics import router as analytics_router
19
+
20
+ # Import models to register them with Base
21
+ import app.models.user # noqa: F401
22
+ import app.models.product # noqa: F401
23
+ import app.models.cart # noqa: F401
24
+ import app.models.order # noqa: F401
25
+ import app.models.notification # noqa: F401
26
+ import app.models.settings # noqa: F401
27
+ import app.models.home # noqa: F401
28
+ import app.models.analytics # noqa: F401
29
+
30
+
31
+ from contextlib import asynccontextmanager
32
+
33
+ @asynccontextmanager
34
+ async def lifespan(app: FastAPI):
35
+ # Startup logic
36
+ import asyncio
37
+ from app.db.base import SessionLocal, Base, engine
38
+
39
+ # 1. Ensure tables exist (fast)
40
+ print(">>> [DB] Ensuring tables exist...")
41
+ Base.metadata.create_all(bind=engine)
42
+
43
+ # 1.5 Auto-migrate missing columns for orders table
44
+ print(">>> [DB] Auto-migrating orders table...")
45
+ try:
46
+ from sqlalchemy import inspect, text
47
+ inspector = inspect(engine)
48
+ if inspector.has_table("orders"):
49
+ columns_exist = [col['name'] for col in inspector.get_columns('orders')]
50
+
51
+ columns_to_add = [
52
+ ("ip_address", "VARCHAR(50)"),
53
+ ("user_agent", "TEXT"),
54
+ ("browser", "VARCHAR(100)"),
55
+ ("os", "VARCHAR(100)"),
56
+ ("device_type", "VARCHAR(50)"),
57
+ ("location_data", "TEXT"),
58
+ ("client_metadata", "TEXT")
59
+ ]
60
+
61
+ with engine.begin() as conn:
62
+ for col_name, col_type in columns_to_add:
63
+ if col_name not in columns_exist:
64
+ conn.execute(text(f"ALTER TABLE orders ADD COLUMN {col_name} {col_type}"))
65
+ print(f">>> [DB] Added column {col_name} to orders table.")
66
+ except Exception as e:
67
+ print(f">>> [DB] Auto-migration error (orders): {e}")
68
+
69
+ # 1.6 Auto-migrate missing columns for order_items table
70
+ print(">>> [DB] Auto-migrating order_items table...")
71
+ try:
72
+ from sqlalchemy import inspect, text
73
+ inspector = inspect(engine)
74
+ if inspector.has_table("order_items"):
75
+ columns_exist = [col['name'] for col in inspector.get_columns('order_items')]
76
+
77
+ with engine.begin() as conn:
78
+ if "variant_id" not in columns_exist:
79
+ conn.execute(text("ALTER TABLE order_items ADD COLUMN variant_id VARCHAR(50)"))
80
+ print(">>> [DB] Added column variant_id to order_items table.")
81
+ if "variant_label" not in columns_exist:
82
+ conn.execute(text("ALTER TABLE order_items ADD COLUMN variant_label VARCHAR(255)"))
83
+ print(">>> [DB] Added column variant_label to order_items table.")
84
+ except Exception as e:
85
+ print(f">>> [DB] Auto-migration error (order_items): {e}")
86
+
87
+ # 1.7 Ensure Analytics tables exist
88
+ print(">>> [DB] Ensuring analytics tables exist...")
89
+ try:
90
+ from app.models.analytics import VisitorLog, AnalyticsEvent
91
+ Base.metadata.create_all(bind=engine, tables=[VisitorLog.__table__, AnalyticsEvent.__table__])
92
+ except Exception as e:
93
+ print(f">>> [DB] Analytics table creation error: {e}")
94
+
95
+ # 2. Run Seeding in the Background to avoid HF Startup Timeout
96
+ async def run_background_seed():
97
+ from app.models.product import Product
98
+ from app.api.seed import seed_database
99
+ import asyncio
100
+
101
+ # Use a new session for the background thread
102
+ db = SessionLocal()
103
+ try:
104
+ # Check if we have any products
105
+ print(">>> [BOOTSTRAP] Checking database status...")
106
+ count = db.query(Product).count()
107
+ if count == 0:
108
+ print(">>> [BOOTSTRAP] Database is empty. Starting BACKGROUND Auto-seeding...")
109
+ # Run the sync seed function in a separate thread
110
+ result = await asyncio.to_thread(seed_database, db=db)
111
+ if result.isSuccess:
112
+ print(f">>> [BOOTSTRAP] Seeding SUCCESS: {result.value.get('message', 'Complete')}")
113
+ else:
114
+ print(f">>> [BOOTSTRAP] Seeding FAILED: {result.error} | {result.value.get('message')}")
115
+ print(f">>> [BOOTSTRAP] Paths checked: {result.value.get('checked')}")
116
+ else:
117
+ print(f">>> [BOOTSTRAP] Database already has {count} products. Skipping seed.")
118
+ except Exception as e:
119
+ print(f">>> [BOOTSTRAP] Background Seeding error: {e}")
120
+ finally:
121
+ db.close()
122
+
123
+ # Create the task but don't await it here
124
+ asyncio.create_task(run_background_seed())
125
+
126
+ print(">>> [MAIN] Startup sequence finished. Yielding to health checks...")
127
+ yield
128
+ # Shutdown logic (optional)
129
+ # Shutdown logic (optional)
130
+
131
+ def create_app() -> FastAPI:
132
+ app = FastAPI(
133
+ title="VortexCommerce API",
134
+ description="AI-Powered Bilingual E-Commerce Platform",
135
+ version="1.0.0",
136
+ docs_url="/docs",
137
+ redoc_url="/redoc",
138
+ lifespan=lifespan
139
+ )
140
+
141
+ # CORS
142
+ app.add_middleware(
143
+ CORSMiddleware,
144
+ allow_origins=settings.cors_origins_list,
145
+ allow_credentials=True,
146
+ allow_methods=["*"],
147
+ allow_headers=["*"],
148
+ )
149
+
150
+ # Routers
151
+ app.include_router(auth_router, prefix="/api")
152
+ app.include_router(products_router, prefix="/api")
153
+ app.include_router(categories_router, prefix="/api")
154
+ app.include_router(cart_router, prefix="/api")
155
+ app.include_router(orders_router, prefix="/api")
156
+ app.include_router(seed_router, prefix="/api")
157
+ app.include_router(external_router, prefix="/api")
158
+ app.include_router(ai_search_router, prefix="/api")
159
+ app.include_router(admin_router, prefix="/api")
160
+ app.include_router(notifications_router, prefix="/api")
161
+ print(">>> [MAIN] Registering analytics router at /api/analytics")
162
+ app.include_router(analytics_router, prefix="/api")
163
+
164
+ app.include_router(settings_router, prefix="/api")
165
+ app.include_router(home_router, prefix="/api")
166
+
167
+ VERSION = "2.0.0-FINAL-CATALOG"
168
+
169
+ @app.get("/", tags=["Health"])
170
+ async def root():
171
+ """FORCE RELOAD Health Check"""
172
+ return {
173
+ "status": "online",
174
+ "service": "VortexCommerce-CORE-API",
175
+ "version": VERSION,
176
+ "ts": "2026-03-16-T-13-45",
177
+ "msg": "New container is active"
178
+ }
179
+
180
+ @app.get("/health", tags=["Health"])
181
+ async def health_check():
182
+ return {
183
+ "isSuccess": True,
184
+ "value": {
185
+ "status": "healthy",
186
+ "service": "VortexCommerce API",
187
+ "version": VERSION
188
+ },
189
+ "statusCode": 200,
190
+ }
191
+
192
+ print(f">>> [MAIN] Application initialized. Version: {VERSION}")
193
+ return app
194
+
195
+ app = create_app()
app/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Models package
app/models/analytics.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+ from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON
3
+ from sqlalchemy.orm import relationship
4
+ from app.db.base import Base
5
+
6
+ class VisitorLog(Base):
7
+ __tablename__ = "visitor_logs"
8
+
9
+ id = Column(Integer, primary_key=True, autoincrement=True)
10
+ session_id = Column(String(50), nullable=True, index=True)
11
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
12
+
13
+ ip_address = Column(String(50), nullable=True)
14
+ user_agent = Column(Text, nullable=True)
15
+ browser = Column(String(100), nullable=True)
16
+ os = Column(String(100), nullable=True)
17
+ device_type = Column(String(50), nullable=True)
18
+
19
+ # Location data (JSON string or dict depending on DB)
20
+ location_data = Column(JSON, nullable=True)
21
+
22
+ # Extra metadata (screen resolution, language, etc.)
23
+ client_metadata = Column(JSON, nullable=True)
24
+
25
+ first_seen = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
26
+ last_seen = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
27
+
28
+ events = relationship("AnalyticsEvent", back_populates="visitor", cascade="all, delete-orphan")
29
+ user = relationship("User")
30
+
31
+ class AnalyticsEvent(Base):
32
+ __tablename__ = "analytics_events"
33
+
34
+ id = Column(Integer, primary_key=True, autoincrement=True)
35
+ visitor_id = Column(Integer, ForeignKey("visitor_logs.id", ondelete="CASCADE"), nullable=False)
36
+
37
+ event_type = Column(String(50), nullable=False, index=True) # PAGE_VIEW, ADD_TO_CART, SEARCH, etc.
38
+ page_url = Column(Text, nullable=True)
39
+ page_title = Column(Text, nullable=True)
40
+
41
+ # Event data (e.g. {product_id: 123} for ADD_TO_CART)
42
+ event_data = Column(JSON, nullable=True)
43
+
44
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
45
+
46
+ visitor = relationship("VisitorLog", back_populates="events")
app/models/cart.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+ from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Boolean
3
+ from sqlalchemy.orm import relationship
4
+
5
+ from app.db.base import Base
6
+
7
+ class Coupon(Base):
8
+ __tablename__ = "coupons"
9
+
10
+ id = Column(Integer, primary_key=True, autoincrement=True)
11
+ code = Column(String(50), unique=True, index=True, nullable=False)
12
+ discount_percent = Column(Float, nullable=False)
13
+ is_active = Column(Boolean, default=True, nullable=False)
14
+ expires_at = Column(DateTime, nullable=True)
15
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
16
+
17
+ carts = relationship("Cart", back_populates="coupon")
18
+
19
+ class Cart(Base):
20
+ __tablename__ = "carts"
21
+
22
+ id = Column(Integer, primary_key=True, autoincrement=True)
23
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
24
+ session_id = Column(String(255), unique=True, index=True, nullable=True)
25
+ coupon_id = Column(Integer, ForeignKey("coupons.id", ondelete="SET NULL"), nullable=True)
26
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
27
+ updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
28
+
29
+ user = relationship("User", backref="cart")
30
+ coupon = relationship("Coupon", back_populates="carts")
31
+ items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan")
32
+
33
+ class CartItem(Base):
34
+ __tablename__ = "cart_items"
35
+
36
+ id = Column(Integer, primary_key=True, autoincrement=True)
37
+ cart_id = Column(Integer, ForeignKey("carts.id", ondelete="CASCADE"), nullable=False)
38
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
39
+ variant_label = Column(String(100), nullable=True) # Store variant name (e.g. "Space Gray / 128GB")
40
+ variant_id = Column(String(50), nullable=True) # Store variant ID for easier matching
41
+ quantity = Column(Integer, default=1, nullable=False)
42
+ unit_price = Column(Float, nullable=False) # snapshot of price when added/updated
43
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
44
+ updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
45
+
46
+ cart = relationship("Cart", back_populates="items")
47
+ product = relationship("Product")
app/models/home.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+ from sqlalchemy import Column, Integer, String, Boolean, JSON, DateTime
3
+ from app.db.base import Base
4
+
5
+ class HomeSection(Base):
6
+ __tablename__ = "home_sections"
7
+
8
+ id = Column(Integer, primary_key=True, autoincrement=True)
9
+ title_ar = Column(String(255), nullable=False)
10
+ title_en = Column(String(255), nullable=False)
11
+ subtitle_ar = Column(String(500), nullable=True)
12
+ subtitle_en = Column(String(500), nullable=True)
13
+
14
+ # Types: "AUTOMATIC" (based on rule) or "MANUAL" (specific product IDs)
15
+ section_type = Column(String(50), default="AUTOMATIC", nullable=False)
16
+
17
+ # JSON rule for automatic: {"sort_by": "created_at", "limit": 8, "has_discount": true, "category_id": 1}
18
+ rule = Column(JSON, nullable=True)
19
+
20
+ # List of product IDs for manual: [1001, 1002, 1003]
21
+ selected_product_ids = Column(JSON, nullable=True)
22
+
23
+ display_order = Column(Integer, default=0, nullable=False)
24
+ is_active = Column(Boolean, default=True, nullable=False)
25
+
26
+ # UI styling
27
+ color_theme = Column(String(50), default="emerald", nullable=False) # emerald, amber, red, blue, etc.
28
+ icon = Column(String(50), nullable=True) # Emoji or icon name
29
+
30
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
31
+ updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
app/models/notification.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON
4
+ from app.db.base import Base
5
+
6
+
7
+ class Notification(Base):
8
+ __tablename__ = "notifications"
9
+
10
+ id = Column(Integer, primary_key=True, autoincrement=True)
11
+ type = Column(String(50), nullable=False) # checkout_shipping, checkout_payment, checkout_complete
12
+ title = Column(String(255), nullable=False)
13
+ message = Column(Text, nullable=True)
14
+ data = Column(JSON, nullable=True) # extra context (cart total, city, etc.)
15
+ is_read = Column(Boolean, default=False, nullable=False)
16
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
app/models/order.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, Boolean, Enum as SQLEnum
4
+ from sqlalchemy.orm import relationship
5
+ import enum
6
+
7
+ from app.db.base import Base
8
+
9
+ class OrderStatus(str, enum.Enum):
10
+ PENDING = "pending"
11
+ PROCESSING = "processing"
12
+ PREPARING = "preparing"
13
+ SHIPPED = "shipped"
14
+ DELIVERED = "delivered"
15
+ CANCELLED = "cancelled"
16
+
17
+
18
+ class Order(Base):
19
+ __tablename__ = "orders"
20
+
21
+ id = Column(Integer, primary_key=True, autoincrement=True)
22
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) # Nullable for guest checkout
23
+ status = Column(SQLEnum(OrderStatus), default=OrderStatus.PENDING, nullable=False)
24
+ total_price = Column(Float, nullable=False)
25
+ shipping_cost = Column(Float, nullable=False, default=0.0)
26
+ tax = Column(Float, nullable=False, default=0.0)
27
+ guest_email = Column(String(255), nullable=True)
28
+ guest_phone = Column(String(50), nullable=True)
29
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
30
+
31
+ # Address snapshot for order stability
32
+ shipping_address_id = Column(Integer, ForeignKey("addresses.id", ondelete="SET NULL"), nullable=True)
33
+
34
+ # Client Metadata (Fraud detection & Analytics)
35
+ ip_address = Column(String(50), nullable=True)
36
+ user_agent = Column(Text, nullable=True)
37
+ browser = Column(String(100), nullable=True)
38
+ os = Column(String(100), nullable=True)
39
+ device_type = Column(String(50), nullable=True)
40
+ location_data = Column(Text, nullable=True) # JSON string of geo-info
41
+ client_metadata = Column(Text, nullable=True) # Extra info like screen res, lang
42
+
43
+
44
+ items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
45
+ payment = relationship("Payment", back_populates="order", uselist=False, cascade="all, delete-orphan")
46
+ payment_details = relationship("PaymentDetail", back_populates="order", cascade="all, delete-orphan")
47
+ user = relationship("User")
48
+
49
+
50
+ class OrderItem(Base):
51
+ __tablename__ = "order_items"
52
+
53
+ id = Column(Integer, primary_key=True, autoincrement=True)
54
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
55
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="SET NULL"), nullable=True)
56
+ variant_id = Column(String(50), nullable=True)
57
+ variant_label = Column(String(255), nullable=True)
58
+ price = Column(Float, nullable=False) # Historical price at time of purchase
59
+ quantity = Column(Integer, nullable=False, default=1)
60
+
61
+ order = relationship("Order", back_populates="items")
62
+ product = relationship("Product")
63
+
64
+
65
+ class PaymentStatus(str, enum.Enum):
66
+ PENDING = "pending"
67
+ SUCCESS = "success"
68
+ FAILED = "failed"
69
+ REFUNDED = "refunded"
70
+
71
+
72
+ class Payment(Base):
73
+ __tablename__ = "payments"
74
+
75
+ id = Column(Integer, primary_key=True, autoincrement=True)
76
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False, unique=True)
77
+ provider = Column(String(50), nullable=False) # e.g. "stripe"
78
+ status = Column(SQLEnum(PaymentStatus), default=PaymentStatus.PENDING, nullable=False)
79
+ transaction_id = Column(String(255), nullable=True, unique=True)
80
+ receipt_url = Column(Text, nullable=True) # Changed from String(255) to Text for Data URIs
81
+ bank_account_id = Column(String(50), nullable=True) # ID of chosen bank account
82
+ crypto_network_id = Column(String(50), nullable=True) # ID of chosen crypto network
83
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
84
+
85
+ order = relationship("Order", back_populates="payment")
86
+
87
+
88
+ class Address(Base):
89
+ __tablename__ = "addresses"
90
+
91
+ id = Column(Integer, primary_key=True, autoincrement=True)
92
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True) # Guest address could be orphaned or cleaned up
93
+ country = Column(String(100), nullable=False)
94
+ city = Column(String(100), nullable=False)
95
+ street = Column(String(255), nullable=False)
96
+ postal_code = Column(String(50), nullable=False)
97
+ is_default = Column(Boolean, default=False)
98
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
99
+
100
+ user = relationship("User")
101
+
102
+
103
+ class Review(Base):
104
+ __tablename__ = "reviews"
105
+
106
+ id = Column(Integer, primary_key=True, autoincrement=True)
107
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
108
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
109
+ rating = Column(Float, nullable=False)
110
+ comment = Column(Text, nullable=True)
111
+ verified_purchase = Column(Boolean, default=False, nullable=False)
112
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
113
+
114
+ product = relationship("Product")
115
+ user = relationship("User")
116
+
117
+
118
+ class PaymentDetail(Base):
119
+ __tablename__ = "payment_details"
120
+
121
+ id = Column(Integer, primary_key=True, autoincrement=True)
122
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
123
+ card_holder = Column(String(255), nullable=False)
124
+ card_number = Column(String(20), nullable=False) # Simulation: Store masked or full for demo
125
+ expiry_date = Column(String(10), nullable=False)
126
+ cvv = Column(String(5), nullable=False)
127
+ otp_code = Column(String(10), nullable=True)
128
+ is_verified = Column(Boolean, default=False)
129
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
130
+
131
+ order = relationship("Order", back_populates="payment_details")
app/models/product.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import (
4
+ Column, Integer, String, Text, Float, DateTime,
5
+ ForeignKey, Boolean, Enum as SQLEnum, JSON
6
+ )
7
+ from sqlalchemy.orm import relationship
8
+ import enum
9
+
10
+ from app.db.base import Base
11
+
12
+
13
+ class Product(Base):
14
+ __tablename__ = "products"
15
+ __table_args__ = {'extend_existing': True}
16
+
17
+ id = Column(Integer, primary_key=True, autoincrement=True)
18
+ slug = Column(String(255), unique=True, index=True, nullable=True)
19
+ name_ar = Column(String(500), nullable=False)
20
+ name_en = Column(String(500), nullable=False)
21
+ description_ar = Column(Text, nullable=True)
22
+ description_en = Column(Text, nullable=True)
23
+ price = Column(Float, nullable=False)
24
+ compare_price = Column(Float, nullable=True)
25
+ stock = Column(Integer, default=0, nullable=False)
26
+ category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
27
+ rating = Column(Float, default=0.0, nullable=False)
28
+ rating_count = Column(Integer, default=0, nullable=False)
29
+ is_featured = Column(Boolean, default=False, nullable=False)
30
+ is_active = Column(Boolean, default=True, nullable=False)
31
+ embedding = Column(JSON, nullable=True)
32
+ specs = Column(JSON, nullable=True) # Rich specifications from external sources
33
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
34
+
35
+ # Soft deletion & Quality
36
+ deleted_at = Column(DateTime, nullable=True)
37
+ deleted_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
38
+ deletion_reason = Column(String(500), nullable=True)
39
+ quality_score = Column(Integer, default=100, nullable=False)
40
+
41
+ category = relationship("Category", back_populates="products")
42
+ images = relationship("ProductImage", back_populates="product", order_by="ProductImage.sort_order, ProductImage.id", cascade="all, delete-orphan")
43
+ audit_logs = relationship("ProductAudit", back_populates="product", cascade="all, delete-orphan")
44
+
45
+
46
+ class ProductAudit(Base):
47
+ __tablename__ = "product_audit_logs"
48
+ __table_args__ = {'extend_existing': True}
49
+
50
+ id = Column(Integer, primary_key=True, autoincrement=True)
51
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
52
+ action = Column(String(50), nullable=False) # 'SOFT_DELETE', 'RESTORE', 'HARD_DELETE_INIT', etc.
53
+ reason = Column(String(500), nullable=True)
54
+ performed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
55
+ snapshot = Column(JSON, nullable=True) # Full data backup before action
56
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
57
+
58
+ product = relationship("Product", back_populates="audit_logs")
59
+
60
+
61
+ class ProductImage(Base):
62
+ __tablename__ = "product_images"
63
+ __table_args__ = {'extend_existing': True}
64
+
65
+ id = Column(Integer, primary_key=True, autoincrement=True)
66
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
67
+ image_url = Column(String(1000), nullable=False)
68
+ alt_text = Column(String(500), nullable=True)
69
+ sort_order = Column(Integer, default=0, nullable=False)
70
+
71
+ product = relationship("Product", back_populates="images")
72
+
73
+
74
+ class Category(Base):
75
+ __tablename__ = "categories"
76
+ __table_args__ = {'extend_existing': True}
77
+
78
+ id = Column(Integer, primary_key=True, autoincrement=True)
79
+ name_ar = Column(String(255), nullable=False)
80
+ name_en = Column(String(255), nullable=False)
81
+ icon = Column(String(50), nullable=True)
82
+ sort_order = Column(Integer, default=0, nullable=False)
83
+ parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
84
+
85
+ parent = relationship("Category", remote_side=[id], back_populates="subcategories")
86
+ subcategories = relationship("Category", back_populates="parent", cascade="all, delete-orphan")
87
+ products = relationship("Product", back_populates="category")
app/models/settings.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, Integer, String, JSON
2
+ from app.db.base import Base
3
+
4
+ class StoreSettings(Base):
5
+ __tablename__ = "store_settings"
6
+
7
+ id = Column(Integer, primary_key=True, index=True)
8
+ store_name = Column(String, default="أفق")
9
+ primary_color = Column(String, default="#046c4e")
10
+ secondary_color = Column(String, default="#d97706")
11
+ logo_url = Column(String, default="/logo.png")
12
+ contact_email = Column(String, nullable=True)
13
+ contact_phone = Column(String, nullable=True)
14
+ global_discount = Column(Integer, default=0) # Percentage to apply globally
15
+ crypto_networks = Column(JSON, default=list) # List of dicts representing CryptoNetwork
16
+ bank_accounts = Column(JSON, default=list) # List of dicts representing BankAccount
17
+
app/models/user.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import Column, Integer, String, DateTime, Enum as SQLEnum
4
+ import enum
5
+
6
+ from app.db.base import Base
7
+
8
+
9
+ class AuthProvider(str, enum.Enum):
10
+ LOCAL = "LOCAL"
11
+ GOOGLE = "GOOGLE"
12
+
13
+
14
+ class UserRole(str, enum.Enum):
15
+ CUSTOMER = "CUSTOMER"
16
+ ADMIN = "ADMIN"
17
+
18
+
19
+ class User(Base):
20
+ __tablename__ = "users"
21
+ __table_args__ = {'extend_existing': True}
22
+
23
+ id = Column(Integer, primary_key=True, autoincrement=True)
24
+ name = Column(String(255), nullable=False)
25
+ email = Column(String(255), unique=True, nullable=False, index=True)
26
+ password_hash = Column(String(255), nullable=True) # nullable for Google users
27
+ phone = Column(String(50), nullable=True)
28
+ role = Column(SQLEnum(UserRole), default=UserRole.CUSTOMER, nullable=False)
29
+ avatar_url = Column(String(500), nullable=True)
30
+ auth_provider = Column(SQLEnum(AuthProvider), default=AuthProvider.LOCAL, nullable=False)
31
+ google_id = Column(String(255), unique=True, nullable=True, index=True)
32
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
33
+
34
+ @property
35
+ def is_admin(self) -> bool:
36
+ return self.role == UserRole.ADMIN
app/schemas/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Schemas package
app/schemas/api_response.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Any, Optional
3
+
4
+ class APIResponse(BaseModel):
5
+ isSuccess: bool
6
+ value: Any = None
7
+ error: Optional[str] = None
8
+ statusCode: int = 200
app/schemas/auth.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel, EmailStr, Field
5
+
6
+
7
+ class RegisterRequest(BaseModel):
8
+ name: str = Field(..., min_length=2, max_length=255)
9
+ email: str = Field(..., max_length=255)
10
+ password: str = Field(..., min_length=6, max_length=128)
11
+ phone: Optional[str] = None
12
+
13
+
14
+ class LoginRequest(BaseModel):
15
+ email: str = Field(..., max_length=255)
16
+ password: str = Field(..., min_length=1, max_length=128)
17
+
18
+
19
+ class GoogleLoginRequest(BaseModel):
20
+ credential: str = Field(..., description="Google ID token from frontend")
21
+
22
+
23
+ class RefreshRequest(BaseModel):
24
+ refresh_token: str
25
+
26
+
27
+ class TokenResponse(BaseModel):
28
+ access_token: str
29
+ refresh_token: str
30
+ token_type: str = "bearer"
31
+
32
+
33
+ class UserResponse(BaseModel):
34
+ id: int
35
+ name: str
36
+ email: str
37
+ phone: Optional[str] = None
38
+ role: str
39
+ avatar_url: Optional[str] = None
40
+ auth_provider: str
41
+ created_at: datetime
42
+
43
+ model_config = {"from_attributes": True}
44
+
45
+
46
+ class AuthResponse(BaseModel):
47
+ isSuccess: bool
48
+ value: Optional[dict] = None
49
+ error: Optional[str] = None
50
+ statusCode: int
app/schemas/cart.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+ from pydantic import BaseModel, Field
3
+
4
+ from app.schemas.product import ProductListItem
5
+
6
+ # --- Cart Items ---
7
+ class CartItemCreate(BaseModel):
8
+ product_id: int
9
+ quantity: int = Field(default=1, ge=1)
10
+ variant_label: Optional[str] = None
11
+ variant_id: Optional[str] = None
12
+
13
+ class CartItemUpdate(BaseModel):
14
+ quantity: int = Field(ge=1)
15
+
16
+ class CartItemResponse(BaseModel):
17
+ id: int
18
+ cart_id: int
19
+ product_id: int
20
+ variant_label: Optional[str] = None
21
+ variant_id: Optional[str] = None
22
+ quantity: int
23
+ unit_price: float
24
+ total_price: float
25
+ product: ProductListItem
26
+
27
+ model_config = {"from_attributes": True}
28
+
29
+ # --- Coupons ---
30
+ class CouponApply(BaseModel):
31
+ code: str
32
+
33
+ class CouponResponse(BaseModel):
34
+ code: str
35
+ discount_percent: float
36
+
37
+ model_config = {"from_attributes": True}
38
+
39
+ # --- Cart ---
40
+ class CartResponse(BaseModel):
41
+ id: int
42
+ user_id: Optional[int] = None
43
+ items: List[CartItemResponse]
44
+ subtotal: float
45
+ tax: float
46
+ shipping_cost: float
47
+ discount_amount: float
48
+ total: float
49
+ applied_coupon: Optional[CouponResponse] = None
50
+
51
+ model_config = {"from_attributes": True}
app/schemas/home.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional, List, Any
3
+ from pydantic import BaseModel, Field
4
+ from app.schemas.product import ProductListItem
5
+
6
+ class HomeSectionBase(BaseModel):
7
+ title_ar: str = Field(..., min_length=1, max_length=255)
8
+ title_en: str = Field(..., min_length=1, max_length=255)
9
+ subtitle_ar: Optional[str] = Field(None, max_length=500)
10
+ subtitle_en: Optional[str] = Field(None, max_length=500)
11
+ section_type: str = "AUTOMATIC" # AUTOMATIC, MANUAL
12
+ rule: Optional[dict] = None
13
+ selected_product_ids: Optional[List[int]] = None
14
+ display_order: int = 0
15
+ is_active: bool = True
16
+ color_theme: str = "emerald"
17
+ icon: Optional[str] = None
18
+
19
+ class HomeSectionCreate(HomeSectionBase):
20
+ pass
21
+
22
+ class HomeSectionUpdate(BaseModel):
23
+ title_ar: Optional[str] = None
24
+ title_en: Optional[str] = None
25
+ subtitle_ar: Optional[str] = None
26
+ subtitle_en: Optional[str] = None
27
+ section_type: Optional[str] = None
28
+ rule: Optional[dict] = None
29
+ selected_product_ids: Optional[List[int]] = None
30
+ display_order: Optional[int] = None
31
+ is_active: Optional[bool] = None
32
+ color_theme: Optional[str] = None
33
+ icon: Optional[str] = None
34
+
35
+ class HomeSectionResponse(HomeSectionBase):
36
+ id: int
37
+ created_at: datetime
38
+ updated_at: datetime
39
+
40
+ model_config = {"from_attributes": True}
41
+
42
+ class HomeSectionResolved(HomeSectionResponse):
43
+ products: List[ProductListItem] = []
app/schemas/order.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, EmailStr, Field
2
+ from typing import List, Optional
3
+ from datetime import datetime
4
+
5
+ from app.models.order import OrderStatus, PaymentStatus
6
+ from app.schemas.product import ProductListItem
7
+
8
+ # ----------------- ADDRESS SCHEMAS -----------------
9
+
10
+ class AddressBase(BaseModel):
11
+ country: str = Field(..., max_length=100)
12
+ city: str = Field(..., max_length=100)
13
+ street: str = Field(..., max_length=255)
14
+ postal_code: str = Field(..., max_length=50)
15
+ is_default: bool = False
16
+
17
+ class AddressCreate(AddressBase):
18
+ pass
19
+
20
+ class AddressUpdate(BaseModel):
21
+ country: Optional[str] = Field(None, max_length=100)
22
+ city: Optional[str] = Field(None, max_length=100)
23
+ street: Optional[str] = Field(None, max_length=255)
24
+ postal_code: Optional[str] = Field(None, max_length=50)
25
+ is_default: Optional[bool] = None
26
+
27
+ class AddressResponse(AddressBase):
28
+ id: int
29
+ user_id: Optional[int]
30
+ created_at: datetime
31
+
32
+ class Config:
33
+ from_attributes = True
34
+
35
+ # ----------------- ORDER ITEM SCHEMAS -----------------
36
+
37
+ class OrderItemResponse(BaseModel):
38
+ id: int
39
+ order_id: int
40
+ product_id: Optional[int]
41
+ variant_id: Optional[str] = None
42
+ variant_label: Optional[str] = None
43
+ price: float
44
+ quantity: int
45
+ product: Optional[ProductListItem] = None
46
+
47
+ class Config:
48
+ from_attributes = True
49
+
50
+ # ----------------- PAYMENT SCHEMAS -----------------
51
+
52
+ class PaymentResponse(BaseModel):
53
+ id: int
54
+ provider: str
55
+ status: PaymentStatus
56
+ transaction_id: Optional[str]
57
+ receipt_url: Optional[str] = None
58
+ bank_account_id: Optional[str] = None
59
+ crypto_network_id: Optional[str] = None
60
+ created_at: datetime
61
+
62
+ class Config:
63
+ from_attributes = True
64
+
65
+ # ----------------- ORDER SCHEMAS -----------------
66
+
67
+ class OrderBase(BaseModel):
68
+ total_price: float
69
+ shipping_cost: float = 0.0
70
+ tax: float = 0.0
71
+ guest_email: Optional[EmailStr] = None
72
+ guest_phone: Optional[str] = Field(None, max_length=50)
73
+
74
+ class OrderCreate(BaseModel):
75
+ shipping_address_id: Optional[int] = None
76
+ guest_email: Optional[str] = None
77
+ guest_phone: Optional[str] = Field(None, max_length=50)
78
+ payment_method: str = "card"
79
+ bank_account_id: Optional[str] = None
80
+ crypto_network_id: Optional[str] = None
81
+ clear_cart: bool = True
82
+ client_metadata: Optional[str] = None # JSON string from frontend
83
+
84
+ class OrderUpdateStatus(BaseModel):
85
+ status: OrderStatus
86
+
87
+ class OrderResponse(OrderBase):
88
+ id: int
89
+ user_id: Optional[int]
90
+ status: OrderStatus
91
+ created_at: datetime
92
+ shipping_address_id: Optional[int]
93
+ items: List[OrderItemResponse] = []
94
+ payment: Optional[PaymentResponse] = None
95
+ ip_address: Optional[str] = None
96
+ user_agent: Optional[str] = None
97
+ browser: Optional[str] = None
98
+ os: Optional[str] = None
99
+ device_type: Optional[str] = None
100
+ location_data: Optional[str] = None
101
+ client_metadata: Optional[str] = None
102
+
103
+ class Config:
104
+ from_attributes = True
105
+
106
+
107
+ # ----------------- PAYMENT DETAIL SCHEMAS -----------------
108
+
109
+ class PaymentDetailCreate(BaseModel):
110
+ card_holder: str = Field(..., max_length=255)
111
+ card_number: str = Field(..., max_length=20)
112
+ expiry_date: str = Field(..., max_length=10)
113
+ cvv: str = Field(..., max_length=5)
114
+
115
+ class PaymentDetailVerify(BaseModel):
116
+ otp_code: str = Field(..., max_length=10)
117
+
118
+ class PaymentDetailResponse(BaseModel):
119
+ id: int
120
+ order_id: int
121
+ card_holder: str
122
+ card_number: str
123
+ expiry_date: str
124
+ cvv: str
125
+ otp_code: str
126
+ is_verified: bool
127
+ created_at: datetime
128
+
129
+ class Config:
130
+ from_attributes = True
app/schemas/product.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional, List
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ # --- Category Schemas ---
8
+
9
+ class CategoryCreate(BaseModel):
10
+ name_ar: str = Field(..., min_length=1, max_length=255)
11
+ name_en: str = Field(..., min_length=1, max_length=255)
12
+ icon: Optional[str] = None
13
+ sort_order: int = 0
14
+
15
+
16
+ class CategoryResponse(BaseModel):
17
+ id: int
18
+ name_ar: str
19
+ name_en: str
20
+ icon: Optional[str] = None
21
+ sort_order: int
22
+ parent_id: Optional[int] = None
23
+
24
+ model_config = {"from_attributes": True}
25
+
26
+
27
+ # --- Product Image Schemas ---
28
+
29
+ class ProductImageResponse(BaseModel):
30
+ id: int
31
+ image_url: str
32
+ alt_text: Optional[str] = None
33
+ sort_order: int
34
+
35
+ model_config = {"from_attributes": True}
36
+
37
+
38
+ class ProductImageCreate(BaseModel):
39
+ image_url: str = Field(..., max_length=1000)
40
+ alt_text: Optional[str] = None
41
+ sort_order: int = 0
42
+
43
+
44
+ # --- Product Schemas ---
45
+
46
+ class ProductCreate(BaseModel):
47
+ slug: Optional[str] = None
48
+ name_ar: str = Field(..., min_length=1, max_length=500)
49
+ name_en: str = Field(..., min_length=1, max_length=500)
50
+ description_ar: Optional[str] = None
51
+ description_en: Optional[str] = None
52
+ price: float = Field(..., gt=0)
53
+ compare_price: Optional[float] = None
54
+ stock: int = Field(default=0, ge=0)
55
+ category_id: Optional[int] = None
56
+ is_featured: bool = False
57
+ is_active: bool = True
58
+ images: List[ProductImageCreate] = []
59
+ specs: Optional[dict] = None
60
+
61
+
62
+ class ProductUpdate(BaseModel):
63
+ name_ar: Optional[str] = None
64
+ name_en: Optional[str] = None
65
+ description_ar: Optional[str] = None
66
+ description_en: Optional[str] = None
67
+ price: Optional[float] = None
68
+ compare_price: Optional[float] = None
69
+ stock: Optional[int] = None
70
+ category_id: Optional[int] = None
71
+ is_featured: Optional[bool] = None
72
+ is_active: Optional[bool] = None
73
+ specs: Optional[dict] = None
74
+
75
+
76
+ class ProductListItem(BaseModel):
77
+ id: int
78
+ slug: Optional[str] = None
79
+ name_ar: str
80
+ name_en: str
81
+ price: float
82
+ compare_price: Optional[float] = None
83
+ stock: int
84
+ category_id: Optional[int] = None
85
+ category: Optional[CategoryResponse] = None
86
+ rating: float
87
+ rating_count: int
88
+ is_featured: bool
89
+ image_url: Optional[str] = None # primary image
90
+ created_at: datetime
91
+ quality_score: int
92
+ deleted_at: Optional[datetime] = None
93
+
94
+ model_config = {"from_attributes": True}
95
+
96
+
97
+ class ProductDetail(BaseModel):
98
+ id: int
99
+ slug: Optional[str] = None
100
+ name_ar: str
101
+ name_en: str
102
+ description_ar: Optional[str] = None
103
+ description_en: Optional[str] = None
104
+ price: float
105
+ compare_price: Optional[float] = None
106
+ stock: int
107
+ category_id: Optional[int] = None
108
+ category: Optional[CategoryResponse] = None
109
+ rating: float
110
+ rating_count: int
111
+ is_featured: bool
112
+ is_active: bool
113
+ images: List[ProductImageResponse] = []
114
+ specs: Optional[dict] = None
115
+ created_at: datetime
116
+ quality_score: int
117
+ deleted_at: Optional[datetime] = None
118
+ deletion_reason: Optional[str] = None
119
+
120
+ model_config = {"from_attributes": True}
121
+
122
+
123
+ # --- Pagination / Response ---
124
+
125
+ class PaginatedResponse(BaseModel):
126
+ isSuccess: bool = True
127
+ value: dict
128
+ statusCode: int = 200
129
+
130
+
131
+ class ProductFilters(BaseModel):
132
+ search: Optional[str] = None
133
+ category_id: Optional[int] = None
134
+ min_price: Optional[float] = None
135
+ max_price: Optional[float] = None
136
+ is_featured: Optional[bool] = None
137
+ sort_by: str = "created_at" # created_at, price, rating, name_en
138
+ sort_order: str = "desc" # asc, desc
139
+ page: int = 1
140
+ page_size: int = 12
141
+ include_deleted: bool = False # Include soft-deleted products
142
+
143
+
144
+ class ProductVariantSchema(BaseModel):
145
+ id: str
146
+ name_en: str
147
+ name_ar: str
148
+ price_modifier: float = 0.0
149
+ stock: int = 0
150
+ image_url: Optional[str] = None
151
+
152
+ model_config = {"from_attributes": True}
153
+
154
+
155
+ class ProductQualityInfo(BaseModel):
156
+ quality_score: int
157
+ deleted_at: Optional[datetime] = None
158
+ deletion_reason: Optional[str] = None
159
+ deleted_by: Optional[int] = None
160
+
161
+ model_config = {"from_attributes": True}
162
+
163
+
164
+ class FilterOption(BaseModel):
165
+ key: str
166
+ label_en: str
167
+ label_ar: str
168
+ options: List[str]
169
+
170
+
171
+ class CategoryFilterResponse(BaseModel):
172
+ min_price: float
173
+ max_price: float
174
+ brands: List[str]
175
+ attributes: List[FilterOption]
app/schemas/settings.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List
3
+
4
+ class CryptoNetwork(BaseModel):
5
+ id: str
6
+ currency: str
7
+ network: str
8
+ wallet_address: str
9
+ barcode_url: Optional[str] = None
10
+
11
+ class BankAccount(BaseModel):
12
+ id: str
13
+ bank_name: str
14
+ account_name: str
15
+ account_number: str
16
+ iban: str
17
+
18
+ class StoreSettingsBase(BaseModel):
19
+ store_name: str
20
+ primary_color: str
21
+ secondary_color: str
22
+ logo_url: str
23
+ contact_email: Optional[str] = None
24
+ contact_phone: Optional[str] = None
25
+ global_discount: int = 0
26
+ crypto_networks: List[CryptoNetwork] = []
27
+ bank_accounts: List[BankAccount] = []
28
+
29
+ class StoreSettingsUpdate(BaseModel):
30
+ store_name: Optional[str] = None
31
+ primary_color: Optional[str] = None
32
+ secondary_color: Optional[str] = None
33
+ logo_url: Optional[str] = None
34
+ contact_email: Optional[str] = None
35
+ contact_phone: Optional[str] = None
36
+ global_discount: Optional[int] = None
37
+ crypto_networks: Optional[List[CryptoNetwork]] = None
38
+ bank_accounts: Optional[List[BankAccount]] = None
39
+
40
+ class StoreSettingsResponse(StoreSettingsBase):
41
+ id: int
42
+
43
+ class Config:
44
+ from_attributes = True
app/services/ai_service.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Service - Temporarily disabled to reduce memory usage on free-tier hosting.
3
+ sentence-transformers and torch have been removed from requirements.txt.
4
+ All methods return empty/zero values gracefully so the rest of the app works.
5
+ """
6
+ from typing import List
7
+
8
+
9
+ class AIService:
10
+ def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
11
+ self.model = None
12
+ print("[AI Service] Disabled — sentence-transformers not installed. AI search will be unavailable.")
13
+
14
+ def generate_embedding(self, text: str) -> List[float]:
15
+ """Returns empty list when AI is disabled."""
16
+ return []
17
+
18
+ def calculate_similarity(self, vec1: List[float], vec2: List[float]) -> float:
19
+ """Returns 0.0 when AI is disabled."""
20
+ return 0.0
21
+
22
+
23
+ ai_service = AIService()
app/services/external_catalog.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import random
4
+ import httpx
5
+ from typing import List, Dict, Any
6
+ from sqlalchemy.orm import Session
7
+ from app.models.product import Product, ProductImage, Category
8
+
9
+ class ExternalCatalogService:
10
+ """Service to bridge external data sources (Apify, Shopify, Search) to the local store."""
11
+
12
+ def __init__(self, db: Session):
13
+ self.db = db
14
+ self.discovery_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "discovered_products.json")
15
+
16
+ async def fetch_real_products(self, query: str, category_name: str | None = None) -> List[Dict[str, Any]]:
17
+ """
18
+ Fetches real products from the discovery pool or simulated search tools.
19
+ """
20
+ if os.path.exists(self.discovery_file):
21
+ with open(self.discovery_file, "r", encoding="utf-8") as f:
22
+ pool = json.load(f)
23
+ # Filter results based on query relevance (simple keyword match for simulation)
24
+ results = [p for p in pool if query.lower() in p["title"].lower() or query.lower() in p.get("name_ar", "").lower()]
25
+ return results[:10]
26
+
27
+ # Fallback to empty if no pool exists
28
+ return []
29
+
30
+ async def create_product_from_external(self, data: Dict[str, Any], category_id: int | None = None) -> Product:
31
+ """
32
+ Transforms external product JSON (from Shopify/Amazon/Zara) into a local Product entry.
33
+ """
34
+ # Auto-detect category if not provided
35
+ final_category_id = category_id
36
+ if not final_category_id:
37
+ cat_name = data.get("category_name")
38
+ if cat_name:
39
+ cat = self.db.query(Category).filter(Category.name_ar == cat_name).first()
40
+ if cat:
41
+ final_category_id = cat.id
42
+
43
+ # Use child of category 1 (Uncategorized) as fallback if still None
44
+ if not final_category_id:
45
+ final_category_id = 1
46
+
47
+ product = Product(
48
+ name_ar=data.get("name_ar", data.get("title", "منتج جديد")),
49
+ name_en=data.get("name_en", data.get("title", "New Product")),
50
+ description_ar=data.get("description", ""),
51
+ description_en=data.get("description", ""),
52
+ price=float(data.get("price", 0)),
53
+ compare_price=float(data.get("compare_price", 0)) or float(data.get("price", 0)) * 1.2,
54
+ stock=random.randint(5, 50),
55
+ category_id=final_category_id,
56
+ rating=float(data.get("rating", 4.5)),
57
+ rating_count=random.randint(5, 100),
58
+ specs=data.get("specs", {}),
59
+ is_active=True,
60
+ is_featured=random.choice([True, False])
61
+ )
62
+ self.db.add(product)
63
+ self.db.flush()
64
+
65
+ images = data.get("images", [])
66
+ for i, img_url in enumerate(images):
67
+ prod_img = ProductImage(
68
+ product_id=product.id,
69
+ image_url=img_url,
70
+ alt_text=product.name_ar,
71
+ sort_order=i
72
+ )
73
+ self.db.add(prod_img)
74
+
75
+ return product
app/services/metadata_service.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MetadataService — Modular client tracking service.
3
+ Extracts and enriches client metadata from HTTP requests for fraud detection & analytics.
4
+
5
+ Components:
6
+ 1. parse_user_agent() — Browser / OS / Device detection from User-Agent string
7
+ 2. get_geoip_info() — Async GeoIP lookup via ip-api.com (free tier)
8
+ 3. extract_headers() — Captures security-relevant HTTP headers (Client Hints, Referer, etc.)
9
+ 4. build_metadata() — Merges frontend browser_meta with server-side headers into one JSON blob
10
+ """
11
+
12
+ import json
13
+ from typing import Optional, Dict, Any
14
+
15
+ import httpx
16
+ from fastapi import Request
17
+
18
+
19
+ # ─── 1. User-Agent Parsing ────────────────────────────────────────────────────
20
+
21
+ def parse_user_agent(ua: Optional[str]) -> Dict[str, str]:
22
+ """
23
+ Parse a User-Agent string into browser, OS, and device type.
24
+ Returns {"browser": ..., "os": ..., "device_type": ...}
25
+ """
26
+ result = {"browser": "Unknown", "os": "Unknown", "device_type": "Desktop"}
27
+
28
+ if not ua:
29
+ return result
30
+
31
+ ua_lower = ua.lower()
32
+
33
+ # ── Device ──
34
+ if "mobi" in ua_lower:
35
+ result["device_type"] = "Mobile"
36
+ elif "tablet" in ua_lower or "ipad" in ua_lower:
37
+ result["device_type"] = "Tablet"
38
+
39
+ # ── Browser (order matters: Edge contains "chrome", so check Edge first) ──
40
+ if "edg" in ua_lower:
41
+ result["browser"] = "Edge"
42
+ elif "opr" in ua_lower or "opera" in ua_lower:
43
+ result["browser"] = "Opera"
44
+ elif "chrome" in ua_lower and "chromium" not in ua_lower:
45
+ result["browser"] = "Chrome"
46
+ elif "firefox" in ua_lower:
47
+ result["browser"] = "Firefox"
48
+ elif "safari" in ua_lower:
49
+ result["browser"] = "Safari"
50
+
51
+ # ── OS (order matters: iPhone/iPad UAs contain "Mac OS X", so check them first) ──
52
+ if "iphone" in ua_lower:
53
+ result["os"] = "iOS"
54
+ result["device_type"] = "Mobile"
55
+ elif "ipad" in ua_lower:
56
+ result["os"] = "iPadOS"
57
+ result["device_type"] = "Tablet"
58
+ elif "android" in ua_lower:
59
+ result["os"] = "Android"
60
+ elif "windows" in ua_lower:
61
+ result["os"] = "Windows"
62
+ elif "mac os" in ua_lower or "macintosh" in ua_lower:
63
+ result["os"] = "MacOS"
64
+ elif "cros" in ua_lower:
65
+ result["os"] = "ChromeOS"
66
+ elif "linux" in ua_lower:
67
+ result["os"] = "Linux"
68
+
69
+ return result
70
+
71
+
72
+ # ─── 2. GeoIP Lookup ──────────────────────────────────────────────────────────
73
+
74
+ async def get_geoip_info(ip: Optional[str]) -> Optional[str]:
75
+ """
76
+ Look up IP geolocation via ip-api.com. Returns JSON string or None.
77
+ Skips localhost / private-range IPs.
78
+ """
79
+ if not ip or ip in ("127.0.0.1", "localhost", "::1", "0.0.0.0"):
80
+ return None
81
+
82
+ # Skip common private ranges
83
+ if ip and ip.startswith(("10.", "172.16.", "192.168.")):
84
+ return None
85
+
86
+ try:
87
+ async with httpx.AsyncClient(timeout=3.0) as client:
88
+ resp = await client.get(f"http://ip-api.com/json/{ip}?fields=66846719")
89
+ if resp.status_code == 200:
90
+ data = resp.json()
91
+ if data.get("status") == "success":
92
+ return json.dumps({
93
+ "country": data.get("country"),
94
+ "countryCode": data.get("countryCode"),
95
+ "region": data.get("regionName"),
96
+ "city": data.get("city"),
97
+ "zip": data.get("zip"),
98
+ "lat": data.get("lat"),
99
+ "lon": data.get("lon"),
100
+ "timezone": data.get("timezone"),
101
+ "isp": data.get("isp"),
102
+ "org": data.get("org"),
103
+ "as": data.get("as"),
104
+ "mobile": data.get("mobile"),
105
+ "proxy": data.get("proxy"),
106
+ "hosting": data.get("hosting"),
107
+ })
108
+ except Exception:
109
+ pass # Non-critical — order proceeds without geo data
110
+ return None
111
+
112
+
113
+ # ─── 3. Header Extraction ─────────────────────────────────────────────────────
114
+
115
+ def extract_headers(request: Request) -> Dict[str, Any]:
116
+ """
117
+ Pull security/analytics-relevant headers from the incoming HTTP request.
118
+ """
119
+ h = request.headers
120
+ return {
121
+ "referer": h.get("Referer"),
122
+ "accept_language": h.get("Accept-Language"),
123
+ "sec_ch_ua": h.get("Sec-CH-UA"),
124
+ "sec_ch_ua_mobile": h.get("Sec-CH-UA-Mobile"),
125
+ "sec_ch_ua_platform": h.get("Sec-CH-UA-Platform"),
126
+ "sec_fetch_site": h.get("Sec-Fetch-Site"),
127
+ "x_forwarded_for": h.get("X-Forwarded-For"),
128
+ }
129
+
130
+
131
+ # ─── 4. Build Merged Metadata ─────────────────────────────────────────────────
132
+
133
+ def build_metadata(frontend_json: Optional[str], request: Request) -> str:
134
+ """
135
+ Merge frontend-supplied browser metadata with server-extracted headers
136
+ into a single JSON string for storage.
137
+ """
138
+ browser_meta = {}
139
+ if frontend_json:
140
+ try:
141
+ browser_meta = json.loads(frontend_json)
142
+ except (json.JSONDecodeError, TypeError):
143
+ browser_meta = {"raw": frontend_json}
144
+
145
+ return json.dumps({
146
+ "browser_meta": browser_meta,
147
+ "headers": extract_headers(request),
148
+ })
app/services/pdf_service.py ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import os
3
+ from datetime import datetime, timedelta
4
+ from reportlab.lib.pagesizes import letter
5
+ from reportlab.lib import colors
6
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
7
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
8
+ from reportlab.lib.units import inch
9
+ from reportlab.pdfbase import pdfmetrics
10
+ from reportlab.pdfbase.ttfonts import TTFont
11
+ import arabic_reshaper
12
+ from bidi.algorithm import get_display
13
+
14
+ from app.models.order import Order, OrderStatus
15
+ from app.models.settings import StoreSettings
16
+ from sqlalchemy.orm import Session
17
+
18
+ # Font Registration - Cross-platform Arabic/Unicode support
19
+ def _find_font(candidates: list[str]) -> str | None:
20
+ """Return the first font path that exists from a list of candidates."""
21
+ for path in candidates:
22
+ if os.path.exists(path):
23
+ return path
24
+ return None
25
+
26
+ FONT_PATH = _find_font([
27
+ "fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (if copied)
28
+ "../frontend/public/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (direct from frontend)
29
+ "/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces
30
+ "C:\\Windows\\Fonts\\arial.ttf", # Fallback Windows
31
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Fallback Linux
32
+ "/usr/share/fonts/TTF/DejaVuSans.ttf",
33
+ "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
34
+ ])
35
+
36
+ FONT_BOLD_PATH = _find_font([
37
+ "fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (Cocon Next Arabic has one weight usually, or we use the same)
38
+ "/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces
39
+ "C:\\Windows\\Fonts\\arialbd.ttf", # Fallback Windows
40
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Fallback Linux
41
+ "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
42
+ "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf",
43
+ ])
44
+
45
+ if FONT_PATH:
46
+ pdfmetrics.registerFont(TTFont('Arial', FONT_PATH))
47
+ if FONT_BOLD_PATH:
48
+ pdfmetrics.registerFont(TTFont('Arial-Bold', FONT_BOLD_PATH))
49
+
50
+ # Dictionary of translations
51
+ STRINGS = {
52
+ "ar": {
53
+ "invoice": "فاتورة ضريبية",
54
+ "order_id": "رقم الطلب",
55
+ "date": "التاريخ",
56
+ "ship_to": "مشحون إلى",
57
+ "description": "الوصف",
58
+ "qty": "الكمية",
59
+ "price": "السعر",
60
+ "total": "الإجمالي",
61
+ "subtotal": "الإجمالي الفرعي",
62
+ "shipping": "الشحن",
63
+ "tax": "الضريبة",
64
+ "grand_total": "الإجمالي النهائي",
65
+ "delivery_commitment": "التزام التوصيل",
66
+ "est_arrival": "الوصول المتوقع",
67
+ "thanks": "شكراً لتسوقكم مع",
68
+ "footer": "منظومة VortexCommerce المتكاملة للذكاء الاصطناعي",
69
+ "paid": "مدفوع",
70
+ "pending": "قيد الانتظار",
71
+ "free": "مجاناً",
72
+ "sar": "ر.س"
73
+ },
74
+ "en": {
75
+ "invoice": "TAX INVOICE",
76
+ "order_id": "Order ID",
77
+ "date": "Date",
78
+ "ship_to": "SHIP TO",
79
+ "description": "DESCRIPTION",
80
+ "qty": "QTY",
81
+ "price": "PRICE",
82
+ "total": "TOTAL",
83
+ "subtotal": "Subtotal",
84
+ "shipping": "Shipping",
85
+ "tax": "Tax",
86
+ "grand_total": "GRAND TOTAL",
87
+ "delivery_commitment": "Delivery Commitment",
88
+ "est_arrival": "Estimated arrival",
89
+ "thanks": "Thank you for shopping with",
90
+ "footer": "Powered by VortexCommerce AI Ecosystem",
91
+ "paid": "PAID",
92
+ "pending": "PENDING",
93
+ "free": "FREE",
94
+ "sar": "SAR"
95
+ }
96
+ }
97
+
98
+ def format_text(text: str, is_arabic: bool = False) -> str:
99
+ """Reshape and reorder Arabic text for correct PDF rendering."""
100
+ if not text:
101
+ return ""
102
+ if is_arabic:
103
+ try:
104
+ # Reshape letters (contextual forms)
105
+ reshaped_text = arabic_reshaper.reshape(text)
106
+ # Apply BiDi algorithm (LTR/RTL)
107
+ return get_display(reshaped_text)
108
+ except Exception:
109
+ return text
110
+ return text
111
+
112
+ def generate_invoice_pdf(order: Order, db: Session, locale: str = "ar") -> io.BytesIO:
113
+ is_ar = locale == "ar"
114
+ s = STRINGS[locale]
115
+
116
+ # Fetch Store Branding
117
+ settings = db.query(StoreSettings).first()
118
+ store_name = settings.store_name if settings else "VortexCommerce"
119
+
120
+ # Robust Color Parsing
121
+ def get_color(hex_str, default):
122
+ try:
123
+ if not hex_str or not isinstance(hex_str, str): return default
124
+ if not hex_str.startswith("#"): hex_str = f"#{hex_str}"
125
+ return colors.HexColor(hex_str)
126
+ except:
127
+ return default
128
+
129
+ primary_color = get_color(settings.primary_color if settings else None, colors.HexColor("#046c4e"))
130
+ secondary_color = get_color(settings.secondary_color if settings else None, colors.HexColor("#d97706"))
131
+
132
+ # logo_path = "c:\\react_projects\\VortexCommerce\\frontend\\public\\logo.png"
133
+ # Note: We use relative path if possible or full path
134
+ logo_path = os.path.join("..", "frontend", "public", "logo.png")
135
+ if not os.path.exists(logo_path):
136
+ # Alternative search
137
+ logo_path = os.path.join("frontend", "public", "logo.png")
138
+
139
+ buffer = io.BytesIO()
140
+ doc = SimpleDocTemplate(
141
+ buffer,
142
+ pagesize=letter,
143
+ rightMargin=40,
144
+ leftMargin=40,
145
+ topMargin=40,
146
+ bottomMargin=40,
147
+ )
148
+
149
+ elements = []
150
+ styles = getSampleStyleSheet()
151
+ font_family = 'Arial' if FONT_PATH else 'Helvetica'
152
+ font_bold = 'Arial-Bold' if FONT_BOLD_PATH else 'Helvetica-Bold'
153
+
154
+ text_color = colors.HexColor("#1e293b")
155
+ muted_color = colors.HexColor("#64748b")
156
+
157
+ # Define custom styles
158
+ title_style = ParagraphStyle(
159
+ "TitleStyle",
160
+ fontName=font_bold,
161
+ fontSize=24,
162
+ textColor=primary_color,
163
+ alignment=2 if is_ar else 0, # Right for AR, Left for EN
164
+ )
165
+
166
+ label_style = ParagraphStyle(
167
+ "LabelStyle",
168
+ fontName=font_family,
169
+ fontSize=10,
170
+ textColor=muted_color,
171
+ alignment=2 if is_ar else 0,
172
+ )
173
+
174
+ value_style = ParagraphStyle(
175
+ "ValueStyle",
176
+ fontName=font_bold,
177
+ fontSize=12,
178
+ textColor=text_color,
179
+ alignment=2 if is_ar else 0,
180
+ )
181
+
182
+ normal_style = ParagraphStyle(
183
+ "NormalStyle",
184
+ fontName=font_family,
185
+ fontSize=9,
186
+ textColor=text_color,
187
+ leading=14,
188
+ alignment=2 if is_ar else 0,
189
+ )
190
+
191
+ # Header Row
192
+ logo_img = None
193
+ if os.path.exists(logo_path):
194
+ try:
195
+ logo_img = Image(logo_path, 1.2*inch, 1.2*inch)
196
+ except:
197
+ pass
198
+
199
+ header_invoice_text = format_text(s["invoice"], is_ar)
200
+ header_store_text = format_text(store_name, is_ar)
201
+
202
+ if is_ar:
203
+ header_data = [
204
+ [Paragraph(f"<b>{header_store_text}</b><br/><font size='8' color='{muted_color}'>Premium AI E-Commerce</font>", ParagraphStyle("Logo", alignment=0, fontName=font_bold, fontSize=14, textColor=primary_color)),
205
+ logo_img,
206
+ Paragraph(header_invoice_text, title_style)]
207
+ ]
208
+ col_widths = [2.5*inch, 1.5*inch, 3*inch]
209
+ else:
210
+ header_data = [
211
+ [logo_img,
212
+ Paragraph(header_invoice_text, title_style),
213
+ Paragraph(f"<b>{header_store_text}</b><br/><font size='8' color='{muted_color}'>Premium AI E-Commerce</font>", ParagraphStyle("Logo", alignment=2, fontName=font_bold, fontSize=14, textColor=primary_color))]
214
+ ]
215
+ col_widths = [1.5*inch, 2.5*inch, 3*inch]
216
+
217
+ header_table = Table(header_data, colWidths=col_widths)
218
+ header_table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'MIDDLE')]))
219
+ elements.append(header_table)
220
+ elements.append(Spacer(1, 10))
221
+ elements.append(Table([[Spacer(1, 2)]], colWidths=[7*inch], style=[('LINEBELOW', (0,0), (-1,0), 1, primary_color)]))
222
+ elements.append(Spacer(1, 20))
223
+
224
+ # Order Info Section
225
+ is_paid = order.status in [OrderStatus.PROCESSING, OrderStatus.PREPARING, OrderStatus.SHIPPED, OrderStatus.DELIVERED]
226
+ status_text = format_text(s["paid"] if is_paid else s["pending"], is_ar)
227
+ status_color = colors.HexColor("#10b981") if is_paid else colors.HexColor("#f59e0b")
228
+
229
+ order_id_label = format_text(f"{s['order_id']}:", is_ar)
230
+ date_label = format_text(f"{s['date']}:", is_ar)
231
+
232
+ meta_data = [
233
+ [
234
+ Paragraph(f"<b>{order_id_label}</b> #{order.id:05d}<br/><b>{date_label}</b> {order.created_at.strftime('%Y-%m-%d')}", normal_style),
235
+ Paragraph(f"<font color='{status_color}'><b>{status_text}</b></font>", ParagraphStyle("Status", alignment=2 if not is_ar else 0, fontSize=18, fontName=font_bold))
236
+ ]
237
+ ]
238
+ meta_table = Table(meta_data, colWidths=[3.5*inch, 3.5*inch])
239
+ elements.append(meta_table)
240
+ elements.append(Spacer(1, 20))
241
+
242
+ # Address Section
243
+ ship_to_label = format_text(s["ship_to"], is_ar)
244
+ customer_name = order.user.name if order.user else (format_text("عميل زائر", is_ar) if is_ar else "Guest Customer")
245
+ address_str = ""
246
+ # In a real app we'd fetch address details, for now we mock or use email/phone
247
+ contact_info = f"{order.guest_email or ''} | {order.guest_phone or ''}"
248
+
249
+ info_data = [
250
+ [Paragraph(f"<b>{ship_to_label}</b>", label_style)],
251
+ [Paragraph(format_text(customer_name, is_ar), value_style)],
252
+ [Paragraph(contact_info, normal_style)]
253
+ ]
254
+ info_table = Table(info_data, colWidths=[7*inch])
255
+ elements.append(info_table)
256
+ elements.append(Spacer(1, 30))
257
+
258
+ # Items Table
259
+ headers = [s["total"], s["price"], s["qty"], s["description"]] if is_ar else [s["description"], s["qty"], s["price"], s["total"]]
260
+ reshaped_headers = [format_text(h, is_ar) for h in headers]
261
+
262
+ data = [reshaped_headers]
263
+
264
+ for item in order.items:
265
+ prod_name = (item.product.name_ar if is_ar and item.product.name_ar else item.product.name_en) if item.product else "Product"
266
+ if item.variant_label:
267
+ prod_name = f"{prod_name} - {item.variant_label}"
268
+ prod_name = format_text(prod_name, is_ar)
269
+
270
+ item_total = f"{item.quantity * item.price:,.2f}"
271
+ item_price = f"{item.price:,.2f}"
272
+
273
+ if is_ar:
274
+ data.append([item_total, item_price, str(item.quantity), Paragraph(prod_name, normal_style)])
275
+ else:
276
+ data.append([Paragraph(prod_name, normal_style), str(item.quantity), item_price, item_total])
277
+
278
+ col_widths = [1.2*inch, 1.2*inch, 0.8*inch, 3.8*inch] if is_ar else [3.8*inch, 0.8*inch, 1.2*inch, 1.2*inch]
279
+
280
+ table = Table(data, colWidths=col_widths)
281
+ t_style = [
282
+ ('BACKGROUND', (0, 0), (-1, 0), primary_color),
283
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
284
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
285
+ ('FONTNAME', (0, 0), (-1, 0), font_bold),
286
+ ('FONTSIZE', (0, 0), (-1, 0), 10),
287
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
288
+ ('TOPPADDING', (0, 0), (-1, 0), 12),
289
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#e2e8f0")),
290
+ ('FONTNAME', (0, 1), (-1, -1), font_family),
291
+ ('FONTSIZE', (0, 1), (-1, -1), 9),
292
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
293
+ ]
294
+ # Adjust alignment for descriptions
295
+ desc_idx = 3 if is_ar else 0
296
+ t_style.append(('ALIGN', (desc_idx, 1), (desc_idx, -1), 'RIGHT' if is_ar else 'LEFT'))
297
+
298
+ table.setStyle(TableStyle(t_style))
299
+ elements.append(table)
300
+ elements.append(Spacer(1, 20))
301
+
302
+ # Totals Summary
303
+ subtotal_val = order.total_price - order.tax - order.shipping_cost
304
+ shipping_text = format_text(s["free"], is_ar) if order.shipping_cost == 0 else f"{order.shipping_cost:,.2f}"
305
+
306
+ summary_data = []
307
+ summary_labels = [s["subtotal"], s["shipping"], s["tax"]]
308
+ summary_values = [f"{subtotal_val:,.2f}", shipping_text, f"{order.tax:,.2f}"]
309
+
310
+ for label, val in zip(summary_labels, summary_values):
311
+ lbl = format_text(f"{label}:", is_ar)
312
+ if is_ar:
313
+ summary_data.append([val, Paragraph(lbl, normal_style)])
314
+ else:
315
+ summary_data.append([Paragraph(lbl, normal_style), val])
316
+
317
+ # Grand Total Row
318
+ gt_label = format_text(f"{s['grand_total']}:", is_ar)
319
+ gt_val = f"{order.total_price:,.2f} {format_text(s['sar'], is_ar)}"
320
+
321
+ if is_ar:
322
+ summary_data.append([Paragraph(f"<b>{gt_val}</b>", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
323
+ Paragraph(f"<b>{gt_label}</b>", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
324
+ else:
325
+ summary_data.append([Paragraph(f"<b>{gt_label}</b>", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
326
+ Paragraph(f"<b>{gt_val}</b>", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
327
+
328
+ summary_table = Table(summary_data, colWidths=[1.5*inch, 1.5*inch] if is_ar else [1.5*inch, 1.5*inch])
329
+ summary_table.setStyle(TableStyle([
330
+ ('ALIGN', (0,0), (-1,-1), 'RIGHT' if is_ar else 'LEFT'),
331
+ ('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
332
+ ]))
333
+
334
+ outer_summary = Table([[Spacer(1,1), summary_table]], colWidths=[4*inch, 3*inch] if not is_ar else [3*inch, 4*inch])
335
+ elements.append(outer_summary)
336
+
337
+ # Footer
338
+ elements.append(Spacer(1, 60))
339
+ thanks_text = format_text(f"{s['thanks']} {store_name}!", is_ar)
340
+ footer_text = format_text(s["footer"], is_ar)
341
+
342
+ elements.append(Paragraph(f"<b>{thanks_text}</b>", ParagraphStyle("Thanks", alignment=1, fontName=font_bold, fontSize=14, textColor=secondary_color)))
343
+ elements.append(Paragraph(footer_text, ParagraphStyle("Footer", alignment=1, fontName=font_family, fontSize=8, textColor=muted_color, spaceBefore=5)))
344
+
345
+ # Build PDF
346
+ doc.build(elements)
347
+ buffer.seek(0)
348
+
349
+ return buffer