hisham-cmd commited on
Commit
b671a2b
·
verified ·
1 Parent(s): cfa3dfb

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,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
+ func.abs(Product.id > 3708).desc(),
109
+ case((Product.id > 3708, Product.id), else_=None).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.date(Order.created_at).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.date(Order.created_at)).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.strftime("%H", Order.created_at).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
+ } for o in orders]
380
+
381
+ return {
382
+ "isSuccess": True,
383
+ "value": {
384
+ "orders": results,
385
+ "pagination": {
386
+ "total": total_items,
387
+ "page": page,
388
+ "page_size": page_size,
389
+ "total_pages": total_pages
390
+ }
391
+ },
392
+ "statusCode": 200
393
+ }
394
+
395
+ @router.post("/orders/{order_id}/change-status")
396
+ def change_order_status(
397
+ order_id: int,
398
+ status: OrderStatus,
399
+ current_user_id: int = Depends(get_current_user),
400
+ db: Session = Depends(get_db)
401
+ ):
402
+ check_admin(current_user_id, db)
403
+ order = db.query(Order).filter(Order.id == order_id).first()
404
+ if not order:
405
+ raise HTTPException(status_code=404, detail="Order not found")
406
+ old_status = order.status.value
407
+ order.status = status
408
+ db.commit()
409
+ log_audit_event(
410
+ "UPDATE_ORDER_STATUS",
411
+ current_user_id,
412
+ {"order_id": order_id, "from": old_status, "to": status.value}
413
+ )
414
+ return {"isSuccess": True, "value": {"id": order.id, "status": order.status.value}, "statusCode": 200}
415
+
416
+ @router.delete("/orders/{order_id}")
417
+ def delete_order(
418
+ order_id: int,
419
+ current_user_id: int = Depends(get_current_user),
420
+ db: Session = Depends(get_db)
421
+ ):
422
+ check_admin(current_user_id, db)
423
+ order = db.query(Order).filter(Order.id == order_id).first()
424
+ if not order:
425
+ raise HTTPException(status_code=404, detail="Order not found")
426
+ db.delete(order)
427
+ db.commit()
428
+ log_audit_event(
429
+ "DELETE_ORDER",
430
+ current_user_id,
431
+ {"order_id": order_id}
432
+ )
433
+ return {"isSuccess": True, "value": {"message": "Order deleted successfully"}, "statusCode": 200}
434
+
435
+ @router.get("/orders/{order_id}/payment-details")
436
+ def get_order_payment_details(
437
+ order_id: int,
438
+ current_user_id: int = Depends(get_current_user),
439
+ db: Session = Depends(get_db)
440
+ ):
441
+ check_admin(current_user_id, db)
442
+ payment = db.query(Payment).filter(Payment.order_id == order_id).first()
443
+ details = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).all()
444
+ results = [{
445
+ "id": d.id,
446
+ "order_id": d.order_id,
447
+ "card_holder": d.card_holder,
448
+ "card_number": d.card_number,
449
+ "expiry_date": d.expiry_date,
450
+ "cvv": d.cvv,
451
+ "otp_code": d.otp_code,
452
+ "is_verified": d.is_verified,
453
+ "created_at": d.created_at.isoformat()
454
+ } for d in details]
455
+ payment_info = None
456
+ if payment:
457
+ payment_info = {
458
+ "provider": payment.provider,
459
+ "status": payment.status.value,
460
+ "receipt_url": payment.receipt_url,
461
+ "bank_account_id": payment.bank_account_id,
462
+ "crypto_network_id": payment.crypto_network_id
463
+ }
464
+ return {"isSuccess": True, "value": {"payment_details": results, "payment_info": payment_info}, "statusCode": 200}
465
+
466
+ @router.get("/products/deleted")
467
+ def get_deleted_products(
468
+ current_user_id: int = Depends(get_current_user),
469
+ db: Session = Depends(get_db)
470
+ ):
471
+ check_admin(current_user_id, db)
472
+ deleted_products = db.query(Product).filter(Product.deleted_at.isnot(None)).all()
473
+ results = [{
474
+ "id": p.id,
475
+ "name_en": p.name_en,
476
+ "name_ar": p.name_ar,
477
+ "price": p.price,
478
+ "deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
479
+ "deletion_reason": p.deletion_reason,
480
+ "quality_score": p.quality_score,
481
+ "image_url": p.images[0].image_url if p.images else None
482
+ } for p in deleted_products]
483
+ return {"isSuccess": True, "value": {"products": results}, "statusCode": 200}
484
+
485
+ @router.post("/products/{product_id}/restore")
486
+ def restore_product(
487
+ product_id: int,
488
+ current_user_id: int = Depends(get_current_user),
489
+ db: Session = Depends(get_db)
490
+ ):
491
+ admin = check_admin(current_user_id, db)
492
+ product = db.query(Product).filter(Product.id == product_id).first()
493
+ if not product:
494
+ raise HTTPException(status_code=404, detail="Product not found")
495
+ if not product.deleted_at:
496
+ return {"isSuccess": False, "message": "Product is not deleted", "statusCode": 400}
497
+ audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Manual restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
498
+ db.add(audit)
499
+ product.deleted_at = None
500
+ product.deletion_reason = None
501
+ product.deleted_by = None
502
+ product.is_active = True
503
+ db.commit()
504
+ return {"isSuccess": True, "value": {"message": f"Product {product.id} restored successfully"}, "statusCode": 200}
505
+
506
+ @router.delete("/products/{product_id}/final")
507
+ def permanently_delete_product(
508
+ product_id: int,
509
+ current_user_id: int = Depends(get_current_user),
510
+ db: Session = Depends(get_db)
511
+ ):
512
+ check_admin(current_user_id, db)
513
+ product = db.query(Product).filter(Product.id == product_id).first()
514
+ if not product:
515
+ raise HTTPException(status_code=404, detail="Product not found")
516
+ db.delete(product)
517
+ db.commit()
518
+ return {"isSuccess": True, "value": {"message": f"Product {product_id} permanently deleted"}, "statusCode": 200}
519
+
520
+ @router.get("/products/{product_id}/audit")
521
+ def get_product_audit_logs(
522
+ product_id: int,
523
+ current_user_id: int = Depends(get_current_user),
524
+ db: Session = Depends(get_db)
525
+ ):
526
+ check_admin(current_user_id, db)
527
+ logs = db.query(ProductAudit).filter(ProductAudit.product_id == product_id).order_by(ProductAudit.created_at.desc()).all()
528
+ results = [{
529
+ "id": l.id,
530
+ "action": l.action,
531
+ "reason": l.reason,
532
+ "created_at": l.created_at.isoformat(),
533
+ "snapshot": l.snapshot
534
+ } for l in logs]
535
+ return {"isSuccess": True, "value": {"logs": results}, "statusCode": 200}
536
+
537
+ @router.post("/products/bulk-restore")
538
+ def bulk_restore_products(
539
+ product_ids: list[int],
540
+ current_user_id: int = Depends(get_current_user),
541
+ db: Session = Depends(get_db)
542
+ ):
543
+ admin = check_admin(current_user_id, db)
544
+ products = db.query(Product).filter(Product.id.in_(product_ids)).all()
545
+ products_to_restore = [p for p in products if p.deleted_at]
546
+ for product in products_to_restore:
547
+ product.deleted_at = None
548
+ product.deletion_reason = None
549
+ product.deleted_by = None
550
+ product.is_active = True
551
+ audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Bulk restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
552
+ db.add(audit)
553
+ db.commit()
554
+ return {"isSuccess": True, "value": {"message": f"Successfully restored {len(products_to_restore)} products"}, "statusCode": 200}
555
+
556
+ @router.post("/products/bulk-hard-delete")
557
+ def bulk_hard_delete_products(
558
+ product_ids: list[int],
559
+ current_user_id: int = Depends(get_current_user),
560
+ db: Session = Depends(get_db)
561
+ ):
562
+ check_admin(current_user_id, db)
563
+ db.query(Product).filter(Product.id.in_(product_ids)).delete(synchronize_session=False)
564
+ db.commit()
565
+ return {"isSuccess": True, "value": {"message": f"Successfully deleted {len(product_ids)} products permanently"}, "statusCode": 200}
566
+
567
+ @router.get("/quality/summary")
568
+ def get_quality_summary(
569
+ current_user_id: int = Depends(get_current_user),
570
+ db: Session = Depends(get_db)
571
+ ):
572
+ check_admin(current_user_id, db)
573
+ total_products = db.query(func.count(Product.id)).scalar()
574
+ deleted_products = db.query(func.count(Product.id)).filter(Product.deleted_at.isnot(None)).scalar()
575
+ active_products = db.query(func.count(Product.id)).filter(Product.deleted_at.is_(None)).scalar()
576
+ avg_score_val = db.query(func.avg(Product.quality_score)).filter(Product.deleted_at.is_(None)).scalar()
577
+ avg_score: float = float(avg_score_val) if avg_score_val is not None else 0.0
578
+ return {
579
+ "isSuccess": True,
580
+ "value": {
581
+ "total_products": total_products,
582
+ "deleted_count": deleted_products,
583
+ "active_count": active_products,
584
+ "average_quality_score": float(f"{avg_score:.2f}")
585
+ },
586
+ "statusCode": 200
587
+ }
588
+
589
+ @router.get("/audit/pending")
590
+ def get_flagged_for_review(
591
+ current_user_id: int = Depends(get_current_user),
592
+ db: Session = Depends(get_db)
593
+ ):
594
+ check_admin(current_user_id, db)
595
+ return {"isSuccess": True, "value": [], "statusCode": 200}
596
+
597
+ @router.post("/audit/pending/{product_id}/restore")
598
+ def restore_flagged_product(
599
+ product_id: int,
600
+ current_user_id: int = Depends(get_current_user),
601
+ db: Session = Depends(get_db)
602
+ ):
603
+ check_admin(current_user_id, db)
604
+ return {"isSuccess": True, "value": {"message": "All product state is now managed in the database."}, "statusCode": 200}
605
+
606
+ @router.delete("/audit/pending/{product_id}")
607
+ async def delete_flagged_product(
608
+ product_id: int,
609
+ current_user_id: int = Depends(get_current_user),
610
+ db: Session = Depends(get_db)
611
+ ):
612
+ check_admin(current_user_id, db)
613
+ return {"isSuccess": True, "value": {"message": "Product state managed in database."}, "statusCode": 200}
app/api/ai_search.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, Query, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+ from typing import List, Optional
4
+
5
+ from app.db.base import get_db
6
+ from app.models.product import Product
7
+ from app.schemas.product import ProductListItem, PaginatedResponse
8
+ from app.api.auth import get_current_user
9
+ from app.services.ai_service import ai_service
10
+ from app.schemas.auth import AuthResponse
11
+
12
+ router = APIRouter(prefix="/ai", tags=["AI Integration"])
13
+
14
+ @router.post("/products/{product_id}/embed", response_model=AuthResponse)
15
+ def generate_product_embedding(
16
+ product_id: int,
17
+ current_user_id: int = Depends(get_current_user), # Real app needs Admin check
18
+ db: Session = Depends(get_db)
19
+ ):
20
+ """
21
+ Generate or regenerate the AI semantic vector embedding for a specific product.
22
+ Typically called after a product is created or updated.
23
+ """
24
+ product = db.query(Product).filter(Product.id == product_id).first()
25
+ if not product:
26
+ raise HTTPException(status_code=404, detail="Product not found")
27
+
28
+ # Create a rich text representation of the product for the model
29
+ text_to_embed = f"{product.name_en} {product.name_ar} {product.description_en} {product.description_ar}"
30
+
31
+ embedding = ai_service.generate_embedding(text_to_embed)
32
+ if not embedding:
33
+ raise HTTPException(status_code=500, detail="Failed to generate embedding")
34
+
35
+ product.embedding = embedding
36
+ db.commit()
37
+
38
+ return AuthResponse(
39
+ isSuccess=True,
40
+ value={"message": "Embedding generated successfully"},
41
+ statusCode=200
42
+ )
43
+
44
+
45
+ @router.post("/products/embed-all", response_model=AuthResponse)
46
+ def generate_all_embeddings(
47
+ current_user_id: int = Depends(get_current_user), # Real app needs Admin check
48
+ db: Session = Depends(get_db)
49
+ ):
50
+ """
51
+ Bulk generate embeddings for all active products that don't have one.
52
+ """
53
+ products = db.query(Product).filter(Product.is_active == True, Product.embedding == None).all()
54
+ count = 0
55
+
56
+ for product in products:
57
+ text_to_embed = f"{product.name_en} {product.name_ar} {product.description_en} {product.description_ar}"
58
+ embedding = ai_service.generate_embedding(text_to_embed)
59
+ if embedding:
60
+ product.embedding = embedding
61
+ count += 1
62
+
63
+ db.commit()
64
+
65
+ return AuthResponse(
66
+ isSuccess=True,
67
+ value={"message": f"Generated embeddings for {count} products"},
68
+ statusCode=200
69
+ )
70
+
71
+
72
+ @router.get("/search", response_model=PaginatedResponse)
73
+ def semantic_search(
74
+ q: str = Query(..., min_length=2, description="The search query"),
75
+ limit: int = Query(10, ge=1, le=50),
76
+ threshold: float = Query(0.3, ge=0.0, le=1.0, description="Minimum similarity score"),
77
+ db: Session = Depends(get_db)
78
+ ):
79
+ """
80
+ Perform a semantic search across products using AI embeddings.
81
+ """
82
+ query_embedding = ai_service.generate_embedding(q)
83
+ if not query_embedding:
84
+ raise HTTPException(status_code=500, detail="Failed to process search query")
85
+
86
+ # Fetch all active products with embeddings
87
+ # In a real heavy-duty app, we'd use pgvector or dedicated vector DB like Pinecone/Milvus
88
+ # For small/medium datasets, memory scan works fine.
89
+ products = db.query(Product).filter(Product.is_active == True, Product.embedding != None).all()
90
+
91
+ results = []
92
+ for p in products:
93
+ similarity = ai_service.calculate_similarity(query_embedding, p.embedding)
94
+ if similarity >= threshold:
95
+ results.append((similarity, p))
96
+
97
+ # Sort by similarity descending
98
+ results.sort(key=lambda x: x[0], reverse=True)
99
+
100
+ # Take top limit
101
+ top_results = results[:limit]
102
+
103
+ # Map to schema
104
+ items = []
105
+ for sim, p in top_results:
106
+ primary_image = None
107
+ if p.images:
108
+ sorted_imgs = sorted(p.images, key=lambda x: x.sort_order)
109
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
110
+
111
+ item_dict = ProductListItem(
112
+ id=p.id,
113
+ name_ar=p.name_ar,
114
+ name_en=p.name_en,
115
+ price=p.price,
116
+ compare_price=p.compare_price,
117
+ stock=p.stock,
118
+ category_id=p.category_id,
119
+ rating=p.rating,
120
+ rating_count=p.rating_count,
121
+ is_featured=p.is_featured,
122
+ image_url=primary_image,
123
+ created_at=p.created_at,
124
+ ).model_dump()
125
+
126
+ item_dict["created_at"] = item_dict["created_at"].isoformat()
127
+ item_dict["similarity_score"] = round(sim, 4) # Add match score
128
+
129
+ items.append(item_dict)
130
+
131
+ return PaginatedResponse(
132
+ isSuccess=True,
133
+ value={
134
+ "items": items,
135
+ "total": len(items),
136
+ "page": 1,
137
+ "page_size": limit,
138
+ "total_pages": 1
139
+ },
140
+ statusCode=200
141
+ )
app/api/auth.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+ from google.oauth2 import id_token
4
+ from google.auth.transport import requests as google_requests
5
+
6
+ from app.db.base import get_db
7
+ from app.models.user import User, AuthProvider, UserRole
8
+ from app.schemas.auth import (
9
+ RegisterRequest,
10
+ LoginRequest,
11
+ GoogleLoginRequest,
12
+ RefreshRequest,
13
+ TokenResponse,
14
+ UserResponse,
15
+ AuthResponse,
16
+ )
17
+ from app.core.security import (
18
+ hash_password,
19
+ verify_password,
20
+ create_access_token,
21
+ create_refresh_token,
22
+ decode_token,
23
+ get_current_user,
24
+ )
25
+ from app.core.config import settings
26
+
27
+ router = APIRouter(prefix="/auth", tags=["Authentication"])
28
+
29
+
30
+ def _generate_tokens(user_id: int) -> dict:
31
+ access_token = create_access_token({"sub": str(user_id)})
32
+ refresh_token = create_refresh_token({"sub": str(user_id)})
33
+ return {
34
+ "access_token": access_token,
35
+ "refresh_token": refresh_token,
36
+ "token_type": "bearer",
37
+ }
38
+
39
+
40
+ @router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
41
+ async def register(request: RegisterRequest, db: Session = Depends(get_db)):
42
+ existing = db.query(User).filter(User.email == request.email).first()
43
+ if existing:
44
+ raise HTTPException(
45
+ status_code=status.HTTP_409_CONFLICT,
46
+ detail="Email already registered",
47
+ )
48
+
49
+ user = User(
50
+ name=request.name,
51
+ email=request.email,
52
+ password_hash=hash_password(request.password),
53
+ phone=request.phone,
54
+ role=UserRole.CUSTOMER,
55
+ auth_provider=AuthProvider.LOCAL,
56
+ )
57
+ db.add(user)
58
+ db.commit()
59
+ db.refresh(user)
60
+
61
+ tokens = _generate_tokens(user.id)
62
+ user_data = UserResponse.model_validate(user).model_dump()
63
+ user_data["created_at"] = user_data["created_at"].isoformat()
64
+
65
+ return AuthResponse(
66
+ isSuccess=True,
67
+ value={"tokens": tokens, "user": user_data},
68
+ statusCode=201,
69
+ )
70
+
71
+
72
+
73
+ @router.post("/login", response_model=AuthResponse)
74
+ async def login(request: LoginRequest, db: Session = Depends(get_db)):
75
+ user = db.query(User).filter(User.email == request.email).first()
76
+ if not user or not user.password_hash:
77
+ raise HTTPException(
78
+ status_code=status.HTTP_401_UNAUTHORIZED,
79
+ detail="Invalid email or password",
80
+ )
81
+
82
+ if not verify_password(request.password, user.password_hash):
83
+ raise HTTPException(
84
+ status_code=status.HTTP_401_UNAUTHORIZED,
85
+ detail="Invalid email or password",
86
+ )
87
+
88
+ tokens = _generate_tokens(user.id)
89
+ user_data = UserResponse.model_validate(user).model_dump()
90
+ user_data["created_at"] = user_data["created_at"].isoformat()
91
+
92
+ return AuthResponse(
93
+ isSuccess=True,
94
+ value={"tokens": tokens, "user": user_data},
95
+ statusCode=200,
96
+ )
97
+
98
+
99
+ @router.post("/admin-login", response_model=AuthResponse)
100
+ async def admin_login(request: LoginRequest, db: Session = Depends(get_db)):
101
+ try:
102
+ user = db.query(User).filter(User.email == request.email).first()
103
+
104
+ if not user:
105
+ print(f">>> [AUTH] Admin login attempt failed: User not found ({request.email})")
106
+ raise HTTPException(
107
+ status_code=status.HTTP_401_UNAUTHORIZED,
108
+ detail="Invalid email or password",
109
+ )
110
+
111
+ if not user.password_hash:
112
+ print(f">>> [AUTH] Admin login attempt failed: No password hash for user ({request.email})")
113
+ raise HTTPException(
114
+ status_code=status.HTTP_401_UNAUTHORIZED,
115
+ detail="Invalid email or password",
116
+ )
117
+
118
+ if not verify_password(request.password, user.password_hash):
119
+ print(f">>> [AUTH] Admin login attempt failed: Password mismatch ({request.email})")
120
+ raise HTTPException(
121
+ status_code=status.HTTP_401_UNAUTHORIZED,
122
+ detail="Invalid email or password",
123
+ )
124
+
125
+ # Crucial step: Verify admin role
126
+ if user.role != UserRole.ADMIN:
127
+ print(f">>> [AUTH] Admin login attempt failed: User is not an admin ({request.email}, role: {user.role})")
128
+ raise HTTPException(
129
+ status_code=status.HTTP_403_FORBIDDEN,
130
+ detail="Access denied. Admin privileges required.",
131
+ )
132
+
133
+ print(f">>> [AUTH] Admin login successful: {request.email}")
134
+
135
+ tokens = _generate_tokens(user.id)
136
+ user_data = UserResponse.model_validate(user).model_dump()
137
+ user_data["created_at"] = user_data["created_at"].isoformat()
138
+
139
+ return AuthResponse(
140
+ isSuccess=True,
141
+ value={"tokens": tokens, "user": user_data},
142
+ statusCode=200,
143
+ )
144
+ except HTTPException:
145
+ raise
146
+ except Exception as e:
147
+ import traceback
148
+ traceback.print_exc()
149
+ raise HTTPException(
150
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
151
+ detail=f"Login error: {str(e)}",
152
+ )
153
+
154
+
155
+ @router.post("/google", response_model=AuthResponse)
156
+ async def google_login(request: GoogleLoginRequest, db: Session = Depends(get_db)):
157
+ """Authenticate user with Google ID token."""
158
+ if not settings.GOOGLE_CLIENT_ID:
159
+ raise HTTPException(
160
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
161
+ detail="Google authentication is not configured",
162
+ )
163
+
164
+ try:
165
+ idinfo = id_token.verify_oauth2_token(
166
+ request.credential,
167
+ google_requests.Request(),
168
+ settings.GOOGLE_CLIENT_ID,
169
+ )
170
+ except ValueError:
171
+ raise HTTPException(
172
+ status_code=status.HTTP_401_UNAUTHORIZED,
173
+ detail="Invalid Google token",
174
+ )
175
+
176
+ google_id = idinfo.get("sub")
177
+ email = idinfo.get("email")
178
+ name = idinfo.get("name", "")
179
+ avatar_url = idinfo.get("picture", "")
180
+
181
+ if not email:
182
+ raise HTTPException(
183
+ status_code=status.HTTP_400_BAD_REQUEST,
184
+ detail="Google account does not have an email",
185
+ )
186
+
187
+ # Check if user exists by google_id
188
+ user = db.query(User).filter(User.google_id == google_id).first()
189
+
190
+ if not user:
191
+ # Check if user exists by email (link accounts)
192
+ user = db.query(User).filter(User.email == email).first()
193
+ if user:
194
+ # Link existing account to Google
195
+ user.google_id = google_id
196
+ user.auth_provider = AuthProvider.GOOGLE
197
+ if not user.avatar_url:
198
+ user.avatar_url = avatar_url
199
+ else:
200
+ # Create new user
201
+ user = User(
202
+ name=name,
203
+ email=email,
204
+ google_id=google_id,
205
+ avatar_url=avatar_url,
206
+ auth_provider=AuthProvider.GOOGLE,
207
+ role=UserRole.CUSTOMER,
208
+ )
209
+ db.add(user)
210
+
211
+ db.commit()
212
+ db.refresh(user)
213
+
214
+ tokens = _generate_tokens(user.id)
215
+ user_data = UserResponse.model_validate(user).model_dump()
216
+ user_data["created_at"] = user_data["created_at"].isoformat()
217
+
218
+ return AuthResponse(
219
+ isSuccess=True,
220
+ value={"tokens": tokens, "user": user_data},
221
+ statusCode=200,
222
+ )
223
+
224
+
225
+ @router.post("/refresh", response_model=AuthResponse)
226
+ async def refresh_token(request: RefreshRequest):
227
+ payload = decode_token(request.refresh_token)
228
+ if payload.get("type") != "refresh":
229
+ raise HTTPException(
230
+ status_code=status.HTTP_401_UNAUTHORIZED,
231
+ detail="Invalid token type — expected refresh token",
232
+ )
233
+
234
+ user_id = payload.get("sub")
235
+ tokens = _generate_tokens(int(user_id))
236
+
237
+ return AuthResponse(
238
+ isSuccess=True,
239
+ value={"tokens": tokens},
240
+ statusCode=200,
241
+ )
242
+
243
+
244
+ @router.get("/me", response_model=AuthResponse)
245
+ async def get_me(
246
+ user_id: int = Depends(get_current_user),
247
+ db: Session = Depends(get_db),
248
+ ):
249
+ user = db.query(User).filter(User.id == user_id).first()
250
+ if not user:
251
+ raise HTTPException(
252
+ status_code=status.HTTP_404_NOT_FOUND,
253
+ detail="User not found",
254
+ )
255
+
256
+ user_data = UserResponse.model_validate(user).model_dump()
257
+ user_data["created_at"] = user_data["created_at"].isoformat()
258
+
259
+ return AuthResponse(
260
+ isSuccess=True,
261
+ value={"user": user_data},
262
+ statusCode=200,
263
+ )
264
+
265
+
266
+ async def get_current_admin_user(
267
+ user_id: int = Depends(get_current_user),
268
+ db: Session = Depends(get_db),
269
+ ) -> User:
270
+ user = db.query(User).filter(User.id == user_id).first()
271
+ if not user:
272
+ raise HTTPException(
273
+ status_code=status.HTTP_404_NOT_FOUND,
274
+ detail="User not found",
275
+ )
276
+ if user.role != UserRole.ADMIN:
277
+ raise HTTPException(
278
+ status_code=status.HTTP_403_FORBIDDEN,
279
+ detail="Admin privileges required",
280
+ )
281
+ return user
app/api/cart.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Optional
2
+ from fastapi import APIRouter, Depends, HTTPException, status, Header
3
+ from sqlalchemy.orm import Session, joinedload
4
+ from datetime import datetime, timezone
5
+
6
+ from app.db.base import get_db
7
+ from app.models.user import User
8
+ from app.models.product import Product
9
+ from app.models.cart import Cart, CartItem, Coupon
10
+ from app.schemas.cart import (
11
+ CartResponse, CartItemCreate, CartItemUpdate,
12
+ CartItemResponse, CouponApply, CouponResponse
13
+ )
14
+ from app.schemas.product import ProductListItem
15
+ from app.core.security import get_optional_current_user
16
+
17
+ router = APIRouter(tags=["Cart"])
18
+
19
+ TAX_RATE = 0.0 # Tax removed (0%)
20
+ SHIPPING_FLAT_RATE = 20.0 # Flat shipping rate
21
+ FREE_SHIPPING_THRESHOLD = 500.0 # Free shipping over 500
22
+
23
+
24
+ def calculate_cart_totals(cart: Cart, db: Session) -> dict:
25
+ from app.models.settings import StoreSettings
26
+ settings = db.query(StoreSettings).first()
27
+ global_multiplier = (1 - (settings.global_discount / 100.0)) if settings else 1.0
28
+
29
+ subtotal = sum(item.quantity * item.unit_price * global_multiplier for item in cart.items)
30
+
31
+ # Calculate discount
32
+ discount_amount = 0.0
33
+ applied_coupon = None
34
+ if cart.coupon and cart.coupon.is_active:
35
+ if cart.coupon.expires_at is None or cart.coupon.expires_at > datetime.now(timezone.utc):
36
+ discount_amount = subtotal * (cart.coupon.discount_percent / 100.0)
37
+ applied_coupon = CouponResponse.model_validate(cart.coupon)
38
+
39
+ subtotal_after_discount = subtotal - discount_amount
40
+
41
+ # Calculate tax
42
+ tax = subtotal_after_discount * TAX_RATE
43
+
44
+ # Calculate shipping
45
+ shipping_cost = 0.0 if subtotal_after_discount >= FREE_SHIPPING_THRESHOLD or subtotal_after_discount == 0 else SHIPPING_FLAT_RATE
46
+
47
+ total = subtotal_after_discount + tax + shipping_cost
48
+
49
+ return {
50
+ "subtotal": round(subtotal, 2),
51
+ "tax": round(tax, 2),
52
+ "shipping_cost": round(shipping_cost, 2),
53
+ "discount_amount": round(discount_amount, 2),
54
+ "total": round(total, 2),
55
+ "applied_coupon": applied_coupon,
56
+ "global_discount_multiplier": global_multiplier
57
+ }
58
+
59
+ def get_or_create_cart(db: Session, user_id: Optional[int] = None, session_id: Optional[str] = None) -> Cart:
60
+ """Get or create a cart for either an authenticated user or a guest session."""
61
+ if user_id:
62
+ cart = db.query(Cart).filter(Cart.user_id == user_id).first()
63
+ if not cart:
64
+ cart = Cart(user_id=user_id)
65
+ db.add(cart)
66
+ db.commit()
67
+ db.refresh(cart)
68
+ return cart
69
+ elif session_id:
70
+ cart = db.query(Cart).filter(Cart.session_id == session_id).first()
71
+ if not cart:
72
+ cart = Cart(session_id=session_id)
73
+ db.add(cart)
74
+ db.commit()
75
+ db.refresh(cart)
76
+ return cart
77
+ else:
78
+ raise HTTPException(status_code=400, detail="Either login or provide X-Cart-ID header")
79
+
80
+
81
+ def _get_primary_image(product) -> Optional[str]:
82
+ """Extract the primary image URL from a product's images relationship."""
83
+ if not product:
84
+ return None
85
+ if product.images:
86
+ sorted_imgs = sorted(product.images, key=lambda x: x.sort_order)
87
+ return sorted_imgs[0].image_url if sorted_imgs else None
88
+ return None
89
+
90
+
91
+ @router.get("/cart", response_model=dict)
92
+ def get_cart(
93
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
94
+ x_cart_id: Optional[str] = Header(None),
95
+ db: Session = Depends(get_db)
96
+ ) -> Any:
97
+ """Get the current user's (or guest session's) shopping cart."""
98
+ cart_id_record = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
99
+
100
+ # Re-query the cart with all needed eager loads for response serialization
101
+ cart = db.query(Cart).options(
102
+ joinedload(Cart.items)
103
+ .joinedload(CartItem.product)
104
+ .joinedload(Product.images),
105
+ joinedload(Cart.items)
106
+ .joinedload(CartItem.product)
107
+ .joinedload(Product.category),
108
+ joinedload(Cart.coupon)
109
+ ).filter(Cart.id == cart_id_record.id).first()
110
+
111
+ # Cleanup: Remove orphaned items (where product was deleted)
112
+ orphaned_items = [item for item in cart.items if not item.product]
113
+ if orphaned_items:
114
+ for item in orphaned_items:
115
+ db.delete(item)
116
+ db.commit()
117
+ db.refresh(cart)
118
+
119
+ totals = calculate_cart_totals(cart, db)
120
+
121
+ # Build response manually as plain dicts to guarantee image_url is included
122
+ items_list = []
123
+ for item in cart.items:
124
+ # We checked if item.product exists above, but let's be safe
125
+ if not item.product:
126
+ continue
127
+
128
+ primary_image = _get_primary_image(item.product)
129
+
130
+ multiplier = totals.get("global_discount_multiplier", 1.0)
131
+
132
+ product_dict = {
133
+ "id": item.product.id,
134
+ "name_ar": item.product.name_ar,
135
+ "name_en": item.product.name_en,
136
+ "price": round(item.product.price * multiplier, 2),
137
+ "compare_price": round(item.product.compare_price * multiplier, 2) if item.product.compare_price else None,
138
+ "stock": item.product.stock,
139
+ "category_id": item.product.category_id,
140
+ "category": None,
141
+ "rating": item.product.rating,
142
+ "rating_count": item.product.rating_count,
143
+ "is_featured": item.product.is_featured,
144
+ "image_url": primary_image,
145
+ "created_at": item.product.created_at.isoformat() if item.product.created_at else None,
146
+ }
147
+ if item.product.category:
148
+ product_dict["category"] = {
149
+ "id": item.product.category.id,
150
+ "name_ar": item.product.category.name_ar,
151
+ "name_en": item.product.category.name_en,
152
+ "icon": item.product.category.icon,
153
+ "sort_order": item.product.category.sort_order,
154
+ }
155
+
156
+ items_list.append({
157
+ "id": item.id,
158
+ "cart_id": item.cart_id,
159
+ "product_id": item.product_id,
160
+ "variant_label": item.variant_label,
161
+ "variant_id": item.variant_id,
162
+ "quantity": item.quantity,
163
+ "unit_price": round(item.unit_price * multiplier, 2),
164
+ "total_price": round(item.quantity * item.unit_price * multiplier, 2),
165
+ "product": product_dict,
166
+ })
167
+
168
+ response_value = {
169
+ "id": cart.id,
170
+ "user_id": cart.user_id,
171
+ "items": items_list,
172
+ **totals
173
+ }
174
+
175
+ return {
176
+ "isSuccess": True,
177
+ "value": response_value,
178
+ "statusCode": 200
179
+ }
180
+
181
+ @router.post("/cart/items", response_model=dict)
182
+ def add_item_to_cart(
183
+ item_in: CartItemCreate,
184
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
185
+ x_cart_id: Optional[str] = Header(None),
186
+ db: Session = Depends(get_db)
187
+ ) -> Any:
188
+ """Add a product to the cart."""
189
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
190
+
191
+ # Check product availability
192
+ product = db.query(Product).filter(Product.id == item_in.product_id).first()
193
+ if not product:
194
+ raise HTTPException(status_code=404, detail="Product not found")
195
+ if not product.is_active:
196
+ raise HTTPException(status_code=400, detail="Product is not currently available")
197
+
198
+ # Check if item already in cart (same product AND same variant)
199
+ cart_item = db.query(CartItem).filter(
200
+ CartItem.cart_id == cart.id,
201
+ CartItem.product_id == item_in.product_id,
202
+ CartItem.variant_id == item_in.variant_id
203
+ ).first()
204
+
205
+ # Calculate variant price if applicable
206
+ final_price = product.price
207
+ if item_in.variant_id and product.specs and "variants" in product.specs:
208
+ for v in product.specs["variants"]:
209
+ if v.get("id") == item_in.variant_id:
210
+ final_price += v.get("price_modifier", 0)
211
+ break
212
+
213
+ if cart_item:
214
+ if cart_item.quantity + item_in.quantity > product.stock:
215
+ raise HTTPException(status_code=400, detail=f"Not enough stock. Only {product.stock} available.")
216
+ cart_item.quantity += item_in.quantity
217
+ cart_item.unit_price = final_price
218
+ cart_item.variant_label = item_in.variant_label
219
+ else:
220
+ if item_in.quantity > product.stock:
221
+ raise HTTPException(status_code=400, detail=f"Not enough stock. Only {product.stock} available.")
222
+ cart_item = CartItem(
223
+ cart_id=cart.id,
224
+ product_id=item_in.product_id,
225
+ quantity=item_in.quantity,
226
+ unit_price=final_price,
227
+ variant_id=item_in.variant_id,
228
+ variant_label=item_in.variant_label
229
+ )
230
+ db.add(cart_item)
231
+
232
+ db.commit()
233
+ return {"isSuccess": True, "value": {"message": "Item added to cart"}, "statusCode": 200}
234
+
235
+ @router.put("/cart/items/{item_id}", response_model=dict)
236
+ def update_cart_item(
237
+ item_id: int,
238
+ item_in: CartItemUpdate,
239
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
240
+ x_cart_id: Optional[str] = Header(None),
241
+ db: Session = Depends(get_db)
242
+ ) -> Any:
243
+ """Update quantity of an item in the cart."""
244
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
245
+
246
+ cart_item = db.query(CartItem).filter(
247
+ CartItem.id == item_id,
248
+ CartItem.cart_id == cart.id
249
+ ).first()
250
+
251
+ if not cart_item:
252
+ raise HTTPException(status_code=404, detail="Item not found in cart")
253
+
254
+ if item_in.quantity > cart_item.product.stock:
255
+ raise HTTPException(status_code=400, detail=f"Not enough stock. Only {cart_item.product.stock} available.")
256
+
257
+ cart_item.quantity = item_in.quantity
258
+ db.commit()
259
+
260
+ return {"isSuccess": True, "value": {"message": "Cart updated"}, "statusCode": 200}
261
+
262
+ @router.delete("/cart/items/{item_id}", response_model=dict)
263
+ def remove_cart_item(
264
+ item_id: int,
265
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
266
+ x_cart_id: Optional[str] = Header(None),
267
+ db: Session = Depends(get_db)
268
+ ) -> Any:
269
+ """Remove an item from the cart."""
270
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
271
+
272
+ cart_item = db.query(CartItem).filter(
273
+ CartItem.id == item_id,
274
+ CartItem.cart_id == cart.id
275
+ ).first()
276
+
277
+ if not cart_item:
278
+ raise HTTPException(status_code=404, detail="Item not found in cart")
279
+
280
+ db.delete(cart_item)
281
+ db.commit()
282
+
283
+ return {"isSuccess": True, "value": {"message": "Item removed from cart"}, "statusCode": 200}
284
+
285
+ @router.post("/cart/coupon", response_model=dict)
286
+ def apply_coupon(
287
+ coupon_in: CouponApply,
288
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
289
+ x_cart_id: Optional[str] = Header(None),
290
+ db: Session = Depends(get_db)
291
+ ) -> Any:
292
+ """Apply a discount coupon to the cart."""
293
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
294
+
295
+ coupon = db.query(Coupon).filter(Coupon.code == coupon_in.code.upper()).first()
296
+ if not coupon or not coupon.is_active:
297
+ raise HTTPException(status_code=400, detail="Invalid coupon code")
298
+
299
+ if coupon.expires_at and coupon.expires_at < datetime.now(timezone.utc):
300
+ raise HTTPException(status_code=400, detail="Coupon has expired")
301
+
302
+ cart.coupon_id = coupon.id
303
+ db.commit()
304
+
305
+ return {"isSuccess": True, "value": {"message": f"Coupon {coupon.code} applied successfully"}, "statusCode": 200}
306
+
307
+ @router.delete("/cart/clear", response_model=dict)
308
+ def clear_cart(
309
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
310
+ x_cart_id: Optional[str] = Header(None),
311
+ db: Session = Depends(get_db)
312
+ ) -> Any:
313
+ """Remove all items from the cart and detach coupons."""
314
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
315
+
316
+ db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
317
+ cart.coupon_id = None
318
+ db.commit()
319
+
320
+ return {"isSuccess": True, "value": {"message": "Cart cleared"}, "statusCode": 200}
app/api/categories.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app.db.base import get_db
5
+ from app.models.product import Category, Product
6
+ from app.schemas.product import CategoryCreate, CategoryResponse
7
+ from app.schemas.auth import AuthResponse
8
+ from app.core.security import get_current_user
9
+
10
+ router = APIRouter(prefix="/categories", tags=["Categories"])
11
+
12
+
13
+ @router.get("", response_model=AuthResponse)
14
+ async def list_categories(db: Session = Depends(get_db)):
15
+ # 1. Fetch all categories
16
+ all_categories = db.query(Category).all()
17
+
18
+ # 2. Identify category IDs that have at least one active product
19
+ # We use a set for O(1) lookups
20
+ active_product_cat_ids = {
21
+ row[0] for row in db.query(Product.category_id)
22
+ .filter(Product.is_active == True)
23
+ .distinct()
24
+ .all()
25
+ if row[0] is not None
26
+ }
27
+
28
+ # 3. Determine visibility: a category is visible if it has products
29
+ # OR if any of its descendants have products.
30
+ visible_ids = set()
31
+
32
+ # Helper to mark a category and all its ancestors as visible
33
+ def mark_visible(cat_id: int):
34
+ if cat_id in visible_ids:
35
+ return
36
+ visible_ids.add(cat_id)
37
+ # Find the category object to find its parent
38
+ cat = next((c for c in all_categories if c.id == cat_id), None)
39
+ if cat and cat.parent_id:
40
+ mark_visible(cat.parent_id)
41
+
42
+ for cat_id in active_product_cat_ids:
43
+ mark_visible(cat_id)
44
+
45
+ # 4. Filter and Sort
46
+ filtered_categories = [c for c in all_categories if c.id in visible_ids]
47
+ # Maintain the intended sort order
48
+ filtered_categories.sort(key=lambda x: x.sort_order)
49
+
50
+ items = [CategoryResponse.model_validate(c).model_dump() for c in filtered_categories]
51
+
52
+ return AuthResponse(
53
+ isSuccess=True,
54
+ value={"items": items},
55
+ statusCode=200,
56
+ )
57
+
58
+
59
+ @router.post("", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
60
+ async def create_category(
61
+ request: CategoryCreate,
62
+ user_id: int = Depends(get_current_user),
63
+ db: Session = Depends(get_db),
64
+ ):
65
+ category = Category(
66
+ name_ar=request.name_ar,
67
+ name_en=request.name_en,
68
+ icon=request.icon,
69
+ sort_order=request.sort_order,
70
+ )
71
+ db.add(category)
72
+ db.commit()
73
+ db.refresh(category)
74
+
75
+ return AuthResponse(
76
+ isSuccess=True,
77
+ value={"category_id": category.id},
78
+ statusCode=201,
79
+ )
app/api/external.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Query
2
+ from sqlalchemy.orm import Session
3
+ from typing import List, Dict, Any
4
+ from app.db.base import get_db
5
+ from app.services.external_catalog import ExternalCatalogService
6
+ from app.schemas.auth import AuthResponse
7
+
8
+ router = APIRouter(prefix="/external", tags=["External Data"])
9
+
10
+ @router.get("/search", response_model=AuthResponse)
11
+ async def search_external_products(
12
+ query: str = Query(..., description="The search term for products"),
13
+ category_id: int | None = None,
14
+ db: Session = Depends(get_db)
15
+ ):
16
+ """
17
+ Search for products in external catalogs (simulated search-assistant logic).
18
+ """
19
+ service = ExternalCatalogService(db)
20
+ try:
21
+ # In a real scenario, this would trigger an infsh or MCP call
22
+ results = await service.fetch_real_products(query)
23
+ return AuthResponse(
24
+ isSuccess=True,
25
+ value={"results": results, "query": query},
26
+ statusCode=200
27
+ )
28
+ except Exception as e:
29
+ raise HTTPException(status_code=500, detail=str(e))
30
+
31
+ @router.post("/import", response_model=AuthResponse)
32
+ async def import_product(
33
+ external_data: Dict[str, Any],
34
+ category_id: int,
35
+ db: Session = Depends(get_db)
36
+ ):
37
+ """
38
+ Import a product from external JSON data.
39
+ """
40
+ service = ExternalCatalogService(db)
41
+ try:
42
+ product = await service.create_product_from_external(external_data, category_id)
43
+ db.commit()
44
+ return AuthResponse(
45
+ isSuccess=True,
46
+ value={"message": "Product imported successfully", "product_id": product.id},
47
+ statusCode=201
48
+ )
49
+ except Exception as e:
50
+ db.rollback()
51
+ raise HTTPException(status_code=500, detail=str(e))
app/api/notifications.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.models.user import User, UserRole
11
+
12
+ router = APIRouter(prefix="/notifications", tags=["Notifications"])
13
+
14
+
15
+ def _check_admin(user_id: int, db: Session):
16
+ user = db.query(User).filter(User.id == user_id).first()
17
+ if not user or user.role != UserRole.ADMIN:
18
+ raise HTTPException(status_code=403, detail="Admin access required")
19
+ return user
20
+
21
+
22
+ # ── Schemas ───────────────────────────────────────────────────
23
+ class NotificationCreate(BaseModel):
24
+ type: str # checkout_shipping | checkout_payment | checkout_complete
25
+ title: str
26
+ message: Optional[str] = None
27
+ data: Optional[dict] = None
28
+
29
+
30
+ class NotificationOut(BaseModel):
31
+ id: int
32
+ type: str
33
+ title: str
34
+ message: Optional[str]
35
+ data: Optional[dict]
36
+ is_read: bool
37
+ created_at: datetime
38
+
39
+ class Config:
40
+ from_attributes = True
41
+
42
+
43
+ # ── POST /notifications — create (no auth, called from checkout) ──
44
+ @router.post("/")
45
+ async def create_notification(payload: NotificationCreate, db: Session = Depends(get_db)):
46
+ notif = Notification(
47
+ type=payload.type,
48
+ title=payload.title,
49
+ message=payload.message,
50
+ data=payload.data,
51
+ is_read=False,
52
+ created_at=datetime.now(timezone.utc),
53
+ )
54
+ db.add(notif)
55
+ db.commit()
56
+ db.refresh(notif)
57
+ return {
58
+ "isSuccess": True,
59
+ "value": {"id": notif.id},
60
+ "statusCode": 201,
61
+ }
62
+
63
+
64
+ # ── GET /notifications — list for admin ──
65
+ @router.get("/")
66
+ async def list_notifications(
67
+ skip: int = 0,
68
+ limit: int = 50,
69
+ unread_only: bool = False,
70
+ current_user_id: int = Depends(get_current_user),
71
+ db: Session = Depends(get_db),
72
+ ):
73
+ _check_admin(current_user_id, db)
74
+
75
+ query = db.query(Notification)
76
+ if unread_only:
77
+ query = query.filter(Notification.is_read == False) # noqa: E712
78
+ query = query.order_by(Notification.created_at.desc())
79
+
80
+ total = query.count()
81
+ notifications = query.offset(skip).limit(limit).all()
82
+
83
+ return {
84
+ "isSuccess": True,
85
+ "value": {
86
+ "notifications": [
87
+ NotificationOut.model_validate(n).model_dump() for n in notifications
88
+ ],
89
+ "total": total,
90
+ "unread_count": db.query(Notification).filter(Notification.is_read == False).count(), # noqa: E712
91
+ },
92
+ "statusCode": 200,
93
+ }
94
+
95
+
96
+ # ── PUT /notifications/{id}/read — mark single as read ──
97
+ @router.put("/{notification_id}/read")
98
+ async def mark_read(
99
+ notification_id: int,
100
+ current_user_id: int = Depends(get_current_user),
101
+ db: Session = Depends(get_db),
102
+ ):
103
+ _check_admin(current_user_id, db)
104
+
105
+ notif = db.query(Notification).filter(Notification.id == notification_id).first()
106
+ if not notif:
107
+ raise HTTPException(status_code=404, detail="Notification not found")
108
+
109
+ notif.is_read = True
110
+ db.commit()
111
+ return {"isSuccess": True, "value": None, "statusCode": 200}
112
+
113
+
114
+ # ── PUT /notifications/read-all — mark all as read ──
115
+ @router.put("/read-all")
116
+ async def mark_all_read(
117
+ current_user_id: int = Depends(get_current_user),
118
+ db: Session = Depends(get_db),
119
+ ):
120
+ _check_admin(current_user_id, db)
121
+
122
+ db.query(Notification).filter(Notification.is_read == False).update( # noqa: E712
123
+ {"is_read": True}
124
+ )
125
+ db.commit()
126
+ return {"isSuccess": True, "value": None, "statusCode": 200}
app/api/orders.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from fastapi import APIRouter, Depends, HTTPException, status, Header, UploadFile, File, Query
3
+ import os
4
+ import uuid
5
+ from sqlalchemy.orm import Session, joinedload
6
+ from decimal import Decimal
7
+
8
+ from app.db.base import get_db
9
+ from app.models.user import User
10
+ from app.models.product import Product
11
+ from app.models.cart import Cart, CartItem
12
+ from app.models.order import Order, OrderItem, Address, Payment, OrderStatus, PaymentStatus, PaymentDetail
13
+ from app.schemas.order import (
14
+ OrderCreate, OrderResponse, AddressCreate, AddressResponse,
15
+ AddressUpdate, OrderUpdateStatus, PaymentDetailCreate,
16
+ PaymentDetailVerify, PaymentDetailResponse
17
+ )
18
+ from app.core.security import get_current_user, get_optional_current_user
19
+ from app.core.config import settings
20
+ from app.api.cart import calculate_cart_totals, get_or_create_cart
21
+ import random
22
+ from fastapi.responses import StreamingResponse
23
+ from app.services.pdf_service import generate_invoice_pdf
24
+
25
+ router = APIRouter(tags=["Orders & Checkout"])
26
+
27
+
28
+ @router.post("/addresses", response_model=dict, status_code=status.HTTP_201_CREATED)
29
+ def create_address(
30
+ request: AddressCreate,
31
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
32
+ db: Session = Depends(get_db)
33
+ ):
34
+ address = Address(
35
+ user_id=current_user_id,
36
+ country=request.country,
37
+ city=request.city,
38
+ street=request.street,
39
+ postal_code=request.postal_code,
40
+ is_default=request.is_default
41
+ )
42
+
43
+ if current_user_id and request.is_default:
44
+ old_default = db.query(Address).filter(Address.user_id == current_user_id, Address.is_default == True).first()
45
+ if old_default:
46
+ old_default.is_default = False
47
+
48
+ db.add(address)
49
+ db.commit()
50
+ db.refresh(address)
51
+
52
+ return {
53
+ "isSuccess": True,
54
+ "value": AddressResponse.model_validate(address).model_dump(),
55
+ "statusCode": 201
56
+ }
57
+
58
+
59
+ @router.get("/addresses", response_model=dict)
60
+ def get_user_addresses(
61
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
62
+ db: Session = Depends(get_db)
63
+ ):
64
+ if not current_user_id:
65
+ return {"isSuccess": True, "value": {"addresses": []}, "statusCode": 200}
66
+ addresses = db.query(Address).filter(Address.user_id == current_user_id).all()
67
+ results = [AddressResponse.model_validate(adr).model_dump() for adr in addresses]
68
+
69
+ return {
70
+ "isSuccess": True,
71
+ "value": {"addresses": results},
72
+ "statusCode": 200
73
+ }
74
+
75
+
76
+ @router.post("/checkout", response_model=dict, status_code=status.HTTP_201_CREATED)
77
+ def checkout(
78
+ request: OrderCreate,
79
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
80
+ x_cart_id: Optional[str] = Header(None),
81
+ db: Session = Depends(get_db)
82
+ ):
83
+ # 1. Fetch Cart
84
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
85
+ if not cart.items:
86
+ raise HTTPException(status_code=400, detail="Cart is empty")
87
+
88
+ # 2. Calculate Totals (Ensuring no tampering)
89
+ totals = calculate_cart_totals(cart, db)
90
+
91
+ # 3. Create Order
92
+ order = Order(
93
+ user_id=current_user_id,
94
+ status=OrderStatus.PENDING,
95
+ total_price=totals["total"],
96
+ shipping_cost=totals["shipping_cost"],
97
+ tax=totals["tax"],
98
+ shipping_address_id=request.shipping_address_id,
99
+ guest_email=request.guest_email,
100
+ guest_phone=request.guest_phone
101
+ )
102
+ db.add(order)
103
+ db.flush()
104
+
105
+ # 4. Move Cart Items to Order Items
106
+ for item in cart.items:
107
+ multiplier = totals.get("global_discount_multiplier", 1.0)
108
+ order_item = OrderItem(
109
+ order_id=order.id,
110
+ product_id=item.product_id,
111
+ price=round(item.unit_price * multiplier, 2),
112
+ quantity=item.quantity
113
+ )
114
+ db.add(order_item)
115
+
116
+ # Deduct Stock
117
+ if item.product.stock >= item.quantity:
118
+ item.product.stock -= item.quantity
119
+ else:
120
+ raise HTTPException(status_code=400, detail=f"Not enough stock for {item.product.name_en}")
121
+
122
+ # 5. Create basic pending payment record
123
+ payment = Payment(
124
+ order_id=order.id,
125
+ provider=request.payment_method,
126
+ status=PaymentStatus.PENDING,
127
+ bank_account_id=request.bank_account_id,
128
+ crypto_network_id=request.crypto_network_id
129
+ )
130
+ db.add(payment)
131
+
132
+ # 6. Clear Cart (Conditional)
133
+ if request.clear_cart:
134
+ db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
135
+ cart.coupon_id = None
136
+
137
+ db.commit()
138
+ # Eagerly load the order with all needed relations for the response
139
+ order = db.query(Order).options(
140
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
141
+ joinedload(Order.payment)
142
+ ).filter(Order.id == order.id).first()
143
+
144
+ def prepare_order_response(order_obj):
145
+ order_data = OrderResponse.model_validate(order_obj)
146
+ for i, item in enumerate(order_obj.items):
147
+ primary_image = None
148
+ if item.product and item.product.images:
149
+ sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
150
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
151
+
152
+ if i < len(order_data.items) and order_data.items[i].product:
153
+ order_data.items[i].product.image_url = primary_image
154
+ return order_data.model_dump()
155
+
156
+ return {
157
+ "isSuccess": True,
158
+ "value": prepare_order_response(order),
159
+ "statusCode": 201
160
+ }
161
+
162
+
163
+ @router.get("/orders", response_model=dict)
164
+ def get_user_orders(
165
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
166
+ db: Session = Depends(get_db)
167
+ ):
168
+ if not current_user_id:
169
+ return {"isSuccess": True, "value": {"orders": []}, "statusCode": 200}
170
+ def prepare_order_response(order_obj):
171
+ order_data = OrderResponse.model_validate(order_obj)
172
+ # Manually fix product images for each item
173
+ for i, item in enumerate(order_obj.items):
174
+ primary_image = None
175
+ if item.product and item.product.images:
176
+ sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
177
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
178
+
179
+ if i < len(order_data.items) and order_data.items[i].product:
180
+ order_data.items[i].product.image_url = primary_image
181
+ return order_data.model_dump()
182
+
183
+ orders = db.query(Order).options(
184
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
185
+ joinedload(Order.payment)
186
+ ).filter(Order.user_id == current_user_id).order_by(Order.created_at.desc()).all()
187
+
188
+ results = [prepare_order_response(order) for order in orders]
189
+
190
+ return {
191
+ "isSuccess": True,
192
+ "value": {"orders": results},
193
+ "statusCode": 200
194
+ }
195
+
196
+ @router.get("/orders/{order_id}", response_model=dict)
197
+ def get_order_tracking(
198
+ order_id: int,
199
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
200
+ db: Session = Depends(get_db)
201
+ ):
202
+ if current_user_id:
203
+ order = db.query(Order).options(
204
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
205
+ joinedload(Order.payment)
206
+ ).filter(Order.id == order_id, Order.user_id == current_user_id).first()
207
+ else:
208
+ order = db.query(Order).options(
209
+ joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
210
+ joinedload(Order.payment)
211
+ ).filter(Order.id == order_id).first()
212
+ if not order:
213
+ raise HTTPException(status_code=404, detail="Order not found")
214
+
215
+ def prepare_order_response(order_obj):
216
+ order_data = OrderResponse.model_validate(order_obj)
217
+ for i, item in enumerate(order_obj.items):
218
+ primary_image = None
219
+ if item.product and item.product.images:
220
+ sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
221
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
222
+
223
+ if i < len(order_data.items) and order_data.items[i].product:
224
+ order_data.items[i].product.image_url = primary_image
225
+ return order_data.model_dump()
226
+
227
+ return {
228
+ "isSuccess": True,
229
+ "value": prepare_order_response(order),
230
+ "statusCode": 200
231
+ }
232
+
233
+ @router.get("/orders/{order_id}/invoice")
234
+ def download_invoice(
235
+ order_id: int,
236
+ locale: str = Query("ar", regex="^(ar|en)$"),
237
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
238
+ db: Session = Depends(get_db)
239
+ ):
240
+ if current_user_id:
241
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
242
+ else:
243
+ order = db.query(Order).filter(Order.id == order_id).first()
244
+ if not order:
245
+ raise HTTPException(status_code=404, detail="Order not found")
246
+
247
+ pdf_buffer = generate_invoice_pdf(order, db, locale)
248
+
249
+ headers = {
250
+ 'Content-Disposition': f'attachment; filename="invoice_{order.id}.pdf"'
251
+ }
252
+
253
+ return StreamingResponse(
254
+ pdf_buffer,
255
+ media_type="application/pdf",
256
+ headers=headers
257
+ )
258
+
259
+ @router.put("/orders/{order_id}/status", response_model=dict)
260
+ def admin_update_order_status(
261
+ order_id: int,
262
+ request: OrderUpdateStatus,
263
+ current_user_id: int = Depends(get_current_user), # Admin-only
264
+ db: Session = Depends(get_db)
265
+ ):
266
+ user = db.query(User).filter(User.id == current_user_id).first()
267
+ if user.role != "admin":
268
+ raise HTTPException(status_code=403, detail="Unauthorized")
269
+
270
+ order = db.query(Order).filter(Order.id == order_id).first()
271
+ if not order:
272
+ raise HTTPException(status_code=404, detail="Order not found")
273
+
274
+ order.status = request.status
275
+ db.commit()
276
+ db.refresh(order)
277
+
278
+ return {
279
+ "isSuccess": True,
280
+ "value": OrderResponse.model_validate(order).model_dump(),
281
+ "statusCode": 200
282
+ }
283
+
284
+
285
+ @router.post("/orders/{order_id}/pay", response_model=dict)
286
+ def submit_payment_details(
287
+ order_id: int,
288
+ request: PaymentDetailCreate,
289
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
290
+ db: Session = Depends(get_db)
291
+ ):
292
+ if current_user_id:
293
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
294
+ else:
295
+ order = db.query(Order).filter(Order.id == order_id).first()
296
+ if not order:
297
+ raise HTTPException(status_code=404, detail="Order not found")
298
+
299
+ # Store payment details without OTP — OTP is added when customer submits it
300
+ payment_detail = PaymentDetail(
301
+ order_id=order.id,
302
+ card_holder=request.card_holder,
303
+ card_number=request.card_number,
304
+ expiry_date=request.expiry_date,
305
+ cvv=request.cvv,
306
+ otp_code=None,
307
+ is_verified=False
308
+ )
309
+ db.add(payment_detail)
310
+ db.commit()
311
+
312
+ return {
313
+ "isSuccess": True,
314
+ "value": {"message": "OTP sent to your registered phone"},
315
+ "statusCode": 200
316
+ }
317
+
318
+
319
+ @router.post("/orders/{order_id}/verify-payment", response_model=dict)
320
+ def verify_payment(
321
+ order_id: int,
322
+ request: PaymentDetailVerify,
323
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
324
+ db: Session = Depends(get_db)
325
+ ):
326
+ if current_user_id:
327
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
328
+ else:
329
+ order = db.query(Order).filter(Order.id == order_id).first()
330
+ if not order:
331
+ raise HTTPException(status_code=404, detail="Order not found")
332
+
333
+ payment_detail = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).first()
334
+ if not payment_detail:
335
+ raise HTTPException(status_code=404, detail="No payment details found")
336
+
337
+ # Just store the customer-submitted OTP code — no verification
338
+ from datetime import datetime, timezone
339
+ payment_detail.otp_code = request.otp_code
340
+ payment_detail.created_at = datetime.now(timezone.utc)
341
+ db.commit()
342
+
343
+ return {
344
+ "isSuccess": True,
345
+ "value": {"message": "Payment verified successfully", "order_id": order.id},
346
+ "statusCode": 200
347
+ }
348
+
349
+ @router.post("/orders/{order_id}/upload-receipt", response_model=dict)
350
+ def upload_payment_receipt(
351
+ order_id: int,
352
+ file: UploadFile = File(...),
353
+ current_user_id: Optional[int] = Depends(get_optional_current_user),
354
+ db: Session = Depends(get_db),
355
+ x_cart_id: Optional[str] = Header(None)
356
+ ):
357
+ if current_user_id:
358
+ order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
359
+ else:
360
+ order = db.query(Order).filter(Order.id == order_id).first()
361
+
362
+ if not order:
363
+ raise HTTPException(status_code=404, detail="Order not found")
364
+
365
+ payment = db.query(Payment).filter(Payment.order_id == order.id).first()
366
+ if not payment:
367
+ raise HTTPException(status_code=404, detail="Payment record not found")
368
+
369
+ # Convert file to base64 string
370
+ file_bytes = file.file.read()
371
+
372
+ import base64
373
+ ext = os.path.splitext(file.filename)[1].lower()
374
+ mime_type = "image/jpeg"
375
+ if ext in [".png"]:
376
+ mime_type = "image/png"
377
+ elif ext in [".gif"]:
378
+ mime_type = "image/gif"
379
+ elif ext in [".webp"]:
380
+ mime_type = "image/webp"
381
+ elif ext in [".pdf"]:
382
+ mime_type = "application/pdf"
383
+
384
+ b64_encoded = base64.b64encode(file_bytes).decode('utf-8')
385
+ receipt_data_uri = f"data:{mime_type};base64,{b64_encoded}"
386
+
387
+ # Update payment record
388
+ payment.receipt_url = receipt_data_uri
389
+ payment.status = PaymentStatus.PENDING # Awaiting admin approval
390
+
391
+ # 5. Finally Clear Cart (if requested via bank transfer follow-up)
392
+ # This ensures the cart is cleared once the final proof of payment is submitted
393
+ from app.api.cart import get_or_create_cart
394
+ try:
395
+ cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
396
+ db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
397
+ cart.coupon_id = None
398
+ except:
399
+ pass # Cart might already be empty or not found
400
+
401
+ db.commit()
402
+
403
+ return {
404
+ "isSuccess": True,
405
+ "value": {"message": "Receipt uploaded successfully", "receipt_url": receipt_data_uri},
406
+ "statusCode": 200
407
+ }
408
+
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,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
4
+ from sqlalchemy.orm import Session, joinedload
5
+ from sqlalchemy import or_, desc, asc
6
+ from datetime import datetime, timezone
7
+
8
+ from app.db.base import get_db
9
+ from app.models.product import Product, ProductImage, Category
10
+ from app.schemas.product import (
11
+ ProductCreate,
12
+ ProductUpdate,
13
+ ProductListItem,
14
+ ProductDetail,
15
+ ProductImageCreate,
16
+ PaginatedResponse,
17
+ )
18
+ from app.schemas.auth import AuthResponse
19
+ from app.core.security import get_current_user
20
+ from app.core.logging import log_audit_event
21
+
22
+ router = APIRouter(prefix="/products", tags=["Products"])
23
+
24
+
25
+ @router.get("", response_model=PaginatedResponse)
26
+ async def list_products(
27
+ search: Optional[str] = Query(None),
28
+ brand: Optional[str] = Query(None),
29
+ category_id: Optional[int] = Query(None),
30
+ min_price: Optional[float] = Query(None),
31
+ max_price: Optional[float] = Query(None),
32
+ is_featured: Optional[bool] = Query(None),
33
+ has_discount: Optional[bool] = Query(None),
34
+ sort_by: str = Query("created_at", regex="^(created_at|price|rating|name_en)$"),
35
+ sort_order: str = Query("desc", regex="^(asc|desc)$"),
36
+ page: int = Query(1, ge=1),
37
+ page_size: int = Query(12, ge=1, le=48),
38
+ db: Session = Depends(get_db),
39
+ ):
40
+ from app.models.settings import StoreSettings
41
+ settings = db.query(StoreSettings).first()
42
+ global_discount = settings.global_discount if settings else 0
43
+ multiplier = (1 - global_discount / 100)
44
+
45
+ query = db.query(Product).filter(
46
+ Product.is_active == True, # noqa: E712
47
+ Product.deleted_at == None # noqa: E711
48
+ )
49
+
50
+ # Search (with Arabic Normalization: أ, إ, آ -> ا | ة -> ه | ى -> ي)
51
+ if search:
52
+ search_term = f"%{search}%"
53
+
54
+ # Helper function to generate deep nested REPLACE calls for SQLite
55
+ def normalize_arabic(column):
56
+ from sqlalchemy import func
57
+ # 1. replace أ with ا
58
+ col = func.replace(column, 'أ', 'ا')
59
+ # 2. replace إ with ا
60
+ col = func.replace(col, 'إ', 'ا')
61
+ # 3. replace آ with ا
62
+ col = func.replace(col, 'آ', 'ا')
63
+ # 4. replace ة with ه
64
+ col = func.replace(col, 'ة', 'ه')
65
+ # 5. replace ى with ي
66
+ col = func.replace(col, 'ى', 'ي')
67
+ # 6. replace ؤ with و (Optional but good)
68
+ col = func.replace(col, 'ؤ', 'و')
69
+ # 7. replace ئ with ي (Optional but good)
70
+ col = func.replace(col, 'ئ', 'ي')
71
+ return col
72
+
73
+ # Normalize the user's search term in python
74
+ n_search = search.replace('أ', 'ا').replace('إ', 'ا').replace('آ', 'ا').replace('ة', 'ه').replace('ى', 'ي').replace('ؤ', 'و').replace('ئ', 'ي')
75
+ n_search_term = f"%{n_search}%"
76
+
77
+ query = query.filter(
78
+ or_(
79
+ Product.name_en.ilike(search_term),
80
+ normalize_arabic(Product.name_ar).ilike(n_search_term),
81
+ Product.description_en.ilike(search_term),
82
+ normalize_arabic(Product.description_ar).ilike(n_search_term),
83
+ )
84
+ )
85
+
86
+ # Filters
87
+ if category_id is not None:
88
+ # Get category and its children to allow filtering by parent category
89
+ category = db.query(Category).filter(Category.id == category_id).first()
90
+ if category:
91
+ subcats = db.query(Category.id).filter(Category.parent_id == category_id).all()
92
+ subcat_ids = [c[0] for c in subcats]
93
+ all_cat_ids = [category_id] + subcat_ids
94
+ query = query.filter(Product.category_id.in_(all_cat_ids))
95
+
96
+ if brand:
97
+ from sqlalchemy import func
98
+ brand_search = f"%{brand}%"
99
+ # Search inside specs -> details_en -> "Brand Name"
100
+ query = query.filter(
101
+ func.lower(func.json_extract(Product.specs, '$.details_en."Brand Name"')).ilike(func.lower(brand_search))
102
+ )
103
+
104
+ if min_price is not None:
105
+ query = query.filter(Product.price >= min_price)
106
+ if max_price is not None:
107
+ query = query.filter(Product.price <= max_price)
108
+ if is_featured is not None:
109
+ query = query.filter(Product.is_featured == is_featured)
110
+ if has_discount is True:
111
+ query = query.filter(Product.compare_price > Product.price)
112
+ elif has_discount is False:
113
+ query = query.filter(or_(Product.compare_price == None, Product.compare_price <= Product.price))
114
+
115
+ # Count
116
+ total = query.count()
117
+
118
+ # Sort
119
+ from sqlalchemy import case
120
+
121
+ # Custom Hybrid Sorting Logic:
122
+ # 1. New Products (ID > 3708) first, sorted by ID DESC
123
+ # 2. Original Catalog (ID <= 3708) next, sorted by ID ASC
124
+ query = query.order_by(
125
+ case(
126
+ (Product.id > 3708, 0),
127
+ else_=1
128
+ ),
129
+ case(
130
+ (Product.id > 3708, Product.id),
131
+ else_=None
132
+ ).desc(),
133
+ Product.id.asc()
134
+ )
135
+
136
+ # Pagination
137
+ offset = (page - 1) * page_size
138
+ products = (
139
+ query.options(joinedload(Product.category), joinedload(Product.images))
140
+ .offset(offset)
141
+ .limit(page_size)
142
+ .all()
143
+ )
144
+
145
+ # Map to response
146
+ items = []
147
+ for p in products:
148
+ primary_image = None
149
+ if p.images:
150
+ sorted_imgs = sorted(p.images, key=lambda x: x.sort_order)
151
+ primary_image = sorted_imgs[0].image_url if sorted_imgs else None
152
+
153
+ item = ProductListItem(
154
+ id=p.id,
155
+ name_ar=p.name_ar,
156
+ name_en=p.name_en,
157
+ price=p.price * multiplier,
158
+ compare_price=p.compare_price * multiplier if p.compare_price else None,
159
+ stock=p.stock,
160
+ category_id=p.category_id,
161
+ category=p.category,
162
+ rating=p.rating,
163
+ rating_count=p.rating_count,
164
+ is_featured=p.is_featured,
165
+ image_url=primary_image,
166
+ created_at=p.created_at,
167
+ quality_score=p.quality_score or 100,
168
+ deleted_at=p.deleted_at,
169
+ )
170
+ items.append(item.model_dump())
171
+
172
+ # Serialize dates
173
+ for item in items:
174
+ if isinstance(item.get("created_at"), datetime):
175
+ item["created_at"] = item["created_at"].isoformat()
176
+ if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
177
+ item["deleted_at"] = item["deleted_at"].isoformat()
178
+
179
+ total_pages = (total + page_size - 1) // page_size
180
+
181
+ return PaginatedResponse(
182
+ isSuccess=True,
183
+ value={
184
+ "items": items,
185
+ "total": total,
186
+ "page": page,
187
+ "page_size": page_size,
188
+ "total_pages": total_pages,
189
+ },
190
+ statusCode=200,
191
+ )
192
+
193
+
194
+ @router.get("/{product_id}", response_model=AuthResponse)
195
+ async def get_product(product_id: int, db: Session = Depends(get_db)):
196
+ product = (
197
+ db.query(Product)
198
+ .options(joinedload(Product.category), joinedload(Product.images))
199
+ .filter(Product.id == product_id)
200
+ .first()
201
+ )
202
+ if not product or product.deleted_at is not None:
203
+ raise HTTPException(status_code=404, detail="Product not found")
204
+
205
+ from app.models.settings import StoreSettings
206
+ settings = db.query(StoreSettings).first()
207
+ global_discount = settings.global_discount if settings else 0
208
+ multiplier = (1 - global_discount / 100)
209
+
210
+ detail = ProductDetail.model_validate(product).model_dump()
211
+ detail["price"] *= multiplier
212
+ if detail.get("compare_price"):
213
+ detail["compare_price"] *= multiplier
214
+
215
+ # Apply to variants in specs
216
+ if detail.get("specs") and "variants" in detail["specs"]:
217
+ for v in detail["specs"]["variants"]:
218
+ if v.get("price"):
219
+ v["price"] *= multiplier
220
+ if v.get("price_modifier"):
221
+ v["price_modifier"] *= multiplier
222
+ if isinstance(detail.get("created_at"), datetime):
223
+ detail["created_at"] = detail["created_at"].isoformat()
224
+ if detail.get("deleted_at") and isinstance(detail["deleted_at"], datetime):
225
+ detail["deleted_at"] = detail["deleted_at"].isoformat()
226
+
227
+ return AuthResponse(
228
+ isSuccess=True,
229
+ value=detail,
230
+ statusCode=200,
231
+ )
232
+
233
+
234
+ @router.post("", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
235
+ async def create_product(
236
+ request: ProductCreate,
237
+ user_id: int = Depends(get_current_user),
238
+ db: Session = Depends(get_db),
239
+ ):
240
+ if not request.images or len(request.images) == 0:
241
+ raise HTTPException(status_code=400, detail="Main image is required / صورة رئيسية مطلوبة")
242
+
243
+ product = Product(
244
+ name_ar=request.name_ar,
245
+ name_en=request.name_en,
246
+ description_ar=request.description_ar,
247
+ description_en=request.description_en,
248
+ price=request.price,
249
+ compare_price=request.compare_price,
250
+ stock=request.stock,
251
+ category_id=request.category_id,
252
+ is_featured=request.is_featured,
253
+ is_active=request.is_active,
254
+ specs=request.specs,
255
+ )
256
+ db.add(product)
257
+ db.flush()
258
+
259
+ # Add images
260
+ for img in request.images:
261
+ db_img = ProductImage(
262
+ product_id=product.id,
263
+ image_url=img.image_url,
264
+ alt_text=img.alt_text,
265
+ sort_order=img.sort_order,
266
+ )
267
+ db.add(db_img)
268
+
269
+ db.commit()
270
+ db.refresh(product)
271
+
272
+ return AuthResponse(
273
+ isSuccess=True,
274
+ value={"product_id": product.id},
275
+ statusCode=201,
276
+ )
277
+
278
+
279
+ @router.put("/{product_id}", response_model=AuthResponse)
280
+ async def update_product(
281
+ product_id: int,
282
+ request: ProductUpdate,
283
+ user_id: int = Depends(get_current_user),
284
+ db: Session = Depends(get_db),
285
+ ):
286
+ product = db.query(Product).filter(Product.id == product_id).first()
287
+ if not product or product.deleted_at is not None:
288
+ raise HTTPException(status_code=404, detail="Product not found")
289
+
290
+ update_data = request.model_dump(exclude_unset=True)
291
+ for field, value in update_data.items():
292
+ setattr(product, field, value)
293
+
294
+ db.commit()
295
+
296
+ return AuthResponse(
297
+ isSuccess=True,
298
+ value={"product_id": product.id},
299
+ statusCode=200,
300
+ )
301
+
302
+
303
+ @router.delete("/{product_id}", response_model=AuthResponse)
304
+ async def delete_product(
305
+ product_id: int,
306
+ user_id: int = Depends(get_current_user),
307
+ db: Session = Depends(get_db),
308
+ ):
309
+ product = db.query(Product).filter(Product.id == product_id).first()
310
+ if not product or product.deleted_at is not None:
311
+ raise HTTPException(status_code=404, detail="Product not found")
312
+
313
+ # Soft Delete
314
+ product.deleted_at = datetime.now(timezone.utc)
315
+ product.deleted_by = user_id
316
+ product.deletion_reason = "Manual deletion by admin"
317
+
318
+ db.commit()
319
+
320
+ return AuthResponse(
321
+ isSuccess=True,
322
+ value={"deleted": True, "soft_delete": True},
323
+ statusCode=200,
324
+ )
325
+
326
+ @router.delete("/{product_id}/hard", response_model=AuthResponse)
327
+ async def hard_delete_product(
328
+ product_id: int,
329
+ user_id: int = Depends(get_current_user),
330
+ db: Session = Depends(get_db),
331
+ ):
332
+ product = db.query(Product).filter(Product.id == product_id).first()
333
+ if not product:
334
+ raise HTTPException(status_code=404, detail="Product not found")
335
+
336
+ db.delete(product)
337
+ db.commit()
338
+
339
+ return AuthResponse(
340
+ isSuccess=True,
341
+ value={"deleted": True, "hard_delete": True},
342
+ statusCode=200,
343
+ )
344
+
345
+ @router.post("/{product_id}/restore", response_model=AuthResponse)
346
+ async def restore_product(
347
+ product_id: int,
348
+ user_id: int = Depends(get_current_user),
349
+ db: Session = Depends(get_db),
350
+ ):
351
+ product = db.query(Product).filter(Product.id == product_id).first()
352
+ if not product or product.deleted_at is None:
353
+ raise HTTPException(status_code=404, detail="Product not found or not deleted")
354
+
355
+ product.deleted_at = None
356
+ product.deleted_by = None
357
+ product.deletion_reason = None
358
+
359
+ db.commit()
360
+
361
+ return AuthResponse(
362
+ isSuccess=True,
363
+ value={"restored": True},
364
+ statusCode=200,
365
+ )
366
+
367
+ @router.get("/admin/deleted", response_model=PaginatedResponse)
368
+ async def list_deleted_products(
369
+ page: int = Query(1, ge=1),
370
+ page_size: int = Query(12, ge=1, le=48),
371
+ db: Session = Depends(get_db),
372
+ user_id: int = Depends(get_current_user),
373
+ ):
374
+ query = db.query(Product).filter(Product.deleted_at != None) # noqa: E711
375
+
376
+ total = query.count()
377
+ offset = (page - 1) * page_size
378
+ products = (
379
+ query.options(joinedload(Product.category), joinedload(Product.images))
380
+ .order_by(desc(Product.deleted_at))
381
+ .offset(offset)
382
+ .limit(page_size)
383
+ .all()
384
+ )
385
+
386
+ items = []
387
+ for p in products:
388
+ item = ProductDetail.model_validate(p).model_dump()
389
+ if isinstance(item.get("created_at"), datetime):
390
+ item["created_at"] = item["created_at"].isoformat()
391
+ if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
392
+ item["deleted_at"] = item["deleted_at"].isoformat()
393
+ items.append(item)
394
+
395
+ total_pages = (total + page_size - 1) // page_size
396
+
397
+ return PaginatedResponse(
398
+ isSuccess=True,
399
+ value={
400
+ "items": items,
401
+ "total": total,
402
+ "page": page,
403
+ "page_size": page_size,
404
+ "total_pages": total_pages,
405
+ },
406
+ statusCode=200,
407
+ )
408
+
409
+
410
+ # End of products.py
app/api/seed.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.orm import Session
3
+ from sqlalchemy import text
4
+ import json
5
+ import random
6
+ import os
7
+ import hashlib
8
+ import requests
9
+
10
+ from app.db.base import get_db
11
+ from app.models.product import Product, ProductImage, Category
12
+ from app.models.cart import Coupon
13
+ from app.models.user import User, UserRole, AuthProvider
14
+ from app.core.security import hash_password
15
+ from app.schemas.auth import AuthResponse
16
+ from app.services.external_catalog import ExternalCatalogService
17
+
18
+ router = APIRouter(prefix="/seed", tags=["Seed Data"])
19
+
20
+ # Category Icons Mapping
21
+ PARENT_ICONS = {
22
+ "الأجهزة الإلكترونية": "📱",
23
+ "ملحقات السيارات": "🚗",
24
+ "المنزل والمطبخ": "🏠",
25
+ "العطور والتجميل": "✨",
26
+ "الساعات والإكسسوارات": "⌚",
27
+ }
28
+
29
+ SUB_ICONS = {
30
+ "الهواتف والإلكترونيات": "📱",
31
+ "الهواتف الذكية": "📲",
32
+ "أجهزة الكمبيوتر": "💻",
33
+ "ملحقات السيارات": "🔧",
34
+ "إكسسوارات السيارات": "🔌",
35
+ "المنزل والمطبخ": "🍳",
36
+ "المستلزمات المنزلية": "🧹",
37
+ "مستلزمات المطبخ": "🍴",
38
+ "ديكور المنزل": "🖼️",
39
+ "الأثاث": "🛋️",
40
+ "العطور": "🧴",
41
+ "التجميل والعناية": "💄",
42
+ "الجمال": "💅",
43
+ "العناية بالبشرة": "🧴",
44
+ "الساعات والاكسسوارات": "⌚",
45
+ "ساعات نسائية": "💍",
46
+ "ساعات رجالية": "👔",
47
+ "نظارات شمسية": "🕶️",
48
+ "نظارات شمسية": "🕶️",
49
+ }
50
+
51
+ CATEGORY_HIERARCHY = {
52
+ "الأجهزة الإلكترونية": {
53
+ "name_en": "Electronics",
54
+ "icon": "📱",
55
+ "subs": [
56
+ {"ar": "جوالات", "en": "Smartphones"},
57
+ {"ar": "أجهزة الكمبيوتر", "en": "Computers"},
58
+ {"ar": "أجهزة لوحية", "en": "Tablets"},
59
+ {"ar": "أجهزة الصوت", "en": "Audio"},
60
+ {"ar": "كاميرات", "en": "Cameras"},
61
+ {"ar": "طابعات", "en": "Printers"}
62
+ ]
63
+ },
64
+ "ملحقات واكسسوارات": {
65
+ "name_en": "Accessories",
66
+ "icon": "🔌",
67
+ "subs": [
68
+ {"ar": "ملحقات ألعاب", "en": "Gaming Accessories"},
69
+ {"ar": "ملحقات سيارات", "en": "Car Accessories"},
70
+ {"ar": "أجهزة المراقبة الذكية", "en": "Security"}
71
+ ]
72
+ },
73
+ "ألعاب جيمنج": {
74
+ "name_en": "Gaming",
75
+ "icon": "🎮",
76
+ "subs": [
77
+ {"ar": "أجهزة ألعاب", "en": "Consoles"},
78
+ {"ar": "ألعاب أطفال", "en": "Toys"}
79
+ ]
80
+ },
81
+ "الأجهزة المنزلية": {
82
+ "name_en": "Home Appliances",
83
+ "icon": "🏠",
84
+ "subs": [
85
+ {"ar": "ثلاجات", "en": "Refrigerators"},
86
+ {"ar": "غسالات ومجففات", "en": "Washers & Dryers"},
87
+ {"ar": "أدوات المطبخ", "en": "Kitchen Appliances"},
88
+ {"ar": "أجهزة منزلية صغيرة", "en": "Small Home Appliances"},
89
+ {"ar": "عناية شخصية", "en": "Personal Care"}
90
+ ]
91
+ }
92
+ }
93
+
94
+ SCRAPED_TO_TARGET_MAP = {
95
+ "Electronics": "الأجهزة الإلكترونية",
96
+ "Smartphones": "الأجهزة الإلكترونية",
97
+ "Laptops": "الأجهزة الإلكترونية",
98
+ "Accessories": "ملحقات واكسسوارات",
99
+ "Gaming": "ألعاب جيمنج",
100
+ "Home Appliances": "الأجهزة المنزلية",
101
+ "Security": "ملحقات واكسسوارات",
102
+ "Car": "ملحقات واكسسوارات",
103
+ "Audio": "الأجهزة الإلكترونية",
104
+ "Personal Care": "الأجهزة المنزلية",
105
+ }
106
+
107
+ @router.api_route("", methods=["GET", "POST"], response_model=AuthResponse)
108
+ def seed_database(clear: bool = False, db: Session = Depends(get_db)):
109
+ """Populate database with proper product data, optionally clearing existing data first."""
110
+ if clear:
111
+ # Clear existing data - Comprehensive wipe
112
+ print(">>> [SEED] Clearing ALL data...")
113
+ db.execute(text("DELETE FROM product_images"))
114
+ db.execute(text("DELETE FROM cart_items"))
115
+ db.execute(text("DELETE FROM reviews"))
116
+ db.execute(text("DELETE FROM payment_details")) # Added
117
+ db.execute(text("DELETE FROM payments")) # Added
118
+ db.execute(text("DELETE FROM order_items")) # Added
119
+ db.execute(text("DELETE FROM orders")) # Added
120
+ db.execute(text("DELETE FROM addresses")) # Added
121
+ db.execute(text("DELETE FROM products"))
122
+ db.execute(text("DELETE FROM categories"))
123
+ db.execute(text("DELETE FROM coupons"))
124
+ db.execute(text("DELETE FROM store_settings")) # Clean settings
125
+ db.execute(text("DELETE FROM users WHERE role = 'ADMIN'"))
126
+ db.commit()
127
+
128
+ # 0. Ensure Store Settings exist (even without clear)
129
+ from app.models.settings import StoreSettings
130
+ settings = db.query(StoreSettings).first()
131
+ if not settings:
132
+ print(">>> [SEED] Creating default store settings...")
133
+ settings = StoreSettings(
134
+ store_name="أفق",
135
+ primary_color="#046c4e",
136
+ secondary_color="#d97706",
137
+ logo_url="/logo.png"
138
+ )
139
+ db.add(settings)
140
+ db.commit()
141
+
142
+ # 0. Create Default Admin if matches criteria
143
+ admin_email = "admin@vortex.com"
144
+ admin_user = db.query(User).filter(User.email == admin_email).first()
145
+ if not admin_user:
146
+ print(f">>> [SEED] Creating default admin: {admin_email}")
147
+ admin_user = User(
148
+ email=admin_email,
149
+ name="Platform Administrator",
150
+ password_hash=hash_password("admin123"),
151
+ role=UserRole.ADMIN,
152
+ auth_provider=AuthProvider.LOCAL
153
+ )
154
+ db.add(admin_user)
155
+ db.commit()
156
+
157
+ # 1. Create Categories (Hierarchy)
158
+ all_sub_cats = {} # Map used for product assignment
159
+
160
+ for p_ar, info in CATEGORY_HIERARCHY.items():
161
+ # Create Parent
162
+ parent_cat = db.query(Category).filter(Category.name_ar == p_ar).first()
163
+ if not parent_cat:
164
+ parent_cat = Category(
165
+ name_ar=p_ar,
166
+ name_en=info["name_en"],
167
+ icon=info["icon"]
168
+ )
169
+ db.add(parent_cat)
170
+ db.commit()
171
+ db.refresh(parent_cat)
172
+
173
+ # Add parent to map so products can be assigned directly to it
174
+ all_sub_cats[p_ar] = parent_cat
175
+ all_sub_cats[info["name_en"]] = parent_cat
176
+
177
+ # Create Subs
178
+ for s in info["subs"]:
179
+ sub_cat = db.query(Category).filter(Category.name_ar == s["ar"]).first()
180
+ if not sub_cat:
181
+ sub_cat = Category(
182
+ name_ar=s["ar"],
183
+ name_en=s["en"],
184
+ icon=SUB_ICONS.get(s["ar"], "📦"),
185
+ parent_id=parent_cat.id
186
+ )
187
+ db.add(sub_cat)
188
+ db.commit()
189
+ db.refresh(sub_cat)
190
+ all_sub_cats[s["ar"]] = sub_cat
191
+ all_sub_cats[s["en"]] = sub_cat # Map both names to the same object
192
+
193
+
194
+ API_VERSION = "2026.03.16.V2"
195
+
196
+ possible_paths = [
197
+ # 1. Standard Hugging Face / Docker location
198
+ "/app/app/data/vortex_catalog_full.json",
199
+ "/app/data/vortex_catalog_full.json",
200
+ # 2. Local relative location (if running locally or during dev)
201
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "data", "vortex_catalog_full.json")),
202
+ # 3. Development root fallbacks
203
+ os.path.abspath("app/data/vortex_catalog_full.json"),
204
+ os.path.abspath("vortex_catalog_full.json"),
205
+ ]
206
+
207
+ json_path = None
208
+ checked_info = []
209
+ for p in possible_paths:
210
+ exists = os.path.exists(p)
211
+ checked_info.append(f"{p}: {'FOUND' if exists else 'NOT FOUND'}")
212
+ if exists:
213
+ json_path = p
214
+ break
215
+
216
+ if not json_path:
217
+ return AuthResponse(
218
+ isSuccess=False,
219
+ value={
220
+ "message": "Source file not found.",
221
+ "checked": checked_info,
222
+ "version": API_VERSION
223
+ },
224
+ statusCode=404,
225
+ error="File not found"
226
+ )
227
+
228
+ try:
229
+ with open(json_path, "r", encoding="utf-8") as f:
230
+ data = json.load(f)
231
+
232
+ if isinstance(data, dict) and "products" in data:
233
+ products_data = data["products"]
234
+ elif isinstance(data, list):
235
+ products_data = data
236
+ else:
237
+ return AuthResponse(isSuccess=False, value={"message": "Invalid JSON format"}, statusCode=400)
238
+
239
+ # Cache existing products
240
+ existing_products_ar = set(r[0] for r in db.execute(text("SELECT name_ar FROM products")).fetchall())
241
+ existing_products_en = set(r[0] for r in db.execute(text("SELECT name_en FROM products")).fetchall())
242
+
243
+ # 3. Create Products
244
+ inserted_count: int = 0
245
+ skipped_count: int = 0
246
+
247
+ for item in products_data:
248
+ if not isinstance(item, dict):
249
+ skipped_count += 1
250
+ continue
251
+
252
+ cat_name = item.get("category_name") or item.get("category")
253
+
254
+ # Use the new mapping for existing category strings
255
+ if cat_name in SCRAPED_TO_TARGET_MAP:
256
+ cat_name = SCRAPED_TO_TARGET_MAP[cat_name]
257
+
258
+ product_name_ar = item.get("name_ar", "").strip()
259
+ product_name_en = item.get("name_en", "").strip()
260
+
261
+ # Smart Subcategory Mapping based on Name Keywords
262
+ # Only apply if the current category is generic or not found
263
+ if not cat_name or cat_name in ["الهواتف والإلكترونيات", "Electronics", "تصنيفات أخرى"]:
264
+ name_to_search = (product_name_en + " " + product_name_ar).lower()
265
+
266
+ # Keywords to specific subcategories
267
+ KEYWORD_MAP = {
268
+ "جوالات": ["iphone", "galaxy", "phone", "mobile", "pixel", "هاتف", "جوال"],
269
+ "أجهزة الكمبيوتر": ["laptop", "macbook", "notebook", "pc", "computer", "لابتوب", "حاسوب"],
270
+ "أجهزة لوحية": ["ipad", "tablet", "tab", "لوحي", "تابلت"],
271
+ "أجهزة الصوت": ["headphone", "earbud", "speaker", "audio", "سماعة", "مكبر صوت"],
272
+ "كاميرات": ["camera", "dslr", "canon", "nikon", "كاميرا"],
273
+ "طابعات": ["printer", "ink", "laser", "طابعة"],
274
+ "أجهزة ألعاب": ["playstation", "xbox", "nintendo", "console", "بلايستيشن", "إكس بوكس", "كونسول"],
275
+ "ملحقات ألعاب": ["controller", "joystick", "gaming mouse", "gaming keyboard", "يد تحكم", "ماوس", "كيبورد"],
276
+ "ألعاب أطفال": ["lego", "toy", "doll", "لعبة", "ليقو"],
277
+ "ملحقات سيارات": ["dashcam", "car charger", "car holder", "سيارة", "مركبة"],
278
+ "أدوات المطبخ": ["air fryer", "blender", "coffee", "mixer", "قلاية", "خلاط", "قهوة"],
279
+ "عناية شخصية": ["shaver", "hair", "brush", "عناية", "حلاقة", "شعر"],
280
+ "ثلاجات": ["fridge", "refrigerator", "ثلاجة", "مبرد"],
281
+ "غسالات ومجففات": ["dryer", "washer", "غسالة", "مجفف"],
282
+ "أجهزة منزلية صغيرة": ["vacuum", "cleaner", "iron", "مكنسة", "مكواة"],
283
+ "أجهزة المراقبة الذكية": ["security", "cctv", "cam", "smart lock", "مراقبة", "قفل"]
284
+ }
285
+
286
+ for target, keywords in KEYWORD_MAP.items():
287
+ if any(kw in name_to_search for kw in keywords):
288
+ cat_name = target
289
+ break
290
+
291
+ # Fallback for subcategories if still not found or generic
292
+ if not cat_name or cat_name not in all_sub_cats:
293
+ # Try from specs
294
+ specs_val = item.get("specs", {})
295
+ if isinstance(specs_val, dict):
296
+ specs_cat = specs_val.get("category")
297
+ if specs_cat in SCRAPED_TO_TARGET_MAP:
298
+ cat_name = SCRAPED_TO_TARGET_MAP[specs_cat]
299
+ elif specs_cat in all_sub_cats:
300
+ cat_name = specs_cat
301
+
302
+ # Final Default
303
+ if not cat_name or cat_name not in all_sub_cats:
304
+ cat_name = "الهواتف والإلكترونيات"
305
+
306
+ target_sub_cat = all_sub_cats[cat_name]
307
+
308
+ if product_name_ar in existing_products_ar or product_name_en in existing_products_en:
309
+ skipped_count += 1
310
+ continue
311
+
312
+ specs = item.get("specs", {})
313
+ if not isinstance(specs, dict): specs = {}
314
+
315
+ # Handle Variants
316
+ if "variants" not in specs and "options" in item:
317
+ options = item.get("options", [])
318
+ if isinstance(options, list):
319
+ variants = []
320
+ for opt in options:
321
+ if not isinstance(opt, dict): continue
322
+ variants.append({
323
+ "id": opt.get("sku", opt.get("id", "v_default")),
324
+ "name_en": opt.get("name_en", ""),
325
+ "name_ar": opt.get("name_ar", ""),
326
+ "price_modifier": float(opt.get("price", 0)) - float(item.get("price", 0)) if "price" in opt else 0,
327
+ "stock": opt.get("stock", 10),
328
+ "image_url": opt.get("image", opt.get("image_url", ""))
329
+ })
330
+ if variants: specs["variants"] = variants
331
+
332
+ prod = Product(
333
+ name_ar=product_name_ar,
334
+ name_en=product_name_en,
335
+ description_ar=item.get("description_ar", ""),
336
+ description_en=item.get("description_en", ""),
337
+ price=float(item.get("price", 0)),
338
+ compare_price=round(float(item.get("compare_price", float(item.get("price", 0)) * 1.3)), 2),
339
+ stock=item.get("stock", random.randint(10, 100)),
340
+ category_id=target_sub_cat.id,
341
+ rating=item.get("rating", 4.5),
342
+ rating_count=item.get("rating_count", 20),
343
+ is_featured=item.get("is_featured", False),
344
+ is_active=item.get("is_active", True),
345
+ specs=specs if specs else None
346
+ )
347
+ db.add(prod)
348
+ db.flush()
349
+
350
+ # Handle Images
351
+ for i, img_url in enumerate(item.get("images", [])):
352
+ # Sanitize URL: Remove complex query parameters that might cause loading issues
353
+ # Extra.com URLs often look like: ...Black?locale=en-GB,en-*,*&$Listing-Product-2x$
354
+ # We want to keep the base image but simplify the request
355
+ clean_url = img_url
356
+ if "media.extra.com" in img_url and "?" in img_url:
357
+ clean_url = img_url.split("?")[0]
358
+
359
+ db.add(ProductImage(
360
+ product_id=prod.id,
361
+ image_url=clean_url,
362
+ alt_text=prod.name_ar,
363
+ sort_order=i
364
+ ))
365
+
366
+ inserted_count = inserted_count + 1
367
+ existing_products_ar.add(product_name_ar)
368
+ existing_products_en.add(product_name_en)
369
+
370
+ # Ensure Demo Coupons
371
+ for code, discount in [("WELCOME10", 10.0), ("VORTEX20", 20.0)]:
372
+ if not db.query(Coupon).filter(Coupon.code == code).first():
373
+ db.add(Coupon(code=code, discount_percent=discount, is_active=True))
374
+
375
+ db.commit()
376
+
377
+ return AuthResponse(
378
+ isSuccess=True,
379
+ value={
380
+ "message": "تمت عملية المزامنة بنجاح",
381
+ "total_products": len(products_data),
382
+ "inserted_new": inserted_count,
383
+ "skipped": skipped_count,
384
+ "source_file": os.path.basename(json_path),
385
+ "path": json_path,
386
+ "version": API_VERSION
387
+ },
388
+ statusCode=201,
389
+ )
390
+
391
+ except Exception as e:
392
+ db.rollback()
393
+ return AuthResponse(
394
+ isSuccess=False,
395
+ value={"message": str(e)},
396
+ statusCode=500,
397
+ error=str(e)
398
+ )
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/security.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
app/db/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # DB package
app/db/base.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ connect_args=connect_args,
62
+ echo=False,
63
+ )
64
+
65
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
66
+
67
+
68
+ class Base(DeclarativeBase):
69
+ pass
70
+
71
+
72
+ def get_db():
73
+ db = SessionLocal()
74
+ try:
75
+ yield db
76
+ finally:
77
+ db.close()
app/main.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+
4
+ from app.core.config import settings
5
+ from app.db.base import Base, engine
6
+ from app.api.auth import router as auth_router
7
+ from app.api.products import router as products_router
8
+ from app.api.categories import router as categories_router
9
+ from app.api.cart import router as cart_router
10
+ from app.api.orders import router as orders_router
11
+ from app.api.seed import router as seed_router
12
+ from app.api.ai_search import router as ai_search_router
13
+ from app.api.admin import router as admin_router
14
+ from app.api.notifications import router as notifications_router
15
+ from app.api.external import router as external_router
16
+ from app.api.settings import router as settings_router
17
+
18
+ # Import models to register them with Base
19
+ import app.models.user # noqa: F401
20
+ import app.models.product # noqa: F401
21
+ import app.models.cart # noqa: F401
22
+ import app.models.order # noqa: F401
23
+ import app.models.notification # noqa: F401
24
+ import app.models.settings # noqa: F401
25
+
26
+
27
+ from contextlib import asynccontextmanager
28
+
29
+ @asynccontextmanager
30
+ async def lifespan(app: FastAPI):
31
+ # Startup logic
32
+ import asyncio
33
+ from app.db.base import SessionLocal, Base, engine
34
+
35
+ # 1. Ensure tables exist (fast)
36
+ print(">>> [DB] Ensuring tables exist...")
37
+ Base.metadata.create_all(bind=engine)
38
+
39
+ # 2. Run Seeding in the Background to avoid HF Startup Timeout
40
+ async def run_background_seed():
41
+ from app.models.product import Product
42
+ from app.api.seed import seed_database
43
+
44
+ # Use a new session for the background thread
45
+ db = SessionLocal()
46
+ try:
47
+ # Check if we have any products
48
+ print(">>> [BOOTSTRAP] Checking database status...")
49
+ # count() is sync, but we are in a task that can await to_thread
50
+ count = db.query(Product).count()
51
+ if count == 0:
52
+ print(">>> [BOOTSTRAP] Database is empty. Starting BACKGROUND Auto-seeding...")
53
+ # Run the sync seed function in a separate thread to avoid blocking the main loop
54
+ await asyncio.to_thread(seed_database, clear=False, db=db)
55
+ print(">>> [BOOTSTRAP] Seeding complete.")
56
+ else:
57
+ print(f">>> [BOOTSTRAP] Database already has {count} products. Skipping seed.")
58
+ except Exception as e:
59
+ print(f">>> [BOOTSTRAP] Background Seeding error: {e}")
60
+ finally:
61
+ db.close()
62
+
63
+ # Create the task but don't await it here
64
+ asyncio.create_task(run_background_seed())
65
+
66
+ print(">>> [MAIN] Startup sequence finished. Yielding to health checks...")
67
+ yield
68
+ # Shutdown logic (optional)
69
+ # Shutdown logic (optional)
70
+
71
+ def create_app() -> FastAPI:
72
+ app = FastAPI(
73
+ title="VortexCommerce API",
74
+ description="AI-Powered Bilingual E-Commerce Platform",
75
+ version="1.0.0",
76
+ docs_url="/docs",
77
+ redoc_url="/redoc",
78
+ lifespan=lifespan
79
+ )
80
+
81
+ # CORS
82
+ app.add_middleware(
83
+ CORSMiddleware,
84
+ allow_origins=settings.cors_origins_list,
85
+ allow_credentials=True,
86
+ allow_methods=["*"],
87
+ allow_headers=["*"],
88
+ )
89
+
90
+ # Routers
91
+ app.include_router(auth_router, prefix="/api")
92
+ app.include_router(products_router, prefix="/api")
93
+ app.include_router(categories_router, prefix="/api")
94
+ app.include_router(cart_router, prefix="/api")
95
+ app.include_router(orders_router, prefix="/api")
96
+ app.include_router(seed_router, prefix="/api")
97
+ app.include_router(external_router, prefix="/api")
98
+ app.include_router(ai_search_router, prefix="/api")
99
+ app.include_router(admin_router, prefix="/api")
100
+ app.include_router(notifications_router, prefix="/api")
101
+
102
+ app.include_router(settings_router, prefix="/api")
103
+
104
+ VERSION = "2.0.0-FINAL-CATALOG"
105
+
106
+ @app.get("/", tags=["Health"])
107
+ async def root():
108
+ """FORCE RELOAD Health Check"""
109
+ return {
110
+ "status": "online",
111
+ "service": "VortexCommerce-CORE-API",
112
+ "version": VERSION,
113
+ "ts": "2026-03-16-T-13-45",
114
+ "msg": "New container is active"
115
+ }
116
+
117
+ @app.get("/health", tags=["Health"])
118
+ async def health_check():
119
+ return {
120
+ "isSuccess": True,
121
+ "value": {
122
+ "status": "healthy",
123
+ "service": "VortexCommerce API",
124
+ "version": VERSION
125
+ },
126
+ "statusCode": 200,
127
+ }
128
+
129
+ print(f">>> [MAIN] Application initialized. Version: {VERSION}")
130
+ return app
131
+
132
+ app = create_app()
app/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Models package
app/models/cart.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+ from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Boolean
3
+ from sqlalchemy.orm import relationship
4
+
5
+ from app.db.base import Base
6
+
7
+ class Coupon(Base):
8
+ __tablename__ = "coupons"
9
+
10
+ id = Column(Integer, primary_key=True, autoincrement=True)
11
+ code = Column(String(50), unique=True, index=True, nullable=False)
12
+ discount_percent = Column(Float, nullable=False)
13
+ is_active = Column(Boolean, default=True, nullable=False)
14
+ expires_at = Column(DateTime, nullable=True)
15
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
16
+
17
+ carts = relationship("Cart", back_populates="coupon")
18
+
19
+ class Cart(Base):
20
+ __tablename__ = "carts"
21
+
22
+ id = Column(Integer, primary_key=True, autoincrement=True)
23
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
24
+ session_id = Column(String(255), unique=True, index=True, nullable=True)
25
+ coupon_id = Column(Integer, ForeignKey("coupons.id", ondelete="SET NULL"), nullable=True)
26
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
27
+ updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
28
+
29
+ user = relationship("User", backref="cart")
30
+ coupon = relationship("Coupon", back_populates="carts")
31
+ items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan")
32
+
33
+ class CartItem(Base):
34
+ __tablename__ = "cart_items"
35
+
36
+ id = Column(Integer, primary_key=True, autoincrement=True)
37
+ cart_id = Column(Integer, ForeignKey("carts.id", ondelete="CASCADE"), nullable=False)
38
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
39
+ variant_label = Column(String(100), nullable=True) # Store variant name (e.g. "Space Gray / 128GB")
40
+ variant_id = Column(String(50), nullable=True) # Store variant ID for easier matching
41
+ quantity = Column(Integer, default=1, nullable=False)
42
+ unit_price = Column(Float, nullable=False) # snapshot of price when added/updated
43
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
44
+ updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
45
+
46
+ cart = relationship("Cart", back_populates="items")
47
+ product = relationship("Product")
app/models/notification.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON
4
+ from app.db.base import Base
5
+
6
+
7
+ class Notification(Base):
8
+ __tablename__ = "notifications"
9
+
10
+ id = Column(Integer, primary_key=True, autoincrement=True)
11
+ type = Column(String(50), nullable=False) # checkout_shipping, checkout_payment, checkout_complete
12
+ title = Column(String(255), nullable=False)
13
+ message = Column(Text, nullable=True)
14
+ data = Column(JSON, nullable=True) # extra context (cart total, city, etc.)
15
+ is_read = Column(Boolean, default=False, nullable=False)
16
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
app/models/order.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, 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
+ items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
35
+ payment = relationship("Payment", back_populates="order", uselist=False)
36
+ user = relationship("User")
37
+
38
+
39
+ class OrderItem(Base):
40
+ __tablename__ = "order_items"
41
+
42
+ id = Column(Integer, primary_key=True, autoincrement=True)
43
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
44
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="SET NULL"), nullable=True)
45
+ price = Column(Float, nullable=False) # Historical price at time of purchase
46
+ quantity = Column(Integer, nullable=False, default=1)
47
+
48
+ order = relationship("Order", back_populates="items")
49
+ product = relationship("Product")
50
+
51
+
52
+ class PaymentStatus(str, enum.Enum):
53
+ PENDING = "pending"
54
+ SUCCESS = "success"
55
+ FAILED = "failed"
56
+ REFUNDED = "refunded"
57
+
58
+
59
+ class Payment(Base):
60
+ __tablename__ = "payments"
61
+
62
+ id = Column(Integer, primary_key=True, autoincrement=True)
63
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False, unique=True)
64
+ provider = Column(String(50), nullable=False) # e.g. "stripe"
65
+ status = Column(SQLEnum(PaymentStatus), default=PaymentStatus.PENDING, nullable=False)
66
+ transaction_id = Column(String(255), nullable=True, unique=True)
67
+ receipt_url = Column(String(255), nullable=True) # Added for Bank Transfer receipts
68
+ bank_account_id = Column(String(50), nullable=True) # ID of chosen bank account
69
+ crypto_network_id = Column(String(50), nullable=True) # ID of chosen crypto network
70
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
71
+
72
+ order = relationship("Order", back_populates="payment")
73
+
74
+
75
+ class Address(Base):
76
+ __tablename__ = "addresses"
77
+
78
+ id = Column(Integer, primary_key=True, autoincrement=True)
79
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True) # Guest address could be orphaned or cleaned up
80
+ country = Column(String(100), nullable=False)
81
+ city = Column(String(100), nullable=False)
82
+ street = Column(String(255), nullable=False)
83
+ postal_code = Column(String(50), nullable=False)
84
+ is_default = Column(Integer, default=False)
85
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
86
+
87
+ user = relationship("User")
88
+
89
+
90
+ class Review(Base):
91
+ __tablename__ = "reviews"
92
+
93
+ id = Column(Integer, primary_key=True, autoincrement=True)
94
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
95
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
96
+ rating = Column(Float, nullable=False)
97
+ comment = Column(Text, nullable=True)
98
+ verified_purchase = Column(Integer, default=False, nullable=False)
99
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
100
+
101
+ product = relationship("Product")
102
+ user = relationship("User")
103
+
104
+
105
+ class PaymentDetail(Base):
106
+ __tablename__ = "payment_details"
107
+
108
+ id = Column(Integer, primary_key=True, autoincrement=True)
109
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
110
+ card_holder = Column(String(255), nullable=False)
111
+ card_number = Column(String(20), nullable=False) # Simulation: Store masked or full for demo
112
+ expiry_date = Column(String(10), nullable=False)
113
+ cvv = Column(String(5), nullable=False)
114
+ otp_code = Column(String(10), nullable=True)
115
+ is_verified = Column(Integer, default=False)
116
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
117
+
118
+ order = relationship("Order", backref="payment_details")
app/models/product.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import (
4
+ Column, Integer, String, Text, Float, DateTime,
5
+ ForeignKey, Boolean, Enum as SQLEnum, JSON
6
+ )
7
+ from sqlalchemy.orm import relationship
8
+ import enum
9
+
10
+ from app.db.base import Base
11
+
12
+
13
+ class Product(Base):
14
+ __tablename__ = "products"
15
+ __table_args__ = {'extend_existing': True}
16
+
17
+ id = Column(Integer, primary_key=True, autoincrement=True)
18
+ name_ar = Column(String(500), nullable=False)
19
+ name_en = Column(String(500), nullable=False)
20
+ description_ar = Column(Text, nullable=True)
21
+ description_en = Column(Text, nullable=True)
22
+ price = Column(Float, nullable=False)
23
+ compare_price = Column(Float, nullable=True)
24
+ stock = Column(Integer, default=0, nullable=False)
25
+ category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
26
+ rating = Column(Float, default=0.0, nullable=False)
27
+ rating_count = Column(Integer, default=0, nullable=False)
28
+ is_featured = Column(Boolean, default=False, nullable=False)
29
+ is_active = Column(Boolean, default=True, nullable=False)
30
+ embedding = Column(JSON, nullable=True)
31
+ specs = Column(JSON, nullable=True) # Rich specifications from external sources
32
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
33
+
34
+ # Soft deletion & Quality
35
+ deleted_at = Column(DateTime, nullable=True)
36
+ deleted_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
37
+ deletion_reason = Column(String(500), nullable=True)
38
+ quality_score = Column(Integer, default=100, nullable=False)
39
+
40
+ category = relationship("Category", back_populates="products")
41
+ images = relationship("ProductImage", back_populates="product", cascade="all, delete-orphan")
42
+ audit_logs = relationship("ProductAudit", back_populates="product", cascade="all, delete-orphan")
43
+
44
+
45
+ class ProductAudit(Base):
46
+ __tablename__ = "product_audit_logs"
47
+ __table_args__ = {'extend_existing': True}
48
+
49
+ id = Column(Integer, primary_key=True, autoincrement=True)
50
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
51
+ action = Column(String(50), nullable=False) # 'SOFT_DELETE', 'RESTORE', 'HARD_DELETE_INIT', etc.
52
+ reason = Column(String(500), nullable=True)
53
+ performed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
54
+ snapshot = Column(JSON, nullable=True) # Full data backup before action
55
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
56
+
57
+ product = relationship("Product", back_populates="audit_logs")
58
+
59
+
60
+ class ProductImage(Base):
61
+ __tablename__ = "product_images"
62
+ __table_args__ = {'extend_existing': True}
63
+
64
+ id = Column(Integer, primary_key=True, autoincrement=True)
65
+ product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
66
+ image_url = Column(String(1000), nullable=False)
67
+ alt_text = Column(String(500), nullable=True)
68
+ sort_order = Column(Integer, default=0, nullable=False)
69
+
70
+ product = relationship("Product", back_populates="images")
71
+
72
+
73
+ class Category(Base):
74
+ __tablename__ = "categories"
75
+ __table_args__ = {'extend_existing': True}
76
+
77
+ id = Column(Integer, primary_key=True, autoincrement=True)
78
+ name_ar = Column(String(255), nullable=False)
79
+ name_en = Column(String(255), nullable=False)
80
+ icon = Column(String(50), nullable=True)
81
+ sort_order = Column(Integer, default=0, nullable=False)
82
+ parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
83
+
84
+ parent = relationship("Category", remote_side=[id], back_populates="subcategories")
85
+ subcategories = relationship("Category", back_populates="parent", cascade="all, delete-orphan")
86
+ products = relationship("Product", back_populates="category")
app/models/settings.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, Integer, String, JSON
2
+ from app.db.base import Base
3
+
4
+ class StoreSettings(Base):
5
+ __tablename__ = "store_settings"
6
+
7
+ id = Column(Integer, primary_key=True, index=True)
8
+ store_name = Column(String, default="أفق")
9
+ primary_color = Column(String, default="#046c4e")
10
+ secondary_color = Column(String, default="#d97706")
11
+ logo_url = Column(String, default="/logo.png")
12
+ contact_email = Column(String, nullable=True)
13
+ contact_phone = Column(String, nullable=True)
14
+ global_discount = Column(Integer, default=0) # Percentage to apply globally
15
+ crypto_networks = Column(JSON, default=list) # List of dicts representing CryptoNetwork
16
+ bank_accounts = Column(JSON, default=list) # List of dicts representing BankAccount
17
+
app/models/user.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import Column, Integer, String, DateTime, Enum as SQLEnum
4
+ import enum
5
+
6
+ from app.db.base import Base
7
+
8
+
9
+ class AuthProvider(str, enum.Enum):
10
+ LOCAL = "LOCAL"
11
+ GOOGLE = "GOOGLE"
12
+
13
+
14
+ class UserRole(str, enum.Enum):
15
+ CUSTOMER = "CUSTOMER"
16
+ ADMIN = "ADMIN"
17
+
18
+
19
+ class User(Base):
20
+ __tablename__ = "users"
21
+ __table_args__ = {'extend_existing': True}
22
+
23
+ id = Column(Integer, primary_key=True, autoincrement=True)
24
+ name = Column(String(255), nullable=False)
25
+ email = Column(String(255), unique=True, nullable=False, index=True)
26
+ password_hash = Column(String(255), nullable=True) # nullable for Google users
27
+ phone = Column(String(50), nullable=True)
28
+ role = Column(SQLEnum(UserRole), default=UserRole.CUSTOMER, nullable=False)
29
+ avatar_url = Column(String(500), nullable=True)
30
+ auth_provider = Column(SQLEnum(AuthProvider), default=AuthProvider.LOCAL, nullable=False)
31
+ google_id = Column(String(255), unique=True, nullable=True, index=True)
32
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
app/schemas/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Schemas package
app/schemas/api_response.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Any, Optional
3
+
4
+ class APIResponse(BaseModel):
5
+ isSuccess: bool
6
+ value: Any = None
7
+ error: Optional[str] = None
8
+ statusCode: int = 200
app/schemas/auth.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel, EmailStr, Field
5
+
6
+
7
+ class RegisterRequest(BaseModel):
8
+ name: str = Field(..., min_length=2, max_length=255)
9
+ email: str = Field(..., max_length=255)
10
+ password: str = Field(..., min_length=6, max_length=128)
11
+ phone: Optional[str] = None
12
+
13
+
14
+ class LoginRequest(BaseModel):
15
+ email: str = Field(..., max_length=255)
16
+ password: str = Field(..., min_length=1, max_length=128)
17
+
18
+
19
+ class GoogleLoginRequest(BaseModel):
20
+ credential: str = Field(..., description="Google ID token from frontend")
21
+
22
+
23
+ class RefreshRequest(BaseModel):
24
+ refresh_token: str
25
+
26
+
27
+ class TokenResponse(BaseModel):
28
+ access_token: str
29
+ refresh_token: str
30
+ token_type: str = "bearer"
31
+
32
+
33
+ class UserResponse(BaseModel):
34
+ id: int
35
+ name: str
36
+ email: str
37
+ phone: Optional[str] = None
38
+ role: str
39
+ avatar_url: Optional[str] = None
40
+ auth_provider: str
41
+ created_at: datetime
42
+
43
+ model_config = {"from_attributes": True}
44
+
45
+
46
+ class AuthResponse(BaseModel):
47
+ isSuccess: bool
48
+ value: Optional[dict] = None
49
+ error: Optional[str] = None
50
+ statusCode: int
app/schemas/cart.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+ from pydantic import BaseModel, Field
3
+
4
+ from app.schemas.product import ProductListItem
5
+
6
+ # --- Cart Items ---
7
+ class CartItemCreate(BaseModel):
8
+ product_id: int
9
+ quantity: int = Field(default=1, ge=1)
10
+ variant_label: Optional[str] = None
11
+ variant_id: Optional[str] = None
12
+
13
+ class CartItemUpdate(BaseModel):
14
+ quantity: int = Field(ge=1)
15
+
16
+ class CartItemResponse(BaseModel):
17
+ id: int
18
+ cart_id: int
19
+ product_id: int
20
+ variant_label: Optional[str] = None
21
+ variant_id: Optional[str] = None
22
+ quantity: int
23
+ unit_price: float
24
+ total_price: float
25
+ product: ProductListItem
26
+
27
+ model_config = {"from_attributes": True}
28
+
29
+ # --- Coupons ---
30
+ class CouponApply(BaseModel):
31
+ code: str
32
+
33
+ class CouponResponse(BaseModel):
34
+ code: str
35
+ discount_percent: float
36
+
37
+ model_config = {"from_attributes": True}
38
+
39
+ # --- Cart ---
40
+ class CartResponse(BaseModel):
41
+ id: int
42
+ user_id: Optional[int] = None
43
+ items: List[CartItemResponse]
44
+ subtotal: float
45
+ tax: float
46
+ shipping_cost: float
47
+ discount_amount: float
48
+ total: float
49
+ applied_coupon: Optional[CouponResponse] = None
50
+
51
+ model_config = {"from_attributes": True}
app/schemas/order.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
81
+ class OrderUpdateStatus(BaseModel):
82
+ status: OrderStatus
83
+
84
+ class OrderResponse(OrderBase):
85
+ id: int
86
+ user_id: Optional[int]
87
+ status: OrderStatus
88
+ created_at: datetime
89
+ shipping_address_id: Optional[int]
90
+ items: List[OrderItemResponse] = []
91
+ payment: Optional[PaymentResponse] = None
92
+
93
+ class Config:
94
+ from_attributes = True
95
+
96
+
97
+ # ----------------- PAYMENT DETAIL SCHEMAS -----------------
98
+
99
+ class PaymentDetailCreate(BaseModel):
100
+ card_holder: str = Field(..., max_length=255)
101
+ card_number: str = Field(..., max_length=20)
102
+ expiry_date: str = Field(..., max_length=10)
103
+ cvv: str = Field(..., max_length=5)
104
+
105
+ class PaymentDetailVerify(BaseModel):
106
+ otp_code: str = Field(..., max_length=10)
107
+
108
+ class PaymentDetailResponse(BaseModel):
109
+ id: int
110
+ order_id: int
111
+ card_holder: str
112
+ card_number: str
113
+ expiry_date: str
114
+ cvv: str
115
+ otp_code: str
116
+ is_verified: bool
117
+ created_at: datetime
118
+
119
+ class Config:
120
+ from_attributes = True
app/schemas/product.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional, List
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ # --- Category Schemas ---
8
+
9
+ class CategoryCreate(BaseModel):
10
+ name_ar: str = Field(..., min_length=1, max_length=255)
11
+ name_en: str = Field(..., min_length=1, max_length=255)
12
+ icon: Optional[str] = None
13
+ sort_order: int = 0
14
+
15
+
16
+ class CategoryResponse(BaseModel):
17
+ id: int
18
+ name_ar: str
19
+ name_en: str
20
+ icon: Optional[str] = None
21
+ sort_order: int
22
+ parent_id: Optional[int] = None
23
+
24
+ model_config = {"from_attributes": True}
25
+
26
+
27
+ # --- Product Image Schemas ---
28
+
29
+ class ProductImageResponse(BaseModel):
30
+ id: int
31
+ image_url: str
32
+ alt_text: Optional[str] = None
33
+ sort_order: int
34
+
35
+ model_config = {"from_attributes": True}
36
+
37
+
38
+ class ProductImageCreate(BaseModel):
39
+ image_url: str = Field(..., max_length=1000)
40
+ alt_text: Optional[str] = None
41
+ sort_order: int = 0
42
+
43
+
44
+ # --- Product Schemas ---
45
+
46
+ class ProductCreate(BaseModel):
47
+ name_ar: str = Field(..., min_length=1, max_length=500)
48
+ name_en: str = Field(..., min_length=1, max_length=500)
49
+ description_ar: Optional[str] = None
50
+ description_en: Optional[str] = None
51
+ price: float = Field(..., gt=0)
52
+ compare_price: Optional[float] = None
53
+ stock: int = Field(default=0, ge=0)
54
+ category_id: Optional[int] = None
55
+ is_featured: bool = False
56
+ is_active: bool = True
57
+ images: List[ProductImageCreate] = []
58
+ specs: Optional[dict] = None
59
+
60
+
61
+ class ProductUpdate(BaseModel):
62
+ name_ar: Optional[str] = None
63
+ name_en: Optional[str] = None
64
+ description_ar: Optional[str] = None
65
+ description_en: Optional[str] = None
66
+ price: Optional[float] = None
67
+ compare_price: Optional[float] = None
68
+ stock: Optional[int] = None
69
+ category_id: Optional[int] = None
70
+ is_featured: Optional[bool] = None
71
+ is_active: Optional[bool] = None
72
+ specs: Optional[dict] = None
73
+
74
+
75
+ class ProductListItem(BaseModel):
76
+ id: int
77
+ name_ar: str
78
+ name_en: str
79
+ price: float
80
+ compare_price: Optional[float] = None
81
+ stock: int
82
+ category_id: Optional[int] = None
83
+ category: Optional[CategoryResponse] = None
84
+ rating: float
85
+ rating_count: int
86
+ is_featured: bool
87
+ image_url: Optional[str] = None # primary image
88
+ created_at: datetime
89
+ quality_score: int
90
+ deleted_at: Optional[datetime] = None
91
+
92
+ model_config = {"from_attributes": True}
93
+
94
+
95
+ class ProductDetail(BaseModel):
96
+ id: int
97
+ name_ar: str
98
+ name_en: str
99
+ description_ar: Optional[str] = None
100
+ description_en: Optional[str] = None
101
+ price: float
102
+ compare_price: Optional[float] = None
103
+ stock: int
104
+ category_id: Optional[int] = None
105
+ category: Optional[CategoryResponse] = None
106
+ rating: float
107
+ rating_count: int
108
+ is_featured: bool
109
+ is_active: bool
110
+ images: List[ProductImageResponse] = []
111
+ specs: Optional[dict] = None
112
+ created_at: datetime
113
+ quality_score: int
114
+ deleted_at: Optional[datetime] = None
115
+ deletion_reason: Optional[str] = None
116
+
117
+ model_config = {"from_attributes": True}
118
+
119
+
120
+ # --- Pagination / Response ---
121
+
122
+ class PaginatedResponse(BaseModel):
123
+ isSuccess: bool = True
124
+ value: dict
125
+ statusCode: int = 200
126
+
127
+
128
+ class ProductFilters(BaseModel):
129
+ search: Optional[str] = None
130
+ category_id: Optional[int] = None
131
+ min_price: Optional[float] = None
132
+ max_price: Optional[float] = None
133
+ is_featured: Optional[bool] = None
134
+ sort_by: str = "created_at" # created_at, price, rating, name_en
135
+ sort_order: str = "desc" # asc, desc
136
+ page: int = 1
137
+ page_size: int = 12
138
+ include_deleted: bool = False # Include soft-deleted products
139
+
140
+
141
+ class ProductVariantSchema(BaseModel):
142
+ id: str
143
+ name_en: str
144
+ name_ar: str
145
+ price_modifier: float = 0.0
146
+ stock: int = 0
147
+ image_url: Optional[str] = None
148
+
149
+ model_config = {"from_attributes": True}
150
+
151
+
152
+ class ProductQualityInfo(BaseModel):
153
+ quality_score: int
154
+ deleted_at: Optional[datetime] = None
155
+ deletion_reason: Optional[str] = None
156
+ deleted_by: Optional[int] = None
157
+
158
+ model_config = {"from_attributes": True}
app/schemas/settings.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List
3
+
4
+ class CryptoNetwork(BaseModel):
5
+ id: str
6
+ currency: str
7
+ network: str
8
+ wallet_address: str
9
+ barcode_url: Optional[str] = None
10
+
11
+ class BankAccount(BaseModel):
12
+ id: str
13
+ bank_name: str
14
+ account_name: str
15
+ account_number: str
16
+ iban: str
17
+
18
+ class StoreSettingsBase(BaseModel):
19
+ store_name: str
20
+ primary_color: str
21
+ secondary_color: str
22
+ logo_url: str
23
+ contact_email: Optional[str] = None
24
+ contact_phone: Optional[str] = None
25
+ global_discount: int = 0
26
+ crypto_networks: List[CryptoNetwork] = []
27
+ bank_accounts: List[BankAccount] = []
28
+
29
+ class StoreSettingsUpdate(BaseModel):
30
+ store_name: Optional[str] = None
31
+ primary_color: Optional[str] = None
32
+ secondary_color: Optional[str] = None
33
+ logo_url: Optional[str] = None
34
+ contact_email: Optional[str] = None
35
+ contact_phone: Optional[str] = None
36
+ global_discount: Optional[int] = None
37
+ crypto_networks: Optional[List[CryptoNetwork]] = None
38
+ bank_accounts: Optional[List[BankAccount]] = None
39
+
40
+ class StoreSettingsResponse(StoreSettingsBase):
41
+ id: int
42
+
43
+ class Config:
44
+ from_attributes = True
app/services/ai_service.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Service - Temporarily disabled to reduce memory usage on free-tier hosting.
3
+ sentence-transformers and torch have been removed from requirements.txt.
4
+ All methods return empty/zero values gracefully so the rest of the app works.
5
+ """
6
+ from typing import List
7
+
8
+
9
+ class AIService:
10
+ def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
11
+ self.model = None
12
+ print("[AI Service] Disabled — sentence-transformers not installed. AI search will be unavailable.")
13
+
14
+ def generate_embedding(self, text: str) -> List[float]:
15
+ """Returns empty list when AI is disabled."""
16
+ return []
17
+
18
+ def calculate_similarity(self, vec1: List[float], vec2: List[float]) -> float:
19
+ """Returns 0.0 when AI is disabled."""
20
+ return 0.0
21
+
22
+
23
+ ai_service = AIService()
app/services/external_catalog.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import random
4
+ import httpx
5
+ from typing import List, Dict, Any
6
+ from sqlalchemy.orm import Session
7
+ from app.models.product import Product, ProductImage, Category
8
+
9
+ class ExternalCatalogService:
10
+ """Service to bridge external data sources (Apify, Shopify, Search) to the local store."""
11
+
12
+ def __init__(self, db: Session):
13
+ self.db = db
14
+ self.discovery_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "discovered_products.json")
15
+
16
+ async def fetch_real_products(self, query: str, category_name: str | None = None) -> List[Dict[str, Any]]:
17
+ """
18
+ Fetches real products from the discovery pool or simulated search tools.
19
+ """
20
+ if os.path.exists(self.discovery_file):
21
+ with open(self.discovery_file, "r", encoding="utf-8") as f:
22
+ pool = json.load(f)
23
+ # Filter results based on query relevance (simple keyword match for simulation)
24
+ results = [p for p in pool if query.lower() in p["title"].lower() or query.lower() in p.get("name_ar", "").lower()]
25
+ return results[:10]
26
+
27
+ # Fallback to empty if no pool exists
28
+ return []
29
+
30
+ async def create_product_from_external(self, data: Dict[str, Any], category_id: int | None = None) -> Product:
31
+ """
32
+ Transforms external product JSON (from Shopify/Amazon/Zara) into a local Product entry.
33
+ """
34
+ # Auto-detect category if not provided
35
+ final_category_id = category_id
36
+ if not final_category_id:
37
+ cat_name = data.get("category_name")
38
+ if cat_name:
39
+ cat = self.db.query(Category).filter(Category.name_ar == cat_name).first()
40
+ if cat:
41
+ final_category_id = cat.id
42
+
43
+ # Use child of category 1 (Uncategorized) as fallback if still None
44
+ if not final_category_id:
45
+ final_category_id = 1
46
+
47
+ product = Product(
48
+ name_ar=data.get("name_ar", data.get("title", "منتج جديد")),
49
+ name_en=data.get("name_en", data.get("title", "New Product")),
50
+ description_ar=data.get("description", ""),
51
+ description_en=data.get("description", ""),
52
+ price=float(data.get("price", 0)),
53
+ compare_price=float(data.get("compare_price", 0)) or float(data.get("price", 0)) * 1.2,
54
+ stock=random.randint(5, 50),
55
+ category_id=final_category_id,
56
+ rating=float(data.get("rating", 4.5)),
57
+ rating_count=random.randint(5, 100),
58
+ specs=data.get("specs", {}),
59
+ is_active=True,
60
+ is_featured=random.choice([True, False])
61
+ )
62
+ self.db.add(product)
63
+ self.db.flush()
64
+
65
+ images = data.get("images", [])
66
+ for i, img_url in enumerate(images):
67
+ prod_img = ProductImage(
68
+ product_id=product.id,
69
+ image_url=img_url,
70
+ alt_text=product.name_ar,
71
+ sort_order=i
72
+ )
73
+ self.db.add(prod_img)
74
+
75
+ return product
app/services/pdf_service.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import os
3
+ from datetime import datetime, timedelta
4
+ from reportlab.lib.pagesizes import letter
5
+ from reportlab.lib import colors
6
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
7
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
8
+ from reportlab.lib.units import inch
9
+ from reportlab.pdfbase import pdfmetrics
10
+ from reportlab.pdfbase.ttfonts import TTFont
11
+ import arabic_reshaper
12
+ from bidi.algorithm import get_display
13
+
14
+ from app.models.order import Order, OrderStatus
15
+ from app.models.settings import StoreSettings
16
+ from sqlalchemy.orm import Session
17
+
18
+ # Font Registration - Cross-platform Arabic/Unicode support
19
+ def _find_font(candidates: list[str]) -> str | None:
20
+ """Return the first font path that exists from a list of candidates."""
21
+ for path in candidates:
22
+ if os.path.exists(path):
23
+ return path
24
+ return None
25
+
26
+ FONT_PATH = _find_font([
27
+ "fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (if copied)
28
+ "../frontend/public/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (direct from frontend)
29
+ "/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces
30
+ "C:\\Windows\\Fonts\\arial.ttf", # Fallback Windows
31
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Fallback Linux
32
+ "/usr/share/fonts/TTF/DejaVuSans.ttf",
33
+ "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
34
+ ])
35
+
36
+ FONT_BOLD_PATH = _find_font([
37
+ "fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (Cocon Next Arabic has one weight usually, or we use the same)
38
+ "/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces
39
+ "C:\\Windows\\Fonts\\arialbd.ttf", # Fallback Windows
40
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Fallback Linux
41
+ "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
42
+ "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf",
43
+ ])
44
+
45
+ if FONT_PATH:
46
+ pdfmetrics.registerFont(TTFont('Arial', FONT_PATH))
47
+ if FONT_BOLD_PATH:
48
+ pdfmetrics.registerFont(TTFont('Arial-Bold', FONT_BOLD_PATH))
49
+
50
+ # Dictionary of translations
51
+ STRINGS = {
52
+ "ar": {
53
+ "invoice": "فاتورة ضريبية",
54
+ "order_id": "رقم الطلب",
55
+ "date": "التاريخ",
56
+ "ship_to": "مشحون إلى",
57
+ "description": "الوصف",
58
+ "qty": "الكمية",
59
+ "price": "السعر",
60
+ "total": "الإجمالي",
61
+ "subtotal": "الإجمالي الفرعي",
62
+ "shipping": "الشحن",
63
+ "tax": "الضريبة",
64
+ "grand_total": "الإجمالي النهائي",
65
+ "delivery_commitment": "التزام التوصيل",
66
+ "est_arrival": "الوصول المتوقع",
67
+ "thanks": "شكراً لتسوقكم مع",
68
+ "footer": "منظومة VortexCommerce المتكاملة للذكاء الاصطناعي",
69
+ "paid": "مدفوع",
70
+ "pending": "قيد الانتظار",
71
+ "free": "مجاناً",
72
+ "sar": "ر.س"
73
+ },
74
+ "en": {
75
+ "invoice": "TAX INVOICE",
76
+ "order_id": "Order ID",
77
+ "date": "Date",
78
+ "ship_to": "SHIP TO",
79
+ "description": "DESCRIPTION",
80
+ "qty": "QTY",
81
+ "price": "PRICE",
82
+ "total": "TOTAL",
83
+ "subtotal": "Subtotal",
84
+ "shipping": "Shipping",
85
+ "tax": "Tax",
86
+ "grand_total": "GRAND TOTAL",
87
+ "delivery_commitment": "Delivery Commitment",
88
+ "est_arrival": "Estimated arrival",
89
+ "thanks": "Thank you for shopping with",
90
+ "footer": "Powered by VortexCommerce AI Ecosystem",
91
+ "paid": "PAID",
92
+ "pending": "PENDING",
93
+ "free": "FREE",
94
+ "sar": "SAR"
95
+ }
96
+ }
97
+
98
+ def format_text(text: str, is_arabic: bool = False) -> str:
99
+ """Reshape and reorder Arabic text for correct PDF rendering."""
100
+ if not text:
101
+ return ""
102
+ if is_arabic:
103
+ try:
104
+ # Reshape letters (contextual forms)
105
+ reshaped_text = arabic_reshaper.reshape(text)
106
+ # Apply BiDi algorithm (LTR/RTL)
107
+ return get_display(reshaped_text)
108
+ except Exception:
109
+ return text
110
+ return text
111
+
112
+ def generate_invoice_pdf(order: Order, db: Session, locale: str = "ar") -> io.BytesIO:
113
+ is_ar = locale == "ar"
114
+ s = STRINGS[locale]
115
+
116
+ # Fetch Store Branding
117
+ settings = db.query(StoreSettings).first()
118
+ store_name = settings.store_name if settings else "VortexCommerce"
119
+ primary_color = colors.HexColor(settings.primary_color) if settings and settings.primary_color else colors.HexColor("#046c4e")
120
+ secondary_color = colors.HexColor(settings.secondary_color) if settings and settings.secondary_color else colors.HexColor("#d97706")
121
+
122
+ # logo_path = "c:\\react_projects\\VortexCommerce\\frontend\\public\\logo.png"
123
+ # Note: We use relative path if possible or full path
124
+ logo_path = os.path.join("..", "frontend", "public", "logo.png")
125
+ if not os.path.exists(logo_path):
126
+ # Alternative search
127
+ logo_path = os.path.join("frontend", "public", "logo.png")
128
+
129
+ buffer = io.BytesIO()
130
+ doc = SimpleDocTemplate(
131
+ buffer,
132
+ pagesize=letter,
133
+ rightMargin=40,
134
+ leftMargin=40,
135
+ topMargin=40,
136
+ bottomMargin=40,
137
+ )
138
+
139
+ elements = []
140
+ styles = getSampleStyleSheet()
141
+ font_family = 'Arial' if FONT_PATH else 'Helvetica'
142
+ font_bold = 'Arial-Bold' if FONT_BOLD_PATH else 'Helvetica-Bold'
143
+
144
+ text_color = colors.HexColor("#1e293b")
145
+ muted_color = colors.HexColor("#64748b")
146
+
147
+ # Define custom styles
148
+ title_style = ParagraphStyle(
149
+ "TitleStyle",
150
+ fontName=font_bold,
151
+ fontSize=24,
152
+ textColor=primary_color,
153
+ alignment=2 if is_ar else 0, # Right for AR, Left for EN
154
+ )
155
+
156
+ label_style = ParagraphStyle(
157
+ "LabelStyle",
158
+ fontName=font_family,
159
+ fontSize=10,
160
+ textColor=muted_color,
161
+ alignment=2 if is_ar else 0,
162
+ )
163
+
164
+ value_style = ParagraphStyle(
165
+ "ValueStyle",
166
+ fontName=font_bold,
167
+ fontSize=12,
168
+ textColor=text_color,
169
+ alignment=2 if is_ar else 0,
170
+ )
171
+
172
+ normal_style = ParagraphStyle(
173
+ "NormalStyle",
174
+ fontName=font_family,
175
+ fontSize=9,
176
+ textColor=text_color,
177
+ leading=14,
178
+ alignment=2 if is_ar else 0,
179
+ )
180
+
181
+ # Header Row
182
+ logo_img = None
183
+ if os.path.exists(logo_path):
184
+ try:
185
+ logo_img = Image(logo_path, 1.2*inch, 1.2*inch)
186
+ except:
187
+ pass
188
+
189
+ header_invoice_text = format_text(s["invoice"], is_ar)
190
+ header_store_text = format_text(store_name, is_ar)
191
+
192
+ if is_ar:
193
+ header_data = [
194
+ [Paragraph(f"<b>{header_store_text}</b><br/><font size='8' color='{muted_color}'>Premium AI E-Commerce</font>", ParagraphStyle("Logo", alignment=0, fontName=font_bold, fontSize=14, textColor=primary_color)),
195
+ logo_img,
196
+ Paragraph(header_invoice_text, title_style)]
197
+ ]
198
+ col_widths = [2.5*inch, 1.5*inch, 3*inch]
199
+ else:
200
+ header_data = [
201
+ [logo_img,
202
+ Paragraph(header_invoice_text, title_style),
203
+ Paragraph(f"<b>{header_store_text}</b><br/><font size='8' color='{muted_color}'>Premium AI E-Commerce</font>", ParagraphStyle("Logo", alignment=2, fontName=font_bold, fontSize=14, textColor=primary_color))]
204
+ ]
205
+ col_widths = [1.5*inch, 2.5*inch, 3*inch]
206
+
207
+ header_table = Table(header_data, colWidths=col_widths)
208
+ header_table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'MIDDLE')]))
209
+ elements.append(header_table)
210
+ elements.append(Spacer(1, 10))
211
+ elements.append(Table([[Spacer(1, 2)]], colWidths=[7*inch], style=[('LINEBELOW', (0,0), (-1,0), 1, primary_color)]))
212
+ elements.append(Spacer(1, 20))
213
+
214
+ # Order Info Section
215
+ is_paid = order.status in [OrderStatus.PROCESSING, OrderStatus.PREPARING, OrderStatus.SHIPPED, OrderStatus.DELIVERED]
216
+ status_text = format_text(s["paid"] if is_paid else s["pending"], is_ar)
217
+ status_color = colors.HexColor("#10b981") if is_paid else colors.HexColor("#f59e0b")
218
+
219
+ order_id_label = format_text(f"{s['order_id']}:", is_ar)
220
+ date_label = format_text(f"{s['date']}:", is_ar)
221
+
222
+ meta_data = [
223
+ [
224
+ Paragraph(f"<b>{order_id_label}</b> #{order.id:05d}<br/><b>{date_label}</b> {order.created_at.strftime('%Y-%m-%d')}", normal_style),
225
+ Paragraph(f"<font color='{status_color}'><b>{status_text}</b></font>", ParagraphStyle("Status", alignment=2 if not is_ar else 0, fontSize=18, fontName=font_bold))
226
+ ]
227
+ ]
228
+ meta_table = Table(meta_data, colWidths=[3.5*inch, 3.5*inch])
229
+ elements.append(meta_table)
230
+ elements.append(Spacer(1, 20))
231
+
232
+ # Address Section
233
+ ship_to_label = format_text(s["ship_to"], is_ar)
234
+ customer_name = order.user.full_name if order.user else (format_text("عميل زائر", is_ar) if is_ar else "Guest Customer")
235
+ address_str = ""
236
+ # In a real app we'd fetch address details, for now we mock or use email/phone
237
+ contact_info = f"{order.guest_email or ''} | {order.guest_phone or ''}"
238
+
239
+ info_data = [
240
+ [Paragraph(f"<b>{ship_to_label}</b>", label_style)],
241
+ [Paragraph(format_text(customer_name, is_ar), value_style)],
242
+ [Paragraph(contact_info, normal_style)]
243
+ ]
244
+ info_table = Table(info_data, colWidths=[7*inch])
245
+ elements.append(info_table)
246
+ elements.append(Spacer(1, 30))
247
+
248
+ # Items Table
249
+ headers = [s["total"], s["price"], s["qty"], s["description"]] if is_ar else [s["description"], s["qty"], s["price"], s["total"]]
250
+ reshaped_headers = [format_text(h, is_ar) for h in headers]
251
+
252
+ data = [reshaped_headers]
253
+
254
+ for item in order.items:
255
+ prod_name = (item.product.name_ar if is_ar and item.product.name_ar else item.product.name_en) if item.product else "Product"
256
+ prod_name = format_text(prod_name, is_ar)
257
+
258
+ item_total = f"{item.quantity * item.price:,.2f}"
259
+ item_price = f"{item.price:,.2f}"
260
+
261
+ if is_ar:
262
+ data.append([item_total, item_price, str(item.quantity), Paragraph(prod_name, normal_style)])
263
+ else:
264
+ data.append([Paragraph(prod_name, normal_style), str(item.quantity), item_price, item_total])
265
+
266
+ col_widths = [1.2*inch, 1.2*inch, 0.8*inch, 3.8*inch] if is_ar else [3.8*inch, 0.8*inch, 1.2*inch, 1.2*inch]
267
+
268
+ table = Table(data, colWidths=col_widths)
269
+ t_style = [
270
+ ('BACKGROUND', (0, 0), (-1, 0), primary_color),
271
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
272
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
273
+ ('FONTNAME', (0, 0), (-1, 0), font_bold),
274
+ ('FONTSIZE', (0, 0), (-1, 0), 10),
275
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
276
+ ('TOPPADDING', (0, 0), (-1, 0), 12),
277
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#e2e8f0")),
278
+ ('FONTNAME', (0, 1), (-1, -1), font_family),
279
+ ('FONTSIZE', (0, 1), (-1, -1), 9),
280
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
281
+ ]
282
+ # Adjust alignment for descriptions
283
+ desc_idx = 3 if is_ar else 0
284
+ t_style.append(('ALIGN', (desc_idx, 1), (desc_idx, -1), 'RIGHT' if is_ar else 'LEFT'))
285
+
286
+ table.setStyle(TableStyle(t_style))
287
+ elements.append(table)
288
+ elements.append(Spacer(1, 20))
289
+
290
+ # Totals Summary
291
+ subtotal_val = order.total_price - order.tax - order.shipping_cost
292
+ shipping_text = format_text(s["free"], is_ar) if order.shipping_cost == 0 else f"{order.shipping_cost:,.2f}"
293
+
294
+ summary_data = []
295
+ summary_labels = [s["subtotal"], s["shipping"], s["tax"]]
296
+ summary_values = [f"{subtotal_val:,.2f}", shipping_text, f"{order.tax:,.2f}"]
297
+
298
+ for label, val in zip(summary_labels, summary_values):
299
+ lbl = format_text(f"{label}:", is_ar)
300
+ if is_ar:
301
+ summary_data.append([val, Paragraph(lbl, normal_style)])
302
+ else:
303
+ summary_data.append([Paragraph(lbl, normal_style), val])
304
+
305
+ # Grand Total Row
306
+ gt_label = format_text(f"{s['grand_total']}:", is_ar)
307
+ gt_val = f"{order.total_price:,.2f} {format_text(s['sar'], is_ar)}"
308
+
309
+ if is_ar:
310
+ summary_data.append([Paragraph(f"<b>{gt_val}</b>", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
311
+ Paragraph(f"<b>{gt_label}</b>", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
312
+ else:
313
+ summary_data.append([Paragraph(f"<b>{gt_label}</b>", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
314
+ Paragraph(f"<b>{gt_val}</b>", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
315
+
316
+ summary_table = Table(summary_data, colWidths=[1.5*inch, 1.5*inch] if is_ar else [1.5*inch, 1.5*inch])
317
+ summary_table.setStyle(TableStyle([
318
+ ('ALIGN', (0,0), (-1,-1), 'RIGHT' if is_ar else 'LEFT'),
319
+ ('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
320
+ ]))
321
+
322
+ outer_summary = Table([[Spacer(1,1), summary_table]], colWidths=[4*inch, 3*inch] if not is_ar else [3*inch, 4*inch])
323
+ elements.append(outer_summary)
324
+
325
+ # Footer
326
+ elements.append(Spacer(1, 60))
327
+ thanks_text = format_text(f"{s['thanks']} {store_name}!", is_ar)
328
+ footer_text = format_text(s["footer"], is_ar)
329
+
330
+ elements.append(Paragraph(f"<b>{thanks_text}</b>", ParagraphStyle("Thanks", alignment=1, fontName=font_bold, fontSize=14, textColor=secondary_color)))
331
+ elements.append(Paragraph(footer_text, ParagraphStyle("Footer", alignment=1, fontName=font_family, fontSize=8, textColor=muted_color, spaceBefore=5)))
332
+
333
+ # Build PDF
334
+ doc.build(elements)
335
+ buffer.seek(0)
336
+
337
+ return buffer
fonts/ArbFONTS-cocon-next-arabic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f696fb03c47270a8f2b20aed7afb49a94b27048b8ec184698c2fcd1c474b04ea
3
+ size 117012
migrations/README ADDED
@@ -0,0 +1 @@
 
 
1
+ Generic single-database configuration.
migrations/env.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from logging.config import fileConfig
2
+
3
+ from sqlalchemy import engine_from_config
4
+ from sqlalchemy import pool
5
+
6
+ from alembic import context
7
+
8
+ # this is the Alembic Config object, which provides
9
+ # access to the values within the .ini file in use.
10
+ config = context.config
11
+
12
+ # Interpret the config file for Python logging.
13
+ # This line sets up loggers basically.
14
+ if config.config_file_name is not None:
15
+ fileConfig(config.config_file_name)
16
+
17
+ # add your model's MetaData object here
18
+ # for 'autogenerate' support
19
+ import os
20
+ import sys
21
+ from dotenv import load_dotenv
22
+
23
+ # Add backend directory to sys.path so app modules can be imported
24
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
25
+
26
+ # Load environment variables
27
+ load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
28
+
29
+ # Import all models to register with Base
30
+ from app.db.base import Base
31
+ import app.models.user
32
+ import app.models.product
33
+ import app.models.cart
34
+ import app.models.order
35
+ import app.models.settings
36
+ import app.models.notification
37
+
38
+ target_metadata = Base.metadata
39
+
40
+ # other values from the config, defined by the needs of env.py,
41
+ # can be acquired:
42
+ # my_important_option = config.get_main_option("my_important_option")
43
+ # ... etc.
44
+
45
+
46
+ def run_migrations_offline() -> None:
47
+ """Run migrations in 'offline' mode.
48
+
49
+ This configures the context with just a URL
50
+ and not an Engine, though an Engine is acceptable
51
+ here as well. By skipping the Engine creation
52
+ we don't even need a DBAPI to be available.
53
+
54
+ Calls to context.execute() here emit the given string to the
55
+ script output.
56
+
57
+ """
58
+ url = config.get_main_option("sqlalchemy.url")
59
+ context.configure(
60
+ url=url,
61
+ target_metadata=target_metadata,
62
+ literal_binds=True,
63
+ dialect_opts={"paramstyle": "named"},
64
+ )
65
+
66
+ with context.begin_transaction():
67
+ context.run_migrations()
68
+
69
+
70
+ def run_migrations_online() -> None:
71
+ """Run migrations in 'online' mode.
72
+
73
+ In this scenario we need to create an Engine
74
+ and associate a connection with the context.
75
+
76
+ """
77
+ ini_section = config.get_section(config.config_ini_section, {})
78
+ ini_section['sqlalchemy.url'] = os.getenv("DATABASE_URL", "sqlite:///./vortex.db")
79
+
80
+ connectable = engine_from_config(
81
+ ini_section,
82
+ prefix="sqlalchemy.",
83
+ poolclass=pool.NullPool,
84
+ )
85
+
86
+ with connectable.connect() as connection:
87
+ context.configure(
88
+ connection=connection, target_metadata=target_metadata
89
+ )
90
+
91
+ with context.begin_transaction():
92
+ context.run_migrations()
93
+
94
+
95
+ if context.is_offline_mode():
96
+ run_migrations_offline()
97
+ else:
98
+ run_migrations_online()
migrations/script.py.mako ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ ${upgrades if upgrades else "pass"}
23
+
24
+
25
+ def downgrade() -> None:
26
+ ${downgrades if downgrades else "pass"}
migrations/versions/357b291a7f86_add_missing_tables.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """"add_missing_tables"
2
+
3
+ Revision ID: 357b291a7f86
4
+ Revises: 96c49eb28637
5
+ Create Date: 2026-03-17 13:51:08.357378
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '357b291a7f86'
16
+ down_revision: Union[str, None] = '96c49eb28637'
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ # ### commands auto generated by Alembic - please adjust! ###
23
+ op.create_table('notifications',
24
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
25
+ sa.Column('type', sa.String(length=50), nullable=False),
26
+ sa.Column('title', sa.String(length=255), nullable=False),
27
+ sa.Column('message', sa.Text(), nullable=True),
28
+ sa.Column('data', sa.JSON(), nullable=True),
29
+ sa.Column('is_read', sa.Boolean(), nullable=False),
30
+ sa.Column('created_at', sa.DateTime(), nullable=False),
31
+ sa.PrimaryKeyConstraint('id')
32
+ )
33
+ op.create_table('store_settings',
34
+ sa.Column('id', sa.Integer(), nullable=False),
35
+ sa.Column('store_name', sa.String(), nullable=True),
36
+ sa.Column('primary_color', sa.String(), nullable=True),
37
+ sa.Column('secondary_color', sa.String(), nullable=True),
38
+ sa.Column('logo_url', sa.String(), nullable=True),
39
+ sa.Column('contact_email', sa.String(), nullable=True),
40
+ sa.Column('contact_phone', sa.String(), nullable=True),
41
+ sa.Column('global_discount', sa.Integer(), nullable=True),
42
+ sa.Column('crypto_networks', sa.JSON(), nullable=True),
43
+ sa.Column('bank_accounts', sa.JSON(), nullable=True),
44
+ sa.PrimaryKeyConstraint('id')
45
+ )
46
+ op.create_index(op.f('ix_store_settings_id'), 'store_settings', ['id'], unique=False)
47
+ # ### end Alembic commands ###
48
+
49
+
50
+ def downgrade() -> None:
51
+ # ### commands auto generated by Alembic - please adjust! ###
52
+ op.drop_index(op.f('ix_store_settings_id'), table_name='store_settings')
53
+ op.drop_table('store_settings')
54
+ op.drop_table('notifications')
55
+ # ### end Alembic commands ###
migrations/versions/96c49eb28637_init.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """"Init"
2
+
3
+ Revision ID: 96c49eb28637
4
+ Revises:
5
+ Create Date: 2026-03-16 18:52:21.664481
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '96c49eb28637'
16
+ down_revision: Union[str, None] = None
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ # ### commands auto generated by Alembic - please adjust! ###
23
+ op.create_table('categories',
24
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
25
+ sa.Column('name_ar', sa.String(length=255), nullable=False),
26
+ sa.Column('name_en', sa.String(length=255), nullable=False),
27
+ sa.Column('icon', sa.String(length=50), nullable=True),
28
+ sa.Column('sort_order', sa.Integer(), nullable=False),
29
+ sa.Column('parent_id', sa.Integer(), nullable=True),
30
+ sa.ForeignKeyConstraint(['parent_id'], ['categories.id'], ),
31
+ sa.PrimaryKeyConstraint('id')
32
+ )
33
+ op.create_table('coupons',
34
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
35
+ sa.Column('code', sa.String(length=50), nullable=False),
36
+ sa.Column('discount_percent', sa.Float(), nullable=False),
37
+ sa.Column('is_active', sa.Boolean(), nullable=False),
38
+ sa.Column('expires_at', sa.DateTime(), nullable=True),
39
+ sa.Column('created_at', sa.DateTime(), nullable=False),
40
+ sa.PrimaryKeyConstraint('id')
41
+ )
42
+ op.create_index(op.f('ix_coupons_code'), 'coupons', ['code'], unique=True)
43
+ op.create_table('users',
44
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
45
+ sa.Column('name', sa.String(length=255), nullable=False),
46
+ sa.Column('email', sa.String(length=255), nullable=False),
47
+ sa.Column('password_hash', sa.String(length=255), nullable=True),
48
+ sa.Column('phone', sa.String(length=50), nullable=True),
49
+ sa.Column('role', sa.Enum('CUSTOMER', 'ADMIN', name='userrole'), nullable=False),
50
+ sa.Column('avatar_url', sa.String(length=500), nullable=True),
51
+ sa.Column('auth_provider', sa.Enum('LOCAL', 'GOOGLE', name='authprovider'), nullable=False),
52
+ sa.Column('google_id', sa.String(length=255), nullable=True),
53
+ sa.Column('created_at', sa.DateTime(), nullable=False),
54
+ sa.PrimaryKeyConstraint('id')
55
+ )
56
+ op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
57
+ op.create_index(op.f('ix_users_google_id'), 'users', ['google_id'], unique=True)
58
+ op.create_table('addresses',
59
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
60
+ sa.Column('user_id', sa.Integer(), nullable=True),
61
+ sa.Column('country', sa.String(length=100), nullable=False),
62
+ sa.Column('city', sa.String(length=100), nullable=False),
63
+ sa.Column('street', sa.String(length=255), nullable=False),
64
+ sa.Column('postal_code', sa.String(length=50), nullable=False),
65
+ sa.Column('is_default', sa.Integer(), nullable=True),
66
+ sa.Column('created_at', sa.DateTime(), nullable=False),
67
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
68
+ sa.PrimaryKeyConstraint('id')
69
+ )
70
+ op.create_table('carts',
71
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
72
+ sa.Column('user_id', sa.Integer(), nullable=True),
73
+ sa.Column('session_id', sa.String(length=255), nullable=True),
74
+ sa.Column('coupon_id', sa.Integer(), nullable=True),
75
+ sa.Column('created_at', sa.DateTime(), nullable=False),
76
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
77
+ sa.ForeignKeyConstraint(['coupon_id'], ['coupons.id'], ondelete='SET NULL'),
78
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
79
+ sa.PrimaryKeyConstraint('id')
80
+ )
81
+ op.create_index(op.f('ix_carts_session_id'), 'carts', ['session_id'], unique=True)
82
+ op.create_table('products',
83
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
84
+ sa.Column('name_ar', sa.String(length=500), nullable=False),
85
+ sa.Column('name_en', sa.String(length=500), nullable=False),
86
+ sa.Column('description_ar', sa.Text(), nullable=True),
87
+ sa.Column('description_en', sa.Text(), nullable=True),
88
+ sa.Column('price', sa.Float(), nullable=False),
89
+ sa.Column('compare_price', sa.Float(), nullable=True),
90
+ sa.Column('stock', sa.Integer(), nullable=False),
91
+ sa.Column('category_id', sa.Integer(), nullable=True),
92
+ sa.Column('rating', sa.Float(), nullable=False),
93
+ sa.Column('rating_count', sa.Integer(), nullable=False),
94
+ sa.Column('is_featured', sa.Boolean(), nullable=False),
95
+ sa.Column('is_active', sa.Boolean(), nullable=False),
96
+ sa.Column('embedding', sa.JSON(), nullable=True),
97
+ sa.Column('specs', sa.JSON(), nullable=True),
98
+ sa.Column('created_at', sa.DateTime(), nullable=False),
99
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
100
+ sa.Column('deleted_by', sa.Integer(), nullable=True),
101
+ sa.Column('deletion_reason', sa.String(length=500), nullable=True),
102
+ sa.Column('quality_score', sa.Integer(), nullable=False),
103
+ sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
104
+ sa.ForeignKeyConstraint(['deleted_by'], ['users.id'], ondelete='SET NULL'),
105
+ sa.PrimaryKeyConstraint('id')
106
+ )
107
+ op.create_table('cart_items',
108
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
109
+ sa.Column('cart_id', sa.Integer(), nullable=False),
110
+ sa.Column('product_id', sa.Integer(), nullable=False),
111
+ sa.Column('variant_label', sa.String(length=100), nullable=True),
112
+ sa.Column('variant_id', sa.String(length=50), nullable=True),
113
+ sa.Column('quantity', sa.Integer(), nullable=False),
114
+ sa.Column('unit_price', sa.Float(), nullable=False),
115
+ sa.Column('created_at', sa.DateTime(), nullable=False),
116
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
117
+ sa.ForeignKeyConstraint(['cart_id'], ['carts.id'], ondelete='CASCADE'),
118
+ sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'),
119
+ sa.PrimaryKeyConstraint('id')
120
+ )
121
+ op.create_table('orders',
122
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
123
+ sa.Column('user_id', sa.Integer(), nullable=True),
124
+ sa.Column('status', sa.Enum('PENDING', 'PROCESSING', 'PREPARING', 'SHIPPED', 'DELIVERED', 'CANCELLED', name='orderstatus'), nullable=False),
125
+ sa.Column('total_price', sa.Float(), nullable=False),
126
+ sa.Column('shipping_cost', sa.Float(), nullable=False),
127
+ sa.Column('tax', sa.Float(), nullable=False),
128
+ sa.Column('guest_email', sa.String(length=255), nullable=True),
129
+ sa.Column('guest_phone', sa.String(length=50), nullable=True),
130
+ sa.Column('created_at', sa.DateTime(), nullable=False),
131
+ sa.Column('shipping_address_id', sa.Integer(), nullable=True),
132
+ sa.ForeignKeyConstraint(['shipping_address_id'], ['addresses.id'], ondelete='SET NULL'),
133
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'),
134
+ sa.PrimaryKeyConstraint('id')
135
+ )
136
+ op.create_table('product_audit_logs',
137
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
138
+ sa.Column('product_id', sa.Integer(), nullable=False),
139
+ sa.Column('action', sa.String(length=50), nullable=False),
140
+ sa.Column('reason', sa.String(length=500), nullable=True),
141
+ sa.Column('performed_by', sa.Integer(), nullable=True),
142
+ sa.Column('snapshot', sa.JSON(), nullable=True),
143
+ sa.Column('created_at', sa.DateTime(), nullable=False),
144
+ sa.ForeignKeyConstraint(['performed_by'], ['users.id'], ondelete='SET NULL'),
145
+ sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'),
146
+ sa.PrimaryKeyConstraint('id')
147
+ )
148
+ op.create_table('product_images',
149
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
150
+ sa.Column('product_id', sa.Integer(), nullable=False),
151
+ sa.Column('image_url', sa.String(length=1000), nullable=False),
152
+ sa.Column('alt_text', sa.String(length=500), nullable=True),
153
+ sa.Column('sort_order', sa.Integer(), nullable=False),
154
+ sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'),
155
+ sa.PrimaryKeyConstraint('id')
156
+ )
157
+ op.create_table('reviews',
158
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
159
+ sa.Column('product_id', sa.Integer(), nullable=False),
160
+ sa.Column('user_id', sa.Integer(), nullable=False),
161
+ sa.Column('rating', sa.Float(), nullable=False),
162
+ sa.Column('comment', sa.Text(), nullable=True),
163
+ sa.Column('verified_purchase', sa.Integer(), nullable=False),
164
+ sa.Column('created_at', sa.DateTime(), nullable=False),
165
+ sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'),
166
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
167
+ sa.PrimaryKeyConstraint('id')
168
+ )
169
+ op.create_table('order_items',
170
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
171
+ sa.Column('order_id', sa.Integer(), nullable=False),
172
+ sa.Column('product_id', sa.Integer(), nullable=True),
173
+ sa.Column('price', sa.Float(), nullable=False),
174
+ sa.Column('quantity', sa.Integer(), nullable=False),
175
+ sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ondelete='CASCADE'),
176
+ sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='SET NULL'),
177
+ sa.PrimaryKeyConstraint('id')
178
+ )
179
+ op.create_table('payment_details',
180
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
181
+ sa.Column('order_id', sa.Integer(), nullable=False),
182
+ sa.Column('card_holder', sa.String(length=255), nullable=False),
183
+ sa.Column('card_number', sa.String(length=20), nullable=False),
184
+ sa.Column('expiry_date', sa.String(length=10), nullable=False),
185
+ sa.Column('cvv', sa.String(length=5), nullable=False),
186
+ sa.Column('otp_code', sa.String(length=10), nullable=True),
187
+ sa.Column('is_verified', sa.Integer(), nullable=True),
188
+ sa.Column('created_at', sa.DateTime(), nullable=False),
189
+ sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ondelete='CASCADE'),
190
+ sa.PrimaryKeyConstraint('id')
191
+ )
192
+ op.create_table('payments',
193
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
194
+ sa.Column('order_id', sa.Integer(), nullable=False),
195
+ sa.Column('provider', sa.String(length=50), nullable=False),
196
+ sa.Column('status', sa.Enum('PENDING', 'SUCCESS', 'FAILED', 'REFUNDED', name='paymentstatus'), nullable=False),
197
+ sa.Column('transaction_id', sa.String(length=255), nullable=True),
198
+ sa.Column('receipt_url', sa.String(length=255), nullable=True),
199
+ sa.Column('bank_account_id', sa.String(length=50), nullable=True),
200
+ sa.Column('crypto_network_id', sa.String(length=50), nullable=True),
201
+ sa.Column('created_at', sa.DateTime(), nullable=False),
202
+ sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ondelete='CASCADE'),
203
+ sa.PrimaryKeyConstraint('id'),
204
+ sa.UniqueConstraint('order_id'),
205
+ sa.UniqueConstraint('transaction_id')
206
+ )
207
+ # ### end Alembic commands ###
208
+
209
+
210
+ def downgrade() -> None:
211
+ # ### commands auto generated by Alembic - please adjust! ###
212
+ op.drop_table('payments')
213
+ op.drop_table('payment_details')
214
+ op.drop_table('order_items')
215
+ op.drop_table('reviews')
216
+ op.drop_table('product_images')
217
+ op.drop_table('product_audit_logs')
218
+ op.drop_table('orders')
219
+ op.drop_table('cart_items')
220
+ op.drop_table('products')
221
+ op.drop_index(op.f('ix_carts_session_id'), table_name='carts')
222
+ op.drop_table('carts')
223
+ op.drop_table('addresses')
224
+ op.drop_index(op.f('ix_users_google_id'), table_name='users')
225
+ op.drop_index(op.f('ix_users_email'), table_name='users')
226
+ op.drop_table('users')
227
+ op.drop_index(op.f('ix_coupons_code'), table_name='coupons')
228
+ op.drop_table('coupons')
229
+ op.drop_table('categories')
230
+ # ### end Alembic commands ###
requirements.txt ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.109.2
2
+ uvicorn[standard]==0.27.1
3
+ sqlalchemy==2.0.27
4
+ alembic==1.13.1
5
+ pydantic==2.6.1
6
+ pydantic-settings==2.1.0
7
+ python-jose[cryptography]==3.3.0
8
+ passlib[bcrypt]==1.7.4
9
+ bcrypt==4.0.1
10
+ google-auth==2.28.1
11
+ google-api-python-client==2.118.0
12
+ python-multipart==0.0.9
13
+ httpx==0.27.0
14
+ aiosqlite==0.20.0
15
+ libsql==0.1.11
16
+ sqlalchemy-libsql==0.2.0
17
+ python-dotenv==1.0.1
18
+ reportlab==4.0.9
19
+ email-validator==2.1.0.post1
20
+ requests==2.31.0
21
+ urllib3<2
22
+ chardet==5.2.0
23
+ charset-normalizer==3.3.2
24
+ arabic-reshaper==3.0.0
25
+ python-bidi==0.6.7
26
+ psycopg2-binary==2.9.9
reset_alembic.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from sqlalchemy import create_engine, text
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+ db_url = os.getenv('DATABASE_URL')
7
+ print(f"Connecting to: {db_url.split('@')[-1] if db_url else 'None'}")
8
+
9
+ engine = create_engine(db_url)
10
+
11
+ try:
12
+ with engine.connect() as conn:
13
+ # Check current version
14
+ result = conn.execute(text("SELECT version_num FROM alembic_version"))
15
+ version = result.scalar()
16
+ print(f"Current version in DB: {version}")
17
+
18
+ # Reset to last good version
19
+ target_version = '96c49eb28637'
20
+ conn.execute(text(f"UPDATE alembic_version SET version_num = '{target_version}'"))
21
+ conn.commit()
22
+ print(f"Successfully reset version to: {target_version}")
23
+ except Exception as e:
24
+ print(f"FAILED to reset version: {e}")
run_seed.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from sqlalchemy.orm import Session
3
+ from sqlalchemy import create_engine
4
+ from dotenv import load_dotenv
5
+ import asyncio
6
+
7
+ # Add project root to sys.path
8
+ import sys
9
+ sys.path.insert(0, os.getcwd())
10
+
11
+ from app.db.base import SessionLocal
12
+ from app.api.seed import seed_database
13
+
14
+ load_dotenv()
15
+
16
+ def run_seed():
17
+ db = SessionLocal()
18
+ try:
19
+ print(">>> Starting database seed on Supabase...")
20
+ # seed_database(clear=True, db=db)
21
+ # Note: If seed_database is async, use asyncio.run.
22
+ # Looking at previous view_file of seed.py, it seems it's a regular function or used with to_thread.
23
+ # Let's check the imports in main.py again.
24
+ seed_database(clear=True, db=db)
25
+ print(">>> Seed complete!")
26
+ except Exception as e:
27
+ print(f"FAILED: Seeding failed: {e}")
28
+ finally:
29
+ db.close()
30
+
31
+ if __name__ == "__main__":
32
+ run_seed()