hisham-cmd commited on
Commit
bec3a78
·
verified ·
1 Parent(s): 5016805

Update backend with high-quality catalog and fixes

Browse files
app/api/product_quality_checker_lib.py ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ logging.basicConfig(
43
+ level=logging.INFO,
44
+ format='%(asctime)s - %(levelname)s - %(message)s',
45
+ handlers=[
46
+ logging.FileHandler(f'product_quality_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'),
47
+ logging.StreamHandler()
48
+ ]
49
+ )
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ class QualityReport:
54
+ """Quality check report container."""
55
+
56
+ def __init__(self):
57
+ self.timestamp = datetime.now(timezone.utc)
58
+ self.total_checked = 0
59
+ self.total_valid = 0
60
+ self.total_invalid = 0
61
+ self.deleted_products: List[Dict[str, Any]] = []
62
+ self.updated_scores: List[Dict[str, Any]] = []
63
+ self.errors: List[str] = []
64
+
65
+ def to_dict(self) -> Dict[str, Any]:
66
+ return {
67
+ "timestamp": self.timestamp.isoformat(),
68
+ "total_checked": self.total_checked,
69
+ "total_valid": self.total_valid,
70
+ "total_invalid": self.total_invalid,
71
+ "deleted_products": self.deleted_products,
72
+ "updated_scores": self.updated_scores,
73
+ "errors": self.errors
74
+ }
75
+
76
+
77
+ class ProductQualityChecker:
78
+ """Comprehensive product quality validation system."""
79
+
80
+ def __init__(self, db_session=None):
81
+ self.db = db_session
82
+ self.report = QualityReport()
83
+ self.session = requests.Session()
84
+ self.session.headers.update({
85
+ 'User-Agent': 'VortexCommerce-QualityChecker/1.0'
86
+ })
87
+
88
+ def validate_image_url(self, url: str, timeout: int = 10, retries: int = 3) -> Tuple[bool, Optional[str]]:
89
+ """
90
+ Validate if image URL returns valid content (not 404).
91
+ Returns (is_valid, error_message).
92
+ """
93
+ if not url:
94
+ return False, "Empty URL"
95
+
96
+ for attempt in range(retries):
97
+ try:
98
+ # Use GET with stream=True for more reliable check than HEAD
99
+ response = self.session.get(url, timeout=timeout, allow_redirects=True, verify=False, stream=True)
100
+ status_code = response.status_code
101
+ content_type = response.headers.get('Content-Type', '')
102
+ response.close()
103
+
104
+ if status_code == 200 and content_type.startswith('image/'):
105
+ return True, None
106
+
107
+ if status_code == 404:
108
+ return False, f"HTTP {status_code}"
109
+
110
+ # If other error, retry
111
+ last_error = f"HTTP {status_code}"
112
+
113
+ except (requests.exceptions.RequestException, Exception) as e:
114
+ last_error = str(e)[:50]
115
+
116
+ if attempt < retries - 1:
117
+ logger.debug(f"Retrying {url} (attempt {attempt + 2}/{retries})")
118
+
119
+ return False, last_error
120
+
121
+ def validate_product_images(self, product: 'Product') -> Tuple[bool, List[str]]:
122
+ """Validate all images for a product including variants."""
123
+ errors = []
124
+
125
+ # Check primary images
126
+ if not product.images:
127
+ errors.append("No primary images attached to product")
128
+ else:
129
+ for img in product.images:
130
+ is_valid, error = self.validate_image_url(img.image_url)
131
+ if not is_valid:
132
+ errors.append(f"Primary Image {img.id}: {error}")
133
+
134
+ # Check variant images if present in specs
135
+ if product.specs and isinstance(product.specs, dict) and 'variants' in product.specs:
136
+ variants = product.specs['variants']
137
+ for i, var in enumerate(variants):
138
+ img_url = var.get('image_url') or var.get('image')
139
+ if img_url:
140
+ is_valid, error = self.validate_image_url(img_url)
141
+ if not is_valid:
142
+ errors.append(f"Variant {i} Image: {error}")
143
+
144
+ return len(errors) == 0, errors
145
+
146
+ def calculate_quality_score(self, product: 'Product') -> int:
147
+ """
148
+ Calculates a weighted quality score (0-100):
149
+ - Name/Desc (30pts): EN/AR presence and length
150
+ - Images (30pts): Primary image validity and gallery count
151
+ - Variants (20pts): Nested options completeness
152
+ - Metadata (20pts): Specs, brand, category
153
+ """
154
+ score = 0
155
+
156
+ # 1. Names & Descriptions (30 pts)
157
+ if product.name_en and len(product.name_en) > 10: score += 7
158
+ if product.name_ar and len(product.name_ar) > 10: score += 8
159
+ if product.description_en and len(product.description_en) > 50: score += 7
160
+ if product.description_ar and len(product.description_ar) > 50: score += 8
161
+
162
+ # 2. Images (30 pts)
163
+ if product.images and len(product.images) > 0:
164
+ score += 15 # Has at least one image
165
+ if len(product.images) >= 3: score += 15 # Good gallery
166
+ elif len(product.images) == 2: score += 10
167
+
168
+ # 3. Variants/Options (20 pts)
169
+ has_options = False
170
+ if product.specs and isinstance(product.specs, dict):
171
+ options = product.specs.get('options')
172
+ if options and len(options) > 0:
173
+ has_options = True
174
+ score += 20
175
+
176
+ # 4. Metadata (20 pts)
177
+ if product.category_id: score += 5
178
+ if product.price > 0: score += 5
179
+ if product.stock > 0: score += 5
180
+ if product.specs and len(product.specs) > 2: score += 5
181
+
182
+ return min(100, score)
183
+
184
+ def perform_soft_delete(self, product: 'Product', reason: str, deleted_by: Optional[int] = None):
185
+ """
186
+ Soft-deletes a product and archives a snapshot for 30-day recovery.
187
+ """
188
+ try:
189
+ # Prepare recovery snapshot
190
+ snapshot = {
191
+ "name_en": product.name_en,
192
+ "name_ar": product.name_ar,
193
+ "price": product.price,
194
+ "stock": product.stock,
195
+ "specs": product.specs,
196
+ "images": [img.image_url for img in product.images],
197
+ "soft_deleted_at": datetime.now(timezone.utc).isoformat()
198
+ }
199
+
200
+ product.deleted_at = datetime.now(timezone.utc)
201
+ product.is_active = False
202
+ product.deletion_reason = reason
203
+ product.deleted_by = deleted_by
204
+
205
+ # Log audit trail
206
+ audit = ProductAudit(
207
+ product_id=product.id,
208
+ action="IMAGE_FAILURE_AUTO_DELETE",
209
+ reason=reason,
210
+ snapshot=snapshot,
211
+ performed_by=deleted_by
212
+ )
213
+ self.db.add(audit)
214
+
215
+ self.report.deleted_products.append({
216
+ "id": product.id,
217
+ "reason": reason
218
+ })
219
+ self.report.total_invalid += 1
220
+ logger.warning(f"🚨 AUTO-DELETED product {product.id} due to {reason}")
221
+ return True
222
+ except Exception as e:
223
+ logger.error(f"Failed to soft-delete {product.id}: {e}")
224
+ return False
225
+
226
+ def process_product(self, product: 'Product') -> bool:
227
+ """Process quality lifecycle for a single product."""
228
+ self.report.total_checked += 1
229
+
230
+ # 1. Critical Image Integrity Check (Phase 3 Requirement)
231
+ if not product.images:
232
+ return self.perform_soft_delete(product, "Missing all images")
233
+
234
+ for img in product.images:
235
+ is_valid, error = self.validate_image_url(img.image_url)
236
+ if not is_valid:
237
+ return self.perform_soft_delete(product, f"Image 404/Error: {error}")
238
+
239
+ # 2. Score Calculation
240
+ old_score = product.quality_score
241
+ new_score = self.calculate_quality_score(product)
242
+
243
+ if old_score != new_score:
244
+ product.quality_score = new_score
245
+ self.report.updated_scores.append({
246
+ "id": product.id,
247
+ "old": old_score,
248
+ "new": new_score
249
+ })
250
+
251
+ self.report.total_valid += 1
252
+ return True
253
+
254
+ def run_quality_check(self, batch_size: int = 50) -> QualityReport:
255
+ """Run full quality check on all active products."""
256
+ if not self.db:
257
+ logger.error("No database session available")
258
+ return self.report
259
+
260
+ logger.info("=" * 60)
261
+ logger.info("Starting Product Quality Check")
262
+ logger.info("=" * 60)
263
+
264
+ try:
265
+ products = self.db.query(Product).filter(
266
+ Product.is_active == True,
267
+ Product.deleted_at == None
268
+ ).all()
269
+
270
+ logger.info(f"Found {len(products)} active products to check")
271
+
272
+ for idx, product in enumerate(products, 1):
273
+ self.process_product(product)
274
+
275
+ if idx % batch_size == 0:
276
+ self.db.commit()
277
+ logger.info(f"Processed {idx}/{len(products)} products...")
278
+
279
+ self.db.commit()
280
+
281
+ except Exception as e:
282
+ logger.error(f"Quality check failed: {str(e)}")
283
+ self.db.rollback()
284
+ self.report.errors.append(f"Quality check failed: {str(e)}")
285
+
286
+ logger.info("=" * 60)
287
+ logger.info(f"Quality Check Complete")
288
+ logger.info(f" Total Checked: {self.report.total_checked}")
289
+ logger.info(f" Valid Products: {self.report.total_valid}")
290
+ logger.info(f" Invalid (Deleted): {self.report.total_invalid}")
291
+ logger.info(f" Score Updates: {len(self.report.updated_scores)}")
292
+ logger.info("=" * 60)
293
+
294
+ return self.report
295
+
296
+
297
+ class StandaloneQualityChecker:
298
+ """Run quality check on JSON file without database."""
299
+
300
+ def __init__(self, json_path: str):
301
+ self.json_path = json_path
302
+ self.report = QualityReport()
303
+ self.session = requests.Session()
304
+
305
+ def validate_image_url(self, url: str, timeout: int = 10) -> Tuple[bool, Optional[str]]:
306
+ """Validate single image URL."""
307
+ if not url:
308
+ return False, "Empty URL"
309
+
310
+ try:
311
+ response = self.session.head(url, timeout=timeout, allow_redirects=True, verify=False)
312
+
313
+ if response.status_code >= 400:
314
+ response = self.session.get(url, stream=True, timeout=timeout, verify=False)
315
+ response.close()
316
+
317
+ if response.status_code >= 400:
318
+ return False, f"HTTP {response.status_code}"
319
+
320
+ content_type = response.headers.get('Content-Type', '')
321
+ if not content_type.startswith('image/'):
322
+ return False, f"Invalid content type: {content_type}"
323
+
324
+ return True, None
325
+
326
+ except Exception as e:
327
+ return False, str(e)[:50]
328
+
329
+ def check_json_products(self) -> Dict[str, Any]:
330
+ """Check products from JSON file."""
331
+ logger.info(f"Loading products from {self.json_path}")
332
+
333
+ with open(self.json_path, 'r', encoding='utf-8') as f:
334
+ data = json.load(f)
335
+
336
+ products = data.get('products', [])
337
+ logger.info(f"Found {len(products)} products to validate")
338
+
339
+ results = {
340
+ "valid_products": [],
341
+ "invalid_products": [],
342
+ "summary": {
343
+ "total": len(products),
344
+ "valid": 0,
345
+ "invalid": 0
346
+ }
347
+ }
348
+
349
+ for idx, product in enumerate(products, 1):
350
+ product_id = product.get('id', f'unknown_{idx}')
351
+ name_en = product.get('name_en', 'Unknown')
352
+ images = product.get('images', [])
353
+
354
+ invalid_images = []
355
+ valid_images = []
356
+
357
+ for img_url in images:
358
+ is_valid, error = self.validate_image_url(img_url)
359
+ if is_valid:
360
+ valid_images.append(img_url)
361
+ else:
362
+ invalid_images.append({"url": img_url, "error": error})
363
+
364
+ product_result = {
365
+ "id": product_id,
366
+ "name_en": name_en,
367
+ "images": {
368
+ "total": len(images),
369
+ "valid": len(valid_images),
370
+ "invalid": len(invalid_images),
371
+ "invalid_details": invalid_images[:2]
372
+ }
373
+ }
374
+
375
+ if invalid_images:
376
+ results["invalid_products"].append(product_result)
377
+ results["summary"]["invalid"] += 1
378
+ logger.warning(f"Product {product_id}: {len(invalid_images)} invalid images")
379
+ else:
380
+ results["valid_products"].append(product_result)
381
+ results["summary"]["valid"] += 1
382
+
383
+ if idx % 50 == 0:
384
+ logger.info(f"Processed {idx}/{len(products)} products...")
385
+
386
+ logger.info(f"Validation complete: {results['summary']['valid']} valid, {results['summary']['invalid']} invalid")
387
+
388
+ output_path = self.json_path.replace('.json', '_validation.json')
389
+ with open(output_path, 'w', encoding='utf-8') as f:
390
+ json.dump(results, f, ensure_ascii=False, indent=2)
391
+
392
+ logger.info(f"Results saved to {output_path}")
393
+
394
+ return results
395
+
396
+
397
+ def create_backup(db_session) -> Optional[str]:
398
+ """Create a backup of current products before quality operations."""
399
+ try:
400
+ products = db_session.query(Product).all()
401
+
402
+ backup_data = {
403
+ "timestamp": datetime.now(timezone.utc).isoformat(),
404
+ "total_products": len(products),
405
+ "products": []
406
+ }
407
+
408
+ for p in products:
409
+ product_dict = {
410
+ "id": p.id,
411
+ "name_en": p.name_en,
412
+ "name_ar": p.name_ar,
413
+ "price": p.price,
414
+ "stock": p.stock,
415
+ "is_active": p.is_active,
416
+ "deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
417
+ "quality_score": p.quality_score,
418
+ "images": [{"id": img.id, "image_url": img.image_url} for img in p.images]
419
+ }
420
+ backup_data["products"].append(product_dict)
421
+
422
+ backup_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backups")
423
+ os.makedirs(backup_dir, exist_ok=True)
424
+
425
+ backup_filename = f"products_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
426
+ backup_path = os.path.join(backup_dir, backup_filename)
427
+
428
+ with open(backup_path, 'w', encoding='utf-8') as f:
429
+ json.dump(backup_data, f, ensure_ascii=False, indent=2)
430
+
431
+ logger.info(f"Backup created: {backup_path}")
432
+ return backup_path
433
+
434
+ except Exception as e:
435
+ logger.error(f"Backup creation failed: {str(e)}")
436
+ return None
437
+
438
+
439
+ def main():
440
+ """Main entry point."""
441
+ import argparse
442
+
443
+ parser = argparse.ArgumentParser(description='VortexCommerce Product Quality Checker')
444
+ parser.add_argument('--mode', choices=['db', 'json'], default='db',
445
+ help='Run against database or JSON file')
446
+ parser.add_argument('--json-path',
447
+ default=os.path.join(os.path.dirname(os.path.dirname(__file__)),
448
+ 'backend', 'real_products_1000.json'),
449
+ help='Path to JSON file for standalone mode')
450
+ parser.add_argument('--backup', action='store_true',
451
+ help='Create backup before quality operations')
452
+ parser.add_argument('--batch-size', type=int, default=50,
453
+ help='Batch size for database commits')
454
+
455
+ args = parser.parse_args()
456
+
457
+ logger.info("=" * 60)
458
+ logger.info("VortexCommerce Product Quality Checker v2.0")
459
+ logger.info("=" * 60)
460
+
461
+ if args.mode == 'json':
462
+ logger.info(f"Running in standalone JSON mode: {args.json_path}")
463
+ checker = StandaloneQualityChecker(args.json_path)
464
+ results = checker.check_json_products()
465
+ logger.info(f"Summary: {results['summary']}")
466
+ return 0
467
+
468
+ if not DB_AVAILABLE:
469
+ logger.error("Database modules not available. Use --mode json for standalone mode.")
470
+ return 1
471
+
472
+ db = SessionLocal()
473
+
474
+ try:
475
+ if args.backup:
476
+ backup_path = create_backup(db)
477
+ if not backup_path:
478
+ logger.warning("Continuing without backup...")
479
+
480
+ checker = ProductQualityChecker(db)
481
+ report = checker.run_quality_check(args.batch_size)
482
+
483
+ report_path = os.path.join(os.path.dirname(__file__), f"quality_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json")
484
+ with open(report_path, 'w', encoding='utf-8') as f:
485
+ json.dump(report.to_dict(), f, ensure_ascii=False, indent=2)
486
+
487
+ logger.info(f"Quality report saved: {report_path}")
488
+
489
+ finally:
490
+ db.close()
491
+
492
+ return 0
493
+
494
+
495
+ if __name__ == "__main__":
496
+ sys.exit(main())
app/api/seed.py CHANGED
@@ -117,14 +117,26 @@ async def seed_database(clear: bool = False, db: Session = Depends(get_db)):
117
  db.add(admin_user)
