views.py 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935
  1. # views.py (FULLY FIXED with comprehensive logging)
  2. from django.shortcuts import render, get_object_or_404
  3. from django.http import JsonResponse
  4. from django.views import View
  5. from django.core.cache import cache
  6. from django.db.models import Q
  7. from django.views.decorators.csrf import csrf_exempt
  8. from django.utils.decorators import method_decorator
  9. from django.conf import settings
  10. import json
  11. import logging
  12. import os
  13. from core.services.image_scorer import ImageQualityScorer
  14. from core.models import AttributeScore, CategoryAttributeRule, ProductContentRule, Product
  15. from core.services.attribute_scorer import AttributeQualityScorer
  16. logger = logging.getLogger(__name__)
  17. def categorize_issues_and_suggestions(issues, suggestions):
  18. """Categorize issues and suggestions by component"""
  19. categorized = {
  20. 'attributes': {'issues': [], 'suggestions': []},
  21. 'title': {'issues': [], 'suggestions': []},
  22. 'description': {'issues': [], 'suggestions': []},
  23. 'seo': {'issues': [], 'suggestions': []},
  24. 'content_rules': {'issues': [], 'suggestions': []},
  25. 'images': {'issues': [], 'suggestions': []},
  26. 'general': {'issues': [], 'suggestions': []}
  27. }
  28. for issue in issues:
  29. issue_lower = issue.lower()
  30. if issue.startswith('Title:'):
  31. categorized['title']['issues'].append(issue.replace('Title:', '').strip())
  32. elif issue.startswith('Description:'):
  33. categorized['description']['issues'].append(issue.replace('Description:', '').strip())
  34. elif issue.startswith('SEO:'):
  35. categorized['seo']['issues'].append(issue.replace('SEO:', '').strip())
  36. elif issue.startswith('Image:'):
  37. categorized['images']['issues'].append(issue.replace('Image:', '').strip())
  38. elif any(keyword in issue_lower for keyword in ['image', 'photo', 'picture', 'resolution', 'blur', 'background']):
  39. categorized['images']['issues'].append(issue)
  40. elif any(field in issue_lower for field in ['seo_title', 'seo title', 'seo_description', 'seo description']):
  41. categorized['seo']['issues'].append(issue)
  42. elif 'content rule' in issue_lower:
  43. categorized['content_rules']['issues'].append(issue)
  44. elif any(keyword in issue_lower for keyword in ['mandatory field', 'attribute', 'valid values', 'placeholder', 'standardiz']):
  45. categorized['attributes']['issues'].append(issue)
  46. else:
  47. categorized['general']['issues'].append(issue)
  48. for suggestion in suggestions:
  49. suggestion_lower = suggestion.lower()
  50. if any(prefix in suggestion_lower for prefix in ['title:', 'expand title', 'shorten title', 'add brand name']):
  51. categorized['title']['suggestions'].append(suggestion)
  52. elif any(prefix in suggestion_lower for prefix in ['description:', 'expand description', 'write comprehensive', 'add features']):
  53. categorized['description']['suggestions'].append(suggestion)
  54. elif any(prefix in suggestion_lower for prefix in ['seo:', 'improve seo', 'add keywords', 'search', 'discoverability']):
  55. categorized['seo']['suggestions'].append(suggestion)
  56. elif any(keyword in suggestion_lower for keyword in ['image', 'photo', 'resolution', 'compress', 'background', 'white background']):
  57. categorized['images']['suggestions'].append(suggestion)
  58. elif any(field in suggestion_lower for field in ['seo_title', 'seo title', 'seo_description', 'seo description']):
  59. categorized['seo']['suggestions'].append(suggestion)
  60. elif 'content rule' in suggestion_lower:
  61. categorized['content_rules']['suggestions'].append(suggestion)
  62. elif any(keyword in suggestion_lower for keyword in ['add required', 'add mandatory', 'provide a', 'attribute', 'standardize']):
  63. categorized['attributes']['suggestions'].append(suggestion)
  64. else:
  65. categorized['general']['suggestions'].append(suggestion)
  66. return {k: v for k, v in categorized.items() if v['issues'] or v['suggestions']}
  67. def resolve_image_path(image_rel_path):
  68. """
  69. Resolve relative image path to full absolute path with comprehensive logging
  70. """
  71. logger.info(f"[IMAGE PATH] Starting resolution for: {image_rel_path}")
  72. if not image_rel_path:
  73. logger.warning("[IMAGE PATH] No image path provided")
  74. return None
  75. # Log settings
  76. logger.info(f"[IMAGE PATH] MEDIA_ROOT: {settings.MEDIA_ROOT}")
  77. logger.info(f"[IMAGE PATH] MEDIA_URL: {settings.MEDIA_URL}")
  78. # Remove MEDIA_URL prefix if present
  79. cleaned_path = image_rel_path.replace(settings.MEDIA_URL, "").lstrip("/\\")
  80. logger.info(f"[IMAGE PATH] Cleaned path: {cleaned_path}")
  81. # Build full path
  82. full_path = os.path.join(settings.MEDIA_ROOT, cleaned_path)
  83. logger.info(f"[IMAGE PATH] Full path before normalization: {full_path}")
  84. # Normalize path (resolve .., ., etc.)
  85. full_path = os.path.normpath(full_path)
  86. logger.info(f"[IMAGE PATH] Normalized path: {full_path}")
  87. # Security check
  88. media_root_normalized = os.path.normpath(settings.MEDIA_ROOT)
  89. logger.info(f"[IMAGE PATH] Normalized MEDIA_ROOT: {media_root_normalized}")
  90. if not full_path.startswith(media_root_normalized):
  91. logger.error(f"[IMAGE PATH] SECURITY: Path outside MEDIA_ROOT! Path: {full_path}, MEDIA_ROOT: {media_root_normalized}")
  92. return None
  93. # Check if file exists
  94. if not os.path.exists(full_path):
  95. logger.error(f"[IMAGE PATH] FILE NOT FOUND: {full_path}")
  96. # List directory contents for debugging
  97. dir_path = os.path.dirname(full_path)
  98. if os.path.exists(dir_path):
  99. files = os.listdir(dir_path)
  100. logger.info(f"[IMAGE PATH] Files in {dir_path}: {files}")
  101. else:
  102. logger.error(f"[IMAGE PATH] Directory does not exist: {dir_path}")
  103. return None
  104. logger.info(f"[IMAGE PATH] ✓ Image file found and validated: {full_path}")
  105. return full_path
  106. @method_decorator(csrf_exempt, name='dispatch')
  107. class AttributeScoreView(View):
  108. """Enhanced API view with Image scoring support"""
  109. def __init__(self, *args, **kwargs):
  110. super().__init__(*args, **kwargs)
  111. self.scorer = AttributeQualityScorer(use_ai=True)
  112. self.image_scorer = ImageQualityScorer(use_ai=True)
  113. logger.info("[INIT] AttributeScoreView initialized with image scorer")
  114. def post(self, request, *args, **kwargs):
  115. """Score a single product with image scoring"""
  116. logger.info("[POST] ========== NEW SCORING REQUEST ==========")
  117. try:
  118. data = json.loads(request.body)
  119. logger.info(f"[POST] Request data keys: {data.keys()}")
  120. product_data = data.get('product', {})
  121. logger.info(f"[POST] Product data keys: {product_data.keys()}")
  122. sku = product_data.get('sku')
  123. use_ai = data.get('use_ai', True)
  124. if not sku:
  125. logger.error("[POST] SKU is missing")
  126. return JsonResponse({'error': 'SKU is required'}, status=400)
  127. logger.info(f"[POST] Processing SKU: {sku}")
  128. category = product_data.get('category', '')
  129. if not category:
  130. logger.error(f"[POST] Category missing for SKU: {sku}")
  131. return JsonResponse({'error': 'Category is required'}, status=400)
  132. logger.info(f"[POST] Category: {category}")
  133. # Get or create product
  134. product, created = Product.objects.get_or_create(
  135. sku=sku,
  136. defaults={
  137. 'title': product_data.get('title', ''),
  138. 'description': product_data.get('description', ''),
  139. 'short_description': product_data.get('short_description', ''),
  140. 'seo_title': product_data.get('seo_title', ''),
  141. 'seo_description': product_data.get('seo_description', ''),
  142. 'category': category,
  143. 'attributes': product_data.get('attributes', {})
  144. }
  145. )
  146. logger.info(f"[POST] Product {'created' if created else 'retrieved'}: {sku}")
  147. if not created:
  148. product.title = product_data.get('title', product.title)
  149. product.description = product_data.get('description', product.description)
  150. product.short_description = product_data.get('short_description', product.short_description)
  151. product.seo_title = product_data.get('seo_title', product.seo_title)
  152. product.seo_description = product_data.get('seo_description', product.seo_description)
  153. product.attributes = product_data.get('attributes', product.attributes)
  154. product.save()
  155. logger.info(f"[POST] Product updated: {sku}")
  156. # Get rules
  157. cache_key = f"category_rules_{category}"
  158. category_rules = cache.get(cache_key)
  159. if category_rules is None:
  160. category_rules = list(CategoryAttributeRule.objects.filter(category=category).values())
  161. cache.set(cache_key, category_rules, 3600)
  162. logger.info(f"[POST] Loaded {len(category_rules)} category rules for {category}")
  163. if not category_rules:
  164. logger.error(f"[POST] No attribute rules for category: {category}")
  165. return JsonResponse({'error': f'No attribute rules defined for {category}'}, status=400)
  166. # Get content rules
  167. content_cache_key = f"content_rules_{category}"
  168. content_rules = cache.get(content_cache_key)
  169. if content_rules is None:
  170. content_rules = list(
  171. ProductContentRule.objects.filter(
  172. Q(category__isnull=True) | Q(category=category)
  173. ).values()
  174. )
  175. cache.set(content_cache_key, content_rules, 3600)
  176. logger.info(f"[POST] Loaded {len(content_rules)} content rules for {category}")
  177. # Build product dict
  178. product_dict = {
  179. 'sku': product.sku,
  180. 'category': product.category,
  181. 'title': product.title,
  182. 'description': product.description,
  183. 'short_description': product.short_description,
  184. 'seo_title': product.seo_title,
  185. 'seo_description': product.seo_description,
  186. 'attributes': product.attributes
  187. }
  188. logger.info("[POST] Starting attribute and content scoring...")
  189. # Score product attributes and content
  190. score_result = self.scorer.score_product(
  191. product_dict,
  192. category_rules,
  193. content_rules=content_rules,
  194. generate_ai_suggestions=use_ai
  195. )
  196. logger.info(f"[POST] Attribute scoring complete. Score: {score_result.get('final_score')}")
  197. # ========== IMAGE SCORING ==========
  198. logger.info("[POST] ========== STARTING IMAGE SCORING ==========")
  199. image_score_result = None
  200. # Check for image_path in product_data
  201. image_rel_path = product_data.get('image_path')
  202. logger.info(f"[POST] Image path from payload: {image_rel_path}")
  203. if image_rel_path:
  204. image_path = resolve_image_path(image_rel_path)
  205. if image_path:
  206. try:
  207. logger.info(f"[POST] Calling image scorer for: {image_path}")
  208. image_score_result = self.image_scorer.score_image(
  209. product_dict,
  210. image_path=image_path
  211. )
  212. logger.info(f"[POST] ✓ Image scoring complete. Score: {image_score_result.get('image_score')}")
  213. logger.info(f"[POST] Image issues: {len(image_score_result.get('issues', []))}")
  214. logger.info(f"[POST] Image suggestions: {len(image_score_result.get('suggestions', []))}")
  215. except Exception as img_err:
  216. logger.error(f"[POST] ✗ Image scoring FAILED: {img_err}", exc_info=True)
  217. image_score_result = {
  218. 'image_score': None,
  219. 'breakdown': {},
  220. 'issues': [f'Image scoring error: {str(img_err)}'],
  221. 'suggestions': ['Check image file format and accessibility'],
  222. 'image_metadata': {}
  223. }
  224. else:
  225. logger.warning(f"[POST] Image path resolution failed for: {image_rel_path}")
  226. image_score_result = {
  227. 'image_score': None,
  228. 'breakdown': {},
  229. 'issues': ['Image file not found or inaccessible'],
  230. 'suggestions': [f'Verify image exists at path: {image_rel_path}'],
  231. 'image_metadata': {}
  232. }
  233. else:
  234. logger.warning("[POST] No image_path provided in payload")
  235. image_score_result = {
  236. 'image_score': None,
  237. 'breakdown': {},
  238. 'issues': ['No image provided'],
  239. 'suggestions': ['Upload a product image to improve quality score'],
  240. 'image_metadata': {}
  241. }
  242. logger.info("[POST] ========== IMAGE SCORING COMPLETE ==========")
  243. # Combine issues and suggestions
  244. all_issues = score_result['issues'] + (image_score_result.get('issues', []) if image_score_result else [])
  245. all_suggestions = score_result['suggestions'] + (image_score_result.get('suggestions', []) if image_score_result else [])
  246. logger.info(f"[POST] Total issues: {len(all_issues)} (Attributes: {len(score_result['issues'])}, Images: {len(image_score_result.get('issues', []))})")
  247. logger.info(f"[POST] Total suggestions: {len(all_suggestions)}")
  248. # Categorize feedback
  249. categorized = categorize_issues_and_suggestions(all_issues, all_suggestions)
  250. logger.info(f"[POST] Categorized feedback into {len(categorized)} categories")
  251. # Save score
  252. AttributeScore.objects.create(
  253. product=product,
  254. score=score_result['final_score'],
  255. max_score=score_result['max_score'],
  256. details={
  257. **score_result['breakdown'],
  258. 'image_score': image_score_result.get('image_score') if image_score_result else None,
  259. 'image_breakdown': image_score_result.get('breakdown', {}) if image_score_result else {}
  260. },
  261. issues=all_issues,
  262. suggestions=all_suggestions,
  263. ai_suggestions=score_result.get('ai_suggestions', {}),
  264. processing_time=score_result.get('processing_time', 0)
  265. )
  266. logger.info(f"[POST] Score saved to database for SKU: {sku}")
  267. # Enhanced response
  268. response_data = {
  269. 'success': True,
  270. 'product_sku': sku,
  271. 'created': created,
  272. 'score_result': {
  273. 'final_score': score_result['final_score'],
  274. 'max_score': score_result['max_score'],
  275. 'breakdown': score_result['breakdown'],
  276. 'image_score': image_score_result.get('image_score') if image_score_result else None,
  277. 'image_breakdown': image_score_result.get('breakdown', {}) if image_score_result else {},
  278. 'image_metadata': image_score_result.get('image_metadata', {}) if image_score_result else {},
  279. 'categorized_feedback': categorized,
  280. 'ai_suggestions': {
  281. 'content': score_result.get('ai_suggestions', {}),
  282. 'image': image_score_result.get('ai_improvements') if image_score_result else None
  283. },
  284. 'processing_time': score_result.get('processing_time', 0),
  285. 'issues': all_issues,
  286. 'suggestions': all_suggestions
  287. }
  288. }
  289. logger.info(f"[POST] ========== REQUEST COMPLETE: {sku} ==========")
  290. return JsonResponse(response_data)
  291. except json.JSONDecodeError as e:
  292. logger.error(f"[POST] JSON decode error: {e}", exc_info=True)
  293. return JsonResponse({'error': 'Invalid JSON'}, status=400)
  294. except Exception as e:
  295. logger.error(f"[POST] Unexpected error: {str(e)}", exc_info=True)
  296. return JsonResponse({'error': str(e)}, status=500)
  297. @method_decorator(csrf_exempt, name='dispatch')
  298. class BatchScoreView(View):
  299. """Batch scoring with image support"""
  300. def __init__(self, *args, **kwargs):
  301. super().__init__(*args, **kwargs)
  302. self.attribute_scorer = AttributeQualityScorer(use_ai=True)
  303. self.image_scorer = ImageQualityScorer(use_ai=True)
  304. logger.info("[INIT] BatchScoreView initialized")
  305. def get(self, request):
  306. """
  307. Automatically score all products from the database (no payload needed).
  308. """
  309. logger.info("[BATCH][GET] ========== AUTO BATCH SCORING STARTED ==========")
  310. try:
  311. # Fetch all products
  312. products = list(Product.objects.all().values())
  313. logger.info(f"[BATCH][GET] Found {len(products)} products in DB")
  314. if not products:
  315. return JsonResponse({'error': 'No products found in the database.'}, status=404)
  316. results = []
  317. errors = []
  318. for idx, product_data in enumerate(products, 1):
  319. sku = product_data.get('sku')
  320. category = product_data.get('category')
  321. logger.info(f"[BATCH][GET] [{idx}/{len(products)}] Processing: {sku}")
  322. if not sku or not category:
  323. errors.append({'sku': sku, 'error': 'Missing SKU or category'})
  324. continue
  325. try:
  326. # Load rules
  327. category_rules = list(CategoryAttributeRule.objects.filter(category=category).values())
  328. if not category_rules:
  329. errors.append({'sku': sku, 'error': f'No attribute rules for category {category}'})
  330. continue
  331. content_rules = list(
  332. ProductContentRule.objects.filter(
  333. Q(category__isnull=True) | Q(category=category)
  334. ).values()
  335. )
  336. # Score product attributes/content
  337. score_result = self.attribute_scorer.score_product(
  338. product_data,
  339. category_rules,
  340. content_rules=content_rules,
  341. generate_ai_suggestions=True
  342. )
  343. # Score product image
  344. image_path = resolve_image_path(product_data.get('image_path'))
  345. image_score_result = None
  346. if image_path:
  347. try:
  348. logger.info(f"[BATCH][GET] Scoring image for {sku}")
  349. image_score_result = self.image_scorer.score_image(
  350. product_data,
  351. image_path=image_path
  352. )
  353. except Exception as img_err:
  354. logger.warning(f"[BATCH][GET] Image scoring failed for {sku}: {img_err}")
  355. image_score_result = {
  356. 'image_score': None,
  357. 'breakdown': {},
  358. 'issues': ['Image scoring failed'],
  359. 'suggestions': ['Check image file'],
  360. 'image_metadata': {}
  361. }
  362. else:
  363. image_score_result = {
  364. 'image_score': None,
  365. 'breakdown': {},
  366. 'issues': ['No image provided'],
  367. 'suggestions': ['Upload product image'],
  368. 'image_metadata': {}
  369. }
  370. # Combine issues and suggestions
  371. all_issues = score_result['issues'] + image_score_result.get('issues', [])
  372. all_suggestions = score_result['suggestions'] + image_score_result.get('suggestions', [])
  373. categorized = categorize_issues_and_suggestions(all_issues, all_suggestions)
  374. score_result['breakdown']['image_score'] = image_score_result.get('image_score')
  375. score_result['breakdown']['attributes'] = score_result['breakdown']['mandatory_fields']
  376. results.append({
  377. 'sku': sku,
  378. 'title': product_data['title'],
  379. 'description': product_data['description'],
  380. 'image_path': product_data['image_path'],
  381. 'created_at': product_data['created_at'],
  382. 'final_score': score_result['final_score'],
  383. 'max_score': score_result['max_score'],
  384. 'breakdown': score_result['breakdown'],
  385. 'image_score': image_score_result.get('image_score'),
  386. 'image_breakdown': image_score_result.get('breakdown', {}),
  387. 'image_metadata': image_score_result.get('image_metadata', {}),
  388. 'ai_suggestions': {
  389. 'content': score_result.get('ai_suggestions', {}),
  390. 'image': image_score_result.get('ai_improvements', {})
  391. },
  392. 'categorized_feedback': categorized,
  393. 'processing_time': score_result.get('processing_time', 0),
  394. 'issues': all_issues,
  395. 'suggestions': all_suggestions
  396. })
  397. except Exception as e:
  398. logger.error(f"[BATCH][GET] Error scoring {sku}: {str(e)}", exc_info=True)
  399. errors.append({'sku': sku, 'error': str(e)})
  400. logger.info(f"[BATCH][GET] ===== COMPLETE: {len(results)} success, {len(errors)} errors =====")
  401. return JsonResponse({
  402. 'success': True,
  403. 'processed': len(results),
  404. 'results': results,
  405. 'errors': errors
  406. })
  407. except Exception as e:
  408. logger.error(f"[BATCH][GET] Batch scoring error: {str(e)}", exc_info=True)
  409. return JsonResponse({'error': str(e)}, status=500)
  410. def post(self, request):
  411. logger.info("[BATCH] ========== NEW BATCH REQUEST ==========")
  412. try:
  413. data = json.loads(request.body)
  414. products = data.get('products', [])
  415. logger.info(f"[BATCH] Processing {len(products)} products")
  416. print(products)
  417. if not products:
  418. return JsonResponse({'error': 'No products provided'}, status=400)
  419. results = []
  420. errors = []
  421. for idx, product_data in enumerate(products[:100], 1):
  422. sku = product_data.get('sku')
  423. category = product_data.get('category')
  424. logger.info(f"[BATCH] [{idx}/{len(products)}] Processing: {sku}")
  425. if not sku or not category:
  426. errors.append({'sku': sku, 'error': 'Missing SKU or category'})
  427. continue
  428. try:
  429. # Get rules
  430. category_rules = list(CategoryAttributeRule.objects.filter(category=category).values())
  431. if not category_rules:
  432. errors.append({'sku': sku, 'error': f'No attribute rules for category {category}'})
  433. continue
  434. content_rules = list(
  435. ProductContentRule.objects.filter(
  436. Q(category__isnull=True) | Q(category=category)
  437. ).values()
  438. )
  439. # Score attributes
  440. score_result = self.attribute_scorer.score_product(
  441. product_data,
  442. category_rules,
  443. content_rules=content_rules,
  444. generate_ai_suggestions=True
  445. )
  446. # Score image
  447. image_path = resolve_image_path(product_data.get('image_path'))
  448. image_score_result = None
  449. if image_path:
  450. try:
  451. logger.info(f"[BATCH] Scoring image for {sku}")
  452. image_score_result = self.image_scorer.score_image(
  453. product_data,
  454. image_path=image_path
  455. )
  456. logger.info(f"[BATCH] Image score for {sku}: {image_score_result.get('image_score')}")
  457. except Exception as img_err:
  458. logger.warning(f"[BATCH] Image scoring failed for {sku}: {img_err}")
  459. image_score_result = {
  460. 'image_score': None,
  461. 'breakdown': {},
  462. 'issues': ['Image scoring failed'],
  463. 'suggestions': ['Check image file'],
  464. 'image_metadata': {}
  465. }
  466. else:
  467. logger.info(f"[BATCH] No valid image for {sku}")
  468. image_score_result = {
  469. 'image_score': None,
  470. 'breakdown': {},
  471. 'issues': ['No image provided'],
  472. 'suggestions': ['Upload product image'],
  473. 'image_metadata': {}
  474. }
  475. all_issues = score_result['issues'] + image_score_result.get('issues', [])
  476. all_suggestions = score_result['suggestions'] + image_score_result.get('suggestions', [])
  477. categorized = categorize_issues_and_suggestions(all_issues, all_suggestions)
  478. results.append({
  479. 'sku': sku,
  480. 'title': product_data['title'],
  481. 'description': product_data['description'],
  482. 'image_path': image_path,
  483. 'final_score': score_result['final_score'],
  484. 'max_score': score_result['max_score'],
  485. 'breakdown': score_result['breakdown'],
  486. 'image_score': image_score_result.get('image_score'),
  487. 'image_breakdown': image_score_result.get('breakdown', {}),
  488. 'image_metadata': image_score_result.get('image_metadata', {}),
  489. 'ai_suggestions': {
  490. 'content': score_result.get('ai_suggestions', {}),
  491. 'image': image_score_result.get('ai_improvements', {})
  492. },
  493. 'categorized_feedback': categorized,
  494. 'processing_time': score_result.get('processing_time', 0),
  495. 'issues': all_issues,
  496. 'suggestions': all_suggestions
  497. })
  498. except Exception as e:
  499. logger.error(f"[BATCH] Error scoring {sku}: {str(e)}", exc_info=True)
  500. errors.append({'sku': sku, 'error': str(e)})
  501. logger.info(f"[BATCH] ========== BATCH COMPLETE: {len(results)} success, {len(errors)} errors ==========")
  502. return JsonResponse({
  503. 'success': True,
  504. 'processed': len(results),
  505. 'results': results,
  506. 'errors': errors
  507. })
  508. except Exception as e:
  509. logger.error(f"[BATCH] Batch scoring error: {str(e)}", exc_info=True)
  510. return JsonResponse({'error': str(e)}, status=500)
  511. import json
  512. import logging
  513. import concurrent.futures
  514. from django.http import JsonResponse
  515. from django.utils.decorators import method_decorator
  516. from django.views import View
  517. from django.views.decorators.csrf import csrf_exempt
  518. from django.db.models import Q
  519. from textblob import download_corpora
  520. from concurrent.futures import ThreadPoolExecutor, as_completed
  521. import time
  522. from core.models import Product, CategoryAttributeRule, ProductContentRule
  523. # from .scorers.attribute_scorer import AttributeQualityScorer
  524. # from .scorers.image_scorer import ImageQualityScorer
  525. # from .utils import resolve_image_path, categorize_issues_and_suggestions
  526. logger = logging.getLogger(__name__)
  527. # Try to ensure corpora are downloaded once at import
  528. try:
  529. download_corpora.download_all()
  530. logger.info("[INIT] TextBlob corpora verified.")
  531. except Exception as e:
  532. logger.warning(f"[INIT] Failed to auto-download TextBlob corpora: {e}")
  533. @method_decorator(csrf_exempt, name='dispatch')
  534. class BatchScoreViewV2(View):
  535. """Optimized Batch scoring with image support and parallel processing"""
  536. def __init__(self, *args, **kwargs):
  537. super().__init__(*args, **kwargs)
  538. self.attribute_scorer = AttributeQualityScorer(use_ai=True)
  539. self.image_scorer = ImageQualityScorer(use_ai=True)
  540. logger.info("[INIT] BatchScoreView initialized with AI scorers preloaded")
  541. def _process_single_product(self, product_data, idx, total):
  542. """Process a single product safely (run inside thread pool)"""
  543. start = time.time()
  544. sku = product_data.get('sku')
  545. category = product_data.get('category')
  546. img_raw_path = product_data.get('image_path')
  547. if not sku or not category:
  548. return {'sku': sku, 'error': 'Missing SKU or category'}
  549. try:
  550. logger.info(f"[BATCH][{idx}/{total}] Processing SKU: {sku}")
  551. # Fetch rules
  552. category_rules = list(CategoryAttributeRule.objects.filter(category=category).values())
  553. if not category_rules:
  554. return {'sku': sku, 'error': f'No attribute rules for category {category}'}
  555. content_rules = list(
  556. ProductContentRule.objects.filter(
  557. Q(category__isnull=True) | Q(category=category)
  558. ).values()
  559. )
  560. # Score attributes
  561. score_result = self.attribute_scorer.score_product(
  562. product_data,
  563. category_rules,
  564. content_rules=content_rules,
  565. generate_ai_suggestions=True
  566. )
  567. # Score image
  568. image_path = resolve_image_path(product_data.get('image_path'))
  569. image_score_result = None
  570. if image_path:
  571. try:
  572. image_score_result = self.image_scorer.score_image(
  573. product_data,
  574. image_path=image_path
  575. )
  576. except Exception as img_err:
  577. logger.warning(f"[IMG] Scoring failed for {sku}: {img_err}")
  578. image_score_result = {
  579. 'image_score': None,
  580. 'breakdown': {},
  581. 'issues': ['Image scoring failed'],
  582. 'suggestions': ['Check image file'],
  583. 'image_metadata': {}
  584. }
  585. else:
  586. image_score_result = {
  587. 'image_score': None,
  588. 'breakdown': {},
  589. 'issues': ['No image provided'],
  590. 'suggestions': ['Upload product image'],
  591. 'image_metadata': {}
  592. }
  593. # Combine results
  594. all_issues = score_result['issues'] + image_score_result.get('issues', [])
  595. all_suggestions = score_result['suggestions'] + image_score_result.get('suggestions', [])
  596. categorized = categorize_issues_and_suggestions(all_issues, all_suggestions)
  597. total_time = round(time.time() - start, 2)
  598. score_result['breakdown']['attributes'] = score_result['breakdown']['mandatory_fields']
  599. return {
  600. 'sku': sku,
  601. 'title': product_data.get('title'),
  602. 'description': product_data.get('description'),
  603. 'image_path': img_raw_path,
  604. 'final_score': score_result['final_score'],
  605. 'max_score': score_result['max_score'],
  606. 'breakdown': {**score_result['breakdown'], 'image_score': image_score_result.get('image_score')},
  607. 'image_score': image_score_result.get('image_score'),
  608. 'image_breakdown': image_score_result.get('breakdown', {}),
  609. 'image_metadata': image_score_result.get('image_metadata', {}),
  610. 'ai_suggestions': {
  611. 'content': score_result.get('ai_suggestions', {}),
  612. 'image': image_score_result.get('ai_improvements', {})
  613. },
  614. 'categorized_feedback': categorized,
  615. 'issues': all_issues,
  616. 'suggestions': all_suggestions,
  617. 'processing_time': total_time
  618. }
  619. except Exception as e:
  620. logger.error(f"[BATCH][{sku}] Error: {e}", exc_info=True)
  621. return {'sku': sku, 'error': str(e)}
  622. def _run_batch(self, products):
  623. """Run products in parallel using thread pool"""
  624. results, errors = [], []
  625. total = len(products)
  626. start_time = time.time()
  627. # Use up to 8 threads (tune based on your CPU)
  628. with ThreadPoolExecutor(max_workers=8) as executor:
  629. future_to_product = {
  630. executor.submit(self._process_single_product, product, idx + 1, total): product
  631. for idx, product in enumerate(products)
  632. }
  633. for future in as_completed(future_to_product):
  634. res = future.result()
  635. if "error" in res:
  636. errors.append(res)
  637. else:
  638. results.append(res)
  639. elapsed = round(time.time() - start_time, 2)
  640. logger.info(f"[BATCH] Completed {len(results)} products in {elapsed}s ({len(errors)} errors)")
  641. return results, errors, elapsed
  642. def get(self, request):
  643. """Score all products from DB automatically"""
  644. try:
  645. products = list(Product.objects.all().values())
  646. if not products:
  647. return JsonResponse({'error': 'No products found in database'}, status=404)
  648. results, errors, elapsed = self._run_batch(products)
  649. return JsonResponse({
  650. 'success': True,
  651. 'processed': len(results),
  652. 'results': results,
  653. 'errors': errors,
  654. 'elapsed_seconds': elapsed
  655. })
  656. except Exception as e:
  657. logger.error(f"[BATCH][GET] Error: {e}", exc_info=True)
  658. return JsonResponse({'error': str(e)}, status=500)
  659. def post(self, request):
  660. """Batch score via payload"""
  661. try:
  662. data = json.loads(request.body)
  663. products = data.get('products', [])
  664. if not products:
  665. return JsonResponse({'error': 'No products provided'}, status=400)
  666. results, errors, elapsed = self._run_batch(products)
  667. return JsonResponse({
  668. 'success': True,
  669. 'processed': len(results),
  670. 'results': results,
  671. 'errors': errors,
  672. 'elapsed_seconds': elapsed
  673. })
  674. except Exception as e:
  675. logger.error(f"[BATCH][POST] Error: {e}", exc_info=True)
  676. return JsonResponse({'error': str(e)}, status=500)
  677. @method_decorator(csrf_exempt, name='dispatch')
  678. class ContentRulesView(View):
  679. """API to manage ProductContentRules"""
  680. def get(self, request):
  681. try:
  682. category = request.GET.get('category')
  683. if category:
  684. rules = ProductContentRule.objects.filter(
  685. Q(category__isnull=True) | Q(category=category)
  686. )
  687. else:
  688. rules = ProductContentRule.objects.all()
  689. rules_data = list(rules.values())
  690. return JsonResponse({
  691. 'success': True,
  692. 'count': len(rules_data),
  693. 'rules': rules_data
  694. })
  695. except Exception as e:
  696. logger.error(f"Error fetching content rules: {e}", exc_info=True)
  697. return JsonResponse({'error': str(e)}, status=500)
  698. def post(self, request):
  699. try:
  700. data = json.loads(request.body)
  701. if 'field_name' not in data:
  702. return JsonResponse({'error': 'field_name is required'}, status=400)
  703. rule = ProductContentRule.objects.create(
  704. category=data.get('category'),
  705. field_name=data['field_name'],
  706. is_mandatory=data.get('is_mandatory', True),
  707. min_length=data.get('min_length'),
  708. max_length=data.get('max_length'),
  709. min_word_count=data.get('min_word_count'),
  710. max_word_count=data.get('max_word_count'),
  711. must_contain_keywords=data.get('must_contain_keywords', []),
  712. validation_regex=data.get('validation_regex', ''),
  713. description=data.get('description', '')
  714. )
  715. if data.get('category'):
  716. cache.delete(f"content_rules_{data['category']}")
  717. return JsonResponse({
  718. 'success': True,
  719. 'rule_id': rule.id,
  720. 'message': 'Content rule created successfully'
  721. })
  722. except Exception as e:
  723. logger.error(f"Error creating content rule: {e}", exc_info=True)
  724. return JsonResponse({'error': str(e)}, status=500)
  725. # views.py
  726. from rest_framework.views import APIView
  727. from rest_framework.response import Response
  728. from rest_framework import status
  729. import tempfile
  730. import os # Import os to remove the temporary file
  731. # from core.models import Product, CategoryAttributeRule, ProductContentRule # Not strictly needed here
  732. from .management.commands.load_excel_data import Command as ExcelLoader
  733. class ExcelUploadView(APIView):
  734. def post(self, request):
  735. excel_file = request.FILES.get('file')
  736. if not excel_file:
  737. return Response({'error': 'No file uploaded'}, status=status.HTTP_400_BAD_REQUEST)
  738. tmp_path = None
  739. try:
  740. # 1. Save uploaded file to a temporary location
  741. with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp:
  742. for chunk in excel_file.chunks():
  743. tmp.write(chunk)
  744. tmp_path = tmp.name
  745. # 2. Execute the modified loader command
  746. loader = ExcelLoader()
  747. # The handle method now returns a dictionary with 'success' and 'message'
  748. # We pass the path as a keyword argument to match the command structure
  749. result = loader.handle(excel_path=tmp_path)
  750. # 3. Return the response based on the loader result
  751. if result.get('success'):
  752. # Send the detailed message back in the API response
  753. return Response({'success': True, 'message': result['message']}, status=status.HTTP_200_OK)
  754. else:
  755. # Handle error case returned by the loader (e.g., file read failure)
  756. return Response({'success': False, 'error': result.get('error', 'An unknown error occurred during loading.')}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
  757. except Exception as e:
  758. # Catch unexpected exceptions during the process
  759. return Response({'success': False, 'error': f'Upload failed: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
  760. finally:
  761. import time
  762. import gc
  763. if tmp_path and os.path.exists(tmp_path):
  764. try:
  765. # Force garbage collection to ensure pandas releases the file handle
  766. gc.collect()
  767. time.sleep(0.3) # brief pause for Windows file handle release
  768. os.remove(tmp_path)
  769. except PermissionError:
  770. # Retry once after short delay (Windows-specific fix)
  771. time.sleep(0.5)
  772. try:
  773. os.remove(tmp_path)
  774. except Exception:
  775. pass # give up silently; OS will clean temp files later