hisham-cmd commited on
Commit
ab17d2c
·
verified ·
1 Parent(s): 72d4e57

Update backend with high-quality catalog and fixes

Browse files
app/api/admin.py CHANGED
@@ -6,8 +6,9 @@ from datetime import datetime, timedelta
6
  from app.db.base import get_db
7
  from app.models.user import User, UserRole
8
  from app.models.order import Order, OrderStatus, PaymentDetail
9
- from app.models.product import Product
10
  from app.api.auth import get_current_user
 
11
 
12
  router = APIRouter(prefix="/admin", tags=["Administration"])
13
 
@@ -300,3 +301,196 @@ def get_order_payment_details(
300
  "statusCode": 200
301
  }
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  from app.db.base import get_db
7
  from app.models.user import User, UserRole
8
  from app.models.order import Order, OrderStatus, PaymentDetail
9
+ from app.models.product import Product, ProductImage, ProductAudit
10
  from app.api.auth import get_current_user
11
+ import json
12
 
13
  router = APIRouter(prefix="/admin", tags=["Administration"])
14
 
 
301
  "statusCode": 200
302
  }
303
 
304
+ @router.get("/products/deleted")
305
+ def get_deleted_products(
306
+ current_user_id: int = Depends(get_current_user),
307
+ db: Session = Depends(get_db)
308
+ ):
309
+ """List all soft-deleted products pending administrative review."""
310
+ check_admin(current_user_id, db)
311
+
312
+ deleted_products = db.query(Product).filter(Product.deleted_at.isnot(None)).all()
313
+
314
+ results = [{
315
+ "id": p.id,
316
+ "name_en": p.name_en,
317
+ "name_ar": p.name_ar,
318
+ "price": p.price,
319
+ "deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
320
+ "deletion_reason": p.deletion_reason,
321
+ "quality_score": p.quality_score,
322
+ "image_url": p.images[0].image_url if p.images else None
323
+ } for p in deleted_products]
324
+
325
+ return {
326
+ "isSuccess": True,
327
+ "value": {"products": results},
328
+ "statusCode": 200
329
+ }
330
+
331
+ @router.post("/products/{product_id}/restore")
332
+ def restore_product(
333
+ product_id: int,
334
+ current_user_id: int = Depends(get_current_user),
335
+ db: Session = Depends(get_db)
336
+ ):
337
+ """Restore a soft-deleted product and log the action."""
338
+ admin = check_admin(current_user_id, db)
339
+ product = db.query(Product).filter(Product.id == product_id).first()
340
+
341
+ if not product:
342
+ raise HTTPException(status_code=404, detail="Product not found")
343
+
344
+ if not product.deleted_at:
345
+ return {"isSuccess": False, "message": "Product is not deleted", "statusCode": 400}
346
+
347
+ # Log the restore action
348
+ audit = ProductAudit(
349
+ product_id=product.id,
350
+ action="RESTORE",
351
+ reason="Manual restoration by admin",
352
+ performed_by=admin.id,
353
+ created_at=datetime.utcnow()
354
+ )
355
+ db.add(audit)
356
+
357
+ product.deleted_at = None
358
+ product.deletion_reason = None
359
+ product.deleted_by = None
360
+ product.is_active = True
361
+
362
+ db.commit()
363
+
364
+ return {
365
+ "isSuccess": True,
366
+ "value": {"message": f"Product {product.id} restored successfully"},
367
+ "statusCode": 200
368
+ }
369
+
370
+ @router.delete("/products/{product_id}/final")
371
+ def permanently_delete_product(
372
+ product_id: int,
373
+ current_user_id: int = Depends(get_current_user),
374
+ db: Session = Depends(get_db)
375
+ ):
376
+ """Permanently delete a product from the database after review."""
377
+ check_admin(current_user_id, db)
378
+ product = db.query(Product).filter(Product.id == product_id).first()
379
+
380
+ if not product:
381
+ raise HTTPException(status_code=404, detail="Product not found")
382
+
383
+ db.delete(product)
384
+ db.commit()
385
+
386
+ return {
387
+ "isSuccess": True,
388
+ "value": {"message": f"Product {product_id} permanently deleted"},
389
+ "statusCode": 200
390
+ }
391
+
392
+ @router.get("/products/{product_id}/audit")
393
+ def get_product_audit_logs(
394
+ product_id: int,
395
+ current_user_id: int = Depends(get_current_user),
396
+ db: Session = Depends(get_db)
397
+ ):
398
+ """Get all audit logs for a specific product."""
399
+ check_admin(current_user_id, db)
400
+
401
+ logs = db.query(ProductAudit).filter(ProductAudit.product_id == product_id).order_by(ProductAudit.created_at.desc()).all()
402
+
403
+ results = [{
404
+ "id": l.id,
405
+ "action": l.action,
406
+ "reason": l.reason,
407
+ "created_at": l.created_at.isoformat(),
408
+ "snapshot": l.snapshot
409
+ } for l in logs]
410
+
411
+ return {
412
+ "isSuccess": True,
413
+ "value": {"logs": results},
414
+ "statusCode": 200
415
+ }
416
+
417
+ @router.post("/products/bulk-restore")
418
+ def bulk_restore_products(
419
+ product_ids: list[int],
420
+ current_user_id: int = Depends(get_current_user),
421
+ db: Session = Depends(get_db)
422
+ ):
423
+ """Restore multiple soft-deleted products."""
424
+ admin = check_admin(current_user_id, db)
425
+
426
+ products = db.query(Product).filter(Product.id.in_(product_ids)).all()
427
+ products_to_restore = [p for p in products if p.deleted_at]
428
+
429
+ for product in products_to_restore:
430
+ product.deleted_at = None
431
+ product.deletion_reason = None
432
+ product.deleted_by = None
433
+ product.is_active = True
434
+
435
+ audit = ProductAudit(
436
+ product_id=product.id,
437
+ action="RESTORE",
438
+ reason="Bulk restoration by admin",
439
+ performed_by=admin.id,
440
+ created_at=datetime.utcnow()
441
+ )
442
+ db.add(audit)
443
+
444
+ db.commit()
445
+ return {
446
+ "isSuccess": True,
447
+ "value": {"message": f"Successfully restored {len(products_to_restore)} products"},
448
+ "statusCode": 200
449
+ }
450
+
451
+ @router.post("/products/bulk-hard-delete")
452
+ def bulk_hard_delete_products(
453
+ product_ids: list[int],
454
+ current_user_id: int = Depends(get_current_user),
455
+ db: Session = Depends(get_db)
456
+ ):
457
+ """Permanently delete multiple products."""
458
+ check_admin(current_user_id, db)
459
+
460
+ # We can delete them all in one go or loop if we want to log (hard delete has no audit after delete)
461
+ db.query(Product).filter(Product.id.in_(product_ids)).delete(synchronize_session=False)
462
+ db.commit()
463
+
464
+ return {
465
+ "isSuccess": True,
466
+ "value": {"message": f"Successfully deleted {len(product_ids)} products permanently"},
467
+ "statusCode": 200
468
+ }
469
+
470
+ @router.get("/quality/summary")
471
+ def get_quality_summary(
472
+ current_user_id: int = Depends(get_current_user),
473
+ db: Session = Depends(get_db)
474
+ ):
475
+ """Get a summary of product quality issues."""
476
+ check_admin(current_user_id, db)
477
+
478
+ total_products = db.query(func.count(Product.id)).scalar()
479
+ deleted_products = db.query(func.count(Product.id)).filter(Product.deleted_at.isnot(None)).scalar()
480
+ active_products = db.query(func.count(Product.id)).filter(Product.deleted_at.is_ (None)).scalar()
481
+
482
+ # Average quality score of active products
483
+ avg_score_val = db.query(func.avg(Product.quality_score)).filter(Product.deleted_at.is_(None)).scalar()
484
+ avg_score: float = float(avg_score_val) if avg_score_val is not None else 0.0
485
+
486
+ return {
487
+ "isSuccess": True,
488
+ "value": {
489
+ "total_products": total_products,
490
+ "deleted_count": deleted_products,
491
+ "active_count": active_products,
492
+ "average_quality_score": float(f"{avg_score:.2f}")
493
+ },
494
+ "statusCode": 200
495
+ }
496
+
app/api/products.py CHANGED
@@ -329,127 +329,4 @@ async def list_deleted_products(
329
  )