118
  db.commit()
119
 
120
- json_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "backend", "real_products_1000.json")
121
- if not os.path.exists(json_path):
122
- json_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "real_products_1000.json")
123
-
124
- if not os.path.exists(json_path):
 
 
 
 
 
 
 
 
 
 
 
 
125
  return AuthResponse(
126
  isSuccess=False,
127
- value={"message": f"Source file {json_path} not found."},
128
  statusCode=404,
129
  error="File not found"
130
  )
 
117
  db.add(admin_user)
118
  db.commit()
119
 
120
+ # Search for real_products_1000.json in multiple likely locations
121
+ possible_paths = [
122
+ os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "backend", "real_products_1000.json"),
123
+ os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "real_products_1000.json"),
124
+ os.path.abspath("real_products_1000.json"),
125
+ os.path.abspath("backend/real_products_1000.json"),
126
+ "/app/real_products_1000.json",
127
+ "/app/backend/real_products_1000.json"
128
+ ]
129
+
130
+ json_path = None
131
+ for p in possible_paths:
132
+ if os.path.exists(p):
133
+ json_path = p
134
+ break
135
+
136
+ if not json_path:
137
  return AuthResponse(
138
  isSuccess=False,
139
+ value={"message": f"Source file real_products_1000.json not found in attempted paths."},
140
  statusCode=404,
141
  error="File not found"
142
  )
app/main.py CHANGED
@@ -66,6 +66,27 @@ def create_app() -> FastAPI:
66
  "statusCode": 200,
67
  }
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  return app
70
 
71
 
 
66
  "statusCode": 200,
67
  }
68
 
69
+ @app.on_event("startup")
70
+ async def startup_event():
71
+ from app.db.base import SessionLocal
72
+ from app.models.product import Product
73
+ from app.api.seed import seed_database
74
+
75
+ db = SessionLocal()
76
+ try:
77
+ # Check if we have any products
78
+ count = db.query(Product).count()
79
+ if count == 0:
80
+ print(">>> [BOOTSTRAP] Database is empty. Auto-seeding from real_products_1000.json...")
81
+ await seed_database(clear=False, db=db)
82
+ print(">>> [BOOTSTRAP] Seeding complete.")
83
+ else:
84
+ print(f">>> [BOOTSTRAP] Database already has {count} products. Skipping seed.")
85
+ except Exception as e:
86
+ print(f">>> [BOOTSTRAP] Auto-seeding error: {e}")
87
+ finally:
88
+ db.close()
89
+
90
  return app
91
 
92