views.py 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926
  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. results.append({
  376. 'sku': sku,
  377. 'title': product_data['title'],
  378. 'description': product_data['description'],
  379. 'image_path': product_data['image_path'],
  380. 'created_at': product_data['created_at'],
  381. 'final_score': score_result['final_score'],
  382. 'max_score': score_result['max_score'],
  383. 'breakdown': score_result['breakdown'],
  384. 'image_score': image_score_result.get('image_score'),
  385. 'image_breakdown': image_score_result.get('breakdown', {}),
  386. 'image_metadata': image_score_result.get('image_metadata', {}),
  387. 'ai_suggestions': {
  388. 'content': score_result.get('ai_suggestions', {}),
  389. 'image': image_score_result.get('ai_improvements', {})
  390. },
  391. 'categorized_feedback': categorized,
  392. 'processing_time': score_result.get('processing_time', 0),
  393. 'issues': all_issues,
  394. 'suggestions': all_suggestions
  395. })
  396. except Exception as e:
  397. logger.error(f"[BATCH][GET] Error scoring {sku}: {str(e)}", exc_info=True)
  398. errors.append({'sku': sku, 'error': str(e)})
  399. logger.info(f"[BATCH][GET] ===== COMPLETE: {len(results)} success, {len(errors)} errors =====")
  400. return JsonResponse({
  401. 'success': True,
  402. 'processed': len(results),
  403. 'results': results,
  404. 'errors': errors
  405. })
  406. except Exception as e:
  407. logger.error(f"[BATCH][GET] Batch scoring error: {str(e)}", exc_info=True)
  408. return JsonResponse({'error': str(e)}, status=500)
  409. def post(self, request):
  410. logger.info("[BATCH] ========== NEW BATCH REQUEST ==========")
  411. try:
  412. data = json.loads(request.body)
  413. products = data.get('products', [])
  414. logger.info(f"[BATCH] Processing {len(products)} products")
  415. print(products)
  416. if not products:
  417. return JsonResponse({'error': 'No products provided'}, status=400)
  418. results = []
  419. errors = []
  420. for idx, product_data in enumerate(products[:100], 1):
  421. sku = product_data.get('sku')
  422. category = product_data.get('category')
  423. logger.info(f"[BATCH] [{idx}/{len(products)}] Processing: {sku}")
  424. if not sku or not category:
  425. errors.append({'sku': sku, 'error': 'Missing SKU or category'})
  426. continue
  427. try:
  428. # Get rules
  429. category_rules = list(CategoryAttributeRule.objects.filter(category=category).values())
  430. if not category_rules:
  431. errors.append({'sku': sku, 'error': f'No attribute rules for category {category}'})
  432. continue
  433. content_rules = list(
  434. ProductContentRule.objects.filter(
  435. Q(category__isnull=True) | Q(category=category)
  436. ).values()
  437. )
  438. # Score attributes
  439. score_result = self.attribute_scorer.score_product(
  440. product_data,
  441. category_rules,
  442. content_rules=content_rules,
  443. generate_ai_suggestions=True
  444. )
  445. # Score image
  446. image_path = resolve_image_path(product_data.get('image_path'))
  447. image_score_result = None
  448. if image_path:
  449. try:
  450. logger.info(f"[BATCH] Scoring image for {sku}")
  451. image_score_result = self.image_scorer.score_image(
  452. product_data,
  453. image_path=image_path
  454. )
  455. logger.info(f"[BATCH] Image score for {sku}: {image_score_result.get('image_score')}")
  456. except Exception as img_err:
  457. logger.warning(f"[BATCH] Image scoring failed for {sku}: {img_err}")
  458. image_score_result = {
  459. 'image_score': None,
  460. 'breakdown': {},
  461. 'issues': ['Image scoring failed'],
  462. 'suggestions': ['Check image file'],
  463. 'image_metadata': {}
  464. }
  465. else:
  466. logger.info(f"[BATCH] No valid image for {sku}")
  467. image_score_result = {
  468. 'image_score': None,
  469. 'breakdown': {},
  470. 'issues': ['No image provided'],
  471. 'suggestions': ['Upload product image'],
  472. 'image_metadata': {}
  473. }
  474. all_issues = score_result['issues'] + image_score_result.get('issues', [])
  475. all_suggestions = score_result['suggestions'] + image_score_result.get('suggestions', [])
  476. categorized = categorize_issues_and_suggestions(all_issues, all_suggestions)
  477. results.append({
  478. 'sku': sku,
  479. 'title': product_data['title'],
  480. 'description': product_data['description'],
  481. 'image_path': image_path,
  482. 'final_score': score_result['final_score'],
  483. 'max_score': score_result['max_score'],
  484. 'breakdown': score_result['breakdown'],
  485. 'image_score': image_score_result.get('image_score'),
  486. 'image_breakdown': image_score_result.get('breakdown', {}),
  487. 'image_metadata': image_score_result.get('image_metadata', {}),
  488. 'ai_suggestions': {
  489. 'content': score_result.get('ai_suggestions', {}),
  490. 'image': image_score_result.get('ai_improvements', {})
  491. },
  492. 'categorized_feedback': categorized,
  493. 'processing_time': score_result.get('processing_time', 0),
  494. 'issues': all_issues,
  495. 'suggestions': all_suggestions
  496. })
  497. except Exception as e:
  498. logger.error(f"[BATCH] Error scoring {sku}: {str(e)}", exc_info=True)
  499. errors.append({'sku': sku, 'error': str(e)})
  500. logger.info(f"[BATCH] ========== BATCH COMPLETE: {len(results)} success, {len(errors)} errors ==========")
  501. return JsonResponse({
  502. 'success': True,
  503. 'processed': len(results),
  504. 'results': results,
  505. 'errors': errors
  506. })
  507. except Exception as e:
  508. logger.error(f"[BATCH] Batch scoring error: {str(e)}", exc_info=True)
  509. return JsonResponse({'error': str(e)}, status=500)
  510. import json
  511. import logging
  512. import concurrent.futures
  513. from django.http import JsonResponse
  514. from django.utils.decorators import method_decorator
  515. from django.views import View
  516. from django.views.decorators.csrf import csrf_exempt
  517. from django.db.models import Q
  518. from textblob import download_corpora
  519. from concurrent.futures import ThreadPoolExecutor, as_completed
  520. import time
  521. from core.models import Product, CategoryAttributeRule, ProductContentRule
  522. # from .scorers.attribute_scorer import AttributeQualityScorer
  523. # from .scorers.image_scorer import ImageQualityScorer
  524. # from .utils import resolve_image_path, categorize_issues_and_suggestions
  525. logger = logging.getLogger(__name__)
  526. # Try to ensure corpora are downloaded once at import
  527. try:
  528. download_corpora.download_all()
  529. logger.info("[INIT] TextBlob corpora verified.")
  530. except Exception as e:
  531. logger.warning(f"[INIT] Failed to auto-download TextBlob corpora: {e}")
  532. @method_decorator(csrf_exempt, name='dispatch')
  533. class BatchScoreViewV2(View):
  534. """Optimized Batch scoring with image support and parallel processing"""
  535. def __init__(self, *args, **kwargs):
  536. super().__init__(*args, **kwargs)
  537. self.attribute_scorer = AttributeQualityScorer(use_ai=True)
  538. self.image_scorer = ImageQualityScorer(use_ai=True)
  539. logger.info("[INIT] BatchScoreView initialized with AI scorers preloaded")
  540. def _process_single_product(self, product_data, idx, total):
  541. """Process a single product safely (run inside thread pool)"""
  542. start = time.time()
  543. sku = product_data.get('sku')
  544. category = product_data.get('category')
  545. if not sku or not category:
  546. return {'sku': sku, 'error': 'Missing SKU or category'}
  547. try:
  548. logger.info(f"[BATCH][{idx}/{total}] Processing SKU: {sku}")
  549. # Fetch rules
  550. category_rules = list(CategoryAttributeRule.objects.filter(category=category).values())
  551. if not category_rules:
  552. return {'sku': sku, 'error': f'No attribute rules for category {category}'}
  553. content_rules = list(
  554. ProductContentRule.objects.filter(
  555. Q(category__isnull=True) | Q(category=category)
  556. ).values()
  557. )
  558. # Score attributes
  559. score_result = self.attribute_scorer.score_product(
  560. product_data,
  561. category_rules,
  562. content_rules=content_rules,
  563. generate_ai_suggestions=True
  564. )
  565. # Score image
  566. image_path = resolve_image_path(product_data.get('image_path'))
  567. image_score_result = None
  568. if image_path:
  569. try:
  570. image_score_result = self.image_scorer.score_image(
  571. product_data,
  572. image_path=image_path
  573. )
  574. except Exception as img_err:
  575. logger.warning(f"[IMG] Scoring failed for {sku}: {img_err}")
  576. image_score_result = {
  577. 'image_score': None,
  578. 'breakdown': {},
  579. 'issues': ['Image scoring failed'],
  580. 'suggestions': ['Check image file'],
  581. 'image_metadata': {}
  582. }
  583. else:
  584. image_score_result = {
  585. 'image_score': None,
  586. 'breakdown': {},
  587. 'issues': ['No image provided'],
  588. 'suggestions': ['Upload product image'],
  589. 'image_metadata': {}
  590. }
  591. # Combine results
  592. all_issues = score_result['issues'] + image_score_result.get('issues', [])
  593. all_suggestions = score_result['suggestions'] + image_score_result.get('suggestions', [])
  594. categorized = categorize_issues_and_suggestions(all_issues, all_suggestions)
  595. total_time = round(time.time() - start, 2)
  596. return {
  597. 'sku': sku,
  598. 'title': product_data.get('title'),
  599. 'description': product_data.get('description'),
  600. 'image_path': image_path,
  601. 'final_score': score_result['final_score'],
  602. 'max_score': score_result['max_score'],
  603. 'breakdown': {**score_result['breakdown'], 'image_score': image_score_result.get('image_score')},
  604. 'image_score': image_score_result.get('image_score'),
  605. 'image_breakdown': image_score_result.get('breakdown', {}),
  606. 'image_metadata': image_score_result.get('image_metadata', {}),
  607. 'ai_suggestions': {
  608. 'content': score_result.get('ai_suggestions', {}),
  609. 'image': image_score_result.get('ai_improvements', {})
  610. },
  611. 'categorized_feedback': categorized,
  612. 'issues': all_issues,
  613. 'suggestions': all_suggestions,
  614. 'processing_time': total_time
  615. }
  616. except Exception as e:
  617. logger.error(f"[BATCH][{sku}] Error: {e}", exc_info=True)
  618. return {'sku': sku, 'error': str(e)}
  619. def _run_batch(self, products):
  620. """Run products in parallel using thread pool"""
  621. results, errors = [], []
  622. total = len(products)
  623. start_time = time.time()
  624. # Use up to 8 threads (tune based on your CPU)
  625. with ThreadPoolExecutor(max_workers=8) as executor:
  626. future_to_product = {
  627. executor.submit(self._process_single_product, product, idx + 1, total): product
  628. for idx, product in enumerate(products)
  629. }
  630. for future in as_completed(future_to_product):
  631. res = future.result()
  632. if "error" in res:
  633. errors.append(res)
  634. else:
  635. results.append(res)
  636. elapsed = round(time.time() - start_time, 2)
  637. logger.info(f"[BATCH] Completed {len(results)} products in {elapsed}s ({len(errors)} errors)")
  638. return results, errors, elapsed
  639. def get(self, request):
  640. """Score all products from DB automatically"""
  641. try:
  642. products = list(Product.objects.all().values())
  643. if not products:
  644. return JsonResponse({'error': 'No products found in database'}, status=404)
  645. results, errors, elapsed = self._run_batch(products)
  646. return JsonResponse({
  647. 'success': True,
  648. 'processed': len(results),
  649. 'results': results,
  650. 'errors': errors,
  651. 'elapsed_seconds': elapsed
  652. })
  653. except Exception as e:
  654. logger.error(f"[BATCH][GET] Error: {e}", exc_info=True)
  655. return JsonResponse({'error': str(e)}, status=500)
  656. def post(self, request):
  657. """Batch score via payload"""
  658. try:
  659. data = json.loads(request.body)
  660. products = data.get('products', [])
  661. if not products:
  662. return JsonResponse({'error': 'No products provided'}, status=400)
  663. results, errors, elapsed = self._run_batch(products)
  664. return JsonResponse({
  665. 'success': True,
  666. 'processed': len(results),
  667. 'results': results,
  668. 'errors': errors,
  669. 'elapsed_seconds': elapsed
  670. })
  671. except Exception as e:
  672. logger.error(f"[BATCH][POST] Error: {e}", exc_info=True)
  673. return JsonResponse({'error': str(e)}, status=500)
  674. @method_decorator(csrf_exempt, name='dispatch')
  675. class ContentRulesView(View):
  676. """API to manage ProductContentRules"""
  677. def get(self, request):
  678. try:
  679. category = request.GET.get('category')
  680. if category:
  681. rules = ProductContentRule.objects.filter(
  682. Q(category__isnull=True) | Q(category=category)
  683. )
  684. else:
  685. rules = ProductContentRule.objects.all()
  686. rules_data = list(rules.values())
  687. return JsonResponse({
  688. 'success': True,
  689. 'count': len(rules_data),
  690. 'rules': rules_data
  691. })
  692. except Exception as e:
  693. logger.error(f"Error fetching content rules: {e}", exc_info=True)
  694. return JsonResponse({'error': str(e)}, status=500)
  695. def post(self, request):
  696. try:
  697. data = json.loads(request.body)
  698. if 'field_name' not in data:
  699. return JsonResponse({'error': 'field_name is required'}, status=400)
  700. rule = ProductContentRule.objects.create(
  701. category=data.get('category'),
  702. field_name=data['field_name'],
  703. is_mandatory=data.get('is_mandatory', True),
  704. min_length=data.get('min_length'),
  705. max_length=data.get('max_length'),
  706. min_word_count=data.get('min_word_count'),
  707. max_word_count=data.get('max_word_count'),
  708. must_contain_keywords=data.get('must_contain_keywords', []),
  709. validation_regex=data.get('validation_regex', ''),
  710. description=data.get('description', '')
  711. )
  712. if data.get('category'):
  713. cache.delete(f"content_rules_{data['category']}")
  714. return JsonResponse({
  715. 'success': True,
  716. 'rule_id': rule.id,
  717. 'message': 'Content rule created successfully'
  718. })
  719. except Exception as e:
  720. logger.error(f"Error creating content rule: {e}", exc_info=True)
  721. return JsonResponse({'error': str(e)}, status=500)
  722. # views.py
  723. from rest_framework.views import APIView
  724. from rest_framework.response import Response
  725. from rest_framework import status
  726. import tempfile
  727. import os # Import os to remove the temporary file
  728. # from core.models import Product, CategoryAttributeRule, ProductContentRule # Not strictly needed here
  729. from .management.commands.load_excel_data import Command as ExcelLoader
  730. class ExcelUploadView(APIView):
  731. def post(self, request):
  732. excel_file = request.FILES.get('file')
  733. if not excel_file:
  734. return Response({'error': 'No file uploaded'}, status=status.HTTP_400_BAD_REQUEST)
  735. tmp_path = None
  736. try:
  737. # 1. Save uploaded file to a temporary location
  738. with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp:
  739. for chunk in excel_file.chunks():
  740. tmp.write(chunk)
  741. tmp_path = tmp.name
  742. # 2. Execute the modified loader command
  743. loader = ExcelLoader()
  744. # The handle method now returns a dictionary with 'success' and 'message'
  745. # We pass the path as a keyword argument to match the command structure
  746. result = loader.handle(excel_path=tmp_path)
  747. # 3. Return the response based on the loader result
  748. if result.get('success'):
  749. # Send the detailed message back in the API response
  750. return Response({'success': True, 'message': result['message']}, status=status.HTTP_200_OK)
  751. else:
  752. # Handle error case returned by the loader (e.g., file read failure)
  753. return Response({'success': False, 'error': result.get('error', 'An unknown error occurred during loading.')}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
  754. except Exception as e:
  755. # Catch unexpected exceptions during the process
  756. return Response({'success': False, 'error': f'Upload failed: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
  757. finally:
  758. import time
  759. import gc
  760. if tmp_path and os.path.exists(tmp_path):
  761. try:
  762. # Force garbage collection to ensure pandas releases the file handle
  763. gc.collect()
  764. time.sleep(0.3) # brief pause for Windows file handle release
  765. os.remove(tmp_path)
  766. except PermissionError:
  767. # Retry once after short delay (Windows-specific fix)
  768. time.sleep(0.5)
  769. try:
  770. os.remove(tmp_path)
  771. except Exception:
  772. pass # give up silently; OS will clean temp files later