330
 
331
 
332
- from typing import List
333
-
334
-
335
- @router.post("/admin/bulk-delete", response_model=AuthResponse)
336
- async def bulk_soft_delete_products(
337
- product_ids: List[int],
338
- reason: str = "Bulk deletion by admin",
339
- db: Session = Depends(get_db),
340
- user_id: int = Depends(get_current_user),
341
- ):
342
- """Soft delete multiple products at once."""
343
- deleted_count = 0
344
-
345
- for product_id in product_ids:
346
- product = db.query(Product).filter(Product.id == product_id).first()
347
- if product and product.deleted_at is None:
348
- product.deleted_at = datetime.now(timezone.utc)
349
- product.deleted_by = user_id
350
- product.deletion_reason = reason
351
- deleted_count += 1
352
-
353
- db.commit()
354
-
355
- return AuthResponse(
356
- isSuccess=True,
357
- value={"deleted_count": deleted_count, "requested_count": len(product_ids)},
358
- statusCode=200,
359
- )
360
-
361
-
362
- @router.post("/admin/bulk-restore", response_model=AuthResponse)
363
- async def bulk_restore_products(
364
- product_ids: List[int],
365
- db: Session = Depends(get_db),
366
- user_id: int = Depends(get_current_user),
367
- ):
368
- """Restore multiple soft-deleted products."""
369
- restored_count = 0
370
-
371
- for product_id in product_ids:
372
- product = db.query(Product).filter(Product.id == product_id).first()
373
- if product and product.deleted_at is not None:
374
- product.deleted_at = None
375
- product.deleted_by = None
376
- product.deletion_reason = None
377
- restored_count += 1
378
-
379
- db.commit()
380
-
381
- return AuthResponse(
382
- isSuccess=True,
383
- value={"restored_count": restored_count, "requested_count": len(product_ids)},
384
- statusCode=200,
385
- )
386
-
387
-
388
- @router.post("/admin/bulk-hard-delete", response_model=AuthResponse)
389
- async def bulk_hard_delete_products(
390
- product_ids: List[int],
391
- db: Session = Depends(get_db),
392
- user_id: int = Depends(get_current_user),
393
- ):
394
- """Permanently delete multiple products (hard delete)."""
395
- deleted_count = 0
396
-
397
- for product_id in product_ids:
398
- product = db.query(Product).filter(Product.id == product_id).first()
399
- if product:
400
- db.delete(product)
401
- deleted_count += 1
402
-
403
- db.commit()
404
-
405
- return AuthResponse(
406
- isSuccess=True,
407
- value={"deleted_count": deleted_count, "requested_count": len(product_ids)},
408
- statusCode=200,
409
- )
410
-
411
-
412
- @router.get("/admin/quality/summary", response_model=AuthResponse)
413
- async def get_quality_summary(
414
- db: Session = Depends(get_db),
415
- user_id: int = Depends(get_current_user),
416
- ):
417
- """Get quality statistics summary."""
418
- from sqlalchemy import func
419
-
420
- try:
421
- total_products = db.query(Product).filter(Product.deleted_at == None).count()
422
- total_deleted = db.query(Product).filter(Product.deleted_at != None).count()
423
-
424
- avg_quality = db.query(func.avg(Product.quality_score)).filter(
425
- Product.deleted_at == None
426
- ).scalar() or 0
427
-
428
- low_quality = db.query(Product).filter(
429
- Product.deleted_at == None,
430
- Product.quality_score < 70
431
- ).count()
432
-
433
- no_images = 0
434
- products = db.query(Product).filter(Product.deleted_at == None).all()
435
- for p in products:
436
- if not p.images or len(p.images) == 0:
437
- no_images += 1
438
-
439
- return AuthResponse(
440
- isSuccess=True,
441
- value={
442
- "total_active": total_products,
443
- "total_deleted": total_deleted,
444
- "avg_quality_score": round(float(avg_quality), 1),
445
- "low_quality_products": low_quality,
446
- "products_without_images": no_images,
447
- },
448
- statusCode=200,
449
- )
450
- except Exception as e:
451
- return AuthResponse(
452
- isSuccess=False,
453
- value={"error": str(e)},
454
- statusCode=500,
455
- )
 
