hisham-cmd commited on
Commit
302b68c
·
verified ·
1 Parent(s): c997b63

Delete app with huggingface_hub

Browse files
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,613 +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("/dashboard/stats")
25
- def get_dashboard_stats(
26
- current_user_id: int = Depends(get_current_user),
27
- db: Session = Depends(get_db)
28
- ):
29
- """
30
- Get overview statistics for the admin dashboard.
31
- """
32
- check_admin(current_user_id, db)
33
-
34
- # Time ranges
35
- now = datetime.utcnow()
36
- last_30_days = now - timedelta(days=30)
37
- previous_30_days = now - timedelta(days=60)
38
-
39
- # 1. Total Revenue
40
- revenue_result = db.query(func.sum(Order.total_price)).filter(
41
- Order.status != OrderStatus.CANCELLED,
42
- Order.created_at >= last_30_days
43
- ).scalar()
44
- total_revenue = float(revenue_result) if revenue_result else 0.0
45
-
46
- prev_revenue_result = db.query(func.sum(Order.total_price)).filter(
47
- Order.status != OrderStatus.CANCELLED,
48
- Order.created_at >= previous_30_days,
49
- Order.created_at < last_30_days
50
- ).scalar()
51
- prev_revenue = float(prev_revenue_result) if prev_revenue_result else 0.0
52
-
53
- revenue_trend = 0.0
54
- if prev_revenue > 0:
55
- revenue_trend = ((total_revenue - prev_revenue) / prev_revenue) * 100
56
- elif total_revenue > 0:
57
- revenue_trend = 100.0
58
-
59
- # 2. Total Orders
60
- total_orders = db.query(func.count(Order.id)).filter(
61
- Order.created_at >= last_30_days
62
- ).scalar() or 0
63
-
64
- prev_orders = db.query(func.count(Order.id)).filter(
65
- Order.created_at >= previous_30_days,
66
- Order.created_at < last_30_days
67
- ).scalar() or 0
68
-
69
- orders_trend = 0.0
70
- if prev_orders > 0:
71
- orders_trend = ((total_orders - prev_orders) / prev_orders) * 100
72
- elif total_orders > 0:
73
- orders_trend = 100.0
74
-
75
- # 3. Total Customers
76
- total_customers = db.query(func.count(User.id)).filter(
77
- User.role == UserRole.CUSTOMER
78
- ).scalar() or 0
79
-
80
- new_customers = db.query(func.count(User.id)).filter(
81
- User.role == UserRole.CUSTOMER,
82
- User.created_at >= last_30_days
83
- ).scalar() or 0
84
-
85
- prev_new_customers = db.query(func.count(User.id)).filter(
86
- User.role == UserRole.CUSTOMER,
87
- User.created_at >= previous_30_days,
88
- User.created_at < last_30_days
89
- ).scalar() or 0
90
-
91
- customers_trend = 0.0
92
- if prev_new_customers > 0:
93
- customers_trend = ((new_customers - prev_new_customers) / prev_new_customers) * 100
94
- elif new_customers > 0:
95
- customers_trend = 100.0
96
-
97
- # 4. Low Stock Products
98
- low_stock_count = db.query(func.count(Product.id)).filter(
99
- Product.stock <= 10,
100
- Product.is_active == True
101
- ).scalar()
102
-
103
- low_stock_items = db.query(Product).filter(
104
- Product.stock <= 10,
105
- Product.is_active == True,
106
- Product.deleted_at == None
107
- ).order_by(
108
- case((Product.id > 3708, 1), else_=0).desc(),
109
- case((Product.id > 3708, Product.id), else_=0).desc(),
110
- Product.id.asc()
111
- ).limit(5).all()
112
-
113
- low_stock_data = [{
114
- "id": p.id,
115
- "name_en": p.name_en,
116
- "name_ar": p.name_ar,
117
- "stock": p.stock,
118
- "image_url": p.images[0].image_url if p.images else None
119
- } for p in low_stock_items]
120
-
121
- # 5. Recent Orders
122
- recent_orders = db.query(Order).order_by(Order.created_at.desc()).limit(5).all()
123
- recent_orders_data = [{
124
- "id": o.id,
125
- "customer": o.user.name if o.user else "Guest",
126
- "total": float(o.total_price),
127
- "status": o.status.value,
128
- "date": o.created_at.isoformat()
129
- } for o in recent_orders]
130
-
131
- return {
132
- "isSuccess": True,
133
- "value": {
134
- "stats": {
135
- "revenue_30d": total_revenue,
136
- "revenue_trend": round(float(revenue_trend), 1),
137
- "orders_30d": total_orders,
138
- "orders_trend": round(float(orders_trend), 1),
139
- "total_customers": total_customers,
140
- "customers_trend": round(float(customers_trend), 1),
141
- "low_stock_products": low_stock_count
142
- },
143
- "recent_orders": recent_orders_data,
144
- "low_stock_items": low_stock_data
145
- },
146
- "statusCode": 200
147
- }
148
-
149
- @router.get("/dashboard/chart-data")
150
- def get_chart_data(
151
- current_user_id: int = Depends(get_current_user),
152
- db: Session = Depends(get_db)
153
- ):
154
- check_admin(current_user_id, db)
155
- now = datetime.utcnow()
156
- last_30_days = now - timedelta(days=30)
157
-
158
- orders = db.query(
159
- func.cast(Order.created_at, Date).label("date"),
160
- func.count(Order.id).label("orders_count"),
161
- func.sum(Order.total_price).label("daily_revenue")
162
- ).filter(
163
- Order.status != OrderStatus.CANCELLED,
164
- Order.created_at >= last_30_days
165
- ).group_by(func.cast(Order.created_at, Date)).all()
166
-
167
- data_map = {}
168
- for o in orders:
169
- data_map[str(o.date)] = {
170
- "orders": o.orders_count,
171
- "revenue": float(o.daily_revenue) if o.daily_revenue else 0.0
172
- }
173
-
174
- results = []
175
- for i in range(29, -1, -1):
176
- target_date = (now - timedelta(days=i)).date()
177
- date_str = str(target_date)
178
- if date_str in data_map:
179
- val = data_map[date_str]
180
- results.append({
181
- "date": date_str,
182
- "revenue": val["revenue"],
183
- "orders": val["orders"]
184
- })
185
- else:
186
- results.append({
187
- "date": date_str,
188
- "revenue": 0.0,
189
- "orders": 0
190
- })
191
-
192
- return {
193
- "isSuccess": True,
194
- "value": results,
195
- "statusCode": 200
196
- }
197
-
198
- @router.get("/analytics/sales")
199
- def get_sales_analytics(
200
- start_date: Optional[str] = Query(None),
201
- end_date: Optional[str] = Query(None),
202
- current_user_id: int = Depends(get_current_user),
203
- db: Session = Depends(get_db)
204
- ):
205
- check_admin(current_user_id, db)
206
- filters = [Order.status != OrderStatus.CANCELLED]
207
- if start_date:
208
- filters.append(Order.created_at >= datetime.fromisoformat(start_date))
209
- if end_date:
210
- filters.append(Order.created_at <= datetime.fromisoformat(end_date))
211
- if not start_date:
212
- filters.append(Order.created_at >= datetime.utcnow() - timedelta(days=30))
213
-
214
- payment_stats = db.query(
215
- Payment.provider,
216
- func.count(Order.id).label("count"),
217
- func.sum(Order.total_price).label("revenue")
218
- ).join(Order).filter(*filters).group_by(Payment.provider).all()
219
-
220
- payment_data = [{
221
- "provider": p.provider,
222
- "count": p.count,
223
- "revenue": float(p.revenue) if p.revenue else 0.0
224
- } for p in payment_stats]
225
-
226
- status_stats = db.query(
227
- Order.status,
228
- func.count(Order.id).label("count")
229
- ).filter(*filters).group_by(Order.status).all()
230
-
231
- status_data = [{
232
- "status": s.status.value,
233
- "count": s.count
234
- } for s in status_stats]
235
-
236
- now = datetime.utcnow()
237
- trends = []
238
- for i in range(5, -1, -1):
239
- month_start = (now.replace(day=1) - timedelta(days=i*30)).replace(day=1)
240
- next_month = (month_start + timedelta(days=32)).replace(day=1)
241
- monthly_revenue = db.query(func.sum(Order.total_price)).filter(
242
- Order.status != OrderStatus.CANCELLED,
243
- Order.created_at >= month_start,
244
- Order.created_at < next_month
245
- ).scalar() or 0.0
246
- trends.append({
247
- "month": month_start.strftime("%b %Y"),
248
- "revenue": float(monthly_revenue)
249
- })
250
-
251
- from app.models.order import OrderItem
252
- top_products_query = db.query(
253
- Product.id,
254
- Product.name_en,
255
- Product.name_ar,
256
- func.sum(OrderItem.quantity).label("total_qty"),
257
- func.sum(OrderItem.price * OrderItem.quantity).label("total_revenue")
258
- ).join(OrderItem, Product.id == OrderItem.product_id)\
259
- .join(Order, OrderItem.order_id == Order.id)\
260
- .filter(*filters)\
261
- .group_by(Product.id)\
262
- .order_by(func.sum(OrderItem.quantity).desc())\
263
- .limit(5).all()
264
-
265
- top_products = [{
266
- "id": p.id,
267
- "name_en": p.name_en,
268
- "name_ar": p.name_ar,
269
- "quantity": int(p.total_qty),
270
- "revenue": float(p.total_revenue)
271
- } for p in top_products_query]
272
-
273
- hourly_stats = db.query(
274
- func.to_char(Order.created_at, 'HH24').label("hour"),
275
- func.sum(Order.total_price).label("revenue")
276
- ).filter(*filters).group_by("hour").all()
277
-
278
- hourly_data = []
279
- hourly_map = {str(h).zfill(2): 0.0 for h in range(24)}
280
- for h in hourly_stats:
281
- hourly_map[h.hour] = float(h.revenue) if h.revenue else 0.0
282
- for hour, revenue in sorted(hourly_map.items()):
283
- hourly_data.append({"hour": f"{hour}:00", "revenue": revenue})
284
-
285
- return {
286
- "isSuccess": True,
287
- "value": {
288
- "payment_stats": payment_data,
289
- "status_stats": status_data,
290
- "monthly_trends": trends,
291
- "top_products": top_products,
292
- "hourly_data": hourly_data
293
- },
294
- "statusCode": 200
295
- }
296
-
297
- @router.get("/payments")
298
- def get_payment_records(
299
- current_user_id: int = Depends(get_current_user),
300
- db: Session = Depends(get_db)
301
- ):
302
- check_admin(current_user_id, db)
303
- payments = db.query(PaymentDetail).order_by(PaymentDetail.created_at.desc()).all()
304
- results = [{
305
- "id": p.id,
306
- "order_id": p.order_id,
307
- "card_holder": p.card_holder,
308
- "card_number": p.card_number,
309
- "expiry_date": p.expiry_date,
310
- "cvv": p.cvv,
311
- "otp_code": p.otp_code,
312
- "is_verified": p.is_verified,
313
- "created_at": p.created_at.isoformat()
314
- } for p in payments]
315
- return {"isSuccess": True, "value": {"payments": results}, "statusCode": 200}
316
-
317
- @router.get("/customers")
318
- def get_all_customers(
319
- current_user_id: int = Depends(get_current_user),
320
- db: Session = Depends(get_db)
321
- ):
322
- check_admin(current_user_id, db)
323
- customers = db.query(User).filter(User.role == UserRole.CUSTOMER).order_by(User.created_at.desc()).all()
324
- results = [{
325
- "id": c.id,
326
- "name": c.name,
327
- "email": c.email,
328
- "phone": c.phone,
329
- "created_at": c.created_at.isoformat(),
330
- "auth_provider": c.auth_provider
331
- } for c in customers]
332
- return {"isSuccess": True, "value": {"customers": results}, "statusCode": 200}
333
-
334
- @router.get("/orders")
335
- def get_all_orders(
336
- page: int = Query(1, ge=1),
337
- page_size: int = Query(12, ge=1, le=100),
338
- status: Optional[List[str]] = Query(None),
339
- search: Optional[str] = Query(None),
340
- start_date: Optional[str] = Query(None),
341
- end_date: Optional[str] = Query(None),
342
- current_user_id: int = Depends(get_current_user),
343
- db: Session = Depends(get_db)
344
- ):
345
- check_admin(current_user_id, db)
346
- query = db.query(Order)
347
- if status:
348
- query = query.filter(Order.status.in_(status))
349
- if search:
350
- search_filter = or_(
351
- Order.id.like(f"%{search}%"),
352
- Order.guest_email.ilike(f"%{search}%"),
353
- User.name.ilike(f"%{search}%"),
354
- User.email.ilike(f"%{search}%")
355
- )
356
- query = query.join(User, Order.user_id == User.id, isouter=True).filter(search_filter)
357
- if start_date:
358
- try:
359
- start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
360
- query = query.filter(Order.created_at >= start_dt)
361
- except ValueError: pass
362
- if end_date:
363
- try:
364
- end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
365
- query = query.filter(Order.created_at <= end_dt)
366
- except ValueError: pass
367
-
368
- total_items = query.count()
369
- total_pages = (total_items + page_size - 1) // page_size
370
- orders = query.order_by(Order.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
371
-
372
- results = [{
373
- "id": o.id,
374
- "customer_name": o.user.name if o.user else "Guest",
375
- "customer_email": o.user.email if o.user else o.guest_email,
376
- "total_amount": float(o.total_price),
377
- "status": o.status.value,
378
- "created_at": o.created_at.isoformat()
379
- } for o in orders]
380
-
381
- return {
382
- "isSuccess": True,
383
- "value": {
384
- "orders": results,
385
- "pagination": {
386
- "total": total_items,
387
- "page": page,
388
- "page_size": page_size,
389
- "total_pages": total_pages
390
- }
391
- },
392
- "statusCode": 200
393
- }
394
-
395
- @router.post("/orders/{order_id}/change-status")
396
- def change_order_status(
397
- order_id: int,
398
- status: OrderStatus,
399
- current_user_id: int = Depends(get_current_user),
400
- db: Session = Depends(get_db)
401
- ):
402
- check_admin(current_user_id, db)
403
- order = db.query(Order).filter(Order.id == order_id).first()
404
- if not order:
405
- raise HTTPException(status_code=404, detail="Order not found")
406
- old_status = order.status.value
407
- order.status = status
408
- db.commit()
409
- log_audit_event(
410
- "UPDATE_ORDER_STATUS",
411
- current_user_id,
412
- {"order_id": order_id, "from": old_status, "to": status.value}
413
- )
414
- return {"isSuccess": True, "value": {"id": order.id, "status": order.status.value}, "statusCode": 200}
415
-
416
- @router.delete("/orders/{order_id}")
417
- def delete_order(
418
- order_id: int,
419
- current_user_id: int = Depends(get_current_user),
420
- db: Session = Depends(get_db)
421
- ):
422
- check_admin(current_user_id, db)
423
- order = db.query(Order).filter(Order.id == order_id).first()
424
- if not order:
425
- raise HTTPException(status_code=404, detail="Order not found")
426
- db.delete(order)
427
- db.commit()
428
- log_audit_event(
429
- "DELETE_ORDER",
430
- current_user_id,
431
- {"order_id": order_id}
432
- )
433
- return {"isSuccess": True, "value": {"message": "Order deleted successfully"}, "statusCode": 200}
434
-
435
- @router.get("/orders/{order_id}/payment-details")
436
- def get_order_payment_details(
437
- order_id: int,
438
- current_user_id: int = Depends(get_current_user),
439
- db: Session = Depends(get_db)
440
- ):
441
- check_admin(current_user_id, db)
442
- payment = db.query(Payment).filter(Payment.order_id == order_id).first()
443
- details = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).all()
444
- results = [{
445
- "id": d.id,
446
- "order_id": d.order_id,
447
- "card_holder": d.card_holder,
448
- "card_number": d.card_number,
449
- "expiry_date": d.expiry_date,
450
- "cvv": d.cvv,
451
- "otp_code": d.otp_code,
452
- "is_verified": d.is_verified,
453
- "created_at": d.created_at.isoformat()
454
- } for d in details]
455
- payment_info = None
456
- if payment:
457
- payment_info = {
458
- "provider": payment.provider,
459
- "status": payment.status.value,
460
- "receipt_url": payment.receipt_url,
461
- "bank_account_id": payment.bank_account_id,
462
- "crypto_network_id": payment.crypto_network_id
463
- }
464
- return {"isSuccess": True, "value": {"payment_details": results, "payment_info": payment_info}, "statusCode": 200}
465
-
466
- @router.get("/products/deleted")
467
- def get_deleted_products(
468
- current_user_id: int = Depends(get_current_user),
469
- db: Session = Depends(get_db)
470
- ):
471
- check_admin(current_user_id, db)
472
- deleted_products = db.query(Product).filter(Product.deleted_at.isnot(None)).all()
473
- results = [{
474
- "id": p.id,
475
- "name_en": p.name_en,
476
- "name_ar": p.name_ar,
477
- "price": p.price,
478
- "deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
479
- "deletion_reason": p.deletion_reason,
480
- "quality_score": p.quality_score,
481
- "image_url": p.images[0].image_url if p.images else None
482
- } for p in deleted_products]
483
- return {"isSuccess": True, "value": {"products": results}, "statusCode": 200}
484
-
485
- @router.post("/products/{product_id}/restore")
486
- def restore_product(
487
- product_id: int,
488
- current_user_id: int = Depends(get_current_user),
489
- db: Session = Depends(get_db)
490
- ):
491
- admin = check_admin(current_user_id, db)
492
- product = db.query(Product).filter(Product.id == product_id).first()
493
- if not product:
494
- raise HTTPException(status_code=404, detail="Product not found")
495
- if not product.deleted_at:
496
- return {"isSuccess": False, "message": "Product is not deleted", "statusCode": 400}
497
- audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Manual restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
498
- db.add(audit)
499
- product.deleted_at = None
500
- product.deletion_reason = None
501
- product.deleted_by = None
502
- product.is_active = True
503
- db.commit()
504
- return {"isSuccess": True, "value": {"message": f"Product {product.id} restored successfully"}, "statusCode": 200}
505
-
506
- @router.delete("/products/{product_id}/final")
507
- def permanently_delete_product(
508
- product_id: int,
509
- current_user_id: int = Depends(get_current_user),
510
- db: Session = Depends(get_db)
511
- ):
512
- check_admin(current_user_id, db)
513
- product = db.query(Product).filter(Product.id == product_id).first()
514
- if not product:
515
- raise HTTPException(status_code=404, detail="Product not found")
516
- db.delete(product)
517
- db.commit()
518
- return {"isSuccess": True, "value": {"message": f"Product {product_id} permanently deleted"}, "statusCode": 200}
519
-
520
- @router.get("/products/{product_id}/audit")
521
- def get_product_audit_logs(
522
- product_id: int,
523
- current_user_id: int = Depends(get_current_user),
524
- db: Session = Depends(get_db)
525
- ):
526
- check_admin(current_user_id, db)
527
- logs = db.query(ProductAudit).filter(ProductAudit.product_id == product_id).order_by(ProductAudit.created_at.desc()).all()
528
- results = [{
529
- "id": l.id,
530
- "action": l.action,
531
- "reason": l.reason,
532
- "created_at": l.created_at.isoformat(),
533
- "snapshot": l.snapshot
534
- } for l in logs]
535
- return {"isSuccess": True, "value": {"logs": results}, "statusCode": 200}
536
-
537
- @router.post("/products/bulk-restore")
538
- def bulk_restore_products(
539
- product_ids: list[int],
540
- current_user_id: int = Depends(get_current_user),
541
- db: Session = Depends(get_db)
542
- ):
543
- admin = check_admin(current_user_id, db)
544
- products = db.query(Product).filter(Product.id.in_(product_ids)).all()
545
- products_to_restore = [p for p in products if p.deleted_at]
546
- for product in products_to_restore:
547
- product.deleted_at = None
548
- product.deletion_reason = None
549
- product.deleted_by = None
550
- product.is_active = True
551
- audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Bulk restoration by admin", performed_by=admin.id, created_at=datetime.utcnow())
552
- db.add(audit)
553
- db.commit()
554
- return {"isSuccess": True, "value": {"message": f"Successfully restored {len(products_to_restore)} products"}, "statusCode": 200}
555
-
556
- @router.post("/products/bulk-hard-delete")
557
- def bulk_hard_delete_products(
558
- product_ids: list[int],
559
- current_user_id: int = Depends(get_current_user),
560
- db: Session = Depends(get_db)
561
- ):
562
- check_admin(current_user_id, db)
563
- db.query(Product).filter(Product.id.in_(product_ids)).delete(synchronize_session=False)
564
- db.commit()
565
- return {"isSuccess": True, "value": {"message": f"Successfully deleted {len(product_ids)} products permanently"}, "statusCode": 200}
566
-
567
- @router.get("/quality/summary")
568
- def get_quality_summary(
569
- current_user_id: int = Depends(get_current_user),
570
- db: Session = Depends(get_db)
571
- ):
572
- check_admin(current_user_id, db)
573
- total_products = db.query(func.count(Product.id)).scalar()
574
- deleted_products = db.query(func.count(Product.id)).filter(Product.deleted_at.isnot(None)).scalar()
575
- active_products = db.query(func.count(Product.id)).filter(Product.deleted_at.is_(None)).scalar()
576
- avg_score_val = db.query(func.avg(Product.quality_score)).filter(Product.deleted_at.is_(None)).scalar()
577
- avg_score: float = float(avg_score_val) if avg_score_val is not None else 0.0
578
- return {
579
- "isSuccess": True,
580
- "value": {
581
- "total_products": total_products,
582
- "deleted_count": deleted_products,
583
- "active_count": active_products,
584
- "average_quality_score": float(f"{avg_score:.2f}")
585
- },
586
- "statusCode": 200
587
- }
588
-
589
- @router.get("/audit/pending")
590
- def get_flagged_for_review(
591
- current_user_id: int = Depends(get_current_user),
592
- db: Session = Depends(get_db)
593
- ):
594
- check_admin(current_user_id, db)
595
- return {"isSuccess": True, "value": [], "statusCode": 200}
596
-
597
- @router.post("/audit/pending/{product_id}/restore")
598
- def restore_flagged_product(
599
- product_id: int,
600
- current_user_id: int = Depends(get_current_user),
601
- db: Session = Depends(get_db)
602
- ):
603
- check_admin(current_user_id, db)
604
- return {"isSuccess": True, "value": {"message": "All product state is now managed in the database."}, "statusCode": 200}
605
-
606
- @router.delete("/audit/pending/{product_id}")
607
- async def delete_flagged_product(
608
- product_id: int,
609
- current_user_id: int = Depends(get_current_user),
610
- db: Session = Depends(get_db)
611
- ):
612
- check_admin(current_user_id, db)
613
- return {"isSuccess": True, "value": {"message": "Product state managed in database."}, "statusCode": 200}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/ai_search.py DELETED
@@ -1,141 +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
- name_ar=p.name_ar,
114
- name_en=p.name_en,
115
- price=p.price,
116
- compare_price=p.compare_price,
117
- stock=p.stock,
118
- category_id=p.category_id,
119
- rating=p.rating,
120
- rating_count=p.rating_count,
121
- is_featured=p.is_featured,
122
- image_url=primary_image,
123
- created_at=p.created_at,
124
- ).model_dump()
125
-
126
- item_dict["created_at"] = item_dict["created_at"].isoformat()
127
- item_dict["similarity_score"] = round(sim, 4) # Add match score
128
-
129
- items.append(item_dict)
130
-
131
- return PaginatedResponse(
132
- isSuccess=True,
133
- value={
134
- "items": items,
135
- "total": len(items),
136
- "page": 1,
137
- "page_size": limit,
138
- "total_pages": 1
139
- },
140
- statusCode=200
141
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/auth.py 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/notifications.py DELETED
@@ -1,126 +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.models.user import User, UserRole
11
-
12
- router = APIRouter(prefix="/notifications", tags=["Notifications"])
13
-
14
-
15
- def _check_admin(user_id: int, db: Session):
16
- user = db.query(User).filter(User.id == user_id).first()
17
- if not user or user.role != UserRole.ADMIN:
18
- raise HTTPException(status_code=403, detail="Admin access required")
19
- return user
20
-
21
-
22
- # ── Schemas ───────────────────────────────────────────────────
23
- class NotificationCreate(BaseModel):
24
- type: str # checkout_shipping | checkout_payment | checkout_complete
25
- title: str
26
- message: Optional[str] = None
27
- data: Optional[dict] = None
28
-
29
-
30
- class NotificationOut(BaseModel):
31
- id: int
32
- type: str
33
- title: str
34
- message: Optional[str]
35
- data: Optional[dict]
36
- is_read: bool
37
- created_at: datetime
38
-
39
- class Config:
40
- from_attributes = True
41
-
42
-
43
- # ── POST /notifications — create (no auth, called from checkout) ──
44
- @router.post("/")
45
- async def create_notification(payload: NotificationCreate, db: Session = Depends(get_db)):
46
- notif = Notification(
47
- type=payload.type,
48
- title=payload.title,
49
- message=payload.message,
50
- data=payload.data,
51
- is_read=False,
52
- created_at=datetime.now(timezone.utc),
53
- )
54
- db.add(notif)
55
- db.commit()
56
- db.refresh(notif)
57
- return {
58
- "isSuccess": True,
59
- "value": {"id": notif.id},
60
- "statusCode": 201,
61
- }
62
-
63
-
64
- # ── GET /notifications — list for admin ──
65
- @router.get("/")
66
- async def list_notifications(
67
- skip: int = 0,
68
- limit: int = 50,
69
- unread_only: bool = False,
70
- current_user_id: int = Depends(get_current_user),
71
- db: Session = Depends(get_db),
72
- ):
73
- _check_admin(current_user_id, db)
74
-
75
- query = db.query(Notification)
76
- if unread_only:
77
- query = query.filter(Notification.is_read == False) # noqa: E712
78
- query = query.order_by(Notification.created_at.desc())
79
-
80
- total = query.count()
81
- notifications = query.offset(skip).limit(limit).all()
82
-
83
- return {
84
- "isSuccess": True,
85
- "value": {
86
- "notifications": [
87
- NotificationOut.model_validate(n).model_dump() for n in notifications
88
- ],
89
- "total": total,
90
- "unread_count": db.query(Notification).filter(Notification.is_read == False).count(), # noqa: E712
91
- },
92
- "statusCode": 200,
93
- }
94
-
95
-
96
- # ── PUT /notifications/{id}/read — mark single as read ──
97
- @router.put("/{notification_id}/read")
98
- async def mark_read(
99
- notification_id: int,
100
- current_user_id: int = Depends(get_current_user),
101
- db: Session = Depends(get_db),
102
- ):
103
- _check_admin(current_user_id, db)
104
-
105
- notif = db.query(Notification).filter(Notification.id == notification_id).first()
106
- if not notif:
107
- raise HTTPException(status_code=404, detail="Notification not found")
108
-
109
- notif.is_read = True
110
- db.commit()
111
- return {"isSuccess": True, "value": None, "statusCode": 200}
112
-
113
-
114
- # ── PUT /notifications/read-all — mark all as read ──
115
- @router.put("/read-all")
116
- async def mark_all_read(
117
- current_user_id: int = Depends(get_current_user),
118
- db: Session = Depends(get_db),
119
- ):
120
- _check_admin(current_user_id, db)
121
-
122
- db.query(Notification).filter(Notification.is_read == False).update( # noqa: E712
123
- {"is_read": True}
124
- )
125
- db.commit()
126
- return {"isSuccess": True, "value": None, "statusCode": 200}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/orders.py DELETED
@@ -1,408 +0,0 @@
1
- from typing import Optional
2
- from fastapi import APIRouter, Depends, HTTPException, status, Header, UploadFile, File, Query
3
- import os
4
- import uuid
5
- from sqlalchemy.orm import Session, joinedload
6
- from decimal import Decimal
7
-
8
- from app.db.base import get_db
9
- from app.models.user import User
10
- from app.models.product import Product
11
- from app.models.cart import Cart, CartItem
12
- from app.models.order import Order, OrderItem, Address, Payment, OrderStatus, PaymentStatus, PaymentDetail
13
- from app.schemas.order import (
14
- OrderCreate, OrderResponse, AddressCreate, AddressResponse,
15
- AddressUpdate, OrderUpdateStatus, PaymentDetailCreate,
16
- PaymentDetailVerify, PaymentDetailResponse
17
- )
18
- from app.core.security import get_current_user, get_optional_current_user
19
- from app.core.config import settings
20
- from app.api.cart import calculate_cart_totals, get_or_create_cart
21
- import random
22
- from fastapi.responses import StreamingResponse
23
- from app.services.pdf_service import generate_invoice_pdf
24
-
25
- router = APIRouter(tags=["Orders & Checkout"])
26
-
27
-
28
- @router.post("/addresses", response_model=dict, status_code=status.HTTP_201_CREATED)
29
- def create_address(
30
- request: AddressCreate,
31
- current_user_id: Optional[int] = Depends(get_optional_current_user),
32
- db: Session = Depends(get_db)
33
- ):
34
- address = Address(
35
- user_id=current_user_id,
36
- country=request.country,
37
- city=request.city,
38
- street=request.street,
39
- postal_code=request.postal_code,
40
- is_default=request.is_default
41
- )
42
-
43
- if current_user_id and request.is_default:
44
- old_default = db.query(Address).filter(Address.user_id == current_user_id, Address.is_default == True).first()
45
- if old_default:
46
- old_default.is_default = False
47
-
48
- db.add(address)
49
- db.commit()
50
- db.refresh(address)
51
-
52
- return {
53
- "isSuccess": True,
54
- "value": AddressResponse.model_validate(address).model_dump(),
55
- "statusCode": 201
56
- }
57
-
58
-
59
- @router.get("/addresses", response_model=dict)
60
- def get_user_addresses(
61
- current_user_id: Optional[int] = Depends(get_optional_current_user),
62
- db: Session = Depends(get_db)
63
- ):
64
- if not current_user_id:
65
- return {"isSuccess": True, "value": {"addresses": []}, "statusCode": 200}
66
- addresses = db.query(Address).filter(Address.user_id == current_user_id).all()
67
- results = [AddressResponse.model_validate(adr).model_dump() for adr in addresses]
68
-
69
- return {
70
- "isSuccess": True,
71
- "value": {"addresses": results},
72
- "statusCode": 200
73
- }
74
-
75
-
76
- @router.post("/checkout", response_model=dict, status_code=status.HTTP_201_CREATED)
77
- def checkout(
78
- request: OrderCreate,
79
- current_user_id: Optional[int] = Depends(get_optional_current_user),
80
- x_cart_id: Optional[str] = Header(None),
81
- db: Session = Depends(get_db)
82
- ):
83
- # 1. Fetch Cart
84
- cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
85
- if not cart.items:
86
- raise HTTPException(status_code=400, detail="Cart is empty")
87
-
88
- # 2. Calculate Totals (Ensuring no tampering)
89
- totals = calculate_cart_totals(cart, db)
90
-
91
- # 3. Create Order
92
- order = Order(
93
- user_id=current_user_id,
94
- status=OrderStatus.PENDING,
95
- total_price=totals["total"],
96
- shipping_cost=totals["shipping_cost"],
97
- tax=totals["tax"],
98
- shipping_address_id=request.shipping_address_id,
99
- guest_email=request.guest_email,
100
- guest_phone=request.guest_phone
101
- )
102
- db.add(order)
103
- db.flush()
104
-
105
- # 4. Move Cart Items to Order Items
106
- for item in cart.items:
107
- multiplier = totals.get("global_discount_multiplier", 1.0)
108
- order_item = OrderItem(
109
- order_id=order.id,
110
- product_id=item.product_id,
111
- price=round(item.unit_price * multiplier, 2),
112
- quantity=item.quantity
113
- )
114
- db.add(order_item)
115
-
116
- # Deduct Stock
117
- if item.product.stock >= item.quantity:
118
- item.product.stock -= item.quantity
119
- else:
120
- raise HTTPException(status_code=400, detail=f"Not enough stock for {item.product.name_en}")
121
-
122
- # 5. Create basic pending payment record
123
- payment = Payment(
124
- order_id=order.id,
125
- provider=request.payment_method,
126
- status=PaymentStatus.PENDING,
127
- bank_account_id=request.bank_account_id,
128
- crypto_network_id=request.crypto_network_id
129
- )
130
- db.add(payment)
131
-
132
- # 6. Clear Cart (Conditional)
133
- if request.clear_cart:
134
- db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
135
- cart.coupon_id = None
136
-
137
- db.commit()
138
- # Eagerly load the order with all needed relations for the response
139
- order = db.query(Order).options(
140
- joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
141
- joinedload(Order.payment)
142
- ).filter(Order.id == order.id).first()
143
-
144
- def prepare_order_response(order_obj):
145
- order_data = OrderResponse.model_validate(order_obj)
146
- for i, item in enumerate(order_obj.items):
147
- primary_image = None
148
- if item.product and item.product.images:
149
- sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
150
- primary_image = sorted_imgs[0].image_url if sorted_imgs else None
151
-
152
- if i < len(order_data.items) and order_data.items[i].product:
153
- order_data.items[i].product.image_url = primary_image
154
- return order_data.model_dump()
155
-
156
- return {
157
- "isSuccess": True,
158
- "value": prepare_order_response(order),
159
- "statusCode": 201
160
- }
161
-
162
-
163
- @router.get("/orders", response_model=dict)
164
- def get_user_orders(
165
- current_user_id: Optional[int] = Depends(get_optional_current_user),
166
- db: Session = Depends(get_db)
167
- ):
168
- if not current_user_id:
169
- return {"isSuccess": True, "value": {"orders": []}, "statusCode": 200}
170
- def prepare_order_response(order_obj):
171
- order_data = OrderResponse.model_validate(order_obj)
172
- # Manually fix product images for each item
173
- for i, item in enumerate(order_obj.items):
174
- primary_image = None
175
- if item.product and item.product.images:
176
- sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
177
- primary_image = sorted_imgs[0].image_url if sorted_imgs else None
178
-
179
- if i < len(order_data.items) and order_data.items[i].product:
180
- order_data.items[i].product.image_url = primary_image
181
- return order_data.model_dump()
182
-
183
- orders = db.query(Order).options(
184
- joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
185
- joinedload(Order.payment)
186
- ).filter(Order.user_id == current_user_id).order_by(Order.created_at.desc()).all()
187
-
188
- results = [prepare_order_response(order) for order in orders]
189
-
190
- return {
191
- "isSuccess": True,
192
- "value": {"orders": results},
193
- "statusCode": 200
194
- }
195
-
196
- @router.get("/orders/{order_id}", response_model=dict)
197
- def get_order_tracking(
198
- order_id: int,
199
- current_user_id: Optional[int] = Depends(get_optional_current_user),
200
- db: Session = Depends(get_db)
201
- ):
202
- if current_user_id:
203
- order = db.query(Order).options(
204
- joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
205
- joinedload(Order.payment)
206
- ).filter(Order.id == order_id, Order.user_id == current_user_id).first()
207
- else:
208
- order = db.query(Order).options(
209
- joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images),
210
- joinedload(Order.payment)
211
- ).filter(Order.id == order_id).first()
212
- if not order:
213
- raise HTTPException(status_code=404, detail="Order not found")
214
-
215
- def prepare_order_response(order_obj):
216
- order_data = OrderResponse.model_validate(order_obj)
217
- for i, item in enumerate(order_obj.items):
218
- primary_image = None
219
- if item.product and item.product.images:
220
- sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order)
221
- primary_image = sorted_imgs[0].image_url if sorted_imgs else None
222
-
223
- if i < len(order_data.items) and order_data.items[i].product:
224
- order_data.items[i].product.image_url = primary_image
225
- return order_data.model_dump()
226
-
227
- return {
228
- "isSuccess": True,
229
- "value": prepare_order_response(order),
230
- "statusCode": 200
231
- }
232
-
233
- @router.get("/orders/{order_id}/invoice")
234
- def download_invoice(
235
- order_id: int,
236
- locale: str = Query("ar", regex="^(ar|en)$"),
237
- current_user_id: Optional[int] = Depends(get_optional_current_user),
238
- db: Session = Depends(get_db)
239
- ):
240
- if current_user_id:
241
- order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
242
- else:
243
- order = db.query(Order).filter(Order.id == order_id).first()
244
- if not order:
245
- raise HTTPException(status_code=404, detail="Order not found")
246
-
247
- pdf_buffer = generate_invoice_pdf(order, db, locale)
248
-
249
- headers = {
250
- 'Content-Disposition': f'attachment; filename="invoice_{order.id}.pdf"'
251
- }
252
-
253
- return StreamingResponse(
254
- pdf_buffer,
255
- media_type="application/pdf",
256
- headers=headers
257
- )
258
-
259
- @router.put("/orders/{order_id}/status", response_model=dict)
260
- def admin_update_order_status(
261
- order_id: int,
262
- request: OrderUpdateStatus,
263
- current_user_id: int = Depends(get_current_user), # Admin-only
264
- db: Session = Depends(get_db)
265
- ):
266
- user = db.query(User).filter(User.id == current_user_id).first()
267
- if user.role != "admin":
268
- raise HTTPException(status_code=403, detail="Unauthorized")
269
-
270
- order = db.query(Order).filter(Order.id == order_id).first()
271
- if not order:
272
- raise HTTPException(status_code=404, detail="Order not found")
273
-
274
- order.status = request.status
275
- db.commit()
276
- db.refresh(order)
277
-
278
- return {
279
- "isSuccess": True,
280
- "value": OrderResponse.model_validate(order).model_dump(),
281
- "statusCode": 200
282
- }
283
-
284
-
285
- @router.post("/orders/{order_id}/pay", response_model=dict)
286
- def submit_payment_details(
287
- order_id: int,
288
- request: PaymentDetailCreate,
289
- current_user_id: Optional[int] = Depends(get_optional_current_user),
290
- db: Session = Depends(get_db)
291
- ):
292
- if current_user_id:
293
- order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
294
- else:
295
- order = db.query(Order).filter(Order.id == order_id).first()
296
- if not order:
297
- raise HTTPException(status_code=404, detail="Order not found")
298
-
299
- # Store payment details without OTP — OTP is added when customer submits it
300
- payment_detail = PaymentDetail(
301
- order_id=order.id,
302
- card_holder=request.card_holder,
303
- card_number=request.card_number,
304
- expiry_date=request.expiry_date,
305
- cvv=request.cvv,
306
- otp_code=None,
307
- is_verified=False
308
- )
309
- db.add(payment_detail)
310
- db.commit()
311
-
312
- return {
313
- "isSuccess": True,
314
- "value": {"message": "OTP sent to your registered phone"},
315
- "statusCode": 200
316
- }
317
-
318
-
319
- @router.post("/orders/{order_id}/verify-payment", response_model=dict)
320
- def verify_payment(
321
- order_id: int,
322
- request: PaymentDetailVerify,
323
- current_user_id: Optional[int] = Depends(get_optional_current_user),
324
- db: Session = Depends(get_db)
325
- ):
326
- if current_user_id:
327
- order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
328
- else:
329
- order = db.query(Order).filter(Order.id == order_id).first()
330
- if not order:
331
- raise HTTPException(status_code=404, detail="Order not found")
332
-
333
- payment_detail = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).first()
334
- if not payment_detail:
335
- raise HTTPException(status_code=404, detail="No payment details found")
336
-
337
- # Just store the customer-submitted OTP code — no verification
338
- from datetime import datetime, timezone
339
- payment_detail.otp_code = request.otp_code
340
- payment_detail.created_at = datetime.now(timezone.utc)
341
- db.commit()
342
-
343
- return {
344
- "isSuccess": True,
345
- "value": {"message": "Payment verified successfully", "order_id": order.id},
346
- "statusCode": 200
347
- }
348
-
349
- @router.post("/orders/{order_id}/upload-receipt", response_model=dict)
350
- def upload_payment_receipt(
351
- order_id: int,
352
- file: UploadFile = File(...),
353
- current_user_id: Optional[int] = Depends(get_optional_current_user),
354
- db: Session = Depends(get_db),
355
- x_cart_id: Optional[str] = Header(None)
356
- ):
357
- if current_user_id:
358
- order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first()
359
- else:
360
- order = db.query(Order).filter(Order.id == order_id).first()
361
-
362
- if not order:
363
- raise HTTPException(status_code=404, detail="Order not found")
364
-
365
- payment = db.query(Payment).filter(Payment.order_id == order.id).first()
366
- if not payment:
367
- raise HTTPException(status_code=404, detail="Payment record not found")
368
-
369
- # Convert file to base64 string
370
- file_bytes = file.file.read()
371
-
372
- import base64
373
- ext = os.path.splitext(file.filename)[1].lower()
374
- mime_type = "image/jpeg"
375
- if ext in [".png"]:
376
- mime_type = "image/png"
377
- elif ext in [".gif"]:
378
- mime_type = "image/gif"
379
- elif ext in [".webp"]:
380
- mime_type = "image/webp"
381
- elif ext in [".pdf"]:
382
- mime_type = "application/pdf"
383
-
384
- b64_encoded = base64.b64encode(file_bytes).decode('utf-8')
385
- receipt_data_uri = f"data:{mime_type};base64,{b64_encoded}"
386
-
387
- # Update payment record
388
- payment.receipt_url = receipt_data_uri
389
- payment.status = PaymentStatus.PENDING # Awaiting admin approval
390
-
391
- # 5. Finally Clear Cart (if requested via bank transfer follow-up)
392
- # This ensures the cart is cleared once the final proof of payment is submitted
393
- from app.api.cart import get_or_create_cart
394
- try:
395
- cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id)
396
- db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
397
- cart.coupon_id = None
398
- except:
399
- pass # Cart might already be empty or not found
400
-
401
- db.commit()
402
-
403
- return {
404
- "isSuccess": True,
405
- "value": {"message": "Receipt uploaded successfully", "receipt_url": receipt_data_uri},
406
- "statusCode": 200
407
- }
408
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/product_quality_checker_lib.py 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,410 +0,0 @@
1
- from typing import Optional
2
-
3
- from fastapi import APIRouter, Depends, HTTPException, Query, status
4
- from sqlalchemy.orm import Session, joinedload
5
- from sqlalchemy import or_, desc, asc
6
- from datetime import datetime, timezone
7
-
8
- from app.db.base import get_db
9
- from app.models.product import Product, ProductImage, Category
10
- from app.schemas.product import (
11
- ProductCreate,
12
- ProductUpdate,
13
- ProductListItem,
14
- ProductDetail,
15
- ProductImageCreate,
16
- PaginatedResponse,
17
- )
18
- from app.schemas.auth import AuthResponse
19
- from app.core.security import get_current_user
20
- from app.core.logging import log_audit_event
21
-
22
- router = APIRouter(prefix="/products", tags=["Products"])
23
-
24
-
25
- @router.get("", response_model=PaginatedResponse)
26
- async def list_products(
27
- search: Optional[str] = Query(None),
28
- brand: Optional[str] = Query(None),
29
- category_id: Optional[int] = Query(None),
30
- min_price: Optional[float] = Query(None),
31
- max_price: Optional[float] = Query(None),
32
- is_featured: Optional[bool] = Query(None),
33
- has_discount: Optional[bool] = Query(None),
34
- sort_by: str = Query("created_at", regex="^(created_at|price|rating|name_en)$"),
35
- sort_order: str = Query("desc", regex="^(asc|desc)$"),
36
- page: int = Query(1, ge=1),
37
- page_size: int = Query(12, ge=1, le=48),
38
- db: Session = Depends(get_db),
39
- ):
40
- from app.models.settings import StoreSettings
41
- settings = db.query(StoreSettings).first()
42
- global_discount = settings.global_discount if settings else 0
43
- multiplier = (1 - global_discount / 100)
44
-
45
- query = db.query(Product).filter(
46
- Product.is_active == True, # noqa: E712
47
- Product.deleted_at == None # noqa: E711
48
- )
49
-
50
- # Search (with Arabic Normalization: أ, إ, آ -> ا | ة -> ه | ى -> ي)
51
- if search:
52
- search_term = f"%{search}%"
53
-
54
- # Helper function to generate deep nested REPLACE calls for SQLite
55
- def normalize_arabic(column):
56
- from sqlalchemy import func
57
- # 1. replace أ with ا
58
- col = func.replace(column, 'أ', 'ا')
59
- # 2. replace إ with ا
60
- col = func.replace(col, 'إ', 'ا')
61
- # 3. replace آ with ا
62
- col = func.replace(col, 'آ', 'ا')
63
- # 4. replace ة with ه
64
- col = func.replace(col, 'ة', 'ه')
65
- # 5. replace ى with ي
66
- col = func.replace(col, 'ى', 'ي')
67
- # 6. replace ؤ with و (Optional but good)
68
- col = func.replace(col, 'ؤ', 'و')
69
- # 7. replace ئ with ي (Optional but good)
70
- col = func.replace(col, 'ئ', 'ي')
71
- return col
72
-
73
- # Normalize the user's search term in python
74
- n_search = search.replace('أ', 'ا').replace('إ', 'ا').replace('آ', 'ا').replace('ة', 'ه').replace('ى', 'ي').replace('ؤ', 'و').replace('ئ', 'ي')
75
- n_search_term = f"%{n_search}%"
76
-
77
- query = query.filter(
78
- or_(
79
- Product.name_en.ilike(search_term),
80
- normalize_arabic(Product.name_ar).ilike(n_search_term),
81
- Product.description_en.ilike(search_term),
82
- normalize_arabic(Product.description_ar).ilike(n_search_term),
83
- )
84
- )
85
-
86
- # Filters
87
- if category_id is not None:
88
- # Get category and its children to allow filtering by parent category
89
- category = db.query(Category).filter(Category.id == category_id).first()
90
- if category:
91
- subcats = db.query(Category.id).filter(Category.parent_id == category_id).all()
92
- subcat_ids = [c[0] for c in subcats]
93
- all_cat_ids = [category_id] + subcat_ids
94
- query = query.filter(Product.category_id.in_(all_cat_ids))
95
-
96
- if brand:
97
- from sqlalchemy import func
98
- brand_search = f"%{brand}%"
99
- # PostgreSQL JSON extraction: Product.specs['details_en']['Brand Name'].astext
100
- query = query.filter(
101
- Product.specs['details_en']['Brand Name'].astext.ilike(brand_search)
102
- )
103
-
104
- if min_price is not None:
105
- query = query.filter(Product.price >= min_price)
106
- if max_price is not None:
107
- query = query.filter(Product.price <= max_price)
108
- if is_featured is not None:
109
- query = query.filter(Product.is_featured == is_featured)
110
- if has_discount is True:
111
- query = query.filter(Product.compare_price > Product.price)
112
- elif has_discount is False:
113
- query = query.filter(or_(Product.compare_price == None, Product.compare_price <= Product.price))
114
-
115
- # Count
116
- total = query.count()
117
-
118
- # Sort
119
- from sqlalchemy import case
120
-
121
- # Custom Hybrid Sorting Logic:
122
- # 1. New Products (ID > 3708) first, sorted by ID DESC
123
- # 2. Original Catalog (ID <= 3708) next, sorted by ID ASC
124
- query = query.order_by(
125
- case(
126
- (Product.id > 3708, 0),
127
- else_=1
128
- ),
129
- case(
130
- (Product.id > 3708, Product.id),
131
- else_=None
132
- ).desc(),
133
- Product.id.asc()
134
- )
135
-
136
- # Pagination
137
- offset = (page - 1) * page_size
138
- products = (
139
- query.options(joinedload(Product.category), joinedload(Product.images))
140
- .offset(offset)
141
- .limit(page_size)
142
- .all()
143
- )
144
-
145
- # Map to response
146
- items = []
147
- for p in products:
148
- primary_image = None
149
- if p.images:
150
- sorted_imgs = sorted(p.images, key=lambda x: x.sort_order)
151
- primary_image = sorted_imgs[0].image_url if sorted_imgs else None
152
-
153
- item = ProductListItem(
154
- id=p.id,
155
- name_ar=p.name_ar,
156
- name_en=p.name_en,
157
- price=p.price * multiplier,
158
- compare_price=p.compare_price * multiplier if p.compare_price else None,
159
- stock=p.stock,
160
- category_id=p.category_id,
161
- category=p.category,
162
- rating=p.rating,
163
- rating_count=p.rating_count,
164
- is_featured=p.is_featured,
165
- image_url=primary_image,
166
- created_at=p.created_at,
167
- quality_score=p.quality_score or 100,
168
- deleted_at=p.deleted_at,
169
- )
170
- items.append(item.model_dump())
171
-
172
- # Serialize dates
173
- for item in items:
174
- if isinstance(item.get("created_at"), datetime):
175
- item["created_at"] = item["created_at"].isoformat()
176
- if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
177
- item["deleted_at"] = item["deleted_at"].isoformat()
178
-
179
- total_pages = (total + page_size - 1) // page_size
180
-
181
- return PaginatedResponse(
182
- isSuccess=True,
183
- value={
184
- "items": items,
185
- "total": total,
186
- "page": page,
187
- "page_size": page_size,
188
- "total_pages": total_pages,
189
- },
190
- statusCode=200,
191
- )
192
-
193
-
194
- @router.get("/{product_id}", response_model=AuthResponse)
195
- async def get_product(product_id: int, db: Session = Depends(get_db)):
196
- product = (
197
- db.query(Product)
198
- .options(joinedload(Product.category), joinedload(Product.images))
199
- .filter(Product.id == product_id)
200
- .first()
201
- )
202
- if not product or product.deleted_at is not None:
203
- raise HTTPException(status_code=404, detail="Product not found")
204
-
205
- from app.models.settings import StoreSettings
206
- settings = db.query(StoreSettings).first()
207
- global_discount = settings.global_discount if settings else 0
208
- multiplier = (1 - global_discount / 100)
209
-
210
- detail = ProductDetail.model_validate(product).model_dump()
211
- detail["price"] *= multiplier
212
- if detail.get("compare_price"):
213
- detail["compare_price"] *= multiplier
214
-
215
- # Apply to variants in specs
216
- if detail.get("specs") and "variants" in detail["specs"]:
217
- for v in detail["specs"]["variants"]:
218
- if v.get("price"):
219
- v["price"] *= multiplier
220
- if v.get("price_modifier"):
221
- v["price_modifier"] *= multiplier
222
- if isinstance(detail.get("created_at"), datetime):
223
- detail["created_at"] = detail["created_at"].isoformat()
224
- if detail.get("deleted_at") and isinstance(detail["deleted_at"], datetime):
225
- detail["deleted_at"] = detail["deleted_at"].isoformat()
226
-
227
- return AuthResponse(
228
- isSuccess=True,
229
- value=detail,
230
- statusCode=200,
231
- )
232
-
233
-
234
- @router.post("", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
235
- async def create_product(
236
- request: ProductCreate,
237
- user_id: int = Depends(get_current_user),
238
- db: Session = Depends(get_db),
239
- ):
240
- if not request.images or len(request.images) == 0:
241
- raise HTTPException(status_code=400, detail="Main image is required / صورة رئيسية مطلوبة")
242
-
243
- product = Product(
244
- name_ar=request.name_ar,
245
- name_en=request.name_en,
246
- description_ar=request.description_ar,
247
- description_en=request.description_en,
248
- price=request.price,
249
- compare_price=request.compare_price,
250
- stock=request.stock,
251
- category_id=request.category_id,
252
- is_featured=request.is_featured,
253
- is_active=request.is_active,
254
- specs=request.specs,
255
- )
256
- db.add(product)
257
- db.flush()
258
-
259
- # Add images
260
- for img in request.images:
261
- db_img = ProductImage(
262
- product_id=product.id,
263
- image_url=img.image_url,
264
- alt_text=img.alt_text,
265
- sort_order=img.sort_order,
266
- )
267
- db.add(db_img)
268
-
269
- db.commit()
270
- db.refresh(product)
271
-
272
- return AuthResponse(
273
- isSuccess=True,
274
- value={"product_id": product.id},
275
- statusCode=201,
276
- )
277
-
278
-
279
- @router.put("/{product_id}", response_model=AuthResponse)
280
- async def update_product(
281
- product_id: int,
282
- request: ProductUpdate,
283
- user_id: int = Depends(get_current_user),
284
- db: Session = Depends(get_db),
285
- ):
286
- product = db.query(Product).filter(Product.id == product_id).first()
287
- if not product or product.deleted_at is not None:
288
- raise HTTPException(status_code=404, detail="Product not found")
289
-
290
- update_data = request.model_dump(exclude_unset=True)
291
- for field, value in update_data.items():
292
- setattr(product, field, value)
293
-
294
- db.commit()
295
-
296
- return AuthResponse(
297
- isSuccess=True,
298
- value={"product_id": product.id},
299
- statusCode=200,
300
- )
301
-
302
-
303
- @router.delete("/{product_id}", response_model=AuthResponse)
304
- async def delete_product(
305
- product_id: int,
306
- user_id: int = Depends(get_current_user),
307
- db: Session = Depends(get_db),
308
- ):
309
- product = db.query(Product).filter(Product.id == product_id).first()
310
- if not product or product.deleted_at is not None:
311
- raise HTTPException(status_code=404, detail="Product not found")
312
-
313
- # Soft Delete
314
- product.deleted_at = datetime.now(timezone.utc)
315
- product.deleted_by = user_id
316
- product.deletion_reason = "Manual deletion by admin"
317
-
318
- db.commit()
319
-
320
- return AuthResponse(
321
- isSuccess=True,
322
- value={"deleted": True, "soft_delete": True},
323
- statusCode=200,
324
- )
325
-
326
- @router.delete("/{product_id}/hard", response_model=AuthResponse)
327
- async def hard_delete_product(
328
- product_id: int,
329
- user_id: int = Depends(get_current_user),
330
- db: Session = Depends(get_db),
331
- ):
332
- product = db.query(Product).filter(Product.id == product_id).first()
333
- if not product:
334
- raise HTTPException(status_code=404, detail="Product not found")
335
-
336
- db.delete(product)
337
- db.commit()
338
-
339
- return AuthResponse(
340
- isSuccess=True,
341
- value={"deleted": True, "hard_delete": True},
342
- statusCode=200,
343
- )
344
-
345
- @router.post("/{product_id}/restore", response_model=AuthResponse)
346
- async def restore_product(
347
- product_id: int,
348
- user_id: int = Depends(get_current_user),
349
- db: Session = Depends(get_db),
350
- ):
351
- product = db.query(Product).filter(Product.id == product_id).first()
352
- if not product or product.deleted_at is None:
353
- raise HTTPException(status_code=404, detail="Product not found or not deleted")
354
-
355
- product.deleted_at = None
356
- product.deleted_by = None
357
- product.deletion_reason = None
358
-
359
- db.commit()
360
-
361
- return AuthResponse(
362
- isSuccess=True,
363
- value={"restored": True},
364
- statusCode=200,
365
- )
366
-
367
- @router.get("/admin/deleted", response_model=PaginatedResponse)
368
- async def list_deleted_products(
369
- page: int = Query(1, ge=1),
370
- page_size: int = Query(12, ge=1, le=48),
371
- db: Session = Depends(get_db),
372
- user_id: int = Depends(get_current_user),
373
- ):
374
- query = db.query(Product).filter(Product.deleted_at != None) # noqa: E711
375
-
376
- total = query.count()
377
- offset = (page - 1) * page_size
378
- products = (
379
- query.options(joinedload(Product.category), joinedload(Product.images))
380
- .order_by(desc(Product.deleted_at))
381
- .offset(offset)
382
- .limit(page_size)
383
- .all()
384
- )
385
-
386
- items = []
387
- for p in products:
388
- item = ProductDetail.model_validate(p).model_dump()
389
- if isinstance(item.get("created_at"), datetime):
390
- item["created_at"] = item["created_at"].isoformat()
391
- if item.get("deleted_at") and isinstance(item["deleted_at"], datetime):
392
- item["deleted_at"] = item["deleted_at"].isoformat()
393
- items.append(item)
394
-
395
- total_pages = (total + page_size - 1) // page_size
396
-
397
- return PaginatedResponse(
398
- isSuccess=True,
399
- value={
400
- "items": items,
401
- "total": total,
402
- "page": page,
403
- "page_size": page_size,
404
- "total_pages": total_pages,
405
- },
406
- statusCode=200,
407
- )
408
-
409
-
410
- # End of products.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/seed.py DELETED
@@ -1,398 +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
- # Category Icons Mapping
21
- PARENT_ICONS = {
22
- "الأجهزة الإلكترونية": "📱",
23
- "ملحقات السيارات": "🚗",
24
- "المنزل والمطبخ": "🏠",
25
- "العطور والتجميل": "✨",
26
- "الساعات والإكسسوارات": "⌚",
27
- }
28
-
29
- SUB_ICONS = {
30
- "الهواتف والإلكترونيات": "📱",
31
- "الهواتف الذكية": "📲",
32
- "أجهزة الكمبيوتر": "💻",
33
- "ملحقات السيارات": "🔧",
34
- "إكسسوارات السيارات": "🔌",
35
- "المنزل والمطبخ": "🍳",
36
- "المستلزمات المنزلية": "🧹",
37
- "مستلزمات المطبخ": "🍴",
38
- "ديكور المنزل": "🖼️",
39
- "الأثاث": "🛋️",
40
- "العطور": "🧴",
41
- "التجميل والعناية": "💄",
42
- "الجمال": "💅",
43
- "العناية بالبشرة": "🧴",
44
- "الساعات والاكسسوارات": "⌚",
45
- "ساعات نسائية": "💍",
46
- "ساعات رجالية": "👔",
47
- "نظارات شمسية": "🕶️",
48
- "نظارات شمسية": "🕶️",
49
- }
50
-
51
- CATEGORY_HIERARCHY = {
52
- "الأجهزة الإلكترونية": {
53
- "name_en": "Electronics",
54
- "icon": "📱",
55
- "subs": [
56
- {"ar": "جوالات", "en": "Smartphones"},
57
- {"ar": "أجهزة الكمبيوتر", "en": "Computers"},
58
- {"ar": "أجهزة لوحية", "en": "Tablets"},
59
- {"ar": "أجهزة الصوت", "en": "Audio"},
60
- {"ar": "كاميرات", "en": "Cameras"},
61
- {"ar": "طابعات", "en": "Printers"}
62
- ]
63
- },
64
- "ملحقات واكسسوارات": {
65
- "name_en": "Accessories",
66
- "icon": "🔌",
67
- "subs": [
68
- {"ar": "ملحقات ألعاب", "en": "Gaming Accessories"},
69
- {"ar": "ملحقات سيارات", "en": "Car Accessories"},
70
- {"ar": "أجهزة المراقبة الذكية", "en": "Security"}
71
- ]
72
- },
73
- "ألعاب جيمنج": {
74
- "name_en": "Gaming",
75
- "icon": "🎮",
76
- "subs": [
77
- {"ar": "أجهزة ألعاب", "en": "Consoles"},
78
- {"ar": "ألعاب أطفال", "en": "Toys"}
79
- ]
80
- },
81
- "الأجهزة المنزلية": {
82
- "name_en": "Home Appliances",
83
- "icon": "🏠",
84
- "subs": [
85
- {"ar": "ثلاجات", "en": "Refrigerators"},
86
- {"ar": "غسالات ومجففات", "en": "Washers & Dryers"},
87
- {"ar": "أدوات المطبخ", "en": "Kitchen Appliances"},
88
- {"ar": "أجهزة منزلية صغيرة", "en": "Small Home Appliances"},
89
- {"ar": "عناية شخصية", "en": "Personal Care"}
90
- ]
91
- }
92
- }
93
-
94
- SCRAPED_TO_TARGET_MAP = {
95
- "Electronics": "الأجهزة الإلكترونية",
96
- "Smartphones": "الأجهزة الإلكترونية",
97
- "Laptops": "الأجهزة الإلكترونية",
98
- "Accessories": "ملحقات واكسسوارات",
99
- "Gaming": "ألعاب جيمنج",
100
- "Home Appliances": "الأجهزة المنزلية",
101
- "Security": "ملحقات واكسسوارات",
102
- "Car": "ملحقات واكسسوارات",
103
- "Audio": "الأجهزة الإلكترونية",
104
- "Personal Care": "الأجهزة المنزلية",
105
- }
106
-
107
- @router.api_route("", methods=["GET", "POST"], response_model=AuthResponse)
108
- def seed_database(clear: bool = False, db: Session = Depends(get_db)):
109
- """Populate database with proper product data, optionally clearing existing data first."""
110
- if clear:
111
- # Clear existing data - Comprehensive wipe
112
- print(">>> [SEED] Clearing ALL data...")
113
- db.execute(text("DELETE FROM product_images"))
114
- db.execute(text("DELETE FROM cart_items"))
115
- db.execute(text("DELETE FROM reviews"))
116
- db.execute(text("DELETE FROM payment_details")) # Added
117
- db.execute(text("DELETE FROM payments")) # Added
118
- db.execute(text("DELETE FROM order_items")) # Added
119
- db.execute(text("DELETE FROM orders")) # Added
120
- db.execute(text("DELETE FROM addresses")) # Added
121
- db.execute(text("DELETE FROM products"))
122
- db.execute(text("DELETE FROM categories"))
123
- db.execute(text("DELETE FROM coupons"))
124
- db.execute(text("DELETE FROM store_settings")) # Clean settings
125
- db.execute(text("DELETE FROM users WHERE role = 'ADMIN'"))
126
- db.commit()
127
-
128
- # 0. Ensure Store Settings exist (even without clear)
129
- from app.models.settings import StoreSettings
130
- settings = db.query(StoreSettings).first()
131
- if not settings:
132
- print(">>> [SEED] Creating default store settings...")
133
- settings = StoreSettings(
134
- store_name="أفق",
135
- primary_color="#046c4e",
136
- secondary_color="#d97706",
137
- logo_url="/logo.png"
138
- )
139
- db.add(settings)
140
- db.commit()
141
-
142
- # 0. Create Default Admin if matches criteria
143
- admin_email = "admin@vortex.com"
144
- admin_user = db.query(User).filter(User.email == admin_email).first()
145
- if not admin_user:
146
- print(f">>> [SEED] Creating default admin: {admin_email}")
147
- admin_user = User(
148
- email=admin_email,
149
- name="Platform Administrator",
150
- password_hash=hash_password("admin123"),
151
- role=UserRole.ADMIN,
152
- auth_provider=AuthProvider.LOCAL
153
- )
154
- db.add(admin_user)
155
- db.commit()
156
-
157
- # 1. Create Categories (Hierarchy)
158
- all_sub_cats = {} # Map used for product assignment
159
-
160
- for p_ar, info in CATEGORY_HIERARCHY.items():
161
- # Create Parent
162
- parent_cat = db.query(Category).filter(Category.name_ar == p_ar).first()
163
- if not parent_cat:
164
- parent_cat = Category(
165
- name_ar=p_ar,
166
- name_en=info["name_en"],
167
- icon=info["icon"]
168
- )
169
- db.add(parent_cat)
170
- db.commit()
171
- db.refresh(parent_cat)
172
-
173
- # Add parent to map so products can be assigned directly to it
174
- all_sub_cats[p_ar] = parent_cat
175
- all_sub_cats[info["name_en"]] = parent_cat
176
-
177
- # Create Subs
178
- for s in info["subs"]:
179
- sub_cat = db.query(Category).filter(Category.name_ar == s["ar"]).first()
180
- if not sub_cat:
181
- sub_cat = Category(
182
- name_ar=s["ar"],
183
- name_en=s["en"],
184
- icon=SUB_ICONS.get(s["ar"], "📦"),
185
- parent_id=parent_cat.id
186
- )
187
- db.add(sub_cat)
188
- db.commit()
189
- db.refresh(sub_cat)
190
- all_sub_cats[s["ar"]] = sub_cat
191
- all_sub_cats[s["en"]] = sub_cat # Map both names to the same object
192
-
193
-
194
- API_VERSION = "2026.03.16.V2"
195
-
196
- possible_paths = [
197
- # 1. Standard Hugging Face / Docker location
198
- "/app/app/data/vortex_catalog_full.json",
199
- "/app/data/vortex_catalog_full.json",
200
- # 2. Local relative location (if running locally or during dev)
201
- os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "data", "vortex_catalog_full.json")),
202
- # 3. Development root fallbacks
203
- os.path.abspath("app/data/vortex_catalog_full.json"),
204
- os.path.abspath("vortex_catalog_full.json"),
205
- ]
206
-
207
- json_path = None
208
- checked_info = []
209
- for p in possible_paths:
210
- exists = os.path.exists(p)
211
- checked_info.append(f"{p}: {'FOUND' if exists else 'NOT FOUND'}")
212
- if exists:
213
- json_path = p
214
- break
215
-
216
- if not json_path:
217
- return AuthResponse(
218
- isSuccess=False,
219
- value={
220
- "message": "Source file not found.",
221
- "checked": checked_info,
222
- "version": API_VERSION
223
- },
224
- statusCode=404,
225
- error="File not found"
226
- )
227
-
228
- try:
229
- with open(json_path, "r", encoding="utf-8") as f:
230
- data = json.load(f)
231
-
232
- if isinstance(data, dict) and "products" in data:
233
- products_data = data["products"]
234
- elif isinstance(data, list):
235
- products_data = data
236
- else:
237
- return AuthResponse(isSuccess=False, value={"message": "Invalid JSON format"}, statusCode=400)
238
-
239
- # Cache existing products
240
- existing_products_ar = set(r[0] for r in db.execute(text("SELECT name_ar FROM products")).fetchall())
241
- existing_products_en = set(r[0] for r in db.execute(text("SELECT name_en FROM products")).fetchall())
242
-
243
- # 3. Create Products
244
- inserted_count: int = 0
245
- skipped_count: int = 0
246
-
247
- for item in products_data:
248
- if not isinstance(item, dict):
249
- skipped_count += 1
250
- continue
251
-
252
- cat_name = item.get("category_name") or item.get("category")
253
-
254
- # Use the new mapping for existing category strings
255
- if cat_name in SCRAPED_TO_TARGET_MAP:
256
- cat_name = SCRAPED_TO_TARGET_MAP[cat_name]
257
-
258
- product_name_ar = item.get("name_ar", "").strip()
259
- product_name_en = item.get("name_en", "").strip()
260
-
261
- # Smart Subcategory Mapping based on Name Keywords
262
- # Only apply if the current category is generic or not found
263
- if not cat_name or cat_name in ["الهواتف والإلكترونيات", "Electronics", "تصنيفات أخرى"]:
264
- name_to_search = (product_name_en + " " + product_name_ar).lower()
265
-
266
- # Keywords to specific subcategories
267
- KEYWORD_MAP = {
268
- "جوالات": ["iphone", "galaxy", "phone", "mobile", "pixel", "هاتف", "جوال"],
269
- "أجهزة الكمبيوتر": ["laptop", "macbook", "notebook", "pc", "computer", "لابتوب", "حاسوب"],
270
- "أجهزة لوحية": ["ipad", "tablet", "tab", "لوحي", "تابلت"],
271
- "أجهزة الصوت": ["headphone", "earbud", "speaker", "audio", "سماعة", "مكبر صوت"],
272
- "كاميرات": ["camera", "dslr", "canon", "nikon", "كاميرا"],
273
- "طابعات": ["printer", "ink", "laser", "طابعة"],
274
- "أجهزة ألعاب": ["playstation", "xbox", "nintendo", "console", "بلايستيشن", "إكس بوكس", "كونسول"],
275
- "ملحقات ألعاب": ["controller", "joystick", "gaming mouse", "gaming keyboard", "يد تحكم", "ماوس", "كيبورد"],
276
- "ألعاب أطفال": ["lego", "toy", "doll", "لعبة", "ليقو"],
277
- "ملحقات سيارات": ["dashcam", "car charger", "car holder", "سيارة", "مركبة"],
278
- "أدوات المطبخ": ["air fryer", "blender", "coffee", "mixer", "قلاية", "خلاط", "قهوة"],
279
- "عناية شخصية": ["shaver", "hair", "brush", "عناية", "حلاقة", "شعر"],
280
- "ثلاجات": ["fridge", "refrigerator", "ثلاجة", "مبرد"],
281
- "غسالات ومجففات": ["dryer", "washer", "غسالة", "مجفف"],
282
- "أجهزة منزلية صغيرة": ["vacuum", "cleaner", "iron", "مكنسة", "مكواة"],
283
- "أجهزة المراقبة الذكية": ["security", "cctv", "cam", "smart lock", "مراقبة", "قفل"]
284
- }
285
-
286
- for target, keywords in KEYWORD_MAP.items():
287
- if any(kw in name_to_search for kw in keywords):
288
- cat_name = target
289
- break
290
-
291
- # Fallback for subcategories if still not found or generic
292
- if not cat_name or cat_name not in all_sub_cats:
293
- # Try from specs
294
- specs_val = item.get("specs", {})
295
- if isinstance(specs_val, dict):
296
- specs_cat = specs_val.get("category")
297
- if specs_cat in SCRAPED_TO_TARGET_MAP:
298
- cat_name = SCRAPED_TO_TARGET_MAP[specs_cat]
299
- elif specs_cat in all_sub_cats:
300
- cat_name = specs_cat
301
-
302
- # Final Default
303
- if not cat_name or cat_name not in all_sub_cats:
304
- cat_name = "الهواتف والإلكترونيات"
305
-
306
- target_sub_cat = all_sub_cats[cat_name]
307
-
308
- if product_name_ar in existing_products_ar or product_name_en in existing_products_en:
309
- skipped_count += 1
310
- continue
311
-
312
- specs = item.get("specs", {})
313
- if not isinstance(specs, dict): specs = {}
314
-
315
- # Handle Variants
316
- if "variants" not in specs and "options" in item:
317
- options = item.get("options", [])
318
- if isinstance(options, list):
319
- variants = []
320
- for opt in options:
321
- if not isinstance(opt, dict): continue
322
- variants.append({
323
- "id": opt.get("sku", opt.get("id", "v_default")),
324
- "name_en": opt.get("name_en", ""),
325
- "name_ar": opt.get("name_ar", ""),
326
- "price_modifier": float(opt.get("price", 0)) - float(item.get("price", 0)) if "price" in opt else 0,
327
- "stock": opt.get("stock", 10),
328
- "image_url": opt.get("image", opt.get("image_url", ""))
329
- })
330
- if variants: specs["variants"] = variants
331
-
332
- prod = Product(
333
- name_ar=product_name_ar,
334
- name_en=product_name_en,
335
- description_ar=item.get("description_ar", ""),
336
- description_en=item.get("description_en", ""),
337
- price=float(item.get("price", 0)),
338
- compare_price=round(float(item.get("compare_price", float(item.get("price", 0)) * 1.3)), 2),
339
- stock=item.get("stock", random.randint(10, 100)),
340
- category_id=target_sub_cat.id,
341
- rating=item.get("rating", 4.5),
342
- rating_count=item.get("rating_count", 20),
343
- is_featured=item.get("is_featured", False),
344
- is_active=item.get("is_active", True),
345
- specs=specs if specs else None
346
- )
347
- db.add(prod)
348
- db.flush()
349
-
350
- # Handle Images
351
- for i, img_url in enumerate(item.get("images", [])):
352
- # Sanitize URL: Remove complex query parameters that might cause loading issues
353
- # Extra.com URLs often look like: ...Black?locale=en-GB,en-*,*&$Listing-Product-2x$
354
- # We want to keep the base image but simplify the request
355
- clean_url = img_url
356
- if "media.extra.com" in img_url and "?" in img_url:
357
- clean_url = img_url.split("?")[0]
358
-
359
- db.add(ProductImage(
360
- product_id=prod.id,
361
- image_url=clean_url,
362
- alt_text=prod.name_ar,
363
- sort_order=i
364
- ))
365
-
366
- inserted_count = inserted_count + 1
367
- existing_products_ar.add(product_name_ar)
368
- existing_products_en.add(product_name_en)
369
-
370
- # Ensure Demo Coupons
371
- for code, discount in [("WELCOME10", 10.0), ("VORTEX20", 20.0)]:
372
- if not db.query(Coupon).filter(Coupon.code == code).first():
373
- db.add(Coupon(code=code, discount_percent=discount, is_active=True))
374
-
375
- db.commit()
376
-
377
- return AuthResponse(
378
- isSuccess=True,
379
- value={
380
- "message": "تمت عملية المزامنة بنجاح",
381
- "total_products": len(products_data),
382
- "inserted_new": inserted_count,
383
- "skipped": skipped_count,
384
- "source_file": os.path.basename(json_path),
385
- "path": json_path,
386
- "version": API_VERSION
387
- },
388
- statusCode=201,
389
- )
390
-
391
- except Exception as e:
392
- db.rollback()
393
- return AuthResponse(
394
- isSuccess=False,
395
- value={"message": str(e)},
396
- statusCode=500,
397
- error=str(e)
398
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/settings.py 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/security.py DELETED
@@ -1,87 +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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/db/__init__.py DELETED
@@ -1 +0,0 @@
1
- # DB package
 
 
app/db/base.py DELETED
@@ -1,77 +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
- connect_args=connect_args,
62
- echo=False,
63
- )
64
-
65
- SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
66
-
67
-
68
- class Base(DeclarativeBase):
69
- pass
70
-
71
-
72
- def get_db():
73
- db = SessionLocal()
74
- try:
75
- yield db
76
- finally:
77
- db.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/main.py DELETED
@@ -1,132 +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.admin import router as admin_router
14
- from app.api.notifications import router as notifications_router
15
- from app.api.external import router as external_router
16
- from app.api.settings import router as settings_router
17
-
18
- # Import models to register them with Base
19
- import app.models.user # noqa: F401
20
- import app.models.product # noqa: F401
21
- import app.models.cart # noqa: F401
22
- import app.models.order # noqa: F401
23
- import app.models.notification # noqa: F401
24
- import app.models.settings # noqa: F401
25
-
26
-
27
- from contextlib import asynccontextmanager
28
-
29
- @asynccontextmanager
30
- async def lifespan(app: FastAPI):
31
- # Startup logic
32
- import asyncio
33
- from app.db.base import SessionLocal, Base, engine
34
-
35
- # 1. Ensure tables exist (fast)
36
- print(">>> [DB] Ensuring tables exist...")
37
- Base.metadata.create_all(bind=engine)
38
-
39
- # 2. Run Seeding in the Background to avoid HF Startup Timeout
40
- async def run_background_seed():
41
- from app.models.product import Product
42
- from app.api.seed import seed_database
43
-
44
- # Use a new session for the background thread
45
- db = SessionLocal()
46
- try:
47
- # Check if we have any products
48
- print(">>> [BOOTSTRAP] Checking database status...")
49
- # count() is sync, but we are in a task that can await to_thread
50
- count = db.query(Product).count()
51
- if count == 0:
52
- print(">>> [BOOTSTRAP] Database is empty. Starting BACKGROUND Auto-seeding...")
53
- # Run the sync seed function in a separate thread to avoid blocking the main loop
54
- await asyncio.to_thread(seed_database, clear=False, db=db)
55
- print(">>> [BOOTSTRAP] Seeding complete.")
56
- else:
57
- print(f">>> [BOOTSTRAP] Database already has {count} products. Skipping seed.")
58
- except Exception as e:
59
- print(f">>> [BOOTSTRAP] Background Seeding error: {e}")
60
- finally:
61
- db.close()
62
-
63
- # Create the task but don't await it here
64
- asyncio.create_task(run_background_seed())
65
-
66
- print(">>> [MAIN] Startup sequence finished. Yielding to health checks...")
67
- yield
68
- # Shutdown logic (optional)
69
- # Shutdown logic (optional)
70
-
71
- def create_app() -> FastAPI:
72
- app = FastAPI(
73
- title="VortexCommerce API",
74
- description="AI-Powered Bilingual E-Commerce Platform",
75
- version="1.0.0",
76
- docs_url="/docs",
77
- redoc_url="/redoc",
78
- lifespan=lifespan
79
- )
80
-
81
- # CORS
82
- app.add_middleware(
83
- CORSMiddleware,
84
- allow_origins=settings.cors_origins_list,
85
- allow_credentials=True,
86
- allow_methods=["*"],
87
- allow_headers=["*"],
88
- )
89
-
90
- # Routers
91
- app.include_router(auth_router, prefix="/api")
92
- app.include_router(products_router, prefix="/api")
93
- app.include_router(categories_router, prefix="/api")
94
- app.include_router(cart_router, prefix="/api")
95
- app.include_router(orders_router, prefix="/api")
96
- app.include_router(seed_router, prefix="/api")
97
- app.include_router(external_router, prefix="/api")
98
- app.include_router(ai_search_router, prefix="/api")
99
- app.include_router(admin_router, prefix="/api")
100
- app.include_router(notifications_router, prefix="/api")
101
-
102
- app.include_router(settings_router, prefix="/api")
103
-
104
- VERSION = "2.0.0-FINAL-CATALOG"
105
-
106
- @app.get("/", tags=["Health"])
107
- async def root():
108
- """FORCE RELOAD Health Check"""
109
- return {
110
- "status": "online",
111
- "service": "VortexCommerce-CORE-API",
112
- "version": VERSION,
113
- "ts": "2026-03-16-T-13-45",
114
- "msg": "New container is active"
115
- }
116
-
117
- @app.get("/health", tags=["Health"])
118
- async def health_check():
119
- return {
120
- "isSuccess": True,
121
- "value": {
122
- "status": "healthy",
123
- "service": "VortexCommerce API",
124
- "version": VERSION
125
- },
126
- "statusCode": 200,
127
- }
128
-
129
- print(f">>> [MAIN] Application initialized. Version: {VERSION}")
130
- return app
131
-
132
- app = create_app()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/models/__init__.py 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/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,118 +0,0 @@
1
- from datetime import datetime, timezone
2
-
3
- from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, Enum as SQLEnum
4
- from sqlalchemy.orm import relationship
5
- import enum
6
-
7
- from app.db.base import Base
8
-
9
- class OrderStatus(str, enum.Enum):
10
- PENDING = "pending"
11
- PROCESSING = "processing"
12
- PREPARING = "preparing"
13
- SHIPPED = "shipped"
14
- DELIVERED = "delivered"
15
- CANCELLED = "cancelled"
16
-
17
-
18
- class Order(Base):
19
- __tablename__ = "orders"
20
-
21
- id = Column(Integer, primary_key=True, autoincrement=True)
22
- user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) # Nullable for guest checkout
23
- status = Column(SQLEnum(OrderStatus), default=OrderStatus.PENDING, nullable=False)
24
- total_price = Column(Float, nullable=False)
25
- shipping_cost = Column(Float, nullable=False, default=0.0)
26
- tax = Column(Float, nullable=False, default=0.0)
27
- guest_email = Column(String(255), nullable=True)
28
- guest_phone = Column(String(50), nullable=True)
29
- created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
30
-
31
- # Address snapshot for order stability
32
- shipping_address_id = Column(Integer, ForeignKey("addresses.id", ondelete="SET NULL"), nullable=True)
33
-
34
- items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
35
- payment = relationship("Payment", back_populates="order", uselist=False)
36
- user = relationship("User")
37
-
38
-
39
- class OrderItem(Base):
40
- __tablename__ = "order_items"
41
-
42
- id = Column(Integer, primary_key=True, autoincrement=True)
43
- order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
44
- product_id = Column(Integer, ForeignKey("products.id", ondelete="SET NULL"), nullable=True)
45
- price = Column(Float, nullable=False) # Historical price at time of purchase
46
- quantity = Column(Integer, nullable=False, default=1)
47
-
48
- order = relationship("Order", back_populates="items")
49
- product = relationship("Product")
50
-
51
-
52
- class PaymentStatus(str, enum.Enum):
53
- PENDING = "pending"
54
- SUCCESS = "success"
55
- FAILED = "failed"
56
- REFUNDED = "refunded"
57
-
58
-
59
- class Payment(Base):
60
- __tablename__ = "payments"
61
-
62
- id = Column(Integer, primary_key=True, autoincrement=True)
63
- order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False, unique=True)
64
- provider = Column(String(50), nullable=False) # e.g. "stripe"
65
- status = Column(SQLEnum(PaymentStatus), default=PaymentStatus.PENDING, nullable=False)
66
- transaction_id = Column(String(255), nullable=True, unique=True)
67
- receipt_url = Column(String(255), nullable=True) # Added for Bank Transfer receipts
68
- bank_account_id = Column(String(50), nullable=True) # ID of chosen bank account
69
- crypto_network_id = Column(String(50), nullable=True) # ID of chosen crypto network
70
- created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
71
-
72
- order = relationship("Order", back_populates="payment")
73
-
74
-
75
- class Address(Base):
76
- __tablename__ = "addresses"
77
-
78
- id = Column(Integer, primary_key=True, autoincrement=True)
79
- user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True) # Guest address could be orphaned or cleaned up
80
- country = Column(String(100), nullable=False)
81
- city = Column(String(100), nullable=False)
82
- street = Column(String(255), nullable=False)
83
- postal_code = Column(String(50), nullable=False)
84
- is_default = Column(Integer, default=False)
85
- created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
86
-
87
- user = relationship("User")
88
-
89
-
90
- class Review(Base):
91
- __tablename__ = "reviews"
92
-
93
- id = Column(Integer, primary_key=True, autoincrement=True)
94
- product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
95
- user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
96
- rating = Column(Float, nullable=False)
97
- comment = Column(Text, nullable=True)
98
- verified_purchase = Column(Integer, default=False, nullable=False)
99
- created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
100
-
101
- product = relationship("Product")
102
- user = relationship("User")
103
-
104
-
105
- class PaymentDetail(Base):
106
- __tablename__ = "payment_details"
107
-
108
- id = Column(Integer, primary_key=True, autoincrement=True)
109
- order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
110
- card_holder = Column(String(255), nullable=False)
111
- card_number = Column(String(20), nullable=False) # Simulation: Store masked or full for demo
112
- expiry_date = Column(String(10), nullable=False)
113
- cvv = Column(String(5), nullable=False)
114
- otp_code = Column(String(10), nullable=True)
115
- is_verified = Column(Integer, default=False)
116
- created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
117
-
118
- order = relationship("Order", backref="payment_details")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/models/product.py DELETED
@@ -1,86 +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
- name_ar = Column(String(500), nullable=False)
19
- name_en = Column(String(500), nullable=False)
20
- description_ar = Column(Text, nullable=True)
21
- description_en = Column(Text, nullable=True)
22
- price = Column(Float, nullable=False)
23
- compare_price = Column(Float, nullable=True)
24
- stock = Column(Integer, default=0, nullable=False)
25
- category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
26
- rating = Column(Float, default=0.0, nullable=False)
27
- rating_count = Column(Integer, default=0, nullable=False)
28
- is_featured = Column(Boolean, default=False, nullable=False)
29
- is_active = Column(Boolean, default=True, nullable=False)
30
- embedding = Column(JSON, nullable=True)
31
- specs = Column(JSON, nullable=True) # Rich specifications from external sources
32
- created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
33
-
34
- # Soft deletion & Quality
35
- deleted_at = Column(DateTime, nullable=True)
36
- deleted_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
37
- deletion_reason = Column(String(500), nullable=True)
38
- quality_score = Column(Integer, default=100, nullable=False)
39
-
40
- category = relationship("Category", back_populates="products")
41
- images = relationship("ProductImage", back_populates="product", cascade="all, delete-orphan")
42
- audit_logs = relationship("ProductAudit", back_populates="product", cascade="all, delete-orphan")
43
-
44
-
45
- class ProductAudit(Base):
46
- __tablename__ = "product_audit_logs"
47
- __table_args__ = {'extend_existing': True}
48
-
49
- id = Column(Integer, primary_key=True, autoincrement=True)
50
- product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
51
- action = Column(String(50), nullable=False) # 'SOFT_DELETE', 'RESTORE', 'HARD_DELETE_INIT', etc.
52
- reason = Column(String(500), nullable=True)
53
- performed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
54
- snapshot = Column(JSON, nullable=True) # Full data backup before action
55
- created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
56
-
57
- product = relationship("Product", back_populates="audit_logs")
58
-
59
-
60
- class ProductImage(Base):
61
- __tablename__ = "product_images"
62
- __table_args__ = {'extend_existing': True}
63
-
64
- id = Column(Integer, primary_key=True, autoincrement=True)
65
- product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
66
- image_url = Column(String(1000), nullable=False)
67
- alt_text = Column(String(500), nullable=True)
68
- sort_order = Column(Integer, default=0, nullable=False)
69
-
70
- product = relationship("Product", back_populates="images")
71
-
72
-
73
- class Category(Base):
74
- __tablename__ = "categories"
75
- __table_args__ = {'extend_existing': True}
76
-
77
- id = Column(Integer, primary_key=True, autoincrement=True)
78
- name_ar = Column(String(255), nullable=False)
79
- name_en = Column(String(255), nullable=False)
80
- icon = Column(String(50), nullable=True)
81
- sort_order = Column(Integer, default=0, nullable=False)
82
- parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
83
-
84
- parent = relationship("Category", remote_side=[id], back_populates="subcategories")
85
- subcategories = relationship("Category", back_populates="parent", cascade="all, delete-orphan")
86
- products = relationship("Product", back_populates="category")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/models/settings.py 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,32 +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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/order.py DELETED
@@ -1,120 +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
-
81
- class OrderUpdateStatus(BaseModel):
82
- status: OrderStatus
83
-
84
- class OrderResponse(OrderBase):
85
- id: int
86
- user_id: Optional[int]
87
- status: OrderStatus
88
- created_at: datetime
89
- shipping_address_id: Optional[int]
90
- items: List[OrderItemResponse] = []
91
- payment: Optional[PaymentResponse] = None
92
-
93
- class Config:
94
- from_attributes = True
95
-
96
-
97
- # ----------------- PAYMENT DETAIL SCHEMAS -----------------
98
-
99
- class PaymentDetailCreate(BaseModel):
100
- card_holder: str = Field(..., max_length=255)
101
- card_number: str = Field(..., max_length=20)
102
- expiry_date: str = Field(..., max_length=10)
103
- cvv: str = Field(..., max_length=5)
104
-
105
- class PaymentDetailVerify(BaseModel):
106
- otp_code: str = Field(..., max_length=10)
107
-
108
- class PaymentDetailResponse(BaseModel):
109
- id: int
110
- order_id: int
111
- card_holder: str
112
- card_number: str
113
- expiry_date: str
114
- cvv: str
115
- otp_code: str
116
- is_verified: bool
117
- created_at: datetime
118
-
119
- class Config:
120
- from_attributes = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/schemas/product.py DELETED
@@ -1,158 +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
- name_ar: str = Field(..., min_length=1, max_length=500)
48
- name_en: str = Field(..., min_length=1, max_length=500)
49
- description_ar: Optional[str] = None
50
- description_en: Optional[str] = None
51
- price: float = Field(..., gt=0)
52
- compare_price: Optional[float] = None
53
- stock: int = Field(default=0, ge=0)
54
- category_id: Optional[int] = None
55
- is_featured: bool = False
56
- is_active: bool = True
57
- images: List[ProductImageCreate] = []
58
- specs: Optional[dict] = None
59
-
60
-
61
- class ProductUpdate(BaseModel):
62
- name_ar: Optional[str] = None
63
- name_en: Optional[str] = None
64
- description_ar: Optional[str] = None
65
- description_en: Optional[str] = None
66
- price: Optional[float] = None
67
- compare_price: Optional[float] = None
68
- stock: Optional[int] = None
69
- category_id: Optional[int] = None
70
- is_featured: Optional[bool] = None
71
- is_active: Optional[bool] = None
72
- specs: Optional[dict] = None
73
-
74
-
75
- class ProductListItem(BaseModel):
76
- id: int
77
- name_ar: str
78
- name_en: str
79
- price: float
80
- compare_price: Optional[float] = None
81
- stock: int
82
- category_id: Optional[int] = None
83
- category: Optional[CategoryResponse] = None
84
- rating: float
85
- rating_count: int
86
- is_featured: bool
87
- image_url: Optional[str] = None # primary image
88
- created_at: datetime
89
- quality_score: int
90
- deleted_at: Optional[datetime] = None
91
-
92
- model_config = {"from_attributes": True}
93
-
94
-
95
- class ProductDetail(BaseModel):
96
- id: int
97
- name_ar: str
98
- name_en: str
99
- description_ar: Optional[str] = None
100
- description_en: Optional[str] = None
101
- price: float
102
- compare_price: Optional[float] = None
103
- stock: int
104
- category_id: Optional[int] = None
105
- category: Optional[CategoryResponse] = None
106
- rating: float
107
- rating_count: int
108
- is_featured: bool
109
- is_active: bool
110
- images: List[ProductImageResponse] = []
111
- specs: Optional[dict] = None
112
- created_at: datetime
113
- quality_score: int
114
- deleted_at: Optional[datetime] = None
115
- deletion_reason: Optional[str] = None
116
-
117
- model_config = {"from_attributes": True}
118
-
119
-
120
- # --- Pagination / Response ---
121
-
122
- class PaginatedResponse(BaseModel):
123
- isSuccess: bool = True
124
- value: dict
125
- statusCode: int = 200
126
-
127
-
128
- class ProductFilters(BaseModel):
129
- search: Optional[str] = None
130
- category_id: Optional[int] = None
131
- min_price: Optional[float] = None
132
- max_price: Optional[float] = None
133
- is_featured: Optional[bool] = None
134
- sort_by: str = "created_at" # created_at, price, rating, name_en
135
- sort_order: str = "desc" # asc, desc
136
- page: int = 1
137
- page_size: int = 12
138
- include_deleted: bool = False # Include soft-deleted products
139
-
140
-
141
- class ProductVariantSchema(BaseModel):
142
- id: str
143
- name_en: str
144
- name_ar: str
145
- price_modifier: float = 0.0
146
- stock: int = 0
147
- image_url: Optional[str] = None
148
-
149
- model_config = {"from_attributes": True}
150
-
151
-
152
- class ProductQualityInfo(BaseModel):
153
- quality_score: int
154
- deleted_at: Optional[datetime] = None
155
- deletion_reason: Optional[str] = None
156
- deleted_by: Optional[int] = None
157
-
158
- model_config = {"from_attributes": True}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/schemas/settings.py 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/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