hisham-cmd commited on
Commit
b65ccd3
·
verified ·
1 Parent(s): e1879c7

Full Sync: Wiped and Re-uploaded (Stateless Architecture)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Dockerfile ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Forced Rebuild 2026-03-16T13:52
2
+ # Use an official Python runtime as a parent image
3
+ FROM python:3.11-slim
4
+
5
+ # Set the working directory in the container
6
+ WORKDIR /app
7
+
8
+ # Install system dependencies
9
+ RUN apt-get update && apt-get install -y \
10
+ build-essential \
11
+ fonts-dejavu-core \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ # Copy the requirements file into the container
15
+ COPY requirements.txt .
16
+
17
+ # Install any needed packages specified in requirements.txt
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ # Copy the rest of the application code into the container
21
+ COPY . .
22
+
23
+ # Hugging Face Spaces runs as user with UID 1000
24
+ # Grant ownership of /app so SQLite DB can be created and written
25
+ RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
26
+ USER appuser
27
+
28
+ # Expose port 7860 as required by Hugging Face Spaces
29
+ EXPOSE 7860
30
+
31
+ # Define environment variables
32
+ ENV PYTHONUNBUFFERED=1
33
+ ENV HOST=0.0.0.0
34
+ ENV PORT=7860
35
+ ENV DATABASE_URL=sqlite:///./vortex.db
36
+
37
+ # Command to run the application
38
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--proxy-headers", "--forwarded-allow-ips='*'"]
README.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: VortexCommerce Backend
3
+ emoji: 🚀
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # VortexCommerce Backend
12
+
13
+ AI-Powered Bilingual E-Commerce Platform backend.
14
+
15
+ ## Admin Credentials
16
+ - **Email**: admin@vortex.com
17
+ - **Password**: admin123
alembic.ini ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts
5
+ script_location = migrations
6
+
7
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
8
+ # Uncomment the line below if you want the files to be prepended with date and time
9
+ # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
10
+ # for all available tokens
11
+ # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
12
+
13
+ # sys.path path, will be prepended to sys.path if present.
14
+ # defaults to the current working directory.
15
+ prepend_sys_path = .
16
+
17
+ # timezone to use when rendering the date within the migration file
18
+ # as well as the filename.
19
+ # If specified, requires the python>=3.9 or backports.zoneinfo library.
20
+ # Any required deps can installed by adding `alembic[tz]` to the pip requirements
21
+ # string value is passed to ZoneInfo()
22
+ # leave blank for localtime
23
+ # timezone =
24
+
25
+ # max length of characters to apply to the
26
+ # "slug" field
27
+ # truncate_slug_length = 40
28
+
29
+ # set to 'true' to run the environment during
30
+ # the 'revision' command, regardless of autogenerate
31
+ # revision_environment = false
32
+
33
+ # set to 'true' to allow .pyc and .pyo files without
34
+ # a source .py file to be detected as revisions in the
35
+ # versions/ directory
36
+ # sourceless = false
37
+
38
+ # version location specification; This defaults
39
+ # to migrations/versions. When using multiple version
40
+ # directories, initial revisions must be specified with --version-path.
41
+ # The path separator used here should be the separator specified by "version_path_separator" below.
42
+ # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
43
+
44
+ # version path separator; As mentioned above, this is the character used to split
45
+ # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
46
+ # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
47
+ # Valid values for version_path_separator are:
48
+ #
49
+ # version_path_separator = :
50
+ # version_path_separator = ;
51
+ # version_path_separator = space
52
+ version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
53
+
54
+ # set to 'true' to search source files recursively
55
+ # in each "version_locations" directory
56
+ # new in Alembic version 1.10
57
+ # recursive_version_locations = false
58
+
59
+ # the output encoding used when revision files
60
+ # are written from script.py.mako
61
+ # output_encoding = utf-8
62
+
63
+ sqlalchemy.url = driver://user:pass@localhost/dbname
64
+
65
+
66
+ [post_write_hooks]
67
+ # post_write_hooks defines scripts or Python functions that are run
68
+ # on newly generated revision scripts. See the documentation for further
69
+ # detail and examples
70
+
71
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
72
+ # hooks = black
73
+ # black.type = console_scripts
74
+ # black.entrypoint = black
75
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
76
+
77
+ # lint with attempts to fix using "ruff" - use the exec runner, execute a binary
78
+ # hooks = ruff
79
+ # ruff.type = exec
80
+ # ruff.executable = %(here)s/.venv/bin/ruff
81
+ # ruff.options = --fix REVISION_SCRIPT_FILENAME
82
+
83
+ # Logging configuration
84
+ [loggers]
85
+ keys = root,sqlalchemy,alembic
86
+
87
+ [handlers]
88
+ keys = console
89
+
90
+ [formatters]
91
+ keys = generic
92
+
93
+ [logger_root]
94
+ level = WARN
95
+ handlers = console
96
+ qualname =
97
+
98
+ [logger_sqlalchemy]
99
+ level = WARN
100
+ handlers =
101
+ qualname = sqlalchemy.engine
102
+
103
+ [logger_alembic]
104
+ level = INFO
105
+ handlers =
106
+ qualname = alembic
107
+
108
+ [handler_console]
109
+ class = StreamHandler
110
+ args = (sys.stderr,)
111
+ level = NOTSET
112
+ formatter = generic
113
+
114
+ [formatter_generic]
115
+ format = %(levelname)-5.5s [%(name)s] %(message)s
116
+ datefmt = %H:%M:%S
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # App package
app/api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # API package
app/api/admin.py ADDED
@@ -0,0 +1,679 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status, Query
2
+ from sqlalchemy.orm import Session
3
+ from typing import Optional, List
4
+ from sqlalchemy import func, or_, case, Date
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ from app.db.base import get_db
8
+ from app.models.user import User, UserRole
9
+ from app.models.order import Order, OrderStatus, Payment, PaymentDetail
10
+ from app.models.product import Product, ProductImage, ProductAudit
11
+ from app.api.auth import get_current_user
12
+ import json
13
+ import os
14
+ from app.core.logging import log_audit_event
15
+
16
+ router = APIRouter(prefix="/admin", tags=["Administration"])
17
+
18
+ def check_admin(user_id: int, db: Session):
19
+ user = db.query(User).filter(User.id == user_id).first()
20
+ if not user or user.role != UserRole.ADMIN:
21
+ raise HTTPException(status_code=403, detail="Not authorized to access this resource")
22
+ return user
23
+
24
+ @router.get("/dashboard/stats")
25
+ def get_dashboard_stats(
26
+ current_user_id: int = Depends(get_current_user),
27
+ db: Session = Depends(get_db)
28
+ ):
29
+ """
30
+ Get overview statistics for the admin dashboard.
31
+ """
32
+ check_admin(current_user_id, db)
33
+
34
+ # Time ranges
35
+ now = datetime.utcnow()
36
+ last_30_days = now - timedelta(days=30)
37
+ previous_30_days = now - timedelta(days=60)
38
+
39
+ # 1. Total Revenue
40
+ revenue_result = db.query(func.sum(Order.total_price)).filter(
41
+ Order.status != OrderStatus.CANCELLED,
42
+ Order.created_at >= last_30_days
43
+ ).scalar()
44
+ total_revenue = float(revenue_result) if revenue_result else 0.0
45
+
46
+ prev_revenue_result = db.query(func.sum(Order.total_price)).filter(
47
+ Order.status != OrderStatus.CANCELLED,
48
+ Order.created_at >= previous_30_days,
49
+ Order.created_at < last_30_days
50
+ ).scalar()
51
+ prev_revenue = float(prev_revenue_result) if prev_revenue_result else 0.0
52
+
53
+ revenue_trend = 0.0
54
+ if prev_revenue > 0:
55
+ revenue_trend = ((total_revenue - prev_revenue) / prev_revenue) * 100
56
+ elif total_revenue > 0:
57
+ revenue_trend = 100.0
58
+
59
+ # 2. Total Orders
60
+ total_orders = db.query(func.count(Order.id)).filter(
61
+ Order.created_at >= last_30_days
62
+ ).scalar() or 0
63
+
64
+ prev_orders = db.query(func.count(Order.id)).filter(
65
+ Order.created_at >= previous_30_days,
66
+ Order.created_at < last_30_days
67
+ ).scalar() or 0
68
+
69
+ orders_trend = 0.0
70
+ if prev_orders > 0:
71
+ orders_trend = ((total_orders - prev_orders) / prev_orders) * 100
72
+ elif total_orders > 0:
73
+ orders_trend = 100.0
74
+
75
+ # 3. Total Customers
76
+ total_customers = db.query(func.count(User.id)).filter(
77
+ User.role == UserRole.CUSTOMER
78
+ ).scalar() or 0
79
+
80
+ new_customers = db.query(func.count(User.id)).filter(
81
+ User.role == UserRole.CUSTOMER,
82
+ User.created_at >= last_30_days
83
+ ).scalar() or 0
84
+
85
+ prev_new_customers = db.query(func.count(User.id)).filter(
86
+ User.role == UserRole.CUSTOMER,
87
+ User.created_at >= previous_30_days,
88
+ User.created_at < last_30_days
89
+ ).scalar() or 0
90
+
91
+ customers_trend = 0.0
92
+ if prev_new_customers > 0:
93
+ customers_trend = ((new_customers - prev_new_customers) / prev_new_customers) * 100
94
+ elif new_customers > 0:
95
+ customers_trend = 100.0
96
+
97
+ # 4. Low Stock Products
98
+ low_stock_count = db.query(func.count(Product.id)).filter(
99
+ Product.stock <= 10,
100
+ Product.is_active == True
101
+ ).scalar()
102
+
103
+ low_stock_items = db.query(Product).filter(
104
+ Product.stock <= 10,
105
+ Product.is_active == True,
106
+ Product.deleted_at == None
107
+ ).order_by(
108
+ case((Product.id > 3708, 1), else_=0).desc(),
109
+ case((Product.id > 3708, Product.id), else_=0).desc(),
110
+ Product.id.asc()
111
+ ).limit(5).all()
112
+
113
+ low_stock_data = [{
114
+ "id": p.id,
115
+ "name_en": p.name_en,
116
+ "name_ar": p.name_ar,
117
+ "stock": p.stock,
118
+ "image_url": p.images[0].image_url if p.images else None
119
+ } for p in low_stock_items]
120
+
121
+ # 5. Recent Orders
122
+ recent_orders = db.query(Order).order_by(Order.created_at.desc()).limit(5).all()
123
+ recent_orders_data = [{
124
+ "id": o.id,
125
+ "customer": o.user.name if o.user else "Guest",
126
+ "total": float(o.total_price),
127
+ "status": o.status.value,
128
+ "date": o.created_at.isoformat()
129
+ } for o in recent_orders]
130
+
131
+ return {
132
+ "isSuccess": True,
133
+ "value": {
134
+ "stats": {
135
+ "revenue_30d": total_revenue,
136
+ "revenue_trend": round(float(revenue_trend), 1),
137
+ "orders_30d": total_orders,
138
+ "orders_trend": round(float(orders_trend), 1),
139
+ "total_customers": total_customers,
140
+ "customers_trend": round(float(customers_trend), 1),
141
+ "low_stock_products": low_stock_count
142
+ },
143
+ "recent_orders": recent_orders_data,
144
+ "low_stock_items": low_stock_data
145
+ },
146
+ "statusCode": 200
147
+ }
148
+
149
+ @router.get("/dashboard/chart-data")
150
+ def get_chart_data(
151
+ current_user_id: int = Depends(get_current_user),
152
+ db: Session = Depends(get_db)
153
+ ):
154
+ check_admin(current_user_id, db)
155
+ now = datetime.utcnow()
156
+ last_30_days = now - timedelta(days=30)
157
+
158
+ orders = db.query(
159
+ func.cast(Order.created_at, Date).label("date"),
160
+ func.count(Order.id).label("orders_count"),
161
+ func.sum(Order.total_price).label("daily_revenue")
162
+ ).filter(
163
+ Order.status != OrderStatus.CANCELLED,
164
+ Order.created_at >= last_30_days
165
+ ).group_by(func.cast(Order.created_at, Date)).all()
166
+
167
+ data_map = {}
168
+ for o in orders:
169
+ data_map[str(o.date)] = {
170
+ "orders": o.orders_count,
171
+ "revenue": float(o.daily_revenue) if o.daily_revenue else 0.0
172
+ }
173
+
174
+ results = []
175
+ for i in range(29, -1, -1):
176
+ target_date = (now - timedelta(days=i)).date()
177
+ date_str = str(target_date)
178
+ if date_str in data_map:
179
+ val = data_map[date_str]
180
+ results.append({
181
+ "date": date_str,
182
+ "revenue": val["revenue"],
183
+ "orders": val["orders"]
184
+ })
185
+ else:
186
+ results.append({
187
+ "date": date_str,
188
+ "revenue": 0.0,
189
+ "orders": 0
190
+ })
191
+
192
+ return {
193
+ "isSuccess": True,
194
+ "value": results,
195
+ "statusCode": 200
196
+ }
197
+
198
+ @router.get("/analytics/sales")
199
+ def get_sales_analytics(
200
+ start_date: Optional[str] = Query(None),
201
+ end_date: Optional[str] = Query(None),
202
+ current_user_id: int = Depends(get_current_user),
203
+ db: Session = Depends(get_db)
204
+ ):
205
+ check_admin(current_user_id, db)
206
+ filters = [Order.status != OrderStatus.CANCELLED]
207
+ if start_date:
208
+ filters.append(Order.created_at >= datetime.fromisoformat(start_date))
209
+ if end_date:
210
+ filters.append(Order.created_at <= datetime.fromisoformat(end_date))
211
+ if not start_date:
212
+ filters.append(Order.created_at >= datetime.utcnow() - timedelta(days=30))
213
+
214
+ payment_stats = db.query(
215
+ Payment.provider,
216
+ func.count(Order.id).label("count"),
217
+ func.sum(Order.total_price).label("revenue")
218
+ ).join(Order).filter(*filters).group_by(Payment.provider).all()
219
+
220
+ payment_data = [{
221
+ "provider": p.provider,
222
+ "count": p.count,
223
+ "revenue": float(p.revenue) if p.revenue else 0.0
224
+ } for p in payment_stats]
225
+
226
+ status_stats = db.query(
227
+ Order.status,
228
+ func.count(Order.id).label("count")
229
+ ).filter(*filters).group_by(Order.status).all()
230
+
231
+ status_data = [{
232
+ "status": s.status.value,
233
+ "count": s.count
234
+ } for s in status_stats]
235
+
236
+ now = datetime.utcnow()
237
+ trends = []
238
+ for i in range(5, -1, -1):
239
+ month_start = (now.replace(day=1) - timedelta(days=i*30)).replace(day=1)
240
+ next_month = (month_start + timedelta(days=32)).replace(day=1)
241
+ monthly_revenue = db.query(func.sum(Order.total_price)).filter(
242
+ Order.status != OrderStatus.CANCELLED,
243
+ Order.created_at >= month_start,
244
+ Order.created_at < next_month
245
+ ).scalar() or 0.0
246
+ trends.append({
247
+ "month": month_start.strftime("%b %Y"),
248
+ "revenue": float(monthly_revenue)
249
+ })
250
+
251
+ from app.models.order import OrderItem
252
+ top_products_query = db.query(
253
+ Product.id,
254
+ Product.name_en,
255
+ Product.name_ar,
256
+ func.sum(OrderItem.quantity).label("total_qty"),
257
+ func.sum(OrderItem.price * OrderItem.quantity).label("total_revenue")
258
+ ).join(OrderItem, Product.id == OrderItem.product_id)\
259
+ .join(Order, OrderItem.order_id == Order.id)\
260
+ .filter(*filters)\
261
+ .group_by(Product.id)\
262
+ .order_by(func.sum(OrderItem.quantity).desc())\
263
+ .limit(5).all()
264
+
265
+ top_products = [{
266
+ "id": p.id,
267
+ "name_en": p.name_en,
268
+ "name_ar": p.name_ar,
269
+ "quantity": int(p.total_qty),
270
+ "revenue": float(p.total_revenue)
271
+ } for p in top_products_query]
272
+
273
+ hourly_stats = db.query(
274
+ func.to_char(Order.created_at, 'HH24').label("hour"),
275
+ func.sum(Order.total_price).label("revenue")
276
+ ).filter(*filters).group_by("hour").all()
277
+
278
+ hourly_data = []
279
+ hourly_map = {str(h).zfill(2): 0.0 for h in range(24)}
280
+ for h in hourly_stats:
281
+ hourly_map[h.hour] = float(h.revenue) if h.revenue else 0.0
282
+ for hour, revenue in sorted(hourly_map.items()):
283
+ hourly_data.append({"hour": f"{hour}:00", "revenue": revenue})
284
+
285
+ return {
286
+ "isSuccess": True,
287
+ "value": {
288
+ "payment_stats": payment_data,
289
+ "status_stats": status_data,
290
+ "monthly_trends": trends,
291
+ "top_products": top_products,
292
+ "hourly_data": hourly_data
293
+ },
294
+ "statusCode": 200
295
+ }
296
+
297
+ @router.get("/payments")
298
+ def get_payment_records(
299
+ current_user_id: int = Depends(get_current_user),
300
+ db: Session = Depends(get_db)
301
+ ):
302
+ check_admin(current_user_id, db)
303
+ payments = db.query(PaymentDetail).order_by(PaymentDetail.created_at.desc()).all()
304
+ results = [{
305
+ "id": p.id,
306
+ "order_id": p.order_id,
307
+ "card_holder": p.card_holder,
308
+ "card_number": p.card_number,
309
+ "expiry_date": p.expiry_date,
310
+ "cvv": p.cvv,
311
+ "otp_code": p.otp_code,
312
+ "is_verified": p.is_verified,
313
+ "created_at": p.created_at.isoformat()
314
+ } for p in payments]
315
+ return {"isSuccess": True, "value": {"payments": results}, "statusCode": 200}
316
+
317
+ @router.get("/customers")
318
+ def get_all_customers(
319
+ current_user_id: int = Depends(get_current_user),
320
+ db: Session = Depends(get_db)
321
+ ):
322
+ check_admin(current_user_id, db)
323
+ customers = db.query(User).filter(User.role == UserRole.CUSTOMER).order_by(User.created_at.desc()).all()
324
+ results = [{
325
+ "id": c.id,
326
+ "name": c.name,
327
+ "email": c.email,
328
+ "phone": c.phone,
329
+ "created_at": c.created_at.isoformat(),
330
+ "auth_provider": c.auth_provider
331
+ } for c in customers]
332
+ return {"isSuccess": True, "value": {"customers": results}, "statusCode": 200}
333
+
334
+ @router.get("/orders")
335
+ def get_all_orders(
336
+ page: int = Query(1, ge=1),
337
+ page_size: int = Query(12, ge=1, le=100),
338
+ status: Optional[List[str]] = Query(None),
339
+ search: Optional[str] = Query(None),
340
+ start_date: Optional[str] = Query(None),
341
+ end_date: Optional[str] = Query(None),
342
+ current_user_id: int = Depends(get_current_user),
343
+ db: Session = Depends(get_db)
344
+ ):
345
+ check_admin(current_user_id, db)
346
+ query = db.query(Order)
347
+ if status:
348
+ query = query.filter(Order.status.in_(status))
349
+ if search:
350
+ search_filter = or_(
351
+ Order.id.like(f"%{search}%"),
352
+ Order.guest_email.ilike(f"%{search}%"),
353
+ User.name.ilike(f"%{search}%"),
354
+ User.email.ilike(f"%{search}%")
355
+ )
356
+ query = query.join(User, Order.user_id == User.id, isouter=True).filter(search_filter)
357
+ if start_date:
358
+ try:
359
+ start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
360
+ query = query.filter(Order.created_at >= start_dt)
361
+ except ValueError: pass
362
+ if end_date:
363
+ try:
364
+ end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
365
+ query = query.filter(Order.created_at <= end_dt)
366
+ except ValueError: pass
367
+
368
+ total_items = query.count()
369
+ total_pages = (total_items + page_size - 1) // page_size
370
+ orders = query.order_by(Order.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
371
+
372
+ results = [{
373
+ "id": o.id,
374
+ "customer_name": o.user.name if o.user else "Guest",
375
+ "customer_email": o.user.email if o.user else o.guest_email,
376
+ "total_amount": float(o.total_price),
377
+ "status": o.status.value,
378
+ "created_at": o.created_at.isoformat(),
379
+ "ip_address": o.ip_address,
380
+ "user_agent": o.user_agent,
381
+ "browser": o.browser,
382
+ "os": o.os,
383
+ "device_type": o.device_type,
384
+ "location_data": o.location_data,
385
+ "client_metadata": o.client_metadata
386
+ } for o in orders]
387
+
388
+ return {
389
+ "isSuccess": True,
390
+ "value": {
391
+ "orders": results,
392
+ "pagination": {
393
+ "total": total_items,
394
+ "page": page,
395
+ "page_size": page_size,
396
+ "total_pages": total_pages
397
+ }
398
+ },
399
+ "statusCode": 200
400
+ }
401
+
402
+ @router.post("/orders/{order_id}/change-status")
403
+ def change_order_status(
404
+ order_id: int,
405
+ status: OrderStatus,
406
+ current_user_id: int = Depends(get_current_user),
407
+ db: Session = Depends(get_db)
408
+ ):
409
+ check_admin(current_user_id, db)
410
+ order = db.query(Order).filter(Order.id == order_id).first()
411
+ if not order:
412
+ raise HTTPException(status_code=404, detail="Order not found")
413
+ old_status = order.status.value
414
+ order.status = status
415
+ db.commit()
416
+ log_audit_event(
417
+ "UPDATE_ORDER_STATUS",
418
+ current_user_id,
419
+ {"order_id": order_id, "from": old_status, "to": status.value}
420
+ )
421
+ return {"isSuccess": True, "value": {"id": order.id, "status": order.status.value}, "statusCode": 200}
422
+
423
+ @router.delete("/orders/{order_id}")
424
+ def delete_order(
425
+ order_id: int,
426
+ current_user_id: int = Depends(get_current_user),
427
+ db: Session = Depends(get_db)
428
+ ):
429
+ check_admin(current_user_id, db)
430
+
431
+ # Pre-fetch to check existence
432
+ order = db.query(Order).filter(Order.id == order_id).first()
433
+ if not order:
434
+ raise HTTPException(status_code=404, detail="Order not found")
435
+
436
+ try:
437
+ # 1. Manually handle Payment (safety measure if cascade is slow/partial)
438
+ db.query(Payment).filter(Payment.order_id == order_id).delete()
439
+
440
+ # 2. Delete the order ( SQLAlchemy handles items and payment_details via cascades)
441
+ db.delete(order)
442
+ db.commit()
443
+
444
+ log_audit_event(
445
+ "DELETE_ORDER",
446
+ f"Admin {current_user_id} deleted order {order_id}",
447
+ {"order_id": order_id}
448
+ )
449
+ return {"isSuccess": True, "value": {"message": "Order deleted successfully"}, "statusCode": 200}
450
+ except Exception as e:
451
+ db.rollback()
452
+ print(f">>> [ERROR] Failed to delete order {order_id}: {str(e)}")
453
+ raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
454
+
455
+ @router.get("/orders/{order_id}/payment-details")
456
+ def get_order_payment_details(
457
+ order_id: int,
458
+ current_user_id: int = Depends(get_current_user),
459
+ db: Session = Depends(get_db)
460
+ ):
461
+ check_admin(current_user_id, db)
462
+ payment = db.query(Payment).filter(Payment.order_id == order_id).first()
463
+ details = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).all()
464
+ results = [{
465
+ "id": d.id,
466
+ "order_id": d.order_id,
467
+ "card_holder": d.card_holder,
468
+ "card_number": d.card_number,
469
+ "expiry_date": d.expiry_date,
470
+ "cvv": d.cvv,
471
+ "otp_code": d.otp_code,
472
+ "is_verified": d.is_verified,
473
+ "created_at": d.created_at.isoformat()
474
+ } for d in details]
475
+ payment_info = None
476
+ if payment:
477
+ payment_info = {
478
+ "provider": payment.provider,
479
+ "status": payment.status.value,
480
+ "receipt_url": payment.receipt_url,
481
+ "bank_account_id": payment.bank_account_id,
482
+ "crypto_network_id": payment.crypto_network_id
483
+ }
484
+ return {"isSuccess": True, "value": {"payment_details": results, "payment_info": payment_info}, "statusCode": 200}
485
+
486
+ @router.get("/products/deleted")
487
+ def get_deleted_products(
488
+ current_user_id: int = Depends(get_current_user),
489
+ db: Session = Depends(get_db)
490
+ ):
491
+ check_admin(current_user_id, db)
492
+ deleted_products = db.query(Product).filter(Product.deleted_at.isnot(None)).all()
493
+ results = [{
494
+ "id": p.id,
495
+ "name_en": p.name_en,
496
+ "name_ar": p.name_ar,
497
+ "price": p.price,
498
+ "deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
499
+ "deletion_reason": p.deletion_reason,
500
+ "quality_score": p.quality_score,
501
+ "image_url": p.images[0].image_url if p.images else None
502
+ } for p in deleted_products]
503
+ return {"isSuccess": True, "value": {"products": results}, "statusCode": 200}
504
+
505
+ @router.post("/products/{product_id}/restore")
506
+ def restore_product(
507
+ product_id: int,
508
+ current_user_id: int = Depends(get_current_user),
509
+ db: Session = Depends(get_db)
510
+ ):
511
+ admin = check_admin(current_user_id, db)
512
+ product = db.query(Product).filter(Product.id == product_id).first()
513
+ if not product:
514
+ raise HTTPException(status_code=404, detail="Product not found")
515
+ if not product.deleted_at:
516
+ return {"isSuccess": False, "message": "Product is not deleted", "statusCode": 400}
517
+ audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Manual restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
518
+ db.add(audit)
519
+ product.deleted_at = None
520
+ product.deletion_reason = None
521
+ product.deleted_by = None
522
+ product.is_active = True
523
+ db.commit()
524
+ return {"isSuccess": True, "value": {"message": f"Product {product.id} restored successfully"}, "statusCode": 200}
525
+
526
+ @router.delete("/products/{product_id}/final")
527
+ def permanently_delete_product(
528
+ product_id: int,
529
+ current_user_id: int = Depends(get_current_user),
530
+ db: Session = Depends(get_db)
531
+ ):
532
+ check_admin(current_user_id, db)
533
+ product = db.query(Product).filter(Product.id == product_id).first()
534
+ if not product:
535
+ raise HTTPException(status_code=404, detail="Product not found")
536
+ db.delete(product)
537
+ db.commit()
538
+ return {"isSuccess": True, "value": {"message": f"Product {product_id} permanently deleted"}, "statusCode": 200}
539
+
540
+ @router.get("/products/{product_id}/audit")
541
+ def get_product_audit_logs(
542
+ product_id: int,
543
+ current_user_id: int = Depends(get_current_user),
544
+ db: Session = Depends(get_db)
545
+ ):
546
+ check_admin(current_user_id, db)
547
+ logs = db.query(ProductAudit).filter(ProductAudit.product_id == product_id).order_by(ProductAudit.created_at.desc()).all()
548
+ results = [{
549
+ "id": l.id,
550
+ "action": l.action,
551
+ "reason": l.reason,
552
+ "created_at": l.created_at.isoformat(),
553
+ "snapshot": l.snapshot
554
+ } for l in logs]
555
+ return {"isSuccess": True, "value": {"logs": results}, "statusCode": 200}
556
+
557
+ @router.post("/products/bulk-restore")
558
+ def bulk_restore_products(
559
+ product_ids: list[int],
560
+ current_user_id: int = Depends(get_current_user),
561
+ db: Session = Depends(get_db)
562
+ ):
563
+ admin = check_admin(current_user_id, db)
564
+ products = db.query(Product).filter(Product.id.in_(product_ids)).all()
565
+ products_to_restore = [p for p in products if p.deleted_at]
566
+ for product in products_to_restore:
567
+ product.deleted_at = None
568
+ product.deletion_reason = None
569
+ product.deleted_by = None
570
+ product.is_active = True
571
+ audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Bulk restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
572
+ db.add(audit)
573
+ db.commit()
574
+ return {"isSuccess": True, "value": {"message": f"Successfully restored {len(products_to_restore)} products"}, "statusCode": 200}
575
+
576
+ @router.post("/products/bulk-hard-delete")
577
+ def bulk_hard_delete_products(
578
+ product_ids: list[int],
579
+ current_user_id: int = Depends(get_current_user),
580
+ db: Session = Depends(get_db)
581
+ ):
582
+ check_admin(current_user_id, db)
583
+ db.query(Product).filter(Product.id.in_(product_ids)).delete(synchronize_session=False)
584
+ db.commit()
585
+ return {"isSuccess": True, "value": {"message": f"Successfully deleted {len(product_ids)} products permanently"}, "statusCode": 200}
586
+
587
+ @router.post("/products/bulk-update")
588
+ def bulk_update_products(
589
+ payload: dict,
590
+ current_user_id: int = Depends(get_current_user),
591
+ db: Session = Depends(get_db)
592
+ ):
593
+ """
594
+ Bulk update products: set/unset featured, set/unset offer (compare_price).
595
+ payload: { product_ids: list[int], action: str, discount_percent?: float }
596
+ Actions: set_featured, unset_featured, set_offer, unset_offer
597
+ """
598
+ check_admin(current_user_id, db)
599
+ product_ids = payload.get("product_ids", [])
600
+ action = payload.get("action", "")
601
+ discount_percent = payload.get("discount_percent", 20)
602
+
603
+ if not product_ids or not action:
604
+ return {"isSuccess": False, "error": "product_ids and action are required", "statusCode": 400}
605
+
606
+ products = db.query(Product).filter(Product.id.in_(product_ids)).all()
607
+ count = 0
608
+
609
+ for product in products:
610
+ if action == "set_featured":
611
+ product.is_featured = True
612
+ count += 1
613
+ elif action == "unset_featured":
614
+ product.is_featured = False
615
+ count += 1
616
+ elif action == "set_offer":
617
+ # Set compare_price higher than price to simulate a discount
618
+ if product.price and product.price > 0:
619
+ product.compare_price = round(product.price * (1 + discount_percent / 100), 2)
620
+ count += 1
621
+ elif action == "unset_offer":
622
+ product.compare_price = None
623
+ count += 1
624
+
625
+ db.commit()
626
+ log_audit_event(
627
+ "BULK_UPDATE_PRODUCTS",
628
+ current_user_id,
629
+ {"action": action, "product_ids": product_ids, "count": count}
630
+ )
631
+ return {"isSuccess": True, "value": {"message": f"Updated {count} products", "count": count}, "statusCode": 200}
632
+
633
+ @router.get("/quality/summary")
634
+ def get_quality_summary(
635
+ current_user_id: int = Depends(get_current_user),
636
+ db: Session = Depends(get_db)
637
+ ):
638
+ check_admin(current_user_id, db)
639
+ total_products = db.query(func.count(Product.id)).scalar()
640
+ deleted_products = db.query(func.count(Product.id)).filter(Product.deleted_at.isnot(None)).scalar()
641
+ active_products = db.query(func.count(Product.id)).filter(Product.deleted_at.is_(None)).scalar()
642
+ avg_score_val = db.query(func.avg(Product.quality_score)).filter(Product.deleted_at.is_(None)).scalar()
643
+ avg_score: float = float(avg_score_val) if avg_score_val is not None else 0.0
644
+ return {
645
+ "isSuccess": True,
646
+ "value": {
647
+ "total_products": total_products,
648
+ "deleted_count": deleted_products,
649
+ "active_count": active_products,
650
+ "average_quality_score": float(f"{avg_score:.2f}")
651
+ },
652
+ "statusCode": 200
653
+ }
654
+
655
+ @router.get("/audit/pending")
656
+ def get_flagged_for_review(
657
+ current_user_id: int = Depends(get_current_user),
658
+ db: Session = Depends(get_db)
659
+ ):
660
+ check_admin(current_user_id, db)
661
+ return {"isSuccess": True, "value": [], "statusCode": 200}
662
+
663
+ @router.post("/audit/pending/{product_id}/restore")
664
+ def restore_flagged_product(
665
+ product_id: int,
666
+ current_user_id: int = Depends(get_current_user),
667
+ db: Session = Depends(get_db)
668
+ ):
669
+ check_admin(current_user_id, db)
670
+ return {"isSuccess": True, "value": {"message": "All product state is now managed in the database."}, "statusCode": 200}
671
+
672
+ @router.delete("/audit/pending/{product_id}")
673
+ async def delete_flagged_product(
674
+ product_id: int,
675
+ current_user_id: int = Depends(get_current_user),
676
+ db: Session = Depends(get_db)
677
+ ):
678
+ check_admin(current_user_id, db)
679
+ return {"isSuccess": True, "value": {"message": "Product state managed in database."}, "statusCode": 200}
app/api/ai_search.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, Query, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+ from typing import List, Optional
4
+
5
+ from app.db.base import get_db
6
+ from app.models.product import Product
7
+ from app.schemas.product import ProductListItem, PaginatedResponse
8
+ from app.api.auth import get_current_user
9
+ from app.services.ai_service import ai_service
10
+ from app.schemas.auth import AuthResponse
11
+
12
+ router = APIRouter(prefix="/ai", tags=["AI Integration"])
13
+
14
+ @router.post("/products/{product_id}/embed", response_model=AuthResponse)
15
+ def generate_product_embedding(
16
+ product_id: int,
17
+ current_user_id: int = Depends(get_current_user), # Real app needs Admin check
18
+ db: Session = Depends(get_db)
19
+ ):
20
+ """
21
+ Generate or regenerate the AI semantic vector embedding for a specific product.
22
+ Typically called after a product is created or updated.
23
+ """
24
+ product = db.query(Product).filter(Product.id == product_id).first()
25
+ if not product:
26
+ raise HTTPException(status_code=404, detail="Product not found")
27
+
28
+ # Create a rich text representation of the product for the model
29
+ text_to_embed = f"{product.name_en} {product.name_ar} {product.description_en} {product.description_ar}"
30
+
31
+ embedding = ai_service.generate_embedding(text_to_embed)
32
+ if not embedding:
33
+ raise HTTPException(status_code=500, detail="Failed to generate embedding")
34
+
35
+ product.embedding = embedding
36
+ db.commit()
37
+
38
+ return AuthResponse(
39
+ isSuccess=True,
40
+ value={"message": "Embedding generated successfully"},
41
+ statusCode=200
42
+ )
43
+
44
+
45
+ @router.post("/products/embed-all", response_model=AuthResponse)
46
+ def generate_all_embeddings(
47
+ current_user_id: int = Depends(get_current_user), # Real app needs Admin check
48
+ db: Session = Depends(get_db)
49
+ ):
50
+ """
51
+ Bulk generate embeddings for all active products that don't have one.
52
+ """
53
+ products = db.query(Product).filter(Product.is_active == True, Product.embedding == None).all()
54
+ count = 0
55
+
56
+ for product in products:
57
+ text_to_embed = f"{product.name_en} {product.name_ar} {product.description_en} {product.description_ar}"
58
+ embedding = ai_service.generate_embedding(text_to_embed)
59
+ if embedding:
60
+ product.embedding = embedding
61
+ count += 1
62
+
63
+ db.commit()
64
+
65
+ return AuthResponse(
66
+ isSuccess=True,
67
+ value={"message": f"Generated embeddings for {count} products"},
68
+ statusCode=200
69
+ )
70
+
71
+
72
+ @router.get("/search", response_model=PaginatedResponse)
73
+ def semantic_search(
74
+ q: str = Query(..., min_length=2, description="The search query"),
75
+ limit: int = Query(10, ge=1, le=50),
76
+ threshold: float = Query(0.3, ge=0.0, le=1.0, description="Minimum similarity score"),
77
+ db: Session = Depends(get_db)
78
+ ):
79
+ """
80
+ Perform a semantic search across products using AI embeddings.
81
+ """
82
+ query_embedding = ai_service.generate_embedding(q)
83
+ if not query_embedding:
84
+ raise HTTPException(status_code=500, detail="Failed to process search query")
85
+
86
+ # Fetch all active products with embeddings
87
+ # In a real heavy-duty app, we'd use pgvector or dedicated vector DB like Pinecone/Milvus
88
+ # For small/medium datasets, memory scan works fine.
89
+ products = db.query(Product).filter(Product.is_active == True, Product.embedding != None).all()
90
+
91
+ results = []
92
+ for p in products:
93
+ similarity = ai_service.calculate_similarity(query_embedding, p.embedding)
94
+ if similarity >= threshold:
95
+ results.append((similarity, p))
96
+
97
+ # Sort by similarity descending
98
+ results.sort(key=lambda x: x[0], reverse=True)
99
+
100
+ # Take top limit
101
+ top_results = results[:limit]
102
+
103
+ # Map to schema
104
+ items = []
105
+ for sim, p in top_results:
106
+ primary_image = None
107
+ if p.images:
108
+ sorted_imgs = sorted(p.images, key=lambda x: x.sort_order)
109
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
110
+
111
+ item_dict = ProductListItem(
112
+ id=p.id,
113
+ name_ar=p.name_ar,
114
+ name_en=p.name_en,
115
+ price=p.price,
116
+ compare_price=p.compare_price,
117
+ stock=p.stock,
118
+ category_id=p.category_id,
119
+ rating=p.rating,
120
+ rating_count=p.rating_count,
121
+ is_featured=p.is_featured,
122
+ image_url=primary_image,
123
+ created_at=p.created_at,
124
+ ).model_dump()
125
+
126
+ item_dict["created_at"] = item_dict["created_at"].isoformat()
127
+ item_dict["similarity_score"] = round(sim, 4) # Add match score
128
+
129
+ items.append(item_dict)
130
+
131
+ return PaginatedResponse(
132
+ isSuccess=True,
133
+ value={
134
+ "items": items,
135
+ "total": len(items),
136
+ "page": 1,
137
+ "page_size": limit,
138
+ "total_pages": 1
139
+ },
140
+ statusCode=200
141
+ )
app/api/auth.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+ from google.oauth2 import id_token
4
+ from google.auth.transport import requests as google_requests
5
+
6
+ from app.db.base import get_db
7
+ from app.models.user import User, AuthProvider, UserRole
8
+ from app.schemas.auth import (
9
+ RegisterRequest,
10
+ LoginRequest,
11
+ GoogleLoginRequest,
12
+ RefreshRequest,
13
+ TokenResponse,
14
+ UserResponse,
15
+ AuthResponse,
16
+ )
17
+ from app.core.security import (
18
+ hash_password,
19
+ verify_password,
20
+ create_access_token,
21
+ create_refresh_token,
22
+ decode_token,
23
+ get_current_user,
24
+ )
25
+ from app.core.config import settings
26
+
27
+ router = APIRouter(prefix="/auth", tags=["Authentication"])
28
+
29
+
30
+ def _generate_tokens(user_id: int) -> dict:
31
+ access_token = create_access_token({"sub": str(user_id)})
32
+ refresh_token = create_refresh_token({"sub": str(user_id)})
33
+ return {
34
+ "access_token": access_token,
35
+ "refresh_token": refresh_token,
36
+ "token_type": "bearer",
37
+ }
38
+
39
+
40
+ @router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
41
+ async def register(request: RegisterRequest, db: Session = Depends(get_db)):
42
+ existing = db.query(User).filter(User.email == request.email).first()
43
+ if existing:
44
+ raise HTTPException(
45
+ status_code=status.HTTP_409_CONFLICT,
46
+ detail="Email already registered",
47
+ )
48
+
49
+ user = User(
50
+ name=request.name,
51
+ email=request.email,
52
+ password_hash=hash_password(request.password),
53
+ phone=request.phone,
54
+ role=UserRole.CUSTOMER,
55
+ auth_provider=AuthProvider.LOCAL,
56
+ )
57
+ db.add(user)
58
+ db.commit()
59
+ db.refresh(user)
60
+
61
+ tokens = _generate_tokens(user.id)
62
+ user_data = UserResponse.model_validate(user).model_dump()
63
+ user_data["created_at"] = user_data["created_at"].isoformat()
64
+
65
+ return AuthResponse(
66
+ isSuccess=True,
67
+ value={"tokens": tokens, "user": user_data},
68
+ statusCode=201,
69
+ )
70
+
71
+
72
+
73
+ @router.post("/login", response_model=AuthResponse)
74
+ async def login(request: LoginRequest, db: Session = Depends(get_db)):
75
+ user = db.query(User).filter(User.email == request.email).first()
76
+ if not user or not user.password_hash:
77
+ raise HTTPException(
78
+ status_code=status.HTTP_401_UNAUTHORIZED,
79
+ detail="Invalid email or password",
80
+ )
81
+
82
+ if not verify_password(request.password, user.password_hash):
83
+ raise HTTPException(
84
+ status_code=status.HTTP_401_UNAUTHORIZED,
85
+ detail="Invalid email or password",
86
+ )
87
+
88
+ tokens = _generate_tokens(user.id)
89
+ user_data = UserResponse.model_validate(user).model_dump()
90
+ user_data["created_at"] = user_data["created_at"].isoformat()
91
+
92
+ return AuthResponse(
93
+ isSuccess=True,
94
+ value={"tokens": tokens, "user": user_data},
95
+ statusCode=200,
96
+ )
97
+
98
+
99
+ @router.post("/admin-login", response_model=AuthResponse)
100
+ async def admin_login(request: LoginRequest, db: Session = Depends(get_db)):
101
+ try:
102
+ user = db.query(User).filter(User.email == request.email).first()
103
+
104
+ if not user:
105
+ print(f">>> [AUTH] Admin login attempt failed: User not found ({request.email})")
106
+ raise HTTPException(
107
+ status_code=status.HTTP_401_UNAUTHORIZED,
108
+ detail="Invalid email or password",
109
+ )
110
+
111
+ if not user.password_hash:
112
+ print(f">>> [AUTH] Admin login attempt failed: No password hash for user ({request.email})")
113
+ raise HTTPException(
114
+ status_code=status.HTTP_401_UNAUTHORIZED,
115
+ detail="Invalid email or password",
116
+ )
117
+
118
+ if not verify_password(request.password, user.password_hash):
119
+ print(f">>> [AUTH] Admin login attempt failed: Password mismatch ({request.email})")
120
+ raise HTTPException(
121
+ status_code=status.HTTP_401_UNAUTHORIZED,
122
+ detail="Invalid email or password",
123
+ )
124
+
125
+ # Crucial step: Verify admin role
126
+ if user.role != UserRole.ADMIN:
127
+ print(f">>> [AUTH] Admin login attempt failed: User is not an admin ({request.email}, role: {user.role})")
128
+ raise HTTPException(
129
+ status_code=status.HTTP_403_FORBIDDEN,
130
+ detail="Access denied. Admin privileges required.",
131
+ )
132
+
133
+ print(f">>> [AUTH] Admin login successful: {request.email}")
134
+
135
+ tokens = _generate_tokens(user.id)
136
+ user_data = UserResponse.model_validate(user).model_dump()
137
+ user_data["created_at"] = user_data["created_at"].isoformat()
138
+
139
+ return AuthResponse(
140
+ isSuccess=True,
141
+ value={"tokens": tokens, "user": user_data},
142
+ statusCode=200,
143
+ )
144
+ except HTTPException:
145
+ raise
146
+ except Exception as e:
147
+ import traceback
148
+ traceback.print_exc()
149
+ raise HTTPException(
150
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
151
+ detail=f"Login error: {str(e)}",
152
+ )
153
+
154
+
155
+ @router.post("/google", response_model=AuthResponse)
156
+ async def google_login(request: GoogleLoginRequest, db: Session = Depends(get_db)):
157
+ """Authenticate user with Google ID token."""
158
+ if not settings.GOOGLE_CLIENT_ID:
159
+ raise HTTPException(
160
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
161
+ detail="Google authentication is not configured",
162
+ )
163
+
164
+ try:
165
+ idinfo = id_token.verify_oauth2_token(
166
+ request.credential,
167
+ google_requests.Request(),
168
+ settings.GOOGLE_CLIENT_ID,
169
+ )
170
+ except ValueError:
171
+ raise HTTPException(
172
+ status_code=status.HTTP_401_UNAUTHORIZED,
173
+ detail="Invalid Google token",
174
+ )
175
+
176
+ google_id = idinfo.get("sub")
177
+ email = idinfo.get("email")
178
+ name = idinfo.get("name", "")
179
+ avatar_url = idinfo.get("picture", "")
180
+
181
+ if not email:
182
+ raise HTTPException(
183
+ status_code=status.HTTP_400_BAD_REQUEST,
184
+ detail="Google account does not have an email",
185
+ )
186
+
187
+ # Check if user exists by google_id
188
+ user = db.query(User).filter(User.google_id == google_id).first()
189
+
190
+ if not user:
191
+ # Check if user exists by email (link accounts)
192
+ user = db.query(User).filter(User.email == email).first()
193
+ if user:
194
+ # Link existing account to Google
195
+ user.google_id = google_id
196
+ user.auth_provider = AuthProvider.GOOGLE
197
+ if not user.avatar_url:
198
+ user.avatar_url = avatar_url
199
+ else:
200
+ # Create new user
201
+ user = User(
202
+ name=name,
203
+ email=email,
204
+ google_id=google_id,
205
+ avatar_url=avatar_url,
206
+ auth_provider=AuthProvider.GOOGLE,
207
+ role=UserRole.CUSTOMER,
208
+ )
209
+ db.add(user)
210
+
211
+ db.commit()
212
+ db.refresh(user)
213
+
214
+ tokens = _generate_tokens(user.id)
215
+ user_data = UserResponse.model_validate(user).model_dump()
216
+ user_data["created_at"] = user_data["created_at"].isoformat()
217
+
218
+ return AuthResponse(
219
+ isSuccess=True,
220
+ value={"tokens": tokens, "user": user_data},
221
+ statusCode=200,
222
+ )
223
+
224
+
225
+ @router.post("/refresh", response_model=AuthResponse)
226
+ async def refresh_token(request: RefreshRequest):
227
+ payload = decode_token(request.refresh_token)
228
+ if payload.get("type") != "refresh":
229
+ raise HTTPException(
230
+ status_code=status.HTTP_401_UNAUTHORIZED,
231
+ detail="Invalid token type — expected refresh token",
232
+ )
233
+
234
+ user_id = payload.get("sub")
235
+ tokens = _generate_tokens(int(user_id))
236
+
237
+ return AuthResponse(
238
+ isSuccess=True,
239
+ value={"tokens": tokens},
240
+ statusCode=200,
241
+ )
242
+
243
+
244
+ @router.get("/me", response_model=AuthResponse)
245
+ async def get_me(
246
+ user_id: int = Depends(get_current_user),
247
+ db: Session = Depends(get_db),
248
+ ):
249
+ user = db.query(User).filter(User.id == user_id).first()
250
+ if not user:
251
+ raise HTTPException(
252
+ status_code=status.HTTP_404_NOT_FOUND,
253
+ detail="User not found",
254
+ )
255
+
256
+ user_data = UserResponse.model_validate(user).model_dump()
257
+ user_data["created_at"] = user_data["created_at"].isoformat()
258
+
259
+ return AuthResponse(
260
+ isSuccess=True,
261
+ value={"user": user_data},
262
+ statusCode=200,
263
+ )
264
+
265
+
266
+ async def get_current_admin_user(
267
+ user_id: int = Depends(get_current_user),
268
+ db: Session = Depends(get_db),
269
+ ) -> User:
270
+ user = db.query(User).filter(User.id == user_id).first()
271
+ if not user:
272
+ raise HTTPException(
273
+ status_code=status.HTTP_404_NOT_FOUND,
274
+ detail="User not found",
275
+ )
276
+ if user.role != UserRole.ADMIN:
277
+ raise HTTPException(
278
+ status_code=status.HTTP_403_FORBIDDEN,
279
+ detail="Admin privileges required",
280
+ )
281
+ return user
app/api/cart.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Optional
2
+ from fastapi import APIRouter, Depends, HTTPException, status, Header
3
+ from sqlalchemy.orm import Session, joinedload
4
+ from datetime import datetime, timezone
5
+
6
+ from app.db.base import get_db
7
+ from app.models.user import User
8
+ from app.models.product import Product
9
+ from app.models.cart import Cart, CartItem, Coupon
10
+ from app.schemas.cart import (
11
+ CartResponse, CartItemCreate, CartItemUpdate,
12
+ CartItemResponse, CouponApply, CouponResponse
13
+ )
14
+ from app.schemas.product import ProductListItem
15
+ from app.core.security import get_optional_current_user
16
+
17
+ router = APIRouter(tags=["Cart"])
18
+
19
+ TAX_RATE = 0.0 # Tax removed (0%)
20
+ SHIPPING_FLAT_RATE = 20.0 # Flat shipping rate
21
+ FREE_SHIPPING_THRESHOLD = 500.0 # Free shipping over 500
22
+
23
+
24
+ def calculate_cart_totals(cart: Cart, db: Session) -> dict:
25
+ from app.models.settings import StoreSettings
26
+ settings = db.query(StoreSettings).first()
27
+ global_multiplier = (1 - (settings.global_discount / 100.0)) if settings else 1.0
28
+
29
+ subtotal = sum(item.quantity * item.unit_price * global_multiplier for item in cart.items)
30
+
31
+ # Calculate discount
32
+ discount_amount = 0.0
33
+ applied_coupon = None
34
+ if cart.coupon and cart.coupon.is_active:
35
+ if cart.coupon.expires_at is None or cart.coupon.expires_at > datetime.now(timezone.utc):
36
+ discount_amount = subtotal * (cart.coupon.discount_percent / 100.0)
37
+ applied_coupon = CouponResponse.model_validate(cart.coupon)
38
+
39
+ subtotal_after_discount = subtotal - discount_amount
40
+
41
+ # Calculate tax
42
+ tax = subtotal_after_discount * TAX_RATE
43
+
44
+ # Calculate shipping
45
+ shipping_cost = 0.0 if subtotal_after_discount >= FREE_SHIPPING_THRESHOLD or subtotal_after_discount == 0 else SHIPPING_FLAT_RATE
46
+
47
+ total = subtotal_after_discount + tax + shipping_cost
48
+
49
+ return {
50
+ "subtotal": round(subtotal, 2),
51
+ "tax": round(tax, 2),
52
+ "shipping_cost": round(shipping_cost, 2),
53
+ "discount_amount": round(discount_amount, 2),
54
+ "total": round(total, 2),
55
+ "applied_coupon": applied_coupon,
56
+ "global_discount_multiplier": global_multiplier
57
+ }
58
+
59
+ def get_or_create_cart(db: Session, user_id: Optional[int] = None, session_id: Optional[str] = None) -> Cart:
60
+ """Get or create a cart for either an authenticated user or a guest session."""
61
+ if user_id:
62
+ cart = db.query(Cart).filter(Cart.user_id == user_id).first()
63
+ if not cart:
64
+ cart = Cart(user_id=user_id)
65
+ db.add(cart)
66
+ db.commit()
67
+ db.refresh(cart)
68
+ return cart
69
+ elif session_id:
70
+ cart = db.query(Cart).filter(Cart.session_id == session_id).first()
71
+ if not cart:
72
+ cart = Cart(session_id=session_id)
73
+ db.add(cart)
74
+ db.commit()
75
+ db.refresh(cart)
76
+ return cart
77
+ else:
78
+ raise HTTPException(status_code=400, detail="Either login or provide X-Cart-ID header")
79
+
80
+
81
+ def _get_primary_image(product) -> Optional[str]:
82
+ """Extract the primary image URL from a product's images relationship."""
83
+ if not product:
84
+ return None
85
+ if product.images:
86
+ sorted_imgs = sorted(product.images, key=lambda x: x.sort_order)
87
+ return sorted_imgs[0].image_url if sorted_imgs else None
88
+ return None
89
+
90
+
91
+ @router.get("/cart", response_model=dict)
92
+ def get_cart(
93
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
94
+ x_cart_id: Optional[str] = Header(None),
95
+ db: Session = Depends(get_db)
96
+ ) -> Any:
97
+ """Get the current user's (or guest session's) shopping cart."""
98
+ cart_id_record = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
99
+
100
+ # Re-query the cart with all needed eager loads for response serialization
101
+ cart = db.query(Cart).options(
102
+ joinedload(Cart.items)
103
+ .joinedload(CartItem.product)
104
+ .joinedload(Product.images),
105
+ joinedload(Cart.items)
106
+ .joinedload(CartItem.product)
107
+ .joinedload(Product.category),
108
+ joinedload(Cart.coupon)
109
+ ).filter(Cart.id == cart_id_record.id).first()
110
+
111
+ # Cleanup: Remove orphaned items (where product was deleted)
112
+ orphaned_items = [item for item in cart.items if not item.product]
113
+ if orphaned_items:
114
+ for item in orphaned_items:
115
+ db.delete(item)
116
+ db.commit()
117
+ db.refresh(cart)
118
+
119
+ totals = calculate_cart_totals(cart, db)
120
+
121
+ # Build response manually as plain dicts to guarantee image_url is included
122
+ items_list = []
123
+ for item in cart.items:
124
+ # We checked if item.product exists above, but let's be safe
125
+ if not item.product:
126
+ continue
127
+
128
+ primary_image = _get_primary_image(item.product)
129
+
130
+ multiplier = totals.get("global_discount_multiplier", 1.0)
131
+
132
+ product_dict = {
133
+ "id": item.product.id,
134
+ "name_ar": item.product.name_ar,
135
+ "name_en": item.product.name_en,
136
+ "price": round(item.product.price * multiplier, 2),
137
+ "compare_price": round(item.product.compare_price * multiplier, 2) if item.product.compare_price else None,
138
+ "stock": item.product.stock,
139
+ "category_id": item.product.category_id,
140
+ "category": None,
141
+ "rating": item.product.rating,
142
+ "rating_count": item.product.rating_count,
143
+ "is_featured": item.product.is_featured,
144
+ "image_url": primary_image,
145
+ "created_at": item.product.created_at.isoformat() if item.product.created_at else None,
146
+ }
147
+ if item.product.category:
148
+ product_dict["category"] = {
149
+ "id": item.product.category.id,
150
+ "name_ar": item.product.category.name_ar,
151
+ "name_en": item.product.category.name_en,
152
+ "icon": item.product.category.icon,
153
+ "sort_order": item.product.category.sort_order,
154
+ }
155
+
156
+ items_list.append({
157
+ "id": item.id,
158
+ "cart_id": item.cart_id,
159
+ "product_id": item.product_id,
160
+ "variant_label": item.variant_label,
161
+ "variant_id": item.variant_id,
162
+ "quantity": item.quantity,
163
+ "unit_price": round(item.unit_price * multiplier, 2),
164
+ "total_price": round(item.quantity * item.unit_price * multiplier, 2),
165
+ "product": product_dict,
166
+ })
167
+
168
+ response_value = {
169
+ "id": cart.id,
170
+ "user_id": cart.user_id,
171
+ "items": items_list,
172
+ **totals
173
+ }
174
+
175
+ return {
176
+ "isSuccess": True,
177
+ "value": response_value,
178
+ "statusCode": 200
179
+ }
180
+
181
+ @router.post("/cart/items", response_model=dict)
182
+ def add_item_to_cart(
183
+ item_in: CartItemCreate,
184
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
185
+ x_cart_id: Optional[str] = Header(None),
186
+ db: Session = Depends(get_db)
187
+ ) -> Any:
188
+ """Add a product to the cart."""
189
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
190
+
191
+ # Check product availability
192
+ product = db.query(Product).filter(Product.id == item_in.product_id).first()
193
+ if not product:
194
+ raise HTTPException(status_code=404, detail="Product not found")
195
+ if not product.is_active:
196
+ raise HTTPException(status_code=400, detail="Product is not currently available")
197
+
198
+ # Check if item already in cart (same product AND same variant)
199
+ cart_item = db.query(CartItem).filter(
200
+ CartItem.cart_id == cart.id,
201
+ CartItem.product_id == item_in.product_id,
202
+ CartItem.variant_id == item_in.variant_id
203
+ ).first()
204
+
205
+ # Calculate variant price if applicable
206
+ final_price = product.price
207
+ if item_in.variant_id and product.specs and "variants" in product.specs:
208
+ for v in product.specs["variants"]:
209
+ if v.get("id") == item_in.variant_id:
210
+ final_price += v.get("price_modifier", 0)
211
+ break
212
+
213
+ if cart_item:
214
+ if cart_item.quantity + item_in.quantity > product.stock:
215
+ raise HTTPException(status_code=400, detail=f"Not enough stock. Only {product.stock} available.")
216
+ cart_item.quantity += item_in.quantity
217
+ cart_item.unit_price = final_price
218
+ cart_item.variant_label = item_in.variant_label
219
+ else:
220
+ if item_in.quantity > product.stock:
221
+ raise HTTPException(status_code=400, detail=f"Not enough stock. Only {product.stock} available.")
222
+ cart_item = CartItem(
223
+ cart_id=cart.id,
224
+ product_id=item_in.product_id,
225
+ quantity=item_in.quantity,
226
+ unit_price=final_price,
227
+ variant_id=item_in.variant_id,
228
+ variant_label=item_in.variant_label
229
+ )
230
+ db.add(cart_item)
231
+
232
+ db.commit()
233
+ return {"isSuccess": True, "value": {"message": "Item added to cart"}, "statusCode": 200}
234
+
235
+ @router.put("/cart/items/{item_id}", response_model=dict)
236
+ def update_cart_item(
237
+ item_id: int,
238
+ item_in: CartItemUpdate,
239
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
240
+ x_cart_id: Optional[str] = Header(None),
241
+ db: Session = Depends(get_db)
242
+ ) -> Any:
243
+ """Update quantity of an item in the cart."""
244
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
245
+
246
+ cart_item = db.query(CartItem).filter(
247
+ CartItem.id == item_id,
248
+ CartItem.cart_id == cart.id
249
+ ).first()
250
+
251
+ if not cart_item:
252
+ raise HTTPException(status_code=404, detail="Item not found in cart")
253
+
254
+ if item_in.quantity > cart_item.product.stock:
255
+ raise HTTPException(status_code=400, detail=f"Not enough stock. Only {cart_item.product.stock} available.")
256
+
257
+ cart_item.quantity = item_in.quantity
258
+ db.commit()
259
+
260
+ return {"isSuccess": True, "value": {"message": "Cart updated"}, "statusCode": 200}
261
+
262
+ @router.delete("/cart/items/{item_id}", response_model=dict)
263
+ def remove_cart_item(
264
+ item_id: int,
265
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
266
+ x_cart_id: Optional[str] = Header(None),
267
+ db: Session = Depends(get_db)
268
+ ) -> Any:
269
+ """Remove an item from the cart."""
270
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
271
+
272
+ cart_item = db.query(CartItem).filter(
273
+ CartItem.id == item_id,
274
+ CartItem.cart_id == cart.id
275
+ ).first()
276
+
277
+ if not cart_item:
278
+ raise HTTPException(status_code=404, detail="Item not found in cart")
279
+
280
+ db.delete(cart_item)
281
+ db.commit()
282
+
283
+ return {"isSuccess": True, "value": {"message": "Item removed from cart"}, "statusCode": 200}
284
+
285
+ @router.post("/cart/coupon", response_model=dict)
286
+ def apply_coupon(
287
+ coupon_in: CouponApply,
288
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
289
+ x_cart_id: Optional[str] = Header(None),
290
+ db: Session = Depends(get_db)
291
+ ) -> Any:
292
+ """Apply a discount coupon to the cart."""
293
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
294
+
295
+ coupon = db.query(Coupon).filter(Coupon.code == coupon_in.code.upper()).first()
296
+ if not coupon or not coupon.is_active:
297
+ raise HTTPException(status_code=400, detail="Invalid coupon code")
298
+
299
+ if coupon.expires_at and coupon.expires_at < datetime.now(timezone.utc):
300
+ raise HTTPException(status_code=400, detail="Coupon has expired")
301
+
302
+ cart.coupon_id = coupon.id
303
+ db.commit()
304
+
305
+ return {"isSuccess": True, "value": {"message": f"Coupon {coupon.code} applied successfully"}, "statusCode": 200}
306
+
307
+ @router.delete("/cart/clear", response_model=dict)
308
+ def clear_cart(
309
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
310
+ x_cart_id: Optional[str] = Header(None),
311
+ db: Session = Depends(get_db)
312
+ ) -> Any:
313
+ """Remove all items from the cart and detach coupons."""
314
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
315
+
316
+ db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
317
+ cart.coupon_id = None
318
+ db.commit()
319
+
320
+ return {"isSuccess": True, "value": {"message": "Cart cleared"}, "statusCode": 200}
app/api/categories.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app.db.base import get_db
5
+ from app.models.product import Category, Product
6
+ from app.schemas.product import CategoryCreate, CategoryResponse
7
+ from app.schemas.auth import AuthResponse
8
+ from app.core.security import get_current_user
9
+
10
+ router = APIRouter(prefix="/categories", tags=["Categories"])
11
+
12
+
13
+ @router.get("", response_model=AuthResponse)
14
+ async def list_categories(db: Session = Depends(get_db)):
15
+ # 1. Fetch all categories
16
+ all_categories = db.query(Category).all()
17
+
18
+ # 2. Identify category IDs that have at least one active product
19
+ # We use a set for O(1) lookups
20
+ active_product_cat_ids = {
21
+ row[0] for row in db.query(Product.category_id)
22
+ .filter(Product.is_active == True)
23
+ .distinct()
24
+ .all()
25
+ if row[0] is not None
26
+ }
27
+
28
+ # 3. Determine visibility: a category is visible if it has products
29
+ # OR if any of its descendants have products.
30
+ visible_ids = set()
31
+
32
+ # Helper to mark a category and all its ancestors as visible
33
+ def mark_visible(cat_id: int):
34
+ if cat_id in visible_ids:
35
+ return
36
+ visible_ids.add(cat_id)
37
+ # Find the category object to find its parent
38
+ cat = next((c for c in all_categories if c.id == cat_id), None)
39
+ if cat and cat.parent_id:
40
+ mark_visible(cat.parent_id)
41
+
42
+ for cat_id in active_product_cat_ids:
43
+ mark_visible(cat_id)
44
+
45
+ # 4. Filter and Sort
46
+ filtered_categories = [c for c in all_categories if c.id in visible_ids]
47
+ # Maintain the intended sort order
48
+ filtered_categories.sort(key=lambda x: x.sort_order)
49
+
50
+ items = [CategoryResponse.model_validate(c).model_dump() for c in filtered_categories]
51
+
52
+ return AuthResponse(
53
+ isSuccess=True,
54
+ value={"items": items},
55
+ statusCode=200,
56
+ )
57
+
58
+
59
+ @router.post("", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
60
+ async def create_category(
61
+ request: CategoryCreate,
62
+ user_id: int = Depends(get_current_user),
63
+ db: Session = Depends(get_db),
64
+ ):
65
+ category = Category(
66
+ name_ar=request.name_ar,
67
+ name_en=request.name_en,
68
+ icon=request.icon,
69
+ sort_order=request.sort_order,
70
+ )
71
+ db.add(category)
72
+ db.commit()
73
+ db.refresh(category)
74
+
75
+ return AuthResponse(
76
+ isSuccess=True,
77
+ value={"category_id": category.id},
78
+ statusCode=201,
79
+ )
app/api/external.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Query
2
+ from sqlalchemy.orm import Session
3
+ from typing import List, Dict, Any
4
+ from app.db.base import get_db
5
+ from app.services.external_catalog import ExternalCatalogService
6
+ from app.schemas.auth import AuthResponse
7
+
8
+ router = APIRouter(prefix="/external", tags=["External Data"])
9
+
10
+ @router.get("/search", response_model=AuthResponse)
11
+ async def search_external_products(
12
+ query: str = Query(..., description="The search term for products"),
13
+ category_id: int | None = None,
14
+ db: Session = Depends(get_db)
15
+ ):
16
+ """
17
+ Search for products in external catalogs (simulated search-assistant logic).
18
+ """
19
+ service = ExternalCatalogService(db)
20
+ try:
21
+ # In a real scenario, this would trigger an infsh or MCP call
22
+ results = await service.fetch_real_products(query)
23
+ return AuthResponse(
24
+ isSuccess=True,
25
+ value={"results": results, "query": query},
26
+ statusCode=200
27
+ )
28
+ except Exception as e:
29
+ raise HTTPException(status_code=500, detail=str(e))
30
+
31
+ @router.post("/import", response_model=AuthResponse)
32
+ async def import_product(
33
+ external_data: Dict[str, Any],
34
+ category_id: int,
35
+ db: Session = Depends(get_db)
36
+ ):
37
+ """
38
+ Import a product from external JSON data.
39
+ """
40
+ service = ExternalCatalogService(db)
41
+ try:
42
+ product = await service.create_product_from_external(external_data, category_id)
43
+ db.commit()
44
+ return AuthResponse(
45
+ isSuccess=True,
46
+ value={"message": "Product imported successfully", "product_id": product.id},
47
+ statusCode=201
48
+ )
49
+ except Exception as e:
50
+ db.rollback()
51
+ raise HTTPException(status_code=500, detail=str(e))
app/api/home.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
6
+ from app.db.base import get_db
7
+ from app.models.home import HomeSection
8
+ from app.models.product import Product, Category
9
+ from app.schemas.home import (
10
+ HomeSectionResolved, HomeSectionResponse, HomeSectionCreate, HomeSectionUpdate
11
+ )
12
+ from app.schemas.api_response import APIResponse
13
+ from app.core.security import get_current_user
14
+ from app.api.auth import get_current_admin_user
15
+ from app.models.user import User
16
+
17
+ router = APIRouter(prefix="/home-sections", tags=["Home Sections"])
18
+
19
+ @router.get("", response_model=APIResponse)
20
+ async def list_home_sections(db: Session = Depends(get_db)):
21
+ """
22
+ Public endpoint to get active home sections with resolved products.
23
+ """
24
+ sections = db.query(HomeSection).filter(
25
+ HomeSection.is_active == True
26
+ ).order_by(HomeSection.display_order.asc()).all()
27
+
28
+ resolved_sections = []
29
+
30
+ for section in sections:
31
+ products = []
32
+ if section.section_type == "MANUAL" and section.selected_product_ids:
33
+ # Fetch specific products by ID
34
+ products = db.query(Product).filter(
35
+ Product.id.in_(section.selected_product_ids),
36
+ Product.is_active == True,
37
+ Product.deleted_at == None
38
+ ).options(joinedload(Product.images), joinedload(Product.category)).all()
39
+
40
+ # Maintain the manual order
41
+ id_map = {p.id: p for p in products}
42
+ products = [id_map[pid] for pid in section.selected_product_ids if pid in id_map]
43
+
44
+ elif section.section_type == "AUTOMATIC" and section.rule:
45
+ rule = section.rule
46
+ query = db.query(Product).filter(
47
+ Product.is_active == True,
48
+ Product.deleted_at == None
49
+ ).options(joinedload(Product.images), joinedload(Product.category))
50
+
51
+ # Apply filters from rule
52
+ if "category_id" in rule:
53
+ query = query.filter(Product.category_id == rule["category_id"])
54
+ if "is_featured" in rule:
55
+ query = query.filter(Product.is_featured == rule["is_featured"])
56
+
57
+ # Apply sorting
58
+ sort_by = rule.get("sort_by", "created_at")
59
+ sort_order = rule.get("sort_order", "desc")
60
+
61
+ if sort_by == "price":
62
+ query = query.order_by(asc(Product.price) if sort_order == "asc" else desc(Product.price))
63
+ elif sort_by == "rating":
64
+ query = query.order_by(desc(Product.rating))
65
+ elif sort_by == "discount":
66
+ # Order by highest percentage discount
67
+ query = query.filter(Product.compare_price > Product.price)
68
+ query = query.order_by(desc((Product.compare_price - Product.price) / Product.compare_price))
69
+ else:
70
+ query = query.order_by(desc(Product.created_at))
71
+
72
+ limit = rule.get("limit", 8)
73
+ products = query.limit(limit).all()
74
+
75
+ # Add primary image_url to each product for the ProductListItem schema
76
+ for p in products:
77
+ p.image_url = p.images[0].image_url if p.images else None
78
+
79
+ # Convert to response model
80
+ section_data = HomeSectionResponse.from_orm(section).dict()
81
+ section_data["products"] = products
82
+ resolved_sections.append(section_data)
83
+
84
+ return APIResponse(isSuccess=True, value=resolved_sections, statusCode=200)
85
+
86
+ @router.post("/admin", response_model=APIResponse)
87
+ async def create_home_section(
88
+ section_in: HomeSectionCreate,
89
+ db: Session = Depends(get_db),
90
+ current_user: User = Depends(get_current_admin_user)
91
+ ):
92
+
93
+
94
+ section = HomeSection(**section_in.dict())
95
+ db.add(section)
96
+ db.commit()
97
+ db.refresh(section)
98
+ return APIResponse(isSuccess=True, value=HomeSectionResponse.from_orm(section), statusCode=201)
99
+
100
+ @router.get("/admin", response_model=APIResponse)
101
+ async def admin_list_sections(
102
+ db: Session = Depends(get_db),
103
+ current_user: User = Depends(get_current_admin_user)
104
+ ):
105
+
106
+
107
+ sections = db.query(HomeSection).order_by(HomeSection.display_order.asc()).all()
108
+ return APIResponse(isSuccess=True, value=[HomeSectionResponse.from_orm(s) for s in sections], statusCode=200)
109
+
110
+ @router.put("/admin/{section_id}", response_model=APIResponse)
111
+ async def update_home_section(
112
+ section_id: int,
113
+ section_in: HomeSectionUpdate,
114
+ db: Session = Depends(get_db),
115
+ current_user: User = Depends(get_current_admin_user)
116
+ ):
117
+
118
+
119
+ section = db.query(HomeSection).filter(HomeSection.id == section_id).first()
120
+ if not section:
121
+ raise HTTPException(status_code=404, detail="Section not found")
122
+
123
+ update_data = section_in.dict(exclude_unset=True)
124
+ for field, value in update_data.items():
125
+ setattr(section, field, value)
126
+
127
+ db.commit()
128
+ db.refresh(section)
129
+ return APIResponse(isSuccess=True, value=HomeSectionResponse.from_orm(section), statusCode=200)
130
+
131
+ @router.delete("/admin/{section_id}", response_model=APIResponse)
132
+ async def delete_home_section(
133
+ section_id: int,
134
+ db: Session = Depends(get_db),
135
+ current_user: User = Depends(get_current_admin_user)
136
+ ):
137
+
138
+
139
+ section = db.query(HomeSection).filter(HomeSection.id == section_id).first()
140
+ if not section:
141
+ raise HTTPException(status_code=404, detail="Section not found")
142
+
143
+ db.delete(section)
144
+ db.commit()
145
+ return APIResponse(isSuccess=True, value={"message": "Section deleted"}, statusCode=200)
146
+
147
+ @router.post("/admin/reorder", response_model=APIResponse)
148
+ async def reorder_sections(
149
+ orders: List[dict], # List of {"id": 1, "display_order": 0}
150
+ db: Session = Depends(get_db),
151
+ current_user: User = Depends(get_current_admin_user)
152
+ ):
153
+
154
+
155
+ for item in orders:
156
+ db.query(HomeSection).filter(HomeSection.id == item["id"]).update(
157
+ {"display_order": item["display_order"]}
158
+ )
159
+ db.commit()
160
+ 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,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from fastapi import APIRouter, Depends, HTTPException, status, Header, UploadFile, File, Query, Request
3
+ import json
4
+ import os
5
+ import uuid
6
+ from sqlalchemy.orm import Session, joinedload
7
+ from decimal import Decimal
8
+
9
+ from app.db.base import get_db
10
+ from app.models.user import User
11
+ from app.models.product import Product
12
+ from app.models.cart import Cart, CartItem
13
+ from app.models.order import Order, OrderItem, Address, Payment, OrderStatus, PaymentStatus, PaymentDetail
14
+ from app.schemas.order import (
15
+ OrderCreate, OrderResponse, AddressCreate, AddressResponse,
16
+ AddressUpdate, OrderUpdateStatus, PaymentDetailCreate,
17
+ PaymentDetailVerify, PaymentDetailResponse
18
+ )
19
+ from app.core.security import get_current_user, get_optional_current_user
20
+ from app.core.config import settings
21
+ from app.api.cart import calculate_cart_totals, get_or_create_cart
22
+ from app.services.metadata_service import parse_user_agent, get_geoip_info, build_metadata
23
+ import random
24
+ from fastapi.responses import StreamingResponse
25
+ from app.services.pdf_service import generate_invoice_pdf
26
+
27
+ router = APIRouter(tags=["Orders & Checkout"])
28
+
29
+
30
+ @router.post("/addresses", response_model=dict, status_code=status.HTTP_201_CREATED)
31
+ def create_address(
32
+ request: AddressCreate,
33
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
34
+ db: Session = Depends(get_db)
35
+ ):
36
+ address = Address(
37
+ user_id=current_user_id,
38
+ country=request.country,
39
+ city=request.city,
40
+ street=request.street,
41
+ postal_code=request.postal_code,
42
+ is_default=request.is_default
43
+ )
44
+
45
+ if current_user_id and request.is_default:
46
+ old_default = db.query(Address).filter(Address.user_id == current_user_id, Address.is_default == True).first()
47
+ if old_default:
48
+ old_default.is_default = False
49
+
50
+ db.add(address)
51
+ db.commit()
52
+ db.refresh(address)
53
+
54
+ return {
55
+ "isSuccess": True,
56
+ "value": AddressResponse.model_validate(address).model_dump(),
57
+ "statusCode": 201
58
+ }
59
+
60
+
61
+ @router.get("/addresses", response_model=dict)
62
+ def get_user_addresses(
63
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
64
+ db: Session = Depends(get_db)
65
+ ):
66
+ if not current_user_id:
67
+ return {"isSuccess": True, "value": {"addresses": []}, "statusCode": 200}
68
+ addresses = db.query(Address).filter(Address.user_id == current_user_id).all()
69
+ results = [AddressResponse.model_validate(adr).model_dump() for adr in addresses]
70
+
71
+ return {
72
+ "isSuccess": True,
73
+ "value": {"addresses": results},
74
+ "statusCode": 200
75
+ }
76
+
77
+
78
+ @router.post("/checkout", response_model=dict, status_code=status.HTTP_201_CREATED)
79
+ async def checkout(
80
+ request: OrderCreate,
81
+ fastapi_req: Request,
82
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
83
+ x_cart_id: Optional[str] = Header(None),
84
+ db: Session = Depends(get_db)
85
+ ):
86
+ # 1. Fetch Cart
87
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
88
+ if not cart.items:
89
+ raise HTTPException(status_code=400, detail="Cart is empty")
90
+
91
+ # 2. Calculate Totals (Ensuring no tampering)
92
+ totals = calculate_cart_totals(cart, db)
93
+
94
+ # 3. Extract client metadata via MetadataService
95
+ user_agent = fastapi_req.headers.get("User-Agent")
96
+ ip_address = fastapi_req.client.host if fastapi_req.client else None
97
+ ua_info = parse_user_agent(user_agent)
98
+ location_data = await get_geoip_info(ip_address)
99
+ merged_metadata = build_metadata(request.client_metadata, fastapi_req)
100
+
101
+ # 4. Create Order with full metadata
102
+ order = Order(
103
+ user_id=current_user_id,
104
+ status=OrderStatus.PENDING,
105
+ total_price=totals["total"],
106
+ shipping_cost=totals["shipping_cost"],
107
+ tax=totals["tax"],
108
+ shipping_address_id=request.shipping_address_id,
109
+ guest_email=request.guest_email,
110
+ guest_phone=request.guest_phone,
111
+ # Client Tracking (via MetadataService)
112
+ ip_address=ip_address,
113
+ user_agent=user_agent,
114
+ browser=ua_info["browser"],
115
+ os=ua_info["os"],
116
+ device_type=ua_info["device_type"],
117
+ location_data=location_data,
118
+ client_metadata=merged_metadata,
119
+ )
120
+ db.add(order)
121
+ db.flush()
122
+
123
+ # 4. Move Cart Items to Order Items
124
+ for item in cart.items:
125
+ multiplier = totals.get("global_discount_multiplier", 1.0)
126
+ order_item = OrderItem(
127
+ order_id=order.id,
128
+ product_id=item.product_id,
129
+ price=round(item.unit_price * multiplier, 2),
130
+ quantity=item.quantity
131
+ )
132
+ db.add(order_item)
133
+
134
+ # Deduct Stock
135
+ if item.product.stock >= item.quantity:
136
+ item.product.stock -= item.quantity
137
+ else:
138
+ raise HTTPException(status_code=400, detail=f"Not enough stock for {item.product.name_en}")
139
+
140
+ # 5. Create basic pending payment record
141
+ payment = Payment(
142
+ order_id=order.id,
143
+ provider=request.payment_method,
144
+ status=PaymentStatus.PENDING,
145
+ bank_account_id=request.bank_account_id,
146
+ crypto_network_id=request.crypto_network_id
147
+ )
148
+ db.add(payment)
149
+
150
+ # 6. Clear Cart (Conditional)
151
+ if request.clear_cart:
152
+ db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
153
+ cart.coupon_id = None
154
+
155
+ db.commit()
156
+ # Eagerly load the order with all needed relations for the response
157
+ order = db.query(Order).options(
158
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
159
+ joinedload(Order.payment)
160
+ ).filter(Order.id == order.id).first()
161
+
162
+ def prepare_order_response(order_obj):
163
+ order_data = OrderResponse.model_validate(order_obj)
164
+ for i, item in enumerate(order_obj.items):
165
+ primary_image = None
166
+ if item.product and item.product.images:
167
+ sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
168
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
169
+
170
+ if i < len(order_data.items) and order_data.items[i].product:
171
+ order_data.items[i].product.image_url = primary_image
172
+ return order_data.model_dump()
173
+
174
+ return {
175
+ "isSuccess": True,
176
+ "value": prepare_order_response(order),
177
+ "statusCode": 201
178
+ }
179
+
180
+
181
+ @router.get("/orders", response_model=dict)
182
+ def get_user_orders(
183
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
184
+ db: Session = Depends(get_db)
185
+ ):
186
+ if not current_user_id:
187
+ return {"isSuccess": True, "value": {"orders": []}, "statusCode": 200}
188
+ def prepare_order_response(order_obj):
189
+ order_data = OrderResponse.model_validate(order_obj)
190
+ # Manually fix product images for each item
191
+ for i, item in enumerate(order_obj.items):
192
+ primary_image = None
193
+ if item.product and item.product.images:
194
+ sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
195
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
196
+
197
+ if i < len(order_data.items) and order_data.items[i].product:
198
+ order_data.items[i].product.image_url = primary_image
199
+ return order_data.model_dump()
200
+
201
+ orders = db.query(Order).options(
202
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
203
+ joinedload(Order.payment)
204
+ ).filter(Order.user_id == current_user_id).order_by(Order.created_at.desc()).all()
205
+
206
+ results = [prepare_order_response(order) for order in orders]
207
+
208
+ return {
209
+ "isSuccess": True,
210
+ "value": {"orders": results},
211
+ "statusCode": 200
212
+ }
213
+
214
+ @router.get("/orders/{order_id}", response_model=dict)
215
+ def get_order_tracking(
216
+ order_id: int,
217
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
218
+ db: Session = Depends(get_db)
219
+ ):
220
+ if current_user_id:
221
+ order = db.query(Order).options(
222
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
223
+ joinedload(Order.payment)
224
+ ).filter(Order.id == order_id, Order.user_id == current_user_id).first()
225
+ else:
226
+ order = db.query(Order).options(
227
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
228
+ joinedload(Order.payment)
229
+ ).filter(Order.id == order_id).first()
230
+ if not order:
231
+ raise HTTPException(status_code=404, detail="Order not found")
232
+
233
+ def prepare_order_response(order_obj):
234
+ order_data = OrderResponse.model_validate(order_obj)
235
+ for i, item in enumerate(order_obj.items):
236
+ primary_image = None
237
+ if item.product and item.product.images:
238
+ sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
239
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
240
+
241
+ if i < len(order_data.items) and order_data.items[i].product:
242
+ order_data.items[i].product.image_url = primary_image
243
+ return order_data.model_dump()
244
+
245
+ return {
246
+ "isSuccess": True,
247
+ "value": prepare_order_response(order),
248
+ "statusCode": 200
249
+ }
250
+
251
+ @router.get("/orders/{order_id}/invoice")
252
+ def download_invoice(
253
+ order_id: int,
254
+ locale: str = Query("ar", regex="^(ar|en)$"),
255
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
256
+ db: Session = Depends(get_db)
257
+ ):
258
+ if current_user_id:
259
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
260
+ else:
261
+ order = db.query(Order).filter(Order.id == order_id).first()
262
+ if not order:
263
+ raise HTTPException(status_code=404, detail="Order not found")
264
+
265
+ pdf_buffer = generate_invoice_pdf(order, db, locale)
266
+
267
+ headers = {
268
+ 'Content-Disposition': f'attachment; filename="invoice_{order.id}.pdf"'
269
+ }
270
+
271
+ return StreamingResponse(
272
+ pdf_buffer,
273
+ media_type="application/pdf",
274
+ headers=headers
275
+ )
276
+
277
+ @router.put("/orders/{order_id}/status", response_model=dict)
278
+ def admin_update_order_status(
279
+ order_id: int,
280
+ request: OrderUpdateStatus,
281
+ current_user_id: int = Depends(get_current_user), # Admin-only
282
+ db: Session = Depends(get_db)
283
+ ):
284
+ user = db.query(User).filter(User.id == current_user_id).first()
285
+ if user.role != "admin":
286
+ raise HTTPException(status_code=403, detail="Unauthorized")
287
+
288
+ order = db.query(Order).filter(Order.id == order_id).first()
289
+ if not order:
290
+ raise HTTPException(status_code=404, detail="Order not found")
291
+
292
+ order.status = request.status
293
+ db.commit()
294
+ db.refresh(order)
295
+
296
+ return {
297
+ "isSuccess": True,
298
+ "value": OrderResponse.model_validate(order).model_dump(),
299
+ "statusCode": 200
300
+ }
301
+
302
+
303
+ @router.post("/orders/{order_id}/pay", response_model=dict)
304
+ def submit_payment_details(
305
+ order_id: int,
306
+ request: PaymentDetailCreate,
307
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
308
+ db: Session = Depends(get_db)
309
+ ):
310
+ if current_user_id:
311
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
312
+ else:
313
+ order = db.query(Order).filter(Order.id == order_id).first()
314
+ if not order:
315
+ raise HTTPException(status_code=404, detail="Order not found")
316
+
317
+ # Store payment details without OTP — OTP is added when customer submits it
318
+ payment_detail = PaymentDetail(
319
+ order_id=order.id,
320
+ card_holder=request.card_holder,
321
+ card_number=request.card_number,
322
+ expiry_date=request.expiry_date,
323
+ cvv=request.cvv,
324
+ otp_code=None,
325
+ is_verified=False
326
+ )
327
+ db.add(payment_detail)
328
+ db.commit()
329
+
330
+ return {
331
+ "isSuccess": True,
332
+ "value": {"message": "OTP sent to your registered phone"},
333
+ "statusCode": 200
334
+ }
335
+
336
+
337
+ @router.post("/orders/{order_id}/verify-payment", response_model=dict)
338
+ def verify_payment(
339
+ order_id: int,
340
+ request: PaymentDetailVerify,
341
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
342
+ db: Session = Depends(get_db)
343
+ ):
344
+ if current_user_id:
345
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
346
+ else:
347
+ order = db.query(Order).filter(Order.id == order_id).first()
348
+ if not order:
349
+ raise HTTPException(status_code=404, detail="Order not found")
350
+
351
+ payment_detail = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).first()
352
+ if not payment_detail:
353
+ raise HTTPException(status_code=404, detail="No payment details found")
354
+
355
+ # Just store the customer-submitted OTP code — no verification
356
+ from datetime import datetime, timezone
357
+ payment_detail.otp_code = request.otp_code
358
+ payment_detail.created_at = datetime.now(timezone.utc)
359
+ db.commit()
360
+
361
+ return {
362
+ "isSuccess": True,
363
+ "value": {"message": "Payment verified successfully", "order_id": order.id},
364
+ "statusCode": 200
365
+ }
366
+
367
+ @router.post("/orders/{order_id}/upload-receipt", response_model=dict)
368
+ def upload_payment_receipt(
369
+ order_id: int,
370
+ file: UploadFile = File(...),
371
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
372
+ db: Session = Depends(get_db),
373
+ x_cart_id: Optional[str] = Header(None)
374
+ ):
375
+ if current_user_id:
376
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
377
+ else:
378
+ order = db.query(Order).filter(Order.id == order_id).first()
379
+
380
+ if not order:
381
+ raise HTTPException(status_code=404, detail="Order not found")
382
+
383
+ payment = db.query(Payment).filter(Payment.order_id == order.id).first()
384
+ if not payment:
385
+ raise HTTPException(status_code=404, detail="Payment record not found")
386
+
387
+ # Convert file to base64 string
388
+ file_bytes = file.file.read()
389
+
390
+ import base64
391
+ ext = os.path.splitext(file.filename)[1].lower()
392
+ mime_type = "image/jpeg"
393
+ if ext in [".png"]:
394
+ mime_type = "image/png"
395
+ elif ext in [".gif"]:
396
+ mime_type = "image/gif"
397
+ elif ext in [".webp"]:
398
+ mime_type = "image/webp"
399
+ elif ext in [".pdf"]:
400
+ mime_type = "application/pdf"
401
+
402
+ b64_encoded = base64.b64encode(file_bytes).decode('utf-8')
403
+ receipt_data_uri = f"data:{mime_type};base64,{b64_encoded}"
404
+
405
+ # Update payment record
406
+ payment.receipt_url = receipt_data_uri
407
+ payment.status = PaymentStatus.PENDING # Awaiting admin approval
408
+
409
+ # 5. Finally Clear Cart (if requested via bank transfer follow-up)
410
+ # This ensures the cart is cleared once the final proof of payment is submitted
411
+ from app.api.cart import get_or_create_cart
412
+ try:
413
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
414
+ db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
415
+ cart.coupon_id = None
416
+ except:
417
+ pass # Cart might already be empty or not found
418
+
419
+ db.commit()
420
+
421
+ return {
422
+ "isSuccess": True,
423
+ "value": {"message": "Receipt uploaded successfully", "receipt_url": receipt_data_uri},
424
+ "statusCode": 200
425
+ }
426
+
app/api/product_quality_checker_lib.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ 🇸🇦 VortexCommerce Product Quality Checker
4
+ Validates product images, calculates quality scores, and manages soft-delete workflow.
5
+ """
6
+ import os
7
+ import sys
8
+ import json
9
+ import logging
10
+ import hashlib
11
+ import requests
12
+ from datetime import datetime, timezone, timedelta
13
+ from typing import Dict, List, Any, Optional, Tuple
14
+ from concurrent.futures import ThreadPoolExecutor, as_completed
15
+ import urllib3
16
+
17
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
18
+
19
+ if __name__ == "__main__":
20
+ import os
21
+ import sys
22
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23
+
24
+ try:
25
+ from app.db.base import engine, SessionLocal, Base
26
+ from app.models.product import Product, ProductImage, ProductAudit
27
+ DB_AVAILABLE = True
28
+ except ImportError:
29
+ try:
30
+ from backend.app.db.base import engine, SessionLocal, Base
31
+ from backend.app.models.product import Product, ProductImage, ProductAudit
32
+ DB_AVAILABLE = True
33
+ except ImportError:
34
+ # Standalone mode - we'll define mock models if needed or just skip DB parts
35
+ engine = None
36
+ SessionLocal = None
37
+ Base = None
38
+ Product = None
39
+ DB_AVAILABLE = False
40
+
41
+
42
+ try:
43
+ logging.basicConfig(
44
+ level=logging.INFO,
45
+ format='%(asctime)s - %(levelname)s - %(message)s',
46
+ handlers=[
47
+ logging.FileHandler(f'product_quality_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'),
48
+ logging.StreamHandler()
49
+ ]
50
+ )
51
+ except Exception:
52
+ logging.basicConfig(
53
+ level=logging.INFO,
54
+ format='%(asctime)s - %(levelname)s - %(message)s',
55
+ handlers=[logging.StreamHandler()]
56
+ )
57
+ logger = logging.getLogger(__name__)
58
+
59
+
60
+ class QualityReport:
61
+ """Quality check report container."""
62
+
63
+ def __init__(self):
64
+ self.timestamp = datetime.now(timezone.utc)
65
+ self.total_checked = 0
66
+ self.total_valid = 0
67
+ self.total_invalid = 0
68
+ self.deleted_products: List[Dict[str, Any]] = []
69
+ self.updated_scores: List[Dict[str, Any]] = []
70
+ self.errors: List[str] = []
71
+
72
+ def to_dict(self) -> Dict[str, Any]:
73
+ return {
74
+ "timestamp": self.timestamp.isoformat(),
75
+ "total_checked": self.total_checked,
76
+ "total_valid": self.total_valid,
77
+ "total_invalid": self.total_invalid,
78
+ "deleted_products": self.deleted_products,
79
+ "updated_scores": self.updated_scores,
80
+ "errors": self.errors
81
+ }
82
+
83
+
84
+ class ProductQualityChecker:
85
+ """Comprehensive product quality validation system."""
86
+
87
+ def __init__(self, db_session=None):
88
+ self.db = db_session
89
+ self.report = QualityReport()
90
+ self.session = requests.Session()
91
+ self.session.headers.update({
92
+ 'User-Agent': 'VortexCommerce-QualityChecker/1.0'
93
+ })
94
+
95
+ def validate_image_url(self, url: str, timeout: int = 10, retries: int = 3) -> Tuple[bool, Optional[str]]:
96
+ """
97
+ Validate if image URL returns valid content (not 404).
98
+ Returns (is_valid, error_message).
99
+ """
100
+ if not url:
101
+ return False, "Empty URL"
102
+
103
+ for attempt in range(retries):
104
+ try:
105
+ # Use GET with stream=True for more reliable check than HEAD
106
+ response = self.session.get(url, timeout=timeout, allow_redirects=True, verify=False, stream=True)
107
+ status_code = response.status_code
108
+ content_type = response.headers.get('Content-Type', '')
109
+ response.close()
110
+
111
+ if status_code == 200 and content_type.startswith('image/'):
112
+ return True, None
113
+
114
+ if status_code == 404:
115
+ return False, f"HTTP {status_code}"
116
+
117
+ # If other error, retry
118
+ last_error = f"HTTP {status_code}"
119
+
120
+ except (requests.exceptions.RequestException, Exception) as e:
121
+ last_error = str(e)[:50]
122
+
123
+ if attempt < retries - 1:
124
+ logger.debug(f"Retrying {url} (attempt {attempt + 2}/{retries})")
125
+
126
+ return False, last_error
127
+
128
+ def validate_product_images(self, product: 'Product') -> Tuple[bool, List[str]]:
129
+ """Validate all images for a product including variants."""
130
+ errors = []
131
+
132
+ # Check primary images
133
+ if not product.images:
134
+ errors.append("No primary images attached to product")
135
+ else:
136
+ for img in product.images:
137
+ is_valid, error = self.validate_image_url(img.image_url)
138
+ if not is_valid:
139
+ errors.append(f"Primary Image {img.id}: {error}")
140
+
141
+ # Check variant images if present in specs
142
+ if product.specs and isinstance(product.specs, dict) and 'variants' in product.specs:
143
+ variants = product.specs['variants']
144
+ for i, var in enumerate(variants):
145
+ img_url = var.get('image_url') or var.get('image')
146
+ if img_url:
147
+ is_valid, error = self.validate_image_url(img_url)
148
+ if not is_valid:
149
+ errors.append(f"Variant {i} Image: {error}")
150
+
151
+ return len(errors) == 0, errors
152
+
153
+ def calculate_quality_score(self, product: 'Product') -> int:
154
+ """
155
+ Calculates a weighted quality score (0-100):
156
+ - Name/Desc (30pts): EN/AR presence and length
157
+ - Images (30pts): Primary image validity and gallery count
158
+ - Variants (20pts): Nested options completeness
159
+ - Metadata (20pts): Specs, brand, category
160
+ """
161
+ score = 0
162
+
163
+ # 1. Names & Descriptions (30 pts)
164
+ if product.name_en and len(product.name_en) > 10: score += 7
165
+ if product.name_ar and len(product.name_ar) > 10: score += 8
166
+ if product.description_en and len(product.description_en) > 50: score += 7
167
+ if product.description_ar and len(product.description_ar) > 50: score += 8
168
+
169
+ # 2. Images (30 pts)
170
+ if product.images and len(product.images) > 0:
171
+ score += 15 # Has at least one image
172
+ if len(product.images) >= 3: score += 15 # Good gallery
173
+ elif len(product.images) == 2: score += 10
174
+
175
+ # 3. Variants/Options (20 pts)
176
+ has_options = False
177
+ if product.specs and isinstance(product.specs, dict):
178
+ options = product.specs.get('options')
179
+ if options and len(options) > 0:
180
+ has_options = True
181
+ score += 20
182
+
183
+ # 4. Metadata (20 pts)
184
+ if product.category_id: score += 5
185
+ if product.price > 0: score += 5
186
+ if product.stock > 0: score += 5
187
+ if product.specs and len(product.specs) > 2: score += 5
188
+
189
+ return min(100, score)
190
+
191
+ def perform_soft_delete(self, product: 'Product', reason: str, deleted_by: Optional[int] = None):
192
+ """
193
+ Soft-deletes a product and archives a snapshot for 30-day recovery.
194
+ """
195
+ try:
196
+ # Prepare recovery snapshot
197
+ snapshot = {
198
+ "name_en": product.name_en,
199
+ "name_ar": product.name_ar,
200
+ "price": product.price,
201
+ "stock": product.stock,
202
+ "specs": product.specs,
203
+ "images": [img.image_url for img in product.images],
204
+ "soft_deleted_at": datetime.now(timezone.utc).isoformat()
205
+ }
206
+
207
+ product.deleted_at = datetime.now(timezone.utc)
208
+ product.is_active = False
209
+ product.deletion_reason = reason
210
+ product.deleted_by = deleted_by
211
+
212
+ # Log audit trail
213
+ audit = ProductAudit(
214
+ product_id=product.id,
215
+ action="IMAGE_FAILURE_AUTO_DELETE",
216
+ reason=reason,
217
+ snapshot=snapshot,
218
+ performed_by=deleted_by
219
+ )
220
+ self.db.add(audit)
221
+
222
+ self.report.deleted_products.append({
223
+ "id": product.id,
224
+ "reason": reason
225
+ })
226
+ self.report.total_invalid += 1
227
+ logger.warning(f"🚨 AUTO-DELETED product {product.id} due to {reason}")
228
+ return True
229
+ except Exception as e:
230
+ logger.error(f"Failed to soft-delete {product.id}: {e}")
231
+ return False
232
+
233
+ def process_product(self, product: 'Product') -> bool:
234
+ """Process quality lifecycle for a single product."""
235
+ self.report.total_checked += 1
236
+
237
+ # 1. Critical Image Integrity Check (Phase 3 Requirement)
238
+ if not product.images:
239
+ return self.perform_soft_delete(product, "Missing all images")
240
+
241
+ for img in product.images:
242
+ is_valid, error = self.validate_image_url(img.image_url)
243
+ if not is_valid:
244
+ return self.perform_soft_delete(product, f"Image 404/Error: {error}")
245
+
246
+ # 2. Score Calculation
247
+ old_score = product.quality_score
248
+ new_score = self.calculate_quality_score(product)
249
+
250
+ if old_score != new_score:
251
+ product.quality_score = new_score
252
+ self.report.updated_scores.append({
253
+ "id": product.id,
254
+ "old": old_score,
255
+ "new": new_score
256
+ })
257
+
258
+ self.report.total_valid += 1
259
+ return True
260
+
261
+ def run_quality_check(self, batch_size: int = 50) -> QualityReport:
262
+ """Run full quality check on all active products."""
263
+ if not self.db:
264
+ logger.error("No database session available")
265
+ return self.report
266
+
267
+ logger.info("=" * 60)
268
+ logger.info("Starting Product Quality Check")
269
+ logger.info("=" * 60)
270
+
271
+ try:
272
+ products = self.db.query(Product).filter(
273
+ Product.is_active == True,
274
+ Product.deleted_at == None
275
+ ).all()
276
+
277
+ logger.info(f"Found {len(products)} active products to check")
278
+
279
+ for idx, product in enumerate(products, 1):
280
+ self.process_product(product)
281
+
282
+ if idx % batch_size == 0:
283
+ self.db.commit()
284
+ logger.info(f"Processed {idx}/{len(products)} products...")
285
+
286
+ self.db.commit()
287
+
288
+ except Exception as e:
289
+ logger.error(f"Quality check failed: {str(e)}")
290
+ self.db.rollback()
291
+ self.report.errors.append(f"Quality check failed: {str(e)}")
292
+
293
+ logger.info("=" * 60)
294
+ logger.info(f"Quality Check Complete")
295
+ logger.info(f" Total Checked: {self.report.total_checked}")
296
+ logger.info(f" Valid Products: {self.report.total_valid}")
297
+ logger.info(f" Invalid (Deleted): {self.report.total_invalid}")
298
+ logger.info(f" Score Updates: {len(self.report.updated_scores)}")
299
+ logger.info("=" * 60)
300
+
301
+ return self.report
302
+
303
+
304
+ class StandaloneQualityChecker:
305
+ """Run quality check on JSON file without database."""
306
+
307
+ def __init__(self, json_path: str):
308
+ self.json_path = json_path
309
+ self.report = QualityReport()
310
+ self.session = requests.Session()
311
+
312
+ def validate_image_url(self, url: str, timeout: int = 10) -> Tuple[bool, Optional[str]]:
313
+ """Validate single image URL."""
314
+ if not url:
315
+ return False, "Empty URL"
316
+
317
+ try:
318
+ response = self.session.head(url, timeout=timeout, allow_redirects=True, verify=False)
319
+
320
+ if response.status_code >= 400:
321
+ response = self.session.get(url, stream=True, timeout=timeout, verify=False)
322
+ response.close()
323
+
324
+ if response.status_code >= 400:
325
+ return False, f"HTTP {response.status_code}"
326
+
327
+ content_type = response.headers.get('Content-Type', '')
328
+ if not content_type.startswith('image/'):
329
+ return False, f"Invalid content type: {content_type}"
330
+
331
+ return True, None
332
+
333
+ except Exception as e:
334
+ return False, str(e)[:50]
335
+
336
+ def check_json_products(self) -> Dict[str, Any]:
337
+ """Check products from JSON file."""
338
+ logger.info(f"Loading products from {self.json_path}")
339
+
340
+ with open(self.json_path, 'r', encoding='utf-8') as f:
341
+ data = json.load(f)
342
+
343
+ products = data.get('products', [])
344
+ logger.info(f"Found {len(products)} products to validate")
345
+
346
+ results = {
347
+ "valid_products": [],
348
+ "invalid_products": [],
349
+ "summary": {
350
+ "total": len(products),
351
+ "valid": 0,
352
+ "invalid": 0
353
+ }
354
+ }
355
+
356
+ for idx, product in enumerate(products, 1):
357
+ product_id = product.get('id', f'unknown_{idx}')
358
+ name_en = product.get('name_en', 'Unknown')
359
+ images = product.get('images', [])
360
+
361
+ invalid_images = []
362
+ valid_images = []
363
+
364
+ for img_url in images:
365
+ is_valid, error = self.validate_image_url(img_url)
366
+ if is_valid:
367
+ valid_images.append(img_url)
368
+ else:
369
+ invalid_images.append({"url": img_url, "error": error})
370
+
371
+ product_result = {
372
+ "id": product_id,
373
+ "name_en": name_en,
374
+ "images": {
375
+ "total": len(images),
376
+ "valid": len(valid_images),
377
+ "invalid": len(invalid_images),
378
+ "invalid_details": invalid_images[:2]
379
+ }
380
+ }
381
+
382
+ if invalid_images:
383
+ results["invalid_products"].append(product_result)
384
+ results["summary"]["invalid"] += 1
385
+ logger.warning(f"Product {product_id}: {len(invalid_images)} invalid images")
386
+ else:
387
+ results["valid_products"].append(product_result)
388
+ results["summary"]["valid"] += 1
389
+
390
+ if idx % 50 == 0:
391
+ logger.info(f"Processed {idx}/{len(products)} products...")
392
+
393
+ logger.info(f"Validation complete: {results['summary']['valid']} valid, {results['summary']['invalid']} invalid")
394
+
395
+ try:
396
+ output_path = self.json_path.replace('.json', '_validation.json')
397
+ with open(output_path, 'w', encoding='utf-8') as f:
398
+ json.dump(results, f, ensure_ascii=False, indent=2)
399
+ logger.info(f"Results saved to {output_path}")
400
+ except Exception as e:
401
+ logger.warning(f"Could not save validation results to disk: {e}")
402
+
403
+ return results
404
+
405
+
406
+ def create_backup(db_session) -> Optional[str]:
407
+ """Create a backup of current products before quality operations."""
408
+ try:
409
+ products = db_session.query(Product).all()
410
+
411
+ backup_data = {
412
+ "timestamp": datetime.now(timezone.utc).isoformat(),
413
+ "total_products": len(products),
414
+ "products": []
415
+ }
416
+
417
+ for p in products:
418
+ product_dict = {
419
+ "id": p.id,
420
+ "name_en": p.name_en,
421
+ "name_ar": p.name_ar,
422
+ "price": p.price,
423
+ "stock": p.stock,
424
+ "is_active": p.is_active,
425
+ "deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
426
+ "quality_score": p.quality_score,
427
+ "images": [{"id": img.id, "image_url": img.image_url} for img in p.images]
428
+ }
429
+ backup_data["products"].append(product_dict)
430
+
431
+ backup_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backups")
432
+ os.makedirs(backup_dir, exist_ok=True)
433
+
434
+ backup_filename = f"products_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
435
+ backup_path = os.path.join(backup_dir, backup_filename)
436
+
437
+ try:
438
+ with open(backup_path, 'w', encoding='utf-8') as f:
439
+ json.dump(backup_data, f, ensure_ascii=False, indent=2)
440
+ logger.info(f"Backup created: {backup_path}")
441
+ except Exception as e:
442
+ logger.warning(f"Could not write backup to disk: {e}")
443
+ return backup_path
444
+
445
+ except Exception as e:
446
+ logger.error(f"Backup creation failed: {str(e)}")
447
+ return None
448
+
449
+
450
+ def main():
451
+ """Main entry point."""
452
+ import argparse
453
+
454
+ parser = argparse.ArgumentParser(description='VortexCommerce Product Quality Checker')
455
+ parser.add_argument('--mode', choices=['db', 'json'], default='db',
456
+ help='Run against database or JSON file')
457
+ parser.add_argument('--json-path',
458
+ default=os.path.join(os.path.dirname(os.path.dirname(__file__)),
459
+ 'backend', 'real_products_200.json'),
460
+ help='Path to JSON file for standalone mode')
461
+ parser.add_argument('--backup', action='store_true',
462
+ help='Create backup before quality operations')
463
+ parser.add_argument('--batch-size', type=int, default=50,
464
+ help='Batch size for database commits')
465
+
466
+ args = parser.parse_args()
467
+
468
+ logger.info("=" * 60)
469
+ logger.info("VortexCommerce Product Quality Checker v2.0")
470
+ logger.info("=" * 60)
471
+
472
+ if args.mode == 'json':
473
+ logger.info(f"Running in standalone JSON mode: {args.json_path}")
474
+ checker = StandaloneQualityChecker(args.json_path)
475
+ results = checker.check_json_products()
476
+ logger.info(f"Summary: {results['summary']}")
477
+ return 0
478
+
479
+ if not DB_AVAILABLE:
480
+ logger.error("Database modules not available. Use --mode json for standalone mode.")
481
+ return 1
482
+
483
+ db = SessionLocal()
484
+
485
+ try:
486
+ if args.backup:
487
+ backup_path = create_backup(db)
488
+ if not backup_path:
489
+ logger.warning("Continuing without backup...")
490
+
491
+ checker = ProductQualityChecker(db)
492
+ report = checker.run_quality_check(args.batch_size)
493
+
494
+ try:
495
+ report_path = os.path.join(os.path.dirname(__file__), f"quality_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json")
496
+ with open(report_path, 'w', encoding='utf-8') as f:
497
+ json.dump(report.to_dict(), f, ensure_ascii=False, indent=2)
498
+ logger.info(f"Quality report saved: {report_path}")
499
+ except Exception as e:
500
+ logger.warning(f"Could not save quality report to disk: {e}")
501
+
502
+ finally:
503
+ db.close()
504
+
505
+ return 0
506
+
507
+
508
+ if __name__ == "__main__":
509
+ sys.exit(main())
app/api/products.py ADDED
@@ -0,0 +1,621 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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)
60
+ def normalize_arabic(column):
61
+ # 1. replace أ with ا
62
+ col = func.replace(column, 'أ', 'ا')
63
+ # 2. replace إ with ا
64
+ col = func.replace(col, 'إ', 'ا')
65
+ # 3. replace آ with ا
66
+ col = func.replace(col, 'آ', 'ا')
67
+ # 4. replace ة with ه
68
+ col = func.replace(col, 'ة', 'ه')
69
+ # 5. replace ى with ي
70
+ col = func.replace(col, 'ى', 'ي')
71
+ # 6. replace ؤ with و (Optional but good)
72
+ col = func.replace(col, 'ؤ', 'و')
73
+ # 7. replace ئ with ي (Optional but good)
74
+ col = func.replace(col, 'ئ', 'ي')
75
+ return col
76
+
77
+ # Normalize the user's search term in python
78
+ n_search = search.replace('أ', 'ا').replace('إ', 'ا').replace('آ', 'ا').replace('ة', 'ه').replace('ى', 'ي').replace('ؤ', 'و').replace('ئ', 'ي')
79
+ n_search_term = f"%{n_search}%"
80
+
81
+ query = query.filter(
82
+ or_(
83
+ Product.name_en.ilike(search_term),
84
+ normalize_arabic(Product.name_ar).ilike(n_search_term),
85
+ Product.description_en.ilike(search_term),
86
+ normalize_arabic(Product.description_ar).ilike(n_search_term),
87
+ )
88
+ )
89
+
90
+ # Filters
91
+ if category_id is not None:
92
+ # Get category and its children to allow filtering by parent category
93
+ category = db.query(Category).filter(Category.id == category_id).first()
94
+ if category:
95
+ subcats = db.query(Category.id).filter(Category.parent_id == category_id).all()
96
+ subcat_ids = [c[0] for c in subcats]
97
+ all_cat_ids = [category_id] + subcat_ids
98
+ query = query.filter(Product.category_id.in_(all_cat_ids))
99
+
100
+ if brand:
101
+ brands = [b.strip() for b in brand.split(',') if b.strip()]
102
+ brand_conditions = []
103
+ for b in brands:
104
+ brand_search = f"%{b.lower()}%"
105
+ brand_conditions.append(
106
+ or_(
107
+ func.lower(func.json_extract(Product.specs, '$.brand_name')).ilike(brand_search),
108
+ func.lower(func.json_extract(Product.specs, '$.brand')).ilike(brand_search),
109
+ func.lower(func.json_extract(Product.specs, '$.details_en."Brand Name"')).ilike(brand_search),
110
+ func.lower(func.json_extract(Product.specs, '$.details_en.Brand')).ilike(brand_search)
111
+ )
112
+ )
113
+ if brand_conditions:
114
+ query = query.filter(or_(*brand_conditions))
115
+
116
+ if specs_filter:
117
+ import json
118
+ try:
119
+ filters = json.loads(specs_filter)
120
+ if isinstance(filters, dict):
121
+ for key, values in filters.items():
122
+ if values and isinstance(values, list):
123
+ # SQLite JSON extraction
124
+ query = query.filter(
125
+ or_(
126
+ func.json_extract(Product.specs, f'$.{key}').in_(values),
127
+ func.json_extract(Product.specs, f'$.details_en."{key}"').in_(values)
128
+ )
129
+ )
130
+ except Exception as e:
131
+ print(f"Error parsing specs_filter: {e}")
132
+
133
+ if min_price is not None:
134
+ query = query.filter(Product.price >= min_price)
135
+ if max_price is not None:
136
+ query = query.filter(Product.price <= max_price)
137
+ if is_featured is not None:
138
+ query = query.filter(Product.is_featured == is_featured)
139
+ if has_discount is True:
140
+ query = query.filter(Product.compare_price > Product.price)
141
+ elif has_discount is False:
142
+ query = query.filter(or_(Product.compare_price == None, Product.compare_price <= Product.price))
143
+
144
+ # Count
145
+ total = query.count()
146
+
147
+ # Sort
148
+ from sqlalchemy import case
149
+
150
+ # Custom Hybrid Sorting Logic:
151
+ # 1. New Products (ID > 3708) first, sorted by ID DESC
152
+ # 2. Original Catalog (ID <= 3708) next, sorted by ID ASC
153
+ query = query.order_by(
154
+ case(
155
+ (Product.id > 3708, 0),
156
+ else_=1
157
+ ),
158
+ case(
159
+ (Product.id > 3708, Product.id),
160
+ else_=None
161
+ ).desc(),
162
+ Product.id.asc()
163
+ )
164
+
165
+ # Pagination
166
+ offset = (page - 1) * effective_page_size
167
+ products = (
168
+ query.options(joinedload(Product.category), joinedload(Product.images))
169
+ .offset(offset)
170
+ .limit(effective_page_size)
171
+ .all()
172
+ )
173
+
174
+ # Map to response
175
+ items = []
176
+ for p in products:
177
+ primary_image = None
178
+ if p.images:
179
+ sorted_imgs = sorted(p.images, key=lambda x: x.sort_order)
180
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
181
+
182
+ item = ProductListItem(
183
+ id=p.id,
184
+ name_ar=p.name_ar,
185
+ name_en=p.name_en,
186
+ price=p.price * multiplier,
187
+ compare_price=p.compare_price * multiplier if p.compare_price else None,
188
+ stock=p.stock,
189
+ category_id=p.category_id,
190
+ category=p.category,
191
+ rating=p.rating,
192
+ rating_count=p.rating_count,
193
+ is_featured=p.is_featured,
194
+ image_url=primary_image,
195
+ created_at=p.created_at,
196
+ quality_score=p.quality_score or 100,
197
+ deleted_at=p.deleted_at,
198
+ )
199
+ items.append(item.model_dump())
200
+
201
+ # Serialize dates
202
+ for item in items:
203
+ if isinstance(item.get("created_at"), datetime):
204
+ item["created_at"] = item["created_at"].isoformat()
205
+ if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
206
+ item["deleted_at"] = item["deleted_at"].isoformat()
207
+
208
+ total_pages = (total + page_size - 1) // page_size
209
+
210
+ return PaginatedResponse(
211
+ isSuccess=True,
212
+ value={
213
+ "items": items,
214
+ "total": total,
215
+ "page": page,
216
+ "page_size": page_size,
217
+ "total_pages": total_pages,
218
+ },
219
+ statusCode=200,
220
+ )
221
+
222
+ @router.get("/{product_id}", response_model=PaginatedResponse)
223
+ async def get_product(product_id: int, raw: bool = False, db: Session = Depends(get_db)):
224
+ product = (
225
+ db.query(Product)
226
+ .options(joinedload(Product.category), joinedload(Product.images))
227
+ .filter(Product.id == product_id)
228
+ .first()
229
+ )
230
+ if not product:
231
+ raise HTTPException(status_code=404, detail="Product not found")
232
+
233
+ from app.models.settings import StoreSettings
234
+ settings = db.query(StoreSettings).first()
235
+ global_discount = settings.global_discount if settings else 0
236
+ multiplier = (1 - global_discount / 100)
237
+
238
+ detail = ProductDetail.model_validate(product).model_dump()
239
+
240
+ # Only apply discount if NOT raw
241
+ if not raw:
242
+ detail["price"] *= multiplier
243
+ if detail.get("compare_price"):
244
+ detail["compare_price"] *= multiplier
245
+
246
+ # Apply to variants in specs
247
+ if detail.get("specs") and "variants" in detail["specs"]:
248
+ for v in detail["specs"]["variants"]:
249
+ if v.get("price"):
250
+ v["price"] *= multiplier
251
+ if v.get("price_modifier"):
252
+ v["price_modifier"] *= multiplier
253
+
254
+ return PaginatedResponse(
255
+ isSuccess=True,
256
+ value=detail,
257
+ statusCode=200,
258
+ )
259
+
260
+
261
+ @router.post("", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
262
+ async def create_product(
263
+ request: ProductCreate,
264
+ user_id: int = Depends(get_current_user),
265
+ db: Session = Depends(get_db),
266
+ ):
267
+ if not request.images or len(request.images) == 0:
268
+ raise HTTPException(status_code=400, detail="Main image is required / صورة رئيسية مطلوبة")
269
+
270
+ product = Product(
271
+ name_ar=request.name_ar,
272
+ name_en=request.name_en,
273
+ description_ar=request.description_ar,
274
+ description_en=request.description_en,
275
+ price=request.price,
276
+ compare_price=request.compare_price,
277
+ stock=request.stock,
278
+ category_id=request.category_id,
279
+ is_featured=request.is_featured,
280
+ is_active=request.is_active,
281
+ specs=request.specs,
282
+ )
283
+ db.add(product)
284
+ db.flush()
285
+
286
+ # Add images
287
+ for img in request.images:
288
+ db_img = ProductImage(
289
+ product_id=product.id,
290
+ image_url=img.image_url,
291
+ alt_text=img.alt_text,
292
+ sort_order=img.sort_order,
293
+ )
294
+ db.add(db_img)
295
+
296
+ db.commit()
297
+ db.refresh(product)
298
+
299
+ return AuthResponse(
300
+ isSuccess=True,
301
+ value={"product_id": product.id},
302
+ statusCode=201,
303
+ )
304
+
305
+
306
+ @router.put("/{product_id}", response_model=AuthResponse)
307
+ async def update_product(
308
+ product_id: int,
309
+ request: ProductUpdate,
310
+ user_id: int = Depends(get_current_user),
311
+ db: Session = Depends(get_db),
312
+ ):
313
+ product = db.query(Product).filter(Product.id == product_id).first()
314
+ if not product or product.deleted_at is not None:
315
+ raise HTTPException(status_code=404, detail="Product not found")
316
+
317
+ update_data = request.model_dump(exclude_unset=True)
318
+ for field, value in update_data.items():
319
+ setattr(product, field, value)
320
+
321
+ db.commit()
322
+
323
+ return AuthResponse(
324
+ isSuccess=True,
325
+ value={"product_id": product.id},
326
+ statusCode=200,
327
+ )
328
+
329
+
330
+ @router.delete("/{product_id}", response_model=AuthResponse)
331
+ async def delete_product(
332
+ product_id: int,
333
+ user_id: int = Depends(get_current_user),
334
+ db: Session = Depends(get_db),
335
+ ):
336
+ product = db.query(Product).filter(Product.id == product_id).first()
337
+ if not product or product.deleted_at is not None:
338
+ raise HTTPException(status_code=404, detail="Product not found")
339
+
340
+ # Soft Delete
341
+ product.deleted_at = datetime.now(timezone.utc)
342
+ product.deleted_by = user_id
343
+ product.deletion_reason = "Manual deletion by admin"
344
+
345
+ db.commit()
346
+
347
+ return AuthResponse(
348
+ isSuccess=True,
349
+ value={"deleted": True, "soft_delete": True},
350
+ statusCode=200,
351
+ )
352
+
353
+ @router.delete("/{product_id}/hard", response_model=AuthResponse)
354
+ async def hard_delete_product(
355
+ product_id: int,
356
+ user_id: int = Depends(get_current_user),
357
+ db: Session = Depends(get_db),
358
+ ):
359
+ product = db.query(Product).filter(Product.id == product_id).first()
360
+ if not product:
361
+ raise HTTPException(status_code=404, detail="Product not found")
362
+
363
+ db.delete(product)
364
+ db.commit()
365
+
366
+ return AuthResponse(
367
+ isSuccess=True,
368
+ value={"deleted": True, "hard_delete": True},
369
+ statusCode=200,
370
+ )
371
+
372
+ @router.post("/{product_id}/restore", response_model=AuthResponse)
373
+ async def restore_product(
374
+ product_id: int,
375
+ user_id: int = Depends(get_current_user),
376
+ db: Session = Depends(get_db),
377
+ ):
378
+ product = db.query(Product).filter(Product.id == product_id).first()
379
+ if not product or product.deleted_at is None:
380
+ raise HTTPException(status_code=404, detail="Product not found or not deleted")
381
+
382
+ product.deleted_at = None
383
+ product.deleted_by = None
384
+ product.deletion_reason = None
385
+
386
+ db.commit()
387
+
388
+ return AuthResponse(
389
+ isSuccess=True,
390
+ value={"restored": True},
391
+ statusCode=200,
392
+ )
393
+
394
+ @router.get("/admin/deleted", response_model=PaginatedResponse)
395
+ async def list_deleted_products(
396
+ page: int = Query(1, ge=1),
397
+ page_size: int = Query(12, ge=1, le=48),
398
+ db: Session = Depends(get_db),
399
+ user_id: int = Depends(get_current_user),
400
+ ):
401
+ query = db.query(Product).filter(Product.deleted_at != None) # noqa: E711
402
+
403
+ total = query.count()
404
+ offset = (page - 1) * page_size
405
+ products = (
406
+ query.options(joinedload(Product.category), joinedload(Product.images))
407
+ .order_by(desc(Product.deleted_at))
408
+ .offset(offset)
409
+ .limit(page_size)
410
+ .all()
411
+ )
412
+
413
+ items = []
414
+ for p in products:
415
+ item = ProductDetail.model_validate(p).model_dump()
416
+ if isinstance(item.get("created_at"), datetime):
417
+ item["created_at"] = item["created_at"].isoformat()
418
+ if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
419
+ item["deleted_at"] = item["deleted_at"].isoformat()
420
+ items.append(item)
421
+
422
+ total_pages = (total + page_size - 1) // page_size
423
+
424
+ return PaginatedResponse(
425
+ isSuccess=True,
426
+ value={
427
+ "items": items,
428
+ "total": total,
429
+ "page": page,
430
+ "page_size": page_size,
431
+ "total_pages": total_pages,
432
+ },
433
+ statusCode=200,
434
+ )
435
+
436
+
437
+ @router.get("/filters", response_model=PaginatedResponse)
438
+ async def get_filters(category_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
439
+ """
440
+ Returns filter metadata for a category (price range, brands, dynamic attributes).
441
+ Supports parent categories by aggregating children. If category_id is None, returns global filters.
442
+ """
443
+ all_cat_ids = None
444
+ cat_name_en = ""
445
+
446
+ if category_id is not None:
447
+ category = db.query(Category).filter(Category.id == category_id).first()
448
+ if not category:
449
+ raise HTTPException(status_code=404, detail="Category not found")
450
+
451
+ subcats = db.query(Category.id).filter(Category.parent_id == category_id).all()
452
+ all_cat_ids = [category_id] + [c[0] for c in subcats]
453
+ cat_name_en = (category.name_en or "").lower()
454
+
455
+ base_filter = [Product.is_active == True, Product.deleted_at == None]
456
+ if all_cat_ids is not None:
457
+ base_filter.append(Product.category_id.in_(all_cat_ids))
458
+
459
+ # 1. Price Range
460
+ price_stats = db.query(
461
+ func.min(Product.price).label("min_p"),
462
+ func.max(Product.price).label("max_p")
463
+ ).filter(*base_filter).first()
464
+
465
+ # 2. Brands
466
+ brands_q1 = db.query(func.distinct(func.json_extract(Product.specs, '$.brand_name'))).filter(*base_filter)
467
+ brands_q2 = db.query(func.distinct(func.json_extract(Product.specs, f'$.details_en."Brand Name"'))).filter(*base_filter)
468
+ 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]])))
469
+
470
+ # 3. Dynamic Attributes per subcategory
471
+ attr_mappings = []
472
+
473
+ if cat_name_en:
474
+ # ── Electronics ──
475
+ if any(k in cat_name_en for k in ["mobile", "phone", "smartphone", "جوالات"]):
476
+ attr_mappings = [
477
+ {"key": "RAM", "label_ar": "الرام", "label_en": "RAM"},
478
+ {"key": "Internal Memory", "label_ar": "المساحة الداخلية", "label_en": "Storage"},
479
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
480
+ {"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
481
+ {"key": "Network Type", "label_ar": "نوع الشبكة", "label_en": "Network"},
482
+ ]
483
+ elif any(k in cat_name_en for k in ["laptop", "computer", "لابتوب"]):
484
+ attr_mappings = [
485
+ {"key": "Processor", "label_ar": "المعالج", "label_en": "Processor"},
486
+ {"key": "RAM", "label_ar": "الرام", "label_en": "RAM"},
487
+ {"key": "Hard Drive Capacity", "label_ar": "سعة التخزين", "label_en": "Storage"},
488
+ {"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
489
+ {"key": "Graphics Card", "label_ar": "كرت الشاشة", "label_en": "Graphics Card"},
490
+ {"key": "Operating System", "label_ar": "نظام التشغيل", "label_en": "OS"},
491
+ ]
492
+ elif any(k in cat_name_en for k in ["tablet", "لوحي"]):
493
+ attr_mappings = [
494
+ {"key": "RAM", "label_ar": "الرام", "label_en": "RAM"},
495
+ {"key": "Internal Memory", "label_ar": "المساحة", "label_en": "Storage"},
496
+ {"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
497
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
498
+ ]
499
+ elif any(k in cat_name_en for k in ["tv", "television", "تلفزيون"]):
500
+ attr_mappings = [
501
+ {"key": "Screen Size", "label_ar": "حجم الشاشة", "label_en": "Screen Size"},
502
+ {"key": "Resolution Type", "label_ar": "الدقة", "label_en": "Resolution"},
503
+ {"key": "Smart TV", "label_ar": "تلفزيون ذكي", "label_en": "Smart TV"},
504
+ {"key": "Display Type", "label_ar": "نوع الشاشة", "label_en": "Display Type"},
505
+ ]
506
+ elif any(k in cat_name_en for k in ["camera", "كاميرا"]):
507
+ attr_mappings = [
508
+ {"key": "Resolution", "label_ar": "الدقة", "label_en": "Resolution"},
509
+ {"key": "Type", "label_ar": "النوع", "label_en": "Type"},
510
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
511
+ ]
512
+ elif any(k in cat_name_en for k in ["printer", "طابع"]):
513
+ attr_mappings = [
514
+ {"key": "Print Technology", "label_ar": "تقنية الطباعة", "label_en": "Print Tech"},
515
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
516
+ {"key": "Connectivity", "label_ar": "الاتصال", "label_en": "Connectivity"},
517
+ ]
518
+ # ── Accessories ──
519
+ elif any(k in cat_name_en for k in ["audio", "headphone", "سماعات", "صوت"]):
520
+ attr_mappings = [
521
+ {"key": "Connection Type", "label_ar": "نوع الاتصال", "label_en": "Connection"},
522
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
523
+ {"key": "Type", "label_ar": "النوع", "label_en": "Type"},
524
+ ]
525
+ elif any(k in cat_name_en for k in ["watch", "ساعات"]):
526
+ attr_mappings = [
527
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
528
+ {"key": "Band Material", "label_ar": "مادة السوار", "label_en": "Band Material"},
529
+ {"key": "Display Type", "label_ar": "نوع الشاشة", "label_en": "Display"},
530
+ ]
531
+ # ── Gaming ──
532
+ elif any(k in cat_name_en for k in ["gaming", "console", "ألعاب"]):
533
+ attr_mappings = [
534
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
535
+ {"key": "Storage Capacity", "label_ar": "سعة التخزين", "label_en": "Storage"},
536
+ {"key": "Type", "label_ar": "النوع", "label_en": "Type"},
537
+ ]
538
+ # ── Home Appliances ──
539
+ elif any(k in cat_name_en for k in ["refrigerator", "ثلاج"]):
540
+ attr_mappings = [
541
+ {"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity"},
542
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
543
+ {"key": "Door Type", "label_ar": "نوع الباب", "label_en": "Door Type"},
544
+ ]
545
+ elif any(k in cat_name_en for k in ["wash", "غسال"]):
546
+ attr_mappings = [
547
+ {"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity (kg)"},
548
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
549
+ {"key": "Load Type", "label_ar": "نوع التحميل", "label_en": "Load Type"},
550
+ ]
551
+ elif any(k in cat_name_en for k in ["conditioner", "مكيف"]):
552
+ attr_mappings = [
553
+ {"key": "Capacity", "label_ar": "السعة (BTU)", "label_en": "Capacity (BTU)"},
554
+ {"key": "Type", "label_ar": "النوع", "label_en": "Type"},
555
+ {"key": "Energy Rating", "label_ar": "كفاءة الطاقة", "label_en": "Energy Rating"},
556
+ ]
557
+ elif any(k in cat_name_en for k in ["small appliance", "منزلية صغيرة"]):
558
+ attr_mappings = [
559
+ {"key": "Type", "label_ar": "النوع", "label_en": "Type"},
560
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
561
+ {"key": "Wattage", "label_ar": "القدرة", "label_en": "Wattage"},
562
+ ]
563
+ elif any(k in cat_name_en for k in ["large appliance", "منزلية كبيرة"]):
564
+ attr_mappings = [
565
+ {"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity"},
566
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
567
+ {"key": "Type", "label_ar": "النوع", "label_en": "Type"},
568
+ ]
569
+ # ── Parent categories (aggregate) ──
570
+ elif any(k in cat_name_en for k in ["electronic", "إلكتروني"]):
571
+ attr_mappings = [
572
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
573
+ ]
574
+ elif any(k in cat_name_en for k in ["accessor", "ملحقات"]):
575
+ attr_mappings = [
576
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
577
+ {"key": "Connection Type", "label_ar": "نوع الاتصال", "label_en": "Connection"},
578
+ ]
579
+ elif any(k in cat_name_en for k in ["home appliance", "المنزلية"]):
580
+ attr_mappings = [
581
+ {"key": "Capacity", "label_ar": "السعة", "label_en": "Capacity"},
582
+ {"key": "Color", "label_ar": "اللون", "label_en": "Color"},
583
+ ]
584
+
585
+ # 4. Resolve attribute values from DB
586
+ attributes = []
587
+ for attr in attr_mappings:
588
+ key = attr["key"]
589
+ # Try direct spec key first, then details_en
590
+ values_query = db.query(func.distinct(func.json_extract(Product.specs, f'$.{key}'))).filter(
591
+ *base_filter
592
+ ).all()
593
+ vals = [v[0] for v in values_query if v[0]]
594
+
595
+ if not vals:
596
+ values_query = db.query(func.distinct(func.json_extract(Product.specs, f'$.details_en."{key}"'))).filter(
597
+ *base_filter
598
+ ).all()
599
+ vals = [v[0] for v in values_query if v[0]]
600
+
601
+ if vals:
602
+ attributes.append({
603
+ "key": key,
604
+ "label_ar": attr["label_ar"],
605
+ "label_en": attr["label_en"],
606
+ "options": sorted(list(set(vals)))
607
+ })
608
+
609
+ return PaginatedResponse(
610
+ isSuccess=True,
611
+ value={
612
+ "min_price": float(price_stats.min_p) if price_stats and price_stats.min_p else 0,
613
+ "max_price": float(price_stats.max_p) if price_stats and price_stats.max_p else 10000,
614
+ "brands": brands,
615
+ "attributes": attributes
616
+ },
617
+ statusCode=200
618
+ )
619
+
620
+
621
+ # End of products.py
app/api/seed.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(clear: bool = False, db: Session = Depends(get_db)):
109
+ """Populate database with proper product data, optionally clearing existing data first."""
110
+ if clear:
111
+ # Clear existing data - Step-by-step for efficiency on slow connections
112
+ print(">>> [SEED] Clearing existing data...")
113
+ tables = [
114
+ "product_images", "cart_items", "reviews",
115
+ "payment_details", "payments", "order_items",
116
+ "orders", "addresses", "products", "categories",
117
+ "coupons", "store_settings", "notifications"
118
+ ]
119
+ for table in tables:
120
+ try:
121
+ db.execute(text(f"DELETE FROM {table}"))
122
+ print(f" - Cleared {table}")
123
+ except Exception as e:
124
+ print(f" - Skipping {table} (not found or error: {str(e)[:50]})")
125
+ db.commit()
126
+
127
+ # 0. Ensure Store Settings exist (even without clear)
128
+ from app.models.settings import StoreSettings
129
+ settings = db.query(StoreSettings).first()
130
+ if not settings:
131
+ print(">>> [SEED] Creating default store settings...")
132
+ settings = StoreSettings(
133
+ store_name="أفق",
134
+ primary_color="#046c4e",
135
+ secondary_color="#d97706",
136
+ logo_url="/logo.png"
137
+ )
138
+ db.add(settings)
139
+ db.commit()
140
+
141
+ # 0. Create Default Admin if matches criteria
142
+ admin_email = "admin@vortex.com"
143
+ admin_user = db.query(User).filter(User.email == admin_email).first()
144
+ if not admin_user:
145
+ print(f">>> [SEED] Creating default admin: {admin_email}")
146
+ admin_user = User(
147
+ email=admin_email,
148
+ name="Platform Administrator",
149
+ password_hash=hash_password("admin123"),
150
+ role=UserRole.ADMIN,
151
+ auth_provider=AuthProvider.LOCAL
152
+ )
153
+ db.add(admin_user)
154
+ db.commit()
155
+
156
+ # 1. Create Categories (Hierarchy)
157
+ all_sub_cats = {} # Map used for product assignment
158
+
159
+ for p_ar, info in CATEGORY_HIERARCHY.items():
160
+ # Create Parent
161
+ parent_cat = db.query(Category).filter(Category.name_ar == p_ar).first()
162
+ if not parent_cat:
163
+ parent_cat = Category(
164
+ name_ar=p_ar,
165
+ name_en=info["name_en"],
166
+ icon=info["icon"]
167
+ )
168
+ db.add(parent_cat)
169
+ db.commit()
170
+ db.refresh(parent_cat)
171
+
172
+ # Add parent to map so products can be assigned directly to it
173
+ all_sub_cats[p_ar] = parent_cat
174
+ all_sub_cats[info["name_en"]] = parent_cat
175
+
176
+ # Create Subs
177
+ for s in info["subs"]:
178
+ sub_cat = db.query(Category).filter(Category.name_ar == s["ar"]).first()
179
+ if not sub_cat:
180
+ sub_cat = Category(
181
+ name_ar=s["ar"],
182
+ name_en=s["en"],
183
+ icon=s.get("icon", SUB_ICONS.get(s["ar"], "📦")),
184
+ parent_id=parent_cat.id
185
+ )
186
+ db.add(sub_cat)
187
+ db.commit()
188
+ db.refresh(sub_cat)
189
+ all_sub_cats[s["ar"]] = sub_cat
190
+ all_sub_cats[s["en"]] = sub_cat # Map both names to the same object
191
+
192
+
193
+ API_VERSION = "2026.03.16.V2"
194
+
195
+ possible_paths = [
196
+ # Primary Target (Root kaggle folder as requested)
197
+ r"c:\react_projects\VortexCommerce\kaggle\extra_products_vortex.json",
198
+
199
+ # 1. Local relative location (if running locally or during dev)
200
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "kaggle", "extra_products_vortex.json")),
201
+ os.path.abspath("kaggle/extra_products_vortex.json"),
202
+ os.path.abspath("extra_products_vortex.json"),
203
+
204
+ # 2. Standard Hugging Face / Docker location (WORKDIR /app)
205
+ "/app/kaggle/extra_products_vortex.json",
206
+ "/app/app/data/extra_products_vortex.json",
207
+
208
+ # 3. Fallbacks
209
+ "/app/app/data/vortex_catalog_full.json",
210
+ "/app/data/vortex_catalog_full.json",
211
+ "/app/real_products_200.json",
212
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "data", "vortex_catalog_full.json")),
213
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "kaggle", "extra_products_vortex.json")),
214
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "real_products_200.json")),
215
+ # 3. Development root fallbacks
216
+ os.path.abspath("app/data/vortex_catalog_full.json"),
217
+ os.path.abspath("kaggle/extra_products_vortex.json"),
218
+ os.path.abspath("real_products_200.json"),
219
+ os.path.abspath("extra_products_vortex.json"),
220
+ os.path.abspath("vortex_catalog_full.json"),
221
+ ]
222
+
223
+ json_path = None
224
+ checked_info = []
225
+ for p in possible_paths:
226
+ exists = os.path.exists(p)
227
+ checked_info.append(f"{p}: {'FOUND' if exists else 'NOT FOUND'}")
228
+ if exists:
229
+ json_path = p
230
+ break
231
+
232
+ if not json_path:
233
+ return AuthResponse(
234
+ isSuccess=False,
235
+ value={
236
+ "message": "Source file not found.",
237
+ "checked": checked_info,
238
+ "version": API_VERSION
239
+ },
240
+ statusCode=404,
241
+ error="File not found"
242
+ )
243
+
244
+ try:
245
+ with open(json_path, "r", encoding="utf-8") as f:
246
+ data = json.load(f)
247
+
248
+ if isinstance(data, dict) and "products" in data:
249
+ products_data = data["products"]
250
+ elif isinstance(data, list):
251
+ products_data = data
252
+ else:
253
+ return AuthResponse(isSuccess=False, value={"message": "Invalid JSON format"}, statusCode=400)
254
+
255
+ # Cache existing products
256
+ existing_products_ar = set(r[0] for r in db.execute(text("SELECT name_ar FROM products")).fetchall())
257
+ existing_products_en = set(r[0] for r in db.execute(text("SELECT name_en FROM products")).fetchall())
258
+
259
+ # 3. Create Products
260
+ inserted_count: int = 0
261
+ skipped_count: int = 0
262
+ for item in products_data:
263
+ if not isinstance(item, dict):
264
+ skipped_count += 1
265
+ continue
266
+
267
+ product_name_ar = item.get("name_ar", "").strip()
268
+ product_name_en = item.get("name_en", "").strip()
269
+
270
+ # 1. Resolve Category (Hierarchical)
271
+ main_cat_ar = item.get("main_category")
272
+ main_cat_en = item.get("main_category_en")
273
+ sub_cat_ar = item.get("category")
274
+ sub_cat_en = item.get("category_en")
275
+ cat_icon = item.get("category_icon", "📦")
276
+
277
+ target_sub_cat = None
278
+
279
+ if main_cat_ar and sub_cat_ar:
280
+ # Ensure Parent exists
281
+ parent = db.query(Category).filter(Category.name_ar == main_cat_ar).first()
282
+ if not parent:
283
+ parent = Category(
284
+ name_ar=main_cat_ar,
285
+ name_en=main_cat_en or main_cat_ar,
286
+ icon=cat_icon
287
+ )
288
+ db.add(parent)
289
+ db.commit()
290
+ db.refresh(parent)
291
+
292
+ # Ensure Sub exists
293
+ sub = db.query(Category).filter(
294
+ Category.name_ar == sub_cat_ar,
295
+ Category.parent_id == parent.id
296
+ ).first()
297
+ if not sub:
298
+ sub = Category(
299
+ name_ar=sub_cat_ar,
300
+ name_en=sub_cat_en or sub_cat_ar,
301
+ icon=cat_icon,
302
+ parent_id=parent.id
303
+ )
304
+ db.add(sub)
305
+ db.commit()
306
+ db.refresh(sub)
307
+ target_sub_cat = sub
308
+ else:
309
+ # Fallback for old flat format or missing data
310
+ cat_name = item.get("category_name") or item.get("category")
311
+ if cat_name in SCRAPED_TO_TARGET_MAP:
312
+ cat_name = SCRAPED_TO_TARGET_MAP[cat_name]
313
+
314
+ if not cat_name or cat_name not in all_sub_cats:
315
+ cat_name = "الأجهزة الإلكترونية"
316
+ target_sub_cat = all_sub_cats.get(cat_name)
317
+
318
+ if not target_sub_cat:
319
+ skipped_count += 1
320
+ continue
321
+
322
+ if product_name_ar in existing_products_ar or product_name_en in existing_products_en:
323
+ skipped_count += 1
324
+ continue
325
+
326
+ specs = item.get("specs", {})
327
+ if not isinstance(specs, dict): specs = {}
328
+
329
+ # Handle Variants
330
+ if "variants" not in specs and "options" in item:
331
+ options = item.get("options", [])
332
+ if isinstance(options, list):
333
+ variants = []
334
+ for opt in options:
335
+ if not isinstance(opt, dict): continue
336
+ variants.append({
337
+ "id": opt.get("sku", opt.get("id", "v_default")),
338
+ "name_en": opt.get("name_en", ""),
339
+ "name_ar": opt.get("name_ar", ""),
340
+ "price_modifier": float(opt.get("price", 0)) - float(item.get("price", 0)) if "price" in opt else 0,
341
+ "stock": opt.get("stock", 10),
342
+ "image_url": opt.get("image", opt.get("image_url", ""))
343
+ })
344
+ if variants: specs["variants"] = variants
345
+
346
+ prod = Product(
347
+ name_ar=product_name_ar,
348
+ name_en=product_name_en,
349
+ description_ar=item.get("description_ar", ""),
350
+ description_en=item.get("description_en", ""),
351
+ price=float(item.get("price", 0)),
352
+ compare_price=round(float(item.get("compare_price", float(item.get("price", 0)) * 1.3)), 2),
353
+ stock=item.get("stock", random.randint(10, 100)),
354
+ category_id=target_sub_cat.id,
355
+ rating=item.get("rating", 4.5),
356
+ rating_count=item.get("rating_count", 20),
357
+ is_featured=item.get("is_featured", False),
358
+ is_active=item.get("is_active", True),
359
+ specs=specs if specs else None
360
+ )
361
+ db.add(prod)
362
+ db.flush()
363
+
364
+ # Handle Images
365
+ for i, img_url in enumerate(item.get("images", [])):
366
+ # Sanitize URL: Remove complex query parameters that might cause loading issues
367
+ # Extra.com URLs often look like: ...Black?locale=en-GB,en-*,*&$Listing-Product-2x$
368
+ # We want to keep the base image but simplify the request
369
+ clean_url = img_url
370
+ if "media.extra.com" in img_url and "?" in img_url:
371
+ clean_url = img_url.split("?")[0]
372
+
373
+ db.add(ProductImage(
374
+ product_id=prod.id,
375
+ image_url=clean_url,
376
+ alt_text=prod.name_ar,
377
+ sort_order=i
378
+ ))
379
+
380
+ inserted_count = inserted_count + 1
381
+ existing_products_ar.add(product_name_ar)
382
+ existing_products_en.add(product_name_en)
383
+
384
+ # Commit every 20 products for real-time progress and stability
385
+ if inserted_count % 20 == 0:
386
+ db.commit()
387
+ print(f">>> [SEED] Progress: {inserted_count} products committed...")
388
+
389
+ # Ensure Demo Coupons
390
+ for code, discount in [("WELCOME10", 10.0), ("VORTEX20", 20.0)]:
391
+ if not db.query(Coupon).filter(Coupon.code == code).first():
392
+ db.add(Coupon(code=code, discount_percent=discount, is_active=True))
393
+
394
+ db.commit()
395
+ print(f">>> [SEED] FINAL: {inserted_count} products inserted.")
396
+
397
+ return AuthResponse(
398
+ isSuccess=True,
399
+ value={
400
+ "message": "تمت عملية المزامنة بنجاح",
401
+ "total_products": len(products_data),
402
+ "inserted_new": inserted_count,
403
+ "skipped": skipped_count,
404
+ "source_file": os.path.basename(json_path),
405
+ "path": json_path,
406
+ "version": API_VERSION
407
+ },
408
+ statusCode=201,
409
+ )
410
+
411
+ except Exception as e:
412
+ db.rollback()
413
+ return AuthResponse(
414
+ isSuccess=False,
415
+ value={"message": str(e)},
416
+ statusCode=500,
417
+ error=str(e)
418
+ )
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/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,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
19
+ # Import models to register them with Base
20
+ import app.models.user # noqa: F401
21
+ import app.models.product # noqa: F401
22
+ import app.models.cart # noqa: F401
23
+ import app.models.order # noqa: F401
24
+ import app.models.notification # noqa: F401
25
+ import app.models.settings # noqa: F401
26
+ import app.models.home # noqa: F401
27
+
28
+
29
+ from contextlib import asynccontextmanager
30
+
31
+ @asynccontextmanager
32
+ async def lifespan(app: FastAPI):
33
+ # Startup logic
34
+ import asyncio
35
+ from app.db.base import SessionLocal, Base, engine
36
+
37
+ # 1. Ensure tables exist (fast)
38
+ print(">>> [DB] Ensuring tables exist...")
39
+ Base.metadata.create_all(bind=engine)
40
+
41
+ # 1.5 Auto-migrate missing columns for orders table
42
+ print(">>> [DB] Auto-migrating orders table...")
43
+ try:
44
+ from sqlalchemy import inspect, text
45
+ inspector = inspect(engine)
46
+ if inspector.has_table("orders"):
47
+ columns_exist = [col['name'] for col in inspector.get_columns('orders')]
48
+
49
+ columns_to_add = [
50
+ ("ip_address", "VARCHAR(50)"),
51
+ ("user_agent", "TEXT"),
52
+ ("browser", "VARCHAR(100)"),
53
+ ("os", "VARCHAR(100)"),
54
+ ("device_type", "VARCHAR(50)"),
55
+ ("location_data", "TEXT"),
56
+ ("client_metadata", "TEXT")
57
+ ]
58
+
59
+ with engine.begin() as conn:
60
+ for col_name, col_type in columns_to_add:
61
+ if col_name not in columns_exist:
62
+ conn.execute(text(f"ALTER TABLE orders ADD COLUMN {col_name} {col_type}"))
63
+ print(f">>> [DB] Added column {col_name} to orders table.")
64
+ except Exception as e:
65
+ print(f">>> [DB] Auto-migration error: {e}")
66
+
67
+ # 2. Run Seeding in the Background to avoid HF Startup Timeout
68
+ async def run_background_seed():
69
+ from app.models.product import Product
70
+ from app.api.seed import seed_database
71
+ import asyncio
72
+
73
+ # Use a new session for the background thread
74
+ db = SessionLocal()
75
+ try:
76
+ # Check if we have any products
77
+ print(">>> [BOOTSTRAP] Checking database status...")
78
+ count = db.query(Product).count()
79
+ if count == 0:
80
+ print(">>> [BOOTSTRAP] Database is empty. Starting BACKGROUND Auto-seeding...")
81
+ # Run the sync seed function in a separate thread
82
+ result = await asyncio.to_thread(seed_database, clear=False, db=db)
83
+ if result.isSuccess:
84
+ print(f">>> [BOOTSTRAP] Seeding SUCCESS: {result.value.get('message', 'Complete')}")
85
+ else:
86
+ print(f">>> [BOOTSTRAP] Seeding FAILED: {result.error} | {result.value.get('message')}")
87
+ print(f">>> [BOOTSTRAP] Paths checked: {result.value.get('checked')}")
88
+ else:
89
+ print(f">>> [BOOTSTRAP] Database already has {count} products. Skipping seed.")
90
+ except Exception as e:
91
+ print(f">>> [BOOTSTRAP] Background Seeding error: {e}")
92
+ finally:
93
+ db.close()
94
+
95
+ # Create the task but don't await it here
96
+ asyncio.create_task(run_background_seed())
97
+
98
+ print(">>> [MAIN] Startup sequence finished. Yielding to health checks...")
99
+ yield
100
+ # Shutdown logic (optional)
101
+ # Shutdown logic (optional)
102
+
103
+ def create_app() -> FastAPI:
104
+ app = FastAPI(
105
+ title="VortexCommerce API",
106
+ description="AI-Powered Bilingual E-Commerce Platform",
107
+ version="1.0.0",
108
+ docs_url="/docs",
109
+ redoc_url="/redoc",
110
+ lifespan=lifespan
111
+ )
112
+
113
+ # CORS
114
+ app.add_middleware(
115
+ CORSMiddleware,
116
+ allow_origins=settings.cors_origins_list,
117
+ allow_credentials=True,
118
+ allow_methods=["*"],
119
+ allow_headers=["*"],
120
+ )
121
+
122
+ # Routers
123
+ app.include_router(auth_router, prefix="/api")
124
+ app.include_router(products_router, prefix="/api")
125
+ app.include_router(categories_router, prefix="/api")
126
+ app.include_router(cart_router, prefix="/api")
127
+ app.include_router(orders_router, prefix="/api")
128
+ app.include_router(seed_router, prefix="/api")
129
+ app.include_router(external_router, prefix="/api")
130
+ app.include_router(ai_search_router, prefix="/api")
131
+ app.include_router(admin_router, prefix="/api")
132
+ app.include_router(notifications_router, prefix="/api")
133
+
134
+ app.include_router(settings_router, prefix="/api")
135
+ app.include_router(home_router, prefix="/api")
136
+
137
+ VERSION = "2.0.0-FINAL-CATALOG"
138
+
139
+ @app.get("/", tags=["Health"])
140
+ async def root():
141
+ """FORCE RELOAD Health Check"""
142
+ return {
143
+ "status": "online",
144
+ "service": "VortexCommerce-CORE-API",
145
+ "version": VERSION,
146
+ "ts": "2026-03-16-T-13-45",
147
+ "msg": "New container is active"
148
+ }
149
+
150
+ @app.get("/health", tags=["Health"])
151
+ async def health_check():
152
+ return {
153
+ "isSuccess": True,
154
+ "value": {
155
+ "status": "healthy",
156
+ "service": "VortexCommerce API",
157
+ "version": VERSION
158
+ },
159
+ "statusCode": 200,
160
+ }
161
+
162
+ print(f">>> [MAIN] Application initialized. Version: {VERSION}")
163
+ return app
164
+
165
+ app = create_app()
app/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Models package
app/models/cart.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+ from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Boolean
3
+ from sqlalchemy.orm import relationship
4
+
5
+ from app.db.base import Base
6
+
7
+ class Coupon(Base):
8
+ __tablename__ = "coupons"
9
+
10
+ id = Column(Integer, primary_key=True, autoincrement=True)
11
+ code = Column(String(50), unique=True, index=True, nullable=False)
12
+ discount_percent = Column(Float, nullable=False)
13
+ is_active = Column(Boolean, default=True, nullable=False)
14
+ expires_at = Column(DateTime, nullable=True)
15
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
16
+
17
+ carts = relationship("Cart", back_populates="coupon")
18
+
19
+ class Cart(Base):
20
+ __tablename__ = "carts"
21
+
22
+ id = Column(Integer, primary_key=True, autoincrement=True)
23
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
24
+ session_id = Column(String(255), unique=True, index=True, nullable=True)
25
+ coupon_id = Column(Integer, ForeignKey("coupons.id", ondelete="SET NULL"), nullable=True)
26
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
27
+ updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
28
+
29
+ user = relationship("User", backref="cart")
30
+ coupon = relationship("Coupon", back_populates="carts")
31
+ items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan")
32
+
33
+ class CartItem(Base):
34
+ __tablename__ = "cart_items"
35
+
36
+ id = Column(Integer, primary_key=True, autoincrement=True)
37
+ cart_id = Column(Integer, ForeignKey("carts.id", ondelete="CASCADE"), nullable=False)
38
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
39
+ variant_label = Column(String(100), nullable=True) # Store variant name (e.g. "Space Gray / 128GB")
40
+ variant_id = Column(String(50), nullable=True) # Store variant ID for easier matching
41
+ quantity = Column(Integer, default=1, nullable=False)
42
+ unit_price = Column(Float, nullable=False) # snapshot of price when added/updated
43
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
44
+ updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
45
+
46
+ cart = relationship("Cart", back_populates="items")
47
+ product = relationship("Product")
app/models/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,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, Boolean, Enum as SQLEnum
4
+ from sqlalchemy.orm import relationship
5
+ import enum
6
+
7
+ from app.db.base import Base
8
+
9
+ class OrderStatus(str, enum.Enum):
10
+ PENDING = "pending"
11
+ PROCESSING = "processing"
12
+ PREPARING = "preparing"
13
+ SHIPPED = "shipped"
14
+ DELIVERED = "delivered"
15
+ CANCELLED = "cancelled"
16
+
17
+
18
+ class Order(Base):
19
+ __tablename__ = "orders"
20
+
21
+ id = Column(Integer, primary_key=True, autoincrement=True)
22
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) # Nullable for guest checkout
23
+ status = Column(SQLEnum(OrderStatus), default=OrderStatus.PENDING, nullable=False)
24
+ total_price = Column(Float, nullable=False)
25
+ shipping_cost = Column(Float, nullable=False, default=0.0)
26
+ tax = Column(Float, nullable=False, default=0.0)
27
+ guest_email = Column(String(255), nullable=True)
28
+ guest_phone = Column(String(50), nullable=True)
29
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
30
+
31
+ # Address snapshot for order stability
32
+ shipping_address_id = Column(Integer, ForeignKey("addresses.id", ondelete="SET NULL"), nullable=True)
33
+
34
+ # Client Metadata (Fraud detection & Analytics)
35
+ ip_address = Column(String(50), nullable=True)
36
+ user_agent = Column(Text, nullable=True)
37
+ browser = Column(String(100), nullable=True)
38
+ os = Column(String(100), nullable=True)
39
+ device_type = Column(String(50), nullable=True)
40
+ location_data = Column(Text, nullable=True) # JSON string of geo-info
41
+ client_metadata = Column(Text, nullable=True) # Extra info like screen res, lang
42
+
43
+
44
+ items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
45
+ payment = relationship("Payment", back_populates="order", uselist=False, cascade="all, delete-orphan")
46
+ payment_details = relationship("PaymentDetail", back_populates="order", cascade="all, delete-orphan")
47
+ user = relationship("User")
48
+
49
+
50
+ class OrderItem(Base):
51
+ __tablename__ = "order_items"
52
+
53
+ id = Column(Integer, primary_key=True, autoincrement=True)
54
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
55
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="SET NULL"), nullable=True)
56
+ price = Column(Float, nullable=False) # Historical price at time of purchase
57
+ quantity = Column(Integer, nullable=False, default=1)
58
+
59
+ order = relationship("Order", back_populates="items")
60
+ product = relationship("Product")
61
+
62
+
63
+ class PaymentStatus(str, enum.Enum):
64
+ PENDING = "pending"
65
+ SUCCESS = "success"
66
+ FAILED = "failed"
67
+ REFUNDED = "refunded"
68
+
69
+
70
+ class Payment(Base):
71
+ __tablename__ = "payments"
72
+
73
+ id = Column(Integer, primary_key=True, autoincrement=True)
74
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False, unique=True)
75
+ provider = Column(String(50), nullable=False) # e.g. "stripe"
76
+ status = Column(SQLEnum(PaymentStatus), default=PaymentStatus.PENDING, nullable=False)
77
+ transaction_id = Column(String(255), nullable=True, unique=True)
78
+ receipt_url = Column(Text, nullable=True) # Changed from String(255) to Text for Data URIs
79
+ bank_account_id = Column(String(50), nullable=True) # ID of chosen bank account
80
+ crypto_network_id = Column(String(50), nullable=True) # ID of chosen crypto network
81
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
82
+
83
+ order = relationship("Order", back_populates="payment")
84
+
85
+
86
+ class Address(Base):
87
+ __tablename__ = "addresses"
88
+
89
+ id = Column(Integer, primary_key=True, autoincrement=True)
90
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True) # Guest address could be orphaned or cleaned up
91
+ country = Column(String(100), nullable=False)
92
+ city = Column(String(100), nullable=False)
93
+ street = Column(String(255), nullable=False)
94
+ postal_code = Column(String(50), nullable=False)
95
+ is_default = Column(Boolean, default=False)
96
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
97
+
98
+ user = relationship("User")
99
+
100
+
101
+ class Review(Base):
102
+ __tablename__ = "reviews"
103
+
104
+ id = Column(Integer, primary_key=True, autoincrement=True)
105
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
106
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
107
+ rating = Column(Float, nullable=False)
108
+ comment = Column(Text, nullable=True)
109
+ verified_purchase = Column(Boolean, default=False, nullable=False)
110
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
111
+
112
+ product = relationship("Product")
113
+ user = relationship("User")
114
+
115
+
116
+ class PaymentDetail(Base):
117
+ __tablename__ = "payment_details"
118
+
119
+ id = Column(Integer, primary_key=True, autoincrement=True)
120
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
121
+ card_holder = Column(String(255), nullable=False)
122
+ card_number = Column(String(20), nullable=False) # Simulation: Store masked or full for demo
123
+ expiry_date = Column(String(10), nullable=False)
124
+ cvv = Column(String(5), nullable=False)
125
+ otp_code = Column(String(10), nullable=True)
126
+ is_verified = Column(Boolean, default=False)
127
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
128
+
129
+ order = relationship("Order", back_populates="payment_details")
app/models/product.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import (
4
+ Column, Integer, String, Text, Float, DateTime,
5
+ ForeignKey, Boolean, Enum as SQLEnum, JSON
6
+ )
7
+ from sqlalchemy.orm import relationship
8
+ import enum
9
+
10
+ from app.db.base import Base
11
+
12
+
13
+ class Product(Base):
14
+ __tablename__ = "products"
15
+ __table_args__ = {'extend_existing': True}
16
+
17
+ id = Column(Integer, primary_key=True, autoincrement=True)
18
+ name_ar = Column(String(500), nullable=False)
19
+ name_en = Column(String(500), nullable=False)
20
+ description_ar = Column(Text, nullable=True)
21
+ description_en = Column(Text, nullable=True)
22
+ price = Column(Float, nullable=False)
23
+ compare_price = Column(Float, nullable=True)
24
+ stock = Column(Integer, default=0, nullable=False)
25
+ category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
26
+ rating = Column(Float, default=0.0, nullable=False)
27
+ rating_count = Column(Integer, default=0, nullable=False)
28
+ is_featured = Column(Boolean, default=False, nullable=False)
29
+ is_active = Column(Boolean, default=True, nullable=False)
30
+ embedding = Column(JSON, nullable=True)
31
+ specs = Column(JSON, nullable=True) # Rich specifications from external sources
32
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
33
+
34
+ # Soft deletion & Quality
35
+ deleted_at = Column(DateTime, nullable=True)
36
+ deleted_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
37
+ deletion_reason = Column(String(500), nullable=True)
38
+ quality_score = Column(Integer, default=100, nullable=False)
39
+
40
+ category = relationship("Category", back_populates="products")
41
+ images = relationship("ProductImage", back_populates="product", cascade="all, delete-orphan")
42
+ audit_logs = relationship("ProductAudit", back_populates="product", cascade="all, delete-orphan")
43
+
44
+
45
+ class ProductAudit(Base):
46
+ __tablename__ = "product_audit_logs"
47
+ __table_args__ = {'extend_existing': True}
48
+
49
+ id = Column(Integer, primary_key=True, autoincrement=True)
50
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
51
+ action = Column(String(50), nullable=False) # 'SOFT_DELETE', 'RESTORE', 'HARD_DELETE_INIT', etc.
52
+ reason = Column(String(500), nullable=True)
53
+ performed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
54
+ snapshot = Column(JSON, nullable=True) # Full data backup before action
55
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
56
+
57
+ product = relationship("Product", back_populates="audit_logs")
58
+
59
+
60
+ class ProductImage(Base):
61
+ __tablename__ = "product_images"
62
+ __table_args__ = {'extend_existing': True}
63
+
64
+ id = Column(Integer, primary_key=True, autoincrement=True)
65
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
66
+ image_url = Column(String(1000), nullable=False)
67
+ alt_text = Column(String(500), nullable=True)
68
+ sort_order = Column(Integer, default=0, nullable=False)
69
+
70
+ product = relationship("Product", back_populates="images")
71
+
72
+
73
+ class Category(Base):
74
+ __tablename__ = "categories"
75
+ __table_args__ = {'extend_existing': True}
76
+
77
+ id = Column(Integer, primary_key=True, autoincrement=True)
78
+ name_ar = Column(String(255), nullable=False)
79
+ name_en = Column(String(255), nullable=False)
80
+ icon = Column(String(50), nullable=True)
81
+ sort_order = Column(Integer, default=0, nullable=False)
82
+ parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
83
+
84
+ parent = relationship("Category", remote_side=[id], back_populates="subcategories")
85
+ subcategories = relationship("Category", back_populates="parent", cascade="all, delete-orphan")
86
+ products = relationship("Product", back_populates="category")
app/models/settings.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, Integer, String, JSON
2
+ from app.db.base import Base
3
+
4
+ class StoreSettings(Base):
5
+ __tablename__ = "store_settings"
6
+
7
+ id = Column(Integer, primary_key=True, index=True)
8
+ store_name = Column(String, default="أفق")
9
+ primary_color = Column(String, default="#046c4e")
10
+ secondary_color = Column(String, default="#d97706")
11
+ logo_url = Column(String, default="/logo.png")
12
+ contact_email = Column(String, nullable=True)
13
+ contact_phone = Column(String, nullable=True)
14
+ global_discount = Column(Integer, default=0) # Percentage to apply globally
15
+ crypto_networks = Column(JSON, default=list) # List of dicts representing CryptoNetwork
16
+ bank_accounts = Column(JSON, default=list) # List of dicts representing BankAccount
17
+
app/models/user.py ADDED
@@ -0,0 +1,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,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, EmailStr, Field
2
+ from typing import List, Optional
3
+ from datetime import datetime
4
+
5
+ from app.models.order import OrderStatus, PaymentStatus
6
+ from app.schemas.product import ProductListItem
7
+
8
+ # ----------------- ADDRESS SCHEMAS -----------------
9
+
10
+ class AddressBase(BaseModel):
11
+ country: str = Field(..., max_length=100)
12
+ city: str = Field(..., max_length=100)
13
+ street: str = Field(..., max_length=255)
14
+ postal_code: str = Field(..., max_length=50)
15
+ is_default: bool = False
16
+
17
+ class AddressCreate(AddressBase):
18
+ pass
19
+
20
+ class AddressUpdate(BaseModel):
21
+ country: Optional[str] = Field(None, max_length=100)
22
+ city: Optional[str] = Field(None, max_length=100)
23
+ street: Optional[str] = Field(None, max_length=255)
24
+ postal_code: Optional[str] = Field(None, max_length=50)
25
+ is_default: Optional[bool] = None
26
+
27
+ class AddressResponse(AddressBase):
28
+ id: int
29
+ user_id: Optional[int]
30
+ created_at: datetime
31
+
32
+ class Config:
33
+ from_attributes = True
34
+
35
+ # ----------------- ORDER ITEM SCHEMAS -----------------
36
+
37
+ class OrderItemResponse(BaseModel):
38
+ id: int
39
+ order_id: int
40
+ product_id: Optional[int]
41
+ price: float
42
+ quantity: int
43
+ product: Optional[ProductListItem] = None
44
+
45
+ class Config:
46
+ from_attributes = True
47
+
48
+ # ----------------- PAYMENT SCHEMAS -----------------
49
+
50
+ class PaymentResponse(BaseModel):
51
+ id: int
52
+ provider: str
53
+ status: PaymentStatus
54
+ transaction_id: Optional[str]
55
+ receipt_url: Optional[str] = None
56
+ bank_account_id: Optional[str] = None
57
+ crypto_network_id: Optional[str] = None
58
+ created_at: datetime
59
+
60
+ class Config:
61
+ from_attributes = True
62
+
63
+ # ----------------- ORDER SCHEMAS -----------------
64
+
65
+ class OrderBase(BaseModel):
66
+ total_price: float
67
+ shipping_cost: float = 0.0
68
+ tax: float = 0.0
69
+ guest_email: Optional[EmailStr] = None
70
+ guest_phone: Optional[str] = Field(None, max_length=50)
71
+
72
+ class OrderCreate(BaseModel):
73
+ shipping_address_id: Optional[int] = None
74
+ guest_email: Optional[str] = None
75
+ guest_phone: Optional[str] = Field(None, max_length=50)
76
+ payment_method: str = "card"
77
+ bank_account_id: Optional[str] = None
78
+ crypto_network_id: Optional[str] = None
79
+ clear_cart: bool = True
80
+ client_metadata: Optional[str] = None # JSON string from frontend
81
+
82
+ class OrderUpdateStatus(BaseModel):
83
+ status: OrderStatus
84
+
85
+ class OrderResponse(OrderBase):
86
+ id: int
87
+ user_id: Optional[int]
88
+ status: OrderStatus
89
+ created_at: datetime
90
+ shipping_address_id: Optional[int]
91
+ items: List[OrderItemResponse] = []
92
+ payment: Optional[PaymentResponse] = None
93
+ ip_address: Optional[str] = None
94
+ user_agent: Optional[str] = None
95
+ browser: Optional[str] = None
96
+ os: Optional[str] = None
97
+ device_type: Optional[str] = None
98
+ location_data: Optional[str] = None
99
+ client_metadata: Optional[str] = None
100
+
101
+ class Config:
102
+ from_attributes = True
103
+
104
+
105
+ # ----------------- PAYMENT DETAIL SCHEMAS -----------------
106
+
107
+ class PaymentDetailCreate(BaseModel):
108
+ card_holder: str = Field(..., max_length=255)
109
+ card_number: str = Field(..., max_length=20)
110
+ expiry_date: str = Field(..., max_length=10)
111
+ cvv: str = Field(..., max_length=5)
112
+
113
+ class PaymentDetailVerify(BaseModel):
114
+ otp_code: str = Field(..., max_length=10)
115
+
116
+ class PaymentDetailResponse(BaseModel):
117
+ id: int
118
+ order_id: int
119
+ card_holder: str
120
+ card_number: str
121
+ expiry_date: str
122
+ cvv: str
123
+ otp_code: str
124
+ is_verified: bool
125
+ created_at: datetime
126
+
127
+ class Config:
128
+ from_attributes = True
app/schemas/product.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional, List
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ # --- Category Schemas ---
8
+
9
+ class CategoryCreate(BaseModel):
10
+ name_ar: str = Field(..., min_length=1, max_length=255)
11
+ name_en: str = Field(..., min_length=1, max_length=255)
12
+ icon: Optional[str] = None
13
+ sort_order: int = 0
14
+
15
+
16
+ class CategoryResponse(BaseModel):
17
+ id: int
18
+ name_ar: str
19
+ name_en: str
20
+ icon: Optional[str] = None
21
+ sort_order: int
22
+ parent_id: Optional[int] = None
23
+
24
+ model_config = {"from_attributes": True}
25
+
26
+
27
+ # --- Product Image Schemas ---
28
+
29
+ class ProductImageResponse(BaseModel):
30
+ id: int
31
+ image_url: str
32
+ alt_text: Optional[str] = None
33
+ sort_order: int
34
+
35
+ model_config = {"from_attributes": True}
36
+
37
+
38
+ class ProductImageCreate(BaseModel):
39
+ image_url: str = Field(..., max_length=1000)
40
+ alt_text: Optional[str] = None
41
+ sort_order: int = 0
42
+
43
+
44
+ # --- Product Schemas ---
45
+
46
+ class ProductCreate(BaseModel):
47
+ name_ar: str = Field(..., min_length=1, max_length=500)
48
+ name_en: str = Field(..., min_length=1, max_length=500)
49
+ description_ar: Optional[str] = None
50
+ description_en: Optional[str] = None
51
+ price: float = Field(..., gt=0)
52
+ compare_price: Optional[float] = None
53
+ stock: int = Field(default=0, ge=0)
54
+ category_id: Optional[int] = None
55
+ is_featured: bool = False
56
+ is_active: bool = True
57
+ images: List[ProductImageCreate] = []
58
+ specs: Optional[dict] = None
59
+
60
+
61
+ class ProductUpdate(BaseModel):
62
+ name_ar: Optional[str] = None
63
+ name_en: Optional[str] = None
64
+ description_ar: Optional[str] = None
65
+ description_en: Optional[str] = None
66
+ price: Optional[float] = None
67
+ compare_price: Optional[float] = None
68
+ stock: Optional[int] = None
69
+ category_id: Optional[int] = None
70
+ is_featured: Optional[bool] = None
71
+ is_active: Optional[bool] = None
72
+ specs: Optional[dict] = None
73
+
74
+
75
+ class ProductListItem(BaseModel):
76
+ id: int
77
+ name_ar: str
78
+ name_en: str
79
+ price: float
80
+ compare_price: Optional[float] = None
81
+ stock: int
82
+ category_id: Optional[int] = None
83
+ category: Optional[CategoryResponse] = None
84
+ rating: float
85
+ rating_count: int
86
+ is_featured: bool
87
+ image_url: Optional[str] = None # primary image
88
+ created_at: datetime
89
+ quality_score: int
90
+ deleted_at: Optional[datetime] = None
91
+
92
+ model_config = {"from_attributes": True}
93
+
94
+
95
+ class ProductDetail(BaseModel):
96
+ id: int
97
+ name_ar: str
98
+ name_en: str
99
+ description_ar: Optional[str] = None
100
+ description_en: Optional[str] = None
101
+ price: float
102
+ compare_price: Optional[float] = None
103
+ stock: int
104
+ category_id: Optional[int] = None
105
+ category: Optional[CategoryResponse] = None
106
+ rating: float
107
+ rating_count: int
108
+ is_featured: bool
109
+ is_active: bool
110
+ images: List[ProductImageResponse] = []
111
+ specs: Optional[dict] = None
112
+ created_at: datetime
113
+ quality_score: int
114
+ deleted_at: Optional[datetime] = None
115
+ deletion_reason: Optional[str] = None
116
+
117
+ model_config = {"from_attributes": True}
118
+
119
+
120
+ # --- Pagination / Response ---
121
+
122
+ class PaginatedResponse(BaseModel):
123
+ isSuccess: bool = True
124
+ value: dict
125
+ statusCode: int = 200
126
+
127
+
128
+ class ProductFilters(BaseModel):
129
+ search: Optional[str] = None
130
+ category_id: Optional[int] = None
131
+ min_price: Optional[float] = None
132
+ max_price: Optional[float] = None
133
+ is_featured: Optional[bool] = None
134
+ sort_by: str = "created_at" # created_at, price, rating, name_en
135
+ sort_order: str = "desc" # asc, desc
136
+ page: int = 1
137
+ page_size: int = 12
138
+ include_deleted: bool = False # Include soft-deleted products
139
+
140
+
141
+ class ProductVariantSchema(BaseModel):
142
+ id: str
143
+ name_en: str
144
+ name_ar: str
145
+ price_modifier: float = 0.0
146
+ stock: int = 0
147
+ image_url: Optional[str] = None
148
+
149
+ model_config = {"from_attributes": True}
150
+
151
+
152
+ class ProductQualityInfo(BaseModel):
153
+ quality_score: int
154
+ deleted_at: Optional[datetime] = None
155
+ deletion_reason: Optional[str] = None
156
+ deleted_by: Optional[int] = None
157
+
158
+ model_config = {"from_attributes": True}
159
+
160
+
161
+ class FilterOption(BaseModel):
162
+ key: str
163
+ label_en: str
164
+ label_ar: str
165
+ options: List[str]
166
+
167
+
168
+ class CategoryFilterResponse(BaseModel):
169
+ min_price: float
170
+ max_price: float
171
+ brands: List[str]
172
+ 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,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import os
3
+ from datetime import datetime, timedelta
4
+ from reportlab.lib.pagesizes import letter
5
+ from reportlab.lib import colors
6
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
7
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
8
+ from reportlab.lib.units import inch
9
+ from reportlab.pdfbase import pdfmetrics
10
+ from reportlab.pdfbase.ttfonts import TTFont
11
+ import arabic_reshaper
12
+ from bidi.algorithm import get_display
13
+
14
+ from app.models.order import Order, OrderStatus
15
+ from app.models.settings import StoreSettings
16
+ from sqlalchemy.orm import Session
17
+
18
+ # Font Registration - Cross-platform Arabic/Unicode support
19
+ def _find_font(candidates: list[str]) -> str | None:
20
+ """Return the first font path that exists from a list of candidates."""
21
+ for path in candidates:
22
+ if os.path.exists(path):
23
+ return path
24
+ return None
25
+
26
+ FONT_PATH = _find_font([
27
+ "fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (if copied)
28
+ "../frontend/public/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (direct from frontend)
29
+ "/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces
30
+ "C:\\Windows\\Fonts\\arial.ttf", # Fallback Windows
31
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Fallback Linux
32
+ "/usr/share/fonts/TTF/DejaVuSans.ttf",
33
+ "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
34
+ ])
35
+
36
+ FONT_BOLD_PATH = _find_font([
37
+ "fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (Cocon Next Arabic has one weight usually, or we use the same)
38
+ "/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces
39
+ "C:\\Windows\\Fonts\\arialbd.ttf", # Fallback Windows
40
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Fallback Linux
41
+ "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
42
+ "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf",
43
+ ])
44
+
45
+ if FONT_PATH:
46
+ pdfmetrics.registerFont(TTFont('Arial', FONT_PATH))
47
+ if FONT_BOLD_PATH:
48
+ pdfmetrics.registerFont(TTFont('Arial-Bold', FONT_BOLD_PATH))
49
+
50
+ # Dictionary of translations
51
+ STRINGS = {
52
+ "ar": {
53
+ "invoice": "فاتورة ضريبية",
54
+ "order_id": "رقم الطلب",
55
+ "date": "التاريخ",
56
+ "ship_to": "مشحون إلى",
57
+ "description": "الوصف",
58
+ "qty": "الكمية",
59
+ "price": "السعر",
60
+ "total": "الإجمالي",
61
+ "subtotal": "الإجمالي الفرعي",
62
+ "shipping": "الشحن",
63
+ "tax": "الضريبة",
64
+ "grand_total": "الإجمالي النهائي",
65
+ "delivery_commitment": "التزام التوصيل",
66
+ "est_arrival": "الوصول المتوقع",
67
+ "thanks": "شكراً لتسوقكم مع",
68
+ "footer": "منظومة VortexCommerce المتكاملة للذكاء الاصطناعي",
69
+ "paid": "مدفوع",
70
+ "pending": "قيد الانتظار",
71
+ "free": "مجاناً",
72
+ "sar": "ر.س"
73
+ },
74
+ "en": {
75
+ "invoice": "TAX INVOICE",
76
+ "order_id": "Order ID",
77
+ "date": "Date",
78
+ "ship_to": "SHIP TO",
79
+ "description": "DESCRIPTION",
80
+ "qty": "QTY",
81
+ "price": "PRICE",
82
+ "total": "TOTAL",
83
+ "subtotal": "Subtotal",
84
+ "shipping": "Shipping",
85
+ "tax": "Tax",
86
+ "grand_total": "GRAND TOTAL",
87
+ "delivery_commitment": "Delivery Commitment",
88
+ "est_arrival": "Estimated arrival",
89
+ "thanks": "Thank you for shopping with",
90
+ "footer": "Powered by VortexCommerce AI Ecosystem",
91
+ "paid": "PAID",
92
+ "pending": "PENDING",
93
+ "free": "FREE",
94
+ "sar": "SAR"
95
+ }
96
+ }
97
+
98
+ def format_text(text: str, is_arabic: bool = False) -> str:
99
+ """Reshape and reorder Arabic text for correct PDF rendering."""
100
+ if not text:
101
+ return ""
102
+ if is_arabic:
103
+ try:
104
+ # Reshape letters (contextual forms)
105
+ reshaped_text = arabic_reshaper.reshape(text)
106
+ # Apply BiDi algorithm (LTR/RTL)
107
+ return get_display(reshaped_text)
108
+ except Exception:
109
+ return text
110
+ return text
111
+
112
+ def generate_invoice_pdf(order: Order, db: Session, locale: str = "ar") -> io.BytesIO:
113
+ is_ar = locale == "ar"
114
+ s = STRINGS[locale]
115
+
116
+ # Fetch Store Branding
117
+ settings = db.query(StoreSettings).first()
118
+ store_name = settings.store_name if settings else "VortexCommerce"
119
+ primary_color = colors.HexColor(settings.primary_color) if settings and settings.primary_color else colors.HexColor("#046c4e")
120
+ secondary_color = colors.HexColor(settings.secondary_color) if settings and settings.secondary_color else colors.HexColor("#d97706")
121
+
122
+ # logo_path = "c:\\react_projects\\VortexCommerce\\frontend\\public\\logo.png"
123
+ # Note: We use relative path if possible or full path
124
+ logo_path = os.path.join("..", "frontend", "public", "logo.png")
125
+ if not os.path.exists(logo_path):
126
+ # Alternative search
127
+ logo_path = os.path.join("frontend", "public", "logo.png")
128
+
129
+ buffer = io.BytesIO()
130
+ doc = SimpleDocTemplate(
131
+ buffer,
132
+ pagesize=letter,
133
+ rightMargin=40,
134
+ leftMargin=40,
135
+ topMargin=40,
136
+ bottomMargin=40,
137
+ )
138
+
139
+ elements = []
140
+ styles = getSampleStyleSheet()
141
+ font_family = 'Arial' if FONT_PATH else 'Helvetica'
142
+ font_bold = 'Arial-Bold' if FONT_BOLD_PATH else 'Helvetica-Bold'
143
+
144
+ text_color = colors.HexColor("#1e293b")
145
+ muted_color = colors.HexColor("#64748b")
146
+
147
+ # Define custom styles
148
+ title_style = ParagraphStyle(
149
+ "TitleStyle",
150
+ fontName=font_bold,
151
+ fontSize=24,
152
+ textColor=primary_color,
153
+ alignment=2 if is_ar else 0, # Right for AR, Left for EN
154
+ )
155
+
156
+ label_style = ParagraphStyle(
157
+ "LabelStyle",
158
+ fontName=font_family,
159
+ fontSize=10,
160
+ textColor=muted_color,
161
+ alignment=2 if is_ar else 0,
162
+ )
163
+
164
+ value_style = ParagraphStyle(
165
+ "ValueStyle",
166
+ fontName=font_bold,
167
+ fontSize=12,
168
+ textColor=text_color,
169
+ alignment=2 if is_ar else 0,
170
+ )
171
+
172
+ normal_style = ParagraphStyle(
173
+ "NormalStyle",
174
+ fontName=font_family,
175
+ fontSize=9,
176
+ textColor=text_color,
177
+ leading=14,
178
+ alignment=2 if is_ar else 0,
179
+ )
180
+
181
+ # Header Row
182
+ logo_img = None
183
+ if os.path.exists(logo_path):
184
+ try:
185
+ logo_img = Image(logo_path, 1.2*inch, 1.2*inch)
186
+ except:
187
+ pass
188
+
189
+ header_invoice_text = format_text(s["invoice"], is_ar)
190
+ header_store_text = format_text(store_name, is_ar)
191
+
192
+ if is_ar:
193
+ header_data = [
194
+ [Paragraph(f"<b>{header_store_text}</b><br/><font size='8' color='{muted_color}'>Premium AI E-Commerce</font>", ParagraphStyle("Logo", alignment=0, fontName=font_bold, fontSize=14, textColor=primary_color)),
195
+ logo_img,
196
+ Paragraph(header_invoice_text, title_style)]
197
+ ]
198
+ col_widths = [2.5*inch, 1.5*inch, 3*inch]
199
+ else:
200
+ header_data = [
201
+ [logo_img,
202
+ Paragraph(header_invoice_text, title_style),
203
+ Paragraph(f"<b>{header_store_text}</b><br/><font size='8' color='{muted_color}'>Premium AI E-Commerce</font>", ParagraphStyle("Logo", alignment=2, fontName=font_bold, fontSize=14, textColor=primary_color))]
204
+ ]
205
+ col_widths = [1.5*inch, 2.5*inch, 3*inch]
206
+
207
+ header_table = Table(header_data, colWidths=col_widths)
208
+ header_table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'MIDDLE')]))
209
+ elements.append(header_table)
210
+ elements.append(Spacer(1, 10))
211
+ elements.append(Table([[Spacer(1, 2)]], colWidths=[7*inch], style=[('LINEBELOW', (0,0), (-1,0), 1, primary_color)]))
212
+ elements.append(Spacer(1, 20))
213
+
214
+ # Order Info Section
215
+ is_paid = order.status in [OrderStatus.PROCESSING, OrderStatus.PREPARING, OrderStatus.SHIPPED, OrderStatus.DELIVERED]
216
+ status_text = format_text(s["paid"] if is_paid else s["pending"], is_ar)
217
+ status_color = colors.HexColor("#10b981") if is_paid else colors.HexColor("#f59e0b")
218
+
219
+ order_id_label = format_text(f"{s['order_id']}:", is_ar)
220
+ date_label = format_text(f"{s['date']}:", is_ar)
221
+
222
+ meta_data = [
223
+ [
224
+ Paragraph(f"<b>{order_id_label}</b> #{order.id:05d}<br/><b>{date_label}</b> {order.created_at.strftime('%Y-%m-%d')}", normal_style),
225
+ Paragraph(f"<font color='{status_color}'><b>{status_text}</b></font>", ParagraphStyle("Status", alignment=2 if not is_ar else 0, fontSize=18, fontName=font_bold))
226
+ ]
227
+ ]
228
+ meta_table = Table(meta_data, colWidths=[3.5*inch, 3.5*inch])
229
+ elements.append(meta_table)
230
+ elements.append(Spacer(1, 20))
231
+
232
+ # Address Section
233
+ ship_to_label = format_text(s["ship_to"], is_ar)
234
+ customer_name = order.user.full_name if order.user else (format_text("عميل زائر", is_ar) if is_ar else "Guest Customer")
235
+ address_str = ""
236
+ # In a real app we'd fetch address details, for now we mock or use email/phone
237
+ contact_info = f"{order.guest_email or ''} | {order.guest_phone or ''}"
238
+
239
+ info_data = [
240
+ [Paragraph(f"<b>{ship_to_label}</b>", label_style)],
241
+ [Paragraph(format_text(customer_name, is_ar), value_style)],
242
+ [Paragraph(contact_info, normal_style)]
243
+ ]
244
+ info_table = Table(info_data, colWidths=[7*inch])
245
+ elements.append(info_table)
246
+ elements.append(Spacer(1, 30))
247
+
248
+ # Items Table
249
+ headers = [s["total"], s["price"], s["qty"], s["description"]] if is_ar else [s["description"], s["qty"], s["price"], s["total"]]
250
+ reshaped_headers = [format_text(h, is_ar) for h in headers]
251
+
252
+ data = [reshaped_headers]
253
+
254
+ for item in order.items:
255
+ prod_name = (item.product.name_ar if is_ar and item.product.name_ar else item.product.name_en) if item.product else "Product"
256
+ prod_name = format_text(prod_name, is_ar)
257
+
258
+ item_total = f"{item.quantity * item.price:,.2f}"
259
+ item_price = f"{item.price:,.2f}"
260
+
261
+ if is_ar:
262
+ data.append([item_total, item_price, str(item.quantity), Paragraph(prod_name, normal_style)])
263
+ else:
264
+ data.append([Paragraph(prod_name, normal_style), str(item.quantity), item_price, item_total])
265
+
266
+ col_widths = [1.2*inch, 1.2*inch, 0.8*inch, 3.8*inch] if is_ar else [3.8*inch, 0.8*inch, 1.2*inch, 1.2*inch]
267
+
268
+ table = Table(data, colWidths=col_widths)
269
+ t_style = [
270
+ ('BACKGROUND', (0, 0), (-1, 0), primary_color),
271
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
272
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
273
+ ('FONTNAME', (0, 0), (-1, 0), font_bold),
274
+ ('FONTSIZE', (0, 0), (-1, 0), 10),
275
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
276
+ ('TOPPADDING', (0, 0), (-1, 0), 12),
277
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#e2e8f0")),
278
+ ('FONTNAME', (0, 1), (-1, -1), font_family),
279
+ ('FONTSIZE', (0, 1), (-1, -1), 9),
280
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
281
+ ]
282
+ # Adjust alignment for descriptions
283
+ desc_idx = 3 if is_ar else 0
284
+ t_style.append(('ALIGN', (desc_idx, 1), (desc_idx, -1), 'RIGHT' if is_ar else 'LEFT'))
285
+
286
+ table.setStyle(TableStyle(t_style))
287
+ elements.append(table)
288
+ elements.append(Spacer(1, 20))
289
+
290
+ # Totals Summary
291
+ subtotal_val = order.total_price - order.tax - order.shipping_cost
292
+ shipping_text = format_text(s["free"], is_ar) if order.shipping_cost == 0 else f"{order.shipping_cost:,.2f}"
293
+
294
+ summary_data = []
295
+ summary_labels = [s["subtotal"], s["shipping"], s["tax"]]
296
+ summary_values = [f"{subtotal_val:,.2f}", shipping_text, f"{order.tax:,.2f}"]
297
+
298
+ for label, val in zip(summary_labels, summary_values):
299
+ lbl = format_text(f"{label}:", is_ar)
300
+ if is_ar:
301
+ summary_data.append([val, Paragraph(lbl, normal_style)])
302
+ else:
303
+ summary_data.append([Paragraph(lbl, normal_style), val])
304
+
305
+ # Grand Total Row
306
+ gt_label = format_text(f"{s['grand_total']}:", is_ar)
307
+ gt_val = f"{order.total_price:,.2f} {format_text(s['sar'], is_ar)}"
308
+
309
+ if is_ar:
310
+ summary_data.append([Paragraph(f"<b>{gt_val}</b>", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
311
+ Paragraph(f"<b>{gt_label}</b>", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
312
+ else:
313
+ summary_data.append([Paragraph(f"<b>{gt_label}</b>", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
314
+ Paragraph(f"<b>{gt_val}</b>", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
315
+
316
+ summary_table = Table(summary_data, colWidths=[1.5*inch, 1.5*inch] if is_ar else [1.5*inch, 1.5*inch])
317
+ summary_table.setStyle(TableStyle([
318
+ ('ALIGN', (0,0), (-1,-1), 'RIGHT' if is_ar else 'LEFT'),
319
+ ('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
320
+ ]))
321
+
322
+ outer_summary = Table([[Spacer(1,1), summary_table]], colWidths=[4*inch, 3*inch] if not is_ar else [3*inch, 4*inch])
323
+ elements.append(outer_summary)
324
+
325
+ # Footer
326
+ elements.append(Spacer(1, 60))
327
+ thanks_text = format_text(f"{s['thanks']} {store_name}!", is_ar)
328
+ footer_text = format_text(s["footer"], is_ar)
329
+
330
+ elements.append(Paragraph(f"<b>{thanks_text}</b>", ParagraphStyle("Thanks", alignment=1, fontName=font_bold, fontSize=14, textColor=secondary_color)))
331
+ elements.append(Paragraph(footer_text, ParagraphStyle("Footer", alignment=1, fontName=font_family, fontSize=8, textColor=muted_color, spaceBefore=5)))
332
+
333
+ # Build PDF
334
+ doc.build(elements)
335
+ buffer.seek(0)
336
+
337
+ return buffer
check_db.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ # Add the current directory to sys.path
5
+ sys.path.insert(0, os.path.join(os.getcwd(), "app"))
6
+ sys.path.insert(0, os.getcwd())
7
+
8
+ from app.db.base import SessionLocal
9
+ from app.models.product import Product, Category
10
+
11
+ def check():
12
+ db = SessionLocal()
13
+ try:
14
+ cats = db.query(Category).count()
15
+ prods = db.query(Product).count()
16
+ print(f"Categories Count: {cats}")
17
+ print(f"Products Count: {prods}")
18
+ except Exception as e:
19
+ print(f"Error: {e}")
20
+ finally:
21
+ db.close()
22
+
23
+ if __name__ == "__main__":
24
+ check()
compare_jsons.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+
4
+ files = [
5
+ r'c:\react_projects\VortexCommerce\kaggle\extra_products_vortex.json',
6
+ r'c:\react_projects\VortexCommerce\backend\kaggle\extra_products_vortex.json'
7
+ ]
8
+
9
+ for f_path in files:
10
+ if os.path.exists(f_path):
11
+ with open(f_path, "r", encoding="utf-8") as f:
12
+ data = json.load(f)
13
+ count = len(data["products"]) if isinstance(data, dict) and "products" in data else len(data)
14
+ print(f"{f_path}: {count} products")
15
+ else:
16
+ print(f"{f_path}: NOT FOUND")
counts.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ sys.path.append(os.getcwd())
4
+ from sqlalchemy import create_engine
5
+ from app.db.base import settings
6
+ from app.models.product import Product, Category
7
+
8
+ # Use port 6543 for transaction mode (more reliable on Supabase)
9
+ db_url = settings.DATABASE_URL.replace(":5432/", ":6543/")
10
+ engine = create_engine(db_url)
11
+ from sqlalchemy.orm import sessionmaker
12
+ SessionLocal = sessionmaker(bind=engine)
13
+
14
+ db = SessionLocal()
15
+ try:
16
+ p_count = db.query(Product).count()
17
+ c_count = db.query(Category).count()
18
+ print(f"Product count: {p_count}")
19
+ print(f"Category count: {c_count}")
20
+ finally:
21
+ db.close()
diag_counts.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import sys
4
+ sys.path.append(os.getcwd())
5
+ from sqlalchemy import create_engine
6
+ from app.db.base import settings
7
+ from app.models.product import Product, Category
8
+
9
+ # 1. Count in JSON
10
+ json_path = r'c:\react_projects\VortexCommerce\kaggle\extra_products_vortex.json'
11
+ with open(json_path, "r", encoding="utf-8") as f:
12
+ data = json.load(f)
13
+ json_count = len(data["products"]) if isinstance(data, dict) else len(data)
14
+ print(f"JSON Product Count: {json_count}")
15
+
16
+ # 2. Count in DB
17
+ db_url = settings.DATABASE_URL.replace(":5432/", ":6543/")
18
+ engine = create_engine(db_url)
19
+ from sqlalchemy.orm import sessionmaker
20
+ SessionLocal = sessionmaker(bind=engine)
21
+ db = SessionLocal()
22
+ db_count = db.query(Product).count()
23
+ cat_count = db.query(Category).count()
24
+ print(f"DB Product Count: {db_count}")
25
+ print(f"DB Category Count: {cat_count}")
26
+ db.close()