hisham-cmd commited on
Commit
98c417d
·
verified ·
1 Parent(s): 088ef8c

Wipe repository before sync

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