views.py 45 KB

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