329
  )
330
 
331
 
332
+ # End of products.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/models/product.py CHANGED
@@ -39,6 +39,22 @@ class Product(Base):
39
 
40
  category = relationship("Category", back_populates="products")
41
  images = relationship("ProductImage", back_populates="product", cascade="all, delete-orphan")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
 
44
  class ProductImage(Base):
 
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):
final_audit_report.json ADDED
The diff for this file is too large to render. See raw diff
 
product_quality_20260311_071055.log ADDED
File without changes
product_quality_20260311_071142.log ADDED
File without changes
product_quality_20260311_071259.log ADDED
File without changes
product_quality_20260311_071329.log ADDED
File without changes
real_products_1000.json CHANGED
The diff for this file is too large to render. See raw diff
 
real_products_1000_validation.json ADDED
The diff for this file is too large to render. See raw diff
 
seed_orchestrator.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import sys
4
+ import asyncio
5
+ import logging
6
+
7
+ # Set up paths - we assume this script runs from the backend directory
8
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
+
10
+ # Set logging
11
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
12
+ logger = logging.getLogger(__name__)
13
+
14
+ async def main():
15
+ try:
16
+ from app.db.base import SessionLocal, engine, Base
17
+ from app.api.seed import seed_database
18
+ # Models are imported within seed_database and checker
19
+ except ImportError as e:
20
+ logger.error(f"Import failed: {e}")
21
+ return
22
+
23
+ # Seed database logic will handle clearing tables if clear=True is passed
24
+ # Ensure tables exist
25
+ logger.info("Initializing database tables...")
26
+ Base.metadata.create_all(bind=engine)
27
+
28
+ db = SessionLocal()
29
+ try:
30
+ logger.info("Step 1: Seeding database with 1000 models...")
31
+ result = await seed_database(clear=True, db=db)
32
+ logger.info(f"Seed Result: {result.value.get('message')} - {result.value.get('inserted_new')} inserted")
33
+
34
+ logger.info("Step 2: Running dynamic quality audit on DB...")
35
+ # Import checker with correct path
36
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "scripts"))
37
+ from product_quality_checker import ProductQualityChecker
38
+
39
+ checker = ProductQualityChecker(db)
40
+ report = checker.run_quality_check()
41
+
42
+ logger.info(f"Audit Complete: {report.total_valid} valid, {report.total_invalid} soft-deleted")
43
+
44
+ # Save final report
45
+ with open("final_audit_report.json", "w", encoding="utf-8") as f:
46
+ import json
47
+ json.dump(report.to_dict(), f, indent=2, ensure_ascii=False)
48
+
49
+ except Exception as e:
50
+ logger.exception(f"Orchestration failed: {e}")
51
+ finally:
52
+ db.close()
53
+
54
+ if __name__ == "__main__":
55
+ asyncio.run(main())