Franko Fišter commited on
Commit
f40eb86
·
unverified ·
2 Parent(s): 4c7debd1139dbb

Merge pull request #4 from ff1574/product-image

Browse files

Image processing, dictionary check in promo upsert

api/main.py CHANGED
@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
3
  from config.settings import API_HOST, API_PORT
4
  from api.product_routes import router as product_router
5
  from api.receipt_routes import router as receipt_router
 
6
 
7
  # Initialize FastAPI
8
  app = FastAPI(title="Product and Receipt API")
@@ -20,6 +21,7 @@ app.add_middleware(
20
  # Add routers
21
  app.include_router(product_router)
22
  app.include_router(receipt_router)
 
23
 
24
  @app.get("/", tags=["Health"])
25
  def health_check():
 
3
  from config.settings import API_HOST, API_PORT
4
  from api.product_routes import router as product_router
5
  from api.receipt_routes import router as receipt_router
6
+ from api.scrape_routes import router as scrape_router
7
 
8
  # Initialize FastAPI
9
  app = FastAPI(title="Product and Receipt API")
 
21
  # Add routers
22
  app.include_router(product_router)
23
  app.include_router(receipt_router)
24
+ app.include_router(scrape_router)
25
 
26
  @app.get("/", tags=["Health"])
27
  def health_check():
api/product_routes.py CHANGED
@@ -1,7 +1,8 @@
1
- from fastapi import APIRouter, File, UploadFile, HTTPException
2
- from utils.image_processing import read_image_file
3
  from product_detector.detector import ObjectDetector
4
  from config.settings import MODEL_ONNX_PATH, CLASS_NAMES, INPUT_SIZE
 
5
 
6
  # Initialize the detector
7
  detector = ObjectDetector(
@@ -12,10 +13,12 @@ detector = ObjectDetector(
12
 
13
  router = APIRouter(tags=["Product Detection"])
14
 
 
15
  @router.options("/detect-product")
16
  async def detect_options():
17
  return {"Allow": "POST"}
18
 
 
19
  @router.post("/detect-product")
20
  async def detect_objects(file: UploadFile = File(...)):
21
  try:
@@ -31,3 +34,69 @@ async def detect_objects(file: UploadFile = File(...)):
31
  raise
32
  except Exception as e:
33
  raise HTTPException(500, f"Processing error: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, File, UploadFile, HTTPException, Form
2
+ from utils.image_processing import read_image_file, process_product_image
3
  from product_detector.detector import ObjectDetector
4
  from config.settings import MODEL_ONNX_PATH, CLASS_NAMES, INPUT_SIZE
5
+ from utils.image_processing import process_and_store_product_image
6
 
7
  # Initialize the detector
8
  detector = ObjectDetector(
 
13
 
14
  router = APIRouter(tags=["Product Detection"])
15
 
16
+
17
  @router.options("/detect-product")
18
  async def detect_options():
19
  return {"Allow": "POST"}
20
 
21
+
22
  @router.post("/detect-product")
23
  async def detect_objects(file: UploadFile = File(...)):
24
  try:
 
34
  raise
35
  except Exception as e:
36
  raise HTTPException(500, f"Processing error: {str(e)}")
37
+
38
+
39
+ @router.post("/process-image")
40
+ async def process_image(
41
+ file: UploadFile = File(...),
42
+ remove_bg: bool = Form(True),
43
+ upscale: bool = Form(True),
44
+ scale_factor: int = Form(2),
45
+ process_order: str = Form("remove_first")
46
+ ):
47
+ """
48
+ Process product images by removing background and/or upscaling
49
+ """
50
+ try:
51
+ # Validate inputs
52
+ if scale_factor not in [2, 3, 4]:
53
+ raise HTTPException(400, "Scale factor must be 2, 3, or 4")
54
+
55
+ if process_order not in ["remove_first", "upscale_first"]:
56
+ raise HTTPException(400, "Process order must be 'remove_first' or 'upscale_first'")
57
+
58
+ if not file.content_type.startswith("image/"):
59
+ raise HTTPException(400, "File must be an image")
60
+
61
+ # Use the combined processing and storage function
62
+ result = await process_and_store_product_image(
63
+ file,
64
+ remove_bg=remove_bg,
65
+ upscale=upscale,
66
+ scale_factor=scale_factor,
67
+ process_order=process_order
68
+ )
69
+
70
+ return result
71
+ except HTTPException:
72
+ raise
73
+ except Exception as e:
74
+ raise HTTPException(500, f"Image processing error: {str(e)}")
75
+
76
+
77
+ @router.post("/process-product-image")
78
+ async def process_product_image_endpoint(
79
+ file: UploadFile = File(...),
80
+ remove_bg: bool = Form(True),
81
+ upscale: bool = Form(True),
82
+ scale_factor: int = Form(2),
83
+ process_order: str = Form("remove_first"),
84
+ product_id: str = Form(None)
85
+ ):
86
+ """
87
+ Process a product image and update the product record
88
+ """
89
+ try:
90
+ # Use the combined processing, storage and database function
91
+ result = await process_and_store_product_image(
92
+ file,
93
+ remove_bg=remove_bg,
94
+ upscale=upscale,
95
+ scale_factor=scale_factor,
96
+ process_order=process_order,
97
+ product_id=product_id
98
+ )
99
+
100
+ return result
101
+ except Exception as e:
102
+ raise HTTPException(500, f"Image processing error: {str(e)}")
api/scrape_routes.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Request, Depends, BackgroundTasks
2
+ from db.scrape_repository import PromoProductRepository
3
+ from utils.rate_limiter import RateLimiter
4
+ import requests
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+
7
+ # Initialize rate limiter and repository
8
+ rate_limiter = RateLimiter(max_requests=5) # Lower limit for scraping operations
9
+ promo_repository = PromoProductRepository()
10
+
11
+ router = APIRouter(prefix="/scrape", tags=["Data Scraping"])
12
+
13
+ RETAILER_MAPPING = {
14
+ 8: "Studenac",
15
+ 4: "Konzum",
16
+ 3: "Kaufland",
17
+ 9: "Tommy",
18
+ 109: "Spar",
19
+ 6: "Plodine",
20
+ 5: "Lidl"
21
+ }
22
+
23
+ def fetch_page(session, page):
24
+ """Fetch a single page of products"""
25
+ url = f"https://backend.360promo.hr/api/promotions/products?pageNumber={page}&sortBySalePercentage=False"
26
+ try:
27
+ print(f"📄 Fetching page {page}...")
28
+ response = session.get(url, timeout=10)
29
+ response.raise_for_status()
30
+ return page, response.json()
31
+ except Exception as e:
32
+ print(f"❌ Error on page {page}: {str(e)}")
33
+ return page, []
34
+
35
+ def fetch_all_products():
36
+ products = []
37
+ max_workers = 8 # Adjust based on API capacity
38
+
39
+ with requests.Session() as session:
40
+ # First, fetch page 1 to see if there's data
41
+ _, page1_data = fetch_page(session, 1)
42
+ if not page1_data:
43
+ print("No data found on first page")
44
+ return []
45
+
46
+ products.extend(page1_data)
47
+
48
+ # Set up for concurrent fetching of subsequent pages
49
+ last_page_with_data = 1
50
+ while True:
51
+ # Determine next batch of pages to fetch
52
+ start_page = last_page_with_data + 1
53
+ end_page = start_page + max_workers - 1
54
+
55
+ if start_page > 1000: # Safety limit
56
+ print("Reached maximum page limit")
57
+ break
58
+
59
+ pages_to_fetch = list(range(start_page, end_page + 1))
60
+
61
+ # Fetch pages concurrently
62
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
63
+ futures = [executor.submit(fetch_page, session, page) for page in pages_to_fetch]
64
+
65
+ # Process results
66
+ new_data_found = False
67
+ highest_page_with_data = 0
68
+
69
+ for future in as_completed(futures):
70
+ page, data = future.result()
71
+ if data: # If we got data
72
+ products.extend(data)
73
+ new_data_found = True
74
+ highest_page_with_data = max(highest_page_with_data, page)
75
+
76
+ # If no new data was found in this batch, we're done
77
+ if not new_data_found:
78
+ break
79
+
80
+ # Update the last page with data
81
+ last_page_with_data = highest_page_with_data
82
+
83
+ print(f"\n✅ Total products collected: {len(products)}")
84
+ return products
85
+
86
+ def process_products(products):
87
+ unified_products = []
88
+
89
+ for product in products:
90
+ retailer_id = product.get('retailerId')
91
+ if not retailer_id or retailer_id not in RETAILER_MAPPING:
92
+ continue
93
+
94
+ store_name = RETAILER_MAPPING[retailer_id]
95
+ price = product.get('promoPrice') or product.get('regularPrice')
96
+
97
+ if price is None:
98
+ continue
99
+
100
+ item = {
101
+ "store": store_name,
102
+ "pictureId": product.get('id'),
103
+ "name": product.get('name', 'Unknown Product'),
104
+ "description": product.get('description', ''),
105
+ "promoStartDate": product.get('promoStartDate'),
106
+ "promoEndDate": product.get('promoEndDate'),
107
+ "regularPrice": product.get('regularPrice'),
108
+ "promoPrice": product.get('promoPrice')
109
+ }
110
+
111
+ unified_products.append(item)
112
+
113
+ return unified_products
114
+
115
+ async def scrape_and_store_products():
116
+ """Background task to scrape products and store them in the database"""
117
+ try:
118
+ # Fetch products from the API
119
+ products = fetch_all_products()
120
+ if not products:
121
+ print("No products found to scrape")
122
+ return 0
123
+
124
+ # Process products into standardized format
125
+ unified_products = process_products(products)
126
+ if not unified_products:
127
+ print("No valid products found to store")
128
+ return 0
129
+
130
+ # Store products in Supabase
131
+ stored_count = promo_repository.upsert_multiple_products(unified_products)
132
+ print(f"Successfully stored {stored_count} products")
133
+ return stored_count
134
+
135
+ except Exception as e:
136
+ print(f"Error during scraping: {str(e)}")
137
+ return 0
138
+
139
+ @router.post("/promo")
140
+ async def trigger_promo_scrape(
141
+ background_tasks: BackgroundTasks,
142
+ request: Request
143
+ ):
144
+ """
145
+ Admin only: Trigger a promotional product scraping operation.
146
+ This runs in the background to avoid timeout issues.
147
+ """
148
+ try:
149
+ # Add scraping task to background tasks
150
+ background_tasks.add_task(scrape_and_store_products)
151
+
152
+ return {
153
+ "status": "success",
154
+ "message": "Promotional product scraping started. Results will be stored in the database."
155
+ }
156
+ except Exception as e:
157
+ print(f"ERROR: {str(e)}")
158
+ raise HTTPException(500, f"Failed to start scraping operation: {str(e)}")
db/receipt_repository.py CHANGED
@@ -2,7 +2,8 @@ import uuid
2
  import json
3
  from datetime import datetime
4
  from typing import Optional, Dict, Any
5
- from .supabase_client import SupabaseClient
 
6
 
7
  class ReceiptRepository:
8
  def __init__(self):
 
2
  import json
3
  from datetime import datetime
4
  from typing import Optional, Dict, Any
5
+ from db.supabase_client import SupabaseClient
6
+
7
 
8
  class ReceiptRepository:
9
  def __init__(self):
db/scrape_repository.py ADDED
@@ -0,0 +1,601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, List
2
+ from datetime import datetime, timedelta
3
+ import time
4
+ import requests
5
+ from io import BytesIO
6
+ import asyncio
7
+ from db.supabase_client import SupabaseClient
8
+ from utils.image_processing import process_and_store_product_image
9
+
10
+
11
+ class PromoProductRepository:
12
+ def __init__(self):
13
+ self.supabase = SupabaseClient().get_client()
14
+
15
+ def fix_promo_date(self, promo_date: str, date_type: str = "start") -> str:
16
+ """Replace invalid promo dates with appropriate fallback dates"""
17
+ if promo_date is None:
18
+ fallback_date = datetime.now() if date_type == "start" else datetime.now() + timedelta(days=7)
19
+ print(f"⚠️ {date_type} date is None, using fallback: {fallback_date.isoformat()}")
20
+ return fallback_date.isoformat()
21
+
22
+ try:
23
+ # Parse the date string
24
+ dt = datetime.fromisoformat(promo_date.replace('Z', '+00:00'))
25
+
26
+ # Check for Unix epoch start date (1970-01-01)
27
+ if dt.year == 1970 and dt.month == 1 and dt.day == 1:
28
+ fallback_date = datetime.now() if date_type == "start" else datetime.now() + timedelta(days=7)
29
+ print(f"⚠️ {date_type} date is Unix epoch (1970), using fallback: {fallback_date.isoformat()}")
30
+ return fallback_date.isoformat()
31
+
32
+ # Check for dates too far in the past (more than 1 year ago)
33
+ if dt < datetime.now() - timedelta(days=365):
34
+ fallback_date = datetime.now() if date_type == "start" else datetime.now() + timedelta(days=7)
35
+ print(f"⚠️ {date_type} date too old ({dt.date()}), using fallback: {fallback_date.isoformat()}")
36
+ return fallback_date.isoformat()
37
+
38
+ # Check for dates too far in the future (more than 1 year from now)
39
+ if dt > datetime.now() + timedelta(days=365):
40
+ fallback_date = datetime.now() if date_type == "start" else datetime.now() + timedelta(days=7)
41
+ print(f"⚠️ {date_type} date too far in future ({dt.date()}), using fallback: {fallback_date.isoformat()}")
42
+ return fallback_date.isoformat()
43
+
44
+ return promo_date
45
+
46
+ except Exception as e:
47
+ # If parsing fails, replace with fallback
48
+ fallback_date = datetime.now() if date_type == "start" else datetime.now() + timedelta(days=7)
49
+ print(f"⚠️ {date_type} date parsing failed ({promo_date}), using fallback: {fallback_date.isoformat()}")
50
+ return fallback_date.isoformat()
51
+
52
+ def check_dictionary(self, product_name: str, store: str) -> str | None:
53
+ """Check dictionary for existing product match"""
54
+ if not product_name or not store:
55
+ return None
56
+
57
+ # Clean and format store name for column lookup
58
+ store_key = store.lower().strip()
59
+ column_name = f"promo_input_{store_key}"
60
+
61
+ try:
62
+ # Use a more explicit query approach
63
+ query = self.supabase.table("product_input_dictionary").select("product_id")
64
+
65
+ # Apply the filter dynamically
66
+ result = query.filter(column_name, "eq", product_name).execute()
67
+
68
+ # Validate the response structure
69
+ if (result and
70
+ hasattr(result, 'data') and
71
+ result.data is not None and
72
+ len(result.data) > 0):
73
+
74
+ product_id = result.data[0].get("product_id")
75
+ if product_id:
76
+ print(f"✅ Found existing product ID {product_id} for '{product_name}' in column '{column_name}'")
77
+ return product_id
78
+
79
+ print(f"📝 No match found for '{product_name}' in column '{column_name}'")
80
+ return None
81
+
82
+ except Exception as e:
83
+ print(f"❌ Error checking dictionary for '{product_name}' in column '{column_name}': {e}")
84
+ return None
85
+
86
+ def normalize_store_name(self, name: str) -> str:
87
+ """Helper function for relaxed string comparison"""
88
+ if not name:
89
+ return ""
90
+ import unicodedata
91
+ normalized = unicodedata.normalize('NFD', name.lower())
92
+ return ''.join(c for c in normalized if unicodedata.category(c) != 'Mn' and c.isalnum())
93
+
94
+ def get_all_store_chains(self) -> List[Dict]:
95
+ """Get all store chains"""
96
+ try:
97
+ result = self.supabase.table("store_chains") \
98
+ .select("store_chain_id, store_chain_name") \
99
+ .execute()
100
+
101
+ return [{"id": chain["store_chain_id"], "name": chain["store_chain_name"]}
102
+ for chain in result.data]
103
+ except Exception as e:
104
+ print(f"Error fetching store chains: {e}")
105
+ return []
106
+
107
+ def get_stores_by_chain(self, chain_id: str) -> List[Dict]:
108
+ """Get stores for a specific chain"""
109
+ try:
110
+ result = self.supabase.table("stores") \
111
+ .select("store_id, store_location, store_address") \
112
+ .eq("store_chain_id", chain_id) \
113
+ .execute()
114
+
115
+ return [{"id": store["store_id"],
116
+ "location": store["store_location"],
117
+ "address": store["store_address"]}
118
+ for store in result.data]
119
+ except Exception as e:
120
+ print(f"Error fetching stores for chain {chain_id}: {e}")
121
+ return []
122
+
123
+ def validate_date_range(self, start_date: str, end_date: str) -> bool:
124
+ """Validate and limit date range to prevent timeout issues"""
125
+ try:
126
+ start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
127
+ end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
128
+
129
+ # Calculate number of days
130
+ days_diff = (end_dt - start_dt).days + 1
131
+
132
+ if days_diff > 90: # Limit to 90 days to prevent timeouts
133
+ print(f"⚠️ Date range too large ({days_diff} days), limiting to 90 days")
134
+ return False
135
+
136
+ if days_diff < 1: # End date before start date
137
+ print(f"⚠️ Invalid date range (end before start), skipping")
138
+ return False
139
+
140
+ print(f"📅 Date range validated: {days_diff} days ({start_dt.date()} to {end_dt.date()})")
141
+ return True
142
+
143
+ except Exception as e:
144
+ print(f"❌ Error validating date range: {e}")
145
+ return False
146
+
147
+ def process_single_store_pricing(self, store_id: str, product_id: str,
148
+ start_date: str, end_date: str, price: float) -> bool:
149
+ """Process pricing for a single store with enhanced timeout handling"""
150
+ max_retries = 3
151
+ retry_delay = 2
152
+
153
+ # Validate date range first
154
+ if not self.validate_date_range(start_date, end_date):
155
+ print(f"❌ Skipping store {store_id} due to invalid date range")
156
+ return False
157
+
158
+ for attempt in range(max_retries):
159
+ try:
160
+ print(f" 🔄 Attempt {attempt + 1}: Processing store {store_id}")
161
+
162
+ # Check if store-product relationship exists with timeout
163
+ print(f" 📊 Checking store-product relationship...")
164
+ store_product_result = self.supabase.table("store_products") \
165
+ .select("store_product_id") \
166
+ .eq("store_id", store_id) \
167
+ .eq("product_id", product_id) \
168
+ .maybe_single() \
169
+ .execute()
170
+
171
+ if store_product_result.data:
172
+ store_product_id = store_product_result.data["store_product_id"]
173
+ print(f" ✅ Found existing store-product relationship: {store_product_id}")
174
+ else:
175
+ # Create new store-product relationship
176
+ print(f" ➕ Creating new store-product relationship...")
177
+ new_store_product = self.supabase.table("store_products") \
178
+ .insert({"store_id": store_id, "product_id": product_id}) \
179
+ .select("store_product_id") \
180
+ .single() \
181
+ .execute()
182
+ store_product_id = new_store_product.data["store_product_id"]
183
+ print(f" ✅ Created store-product relationship: {store_product_id}")
184
+
185
+ # Count existing entries first to understand the scope
186
+ print(f" 🔍 Checking existing price history entries...")
187
+ existing_count_result = self.supabase.table("product_price_history") \
188
+ .select("*", count="exact") \
189
+ .eq("store_product_id", store_product_id) \
190
+ .gte("price_date", start_date) \
191
+ .lte("price_date", end_date) \
192
+ .execute()
193
+
194
+ existing_count = existing_count_result.count if existing_count_result.count else 0
195
+ print(f" 📈 Found {existing_count} existing price entries to delete")
196
+
197
+ # Delete existing entries in smaller batches if there are many
198
+ if existing_count > 0:
199
+ print(f" 🗑️ Deleting {existing_count} existing entries...")
200
+ if existing_count > 100:
201
+ # For large deletions, do it in smaller chunks
202
+ print(f" ⚠️ Large deletion detected, processing in chunks...")
203
+ # Delete in 30-day chunks to avoid timeouts
204
+ current_start = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
205
+ end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
206
+
207
+ while current_start <= end_dt:
208
+ chunk_end = min(current_start + timedelta(days=30), end_dt)
209
+ chunk_start_str = current_start.strftime("%Y-%m-%d")
210
+ chunk_end_str = chunk_end.strftime("%Y-%m-%d")
211
+
212
+ print(f" 🗑️ Deleting chunk: {chunk_start_str} to {chunk_end_str}")
213
+ self.supabase.table("product_price_history") \
214
+ .delete() \
215
+ .eq("store_product_id", store_product_id) \
216
+ .gte("price_date", chunk_start_str) \
217
+ .lte("price_date", chunk_end_str) \
218
+ .execute()
219
+
220
+ current_start = chunk_end + timedelta(days=1)
221
+ time.sleep(0.2) # Small delay between chunks
222
+ else:
223
+ # Small deletion, do it all at once
224
+ self.supabase.table("product_price_history") \
225
+ .delete() \
226
+ .eq("store_product_id", store_product_id) \
227
+ .gte("price_date", start_date) \
228
+ .lte("price_date", end_date) \
229
+ .execute()
230
+
231
+ # Create price history entries in very small batches
232
+ print(f" 📊 Creating new price history entries...")
233
+ start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
234
+ end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
235
+
236
+ current_date = start_dt
237
+ batch_size = 25 # Very small batch size for Konzum
238
+ price_entries = []
239
+ total_days = (end_dt - start_dt).days + 1
240
+ processed_days = 0
241
+
242
+ while current_date <= end_dt:
243
+ price_entries.append({
244
+ "store_product_id": store_product_id,
245
+ "current_price": price,
246
+ "price_date": current_date.strftime("%Y-%m-%d")
247
+ })
248
+ current_date += timedelta(days=1)
249
+ processed_days += 1
250
+
251
+ # Insert in small batches
252
+ if len(price_entries) >= batch_size:
253
+ print(f" 📈 Inserting batch ({processed_days}/{total_days} days)")
254
+ self.supabase.table("product_price_history") \
255
+ .insert(price_entries) \
256
+ .execute()
257
+ price_entries = []
258
+ time.sleep(0.3) # Longer delay for Konzum
259
+
260
+ # Insert remaining entries
261
+ if price_entries:
262
+ print(f" 📈 Inserting final batch ({processed_days}/{total_days} days)")
263
+ self.supabase.table("product_price_history") \
264
+ .insert(price_entries) \
265
+ .execute()
266
+
267
+ print(f" ✅ Successfully processed store {store_id}")
268
+ return True
269
+
270
+ except Exception as e:
271
+ error_msg = str(e)
272
+ if ("520" in error_msg or "timeout" in error_msg.lower()) and attempt < max_retries - 1:
273
+ print(f" ⚠️ Timeout/520 error on attempt {attempt + 1}, retrying in {retry_delay}s...")
274
+ time.sleep(retry_delay)
275
+ retry_delay *= 2 # Exponential backoff
276
+ continue
277
+ else:
278
+ print(f" ❌ Error processing store {store_id}: {e}")
279
+ return False
280
+
281
+ return False
282
+
283
+ def process_product_pricing(self, product_id: str, store_name: str, start_date: str,
284
+ end_date: str, promo_price: float, regular_price: float) -> bool:
285
+ """Process product pricing for date range across all stores in a chain"""
286
+ if not product_id or not store_name:
287
+ print("Missing required parameters for price processing")
288
+ return False
289
+
290
+ try:
291
+ print(f"Starting price processing for product ID: {product_id}")
292
+
293
+ # Fix invalid dates BEFORE processing
294
+ print(f"📅 Original dates - Start: {start_date}, End: {end_date}")
295
+ fixed_start_date = self.fix_promo_date(start_date, "start")
296
+ fixed_end_date = self.fix_promo_date(end_date, "end")
297
+ print(f"📅 Fixed dates - Start: {fixed_start_date}, End: {fixed_end_date}")
298
+
299
+ # Use the fixed dates
300
+ start_date = fixed_start_date
301
+ end_date = fixed_end_date
302
+
303
+ # Get all store chains
304
+ store_chains = self.get_all_store_chains()
305
+
306
+ # Normalize the promo store name
307
+ promo_store_normalized = self.normalize_store_name(store_name)
308
+
309
+ # Find matching store chain with relaxed comparison
310
+ matched_chain = None
311
+ for chain in store_chains:
312
+ chain_normalized = self.normalize_store_name(chain["name"])
313
+
314
+ if (promo_store_normalized in chain_normalized or
315
+ chain_normalized in promo_store_normalized):
316
+ matched_chain = chain
317
+ print(f"✅ Matched store chain: {matched_chain['name']} (ID: {matched_chain['id']})")
318
+ break
319
+
320
+ if not matched_chain:
321
+ print("No matching store chain found")
322
+ return False
323
+
324
+ # Get stores for the matched chain
325
+ stores_in_chain = self.get_stores_by_chain(matched_chain["id"])
326
+
327
+ if not stores_in_chain:
328
+ print(f"No stores found for chain ID: {matched_chain['id']}")
329
+ return False
330
+
331
+ # Use promo price if available, otherwise use regular price
332
+ price_to_use = promo_price if promo_price and promo_price > 0 else regular_price or 0
333
+
334
+ successful_stores = 0
335
+ total_stores = len(stores_in_chain)
336
+
337
+ # Special handling for Konzum (longer delays)
338
+ is_konzum = "konzum" in matched_chain["name"].lower()
339
+ store_delay = 2.0 if is_konzum else 0.5
340
+
341
+ print(f"📊 Processing {total_stores} stores for {matched_chain['name']}")
342
+ if is_konzum:
343
+ print(f"⚠️ Konzum detected - using enhanced timeout handling")
344
+
345
+ # Process each store individually with delays
346
+ for i, store in enumerate(stores_in_chain):
347
+ print(f"Processing store {i+1}/{total_stores}: {store['location']} (ID: {store['id']})")
348
+
349
+ success = self.process_single_store_pricing(
350
+ store_id=store["id"],
351
+ product_id=product_id,
352
+ start_date=start_date,
353
+ end_date=end_date,
354
+ price=price_to_use
355
+ )
356
+
357
+ if success:
358
+ successful_stores += 1
359
+ print(f" ✅ Store {i+1}/{total_stores} completed successfully")
360
+ else:
361
+ print(f" ❌ Store {i+1}/{total_stores} failed")
362
+
363
+ # Add delay between stores (longer for Konzum)
364
+ if i < total_stores - 1: # Don't sleep after the last store
365
+ print(f" ⏳ Waiting {store_delay}s before next store...")
366
+ time.sleep(store_delay)
367
+
368
+ success_rate = successful_stores / total_stores if total_stores > 0 else 0
369
+ print(f"✅ Completed price processing: {successful_stores}/{total_stores} stores ({success_rate:.1%})")
370
+
371
+ # Consider it successful if at least 70% of stores were updated (lower threshold for Konzum)
372
+ threshold = 0.7 if is_konzum else 0.8
373
+ return success_rate >= threshold
374
+
375
+ except Exception as e:
376
+ print(f"Error processing product pricing: {e}")
377
+ return False
378
+
379
+ def process_product_image_sync(self, picture_id: str, product_id: str) -> bool:
380
+ """Process product image using direct function calls - sync wrapper"""
381
+ if not picture_id or not product_id:
382
+ print("No image or product ID provided for image processing")
383
+ return False
384
+
385
+ try:
386
+ print(f"🖼️ Processing image for product ID: {product_id}")
387
+
388
+ # Get the original image URL (same pattern as admin dashboard)
389
+ original_image_url = f"https://backend.360promo.hr/contents/products/{picture_id}.jpg"
390
+
391
+ # Fetch the image
392
+ print(f"📥 Downloading image from: {original_image_url}")
393
+ response = requests.get(original_image_url, timeout=30)
394
+
395
+ if not response.ok:
396
+ print(f"❌ Failed to fetch image: HTTP {response.status_code}")
397
+ return False
398
+
399
+ # Create a mock UploadFile object from the downloaded image
400
+ class MockUploadFile:
401
+ def __init__(self, content: bytes, filename: str):
402
+ self.file = BytesIO(content)
403
+ self.filename = filename
404
+ self.content_type = "image/jpeg"
405
+
406
+ async def read(self) -> bytes:
407
+ self.file.seek(0)
408
+ return self.file.read()
409
+
410
+ mock_file = MockUploadFile(response.content, f"product_{picture_id}.jpg")
411
+
412
+ # Run the async function in a new event loop
413
+ async def process_image():
414
+ return await process_and_store_product_image(
415
+ file=mock_file,
416
+ remove_bg=True,
417
+ upscale=True,
418
+ scale_factor=2,
419
+ process_order="remove_first",
420
+ product_id=product_id
421
+ )
422
+
423
+ # Process the image directly using the imported function
424
+ print(f"🔄 Processing image directly...")
425
+
426
+ # Check if we're in an event loop
427
+ try:
428
+ loop = asyncio.get_running_loop()
429
+ # We're in an async context, run in thread pool
430
+ import concurrent.futures
431
+ with concurrent.futures.ThreadPoolExecutor() as executor:
432
+ future = executor.submit(asyncio.run, process_image())
433
+ result = future.result(timeout=60)
434
+ except RuntimeError:
435
+ # No event loop running, we can use asyncio.run
436
+ result = asyncio.run(process_image())
437
+
438
+ if result.get('status') == 'success':
439
+ print(f"✅ Image processed successfully: {result.get('image_url')}")
440
+ return True
441
+ else:
442
+ print(f"❌ Image processing failed: {result}")
443
+ return False
444
+
445
+ except Exception as e:
446
+ print(f"❌ Error processing product image: {e}")
447
+ return False
448
+
449
+ def upsert_multiple_products(self, products: List[Dict[str, Any]]) -> int:
450
+ """
451
+ Upsert multiple promo products in batches with dictionary check and image processing
452
+ Returns the number of successfully processed products
453
+ """
454
+ batch_size = 100
455
+ successfully_processed = 0
456
+ automatically_adjusted = 0 # Counter for products found in dictionary
457
+ upserted_to_promo = 0 # Counter for products added to promo_products table
458
+ failed_pricing_updates = 0 # Counter for failed pricing updates
459
+ images_processed = 0 # Counter for successfully processed images
460
+ images_failed = 0 # Counter for failed image processing
461
+ date_fixes = 0 # Counter for fixed dates
462
+ timestamp = datetime.now().isoformat()
463
+
464
+ for i in range(0, len(products), batch_size):
465
+ batch = products[i:i+batch_size]
466
+
467
+ for product in batch:
468
+ store = product.get("store")
469
+ name = product.get("name")
470
+ picture_id = product.get("pictureId")
471
+
472
+ try:
473
+ # Check dictionary first
474
+ existing_product_id = self.check_dictionary(name, store)
475
+
476
+ if existing_product_id:
477
+ # Product exists in dictionary - update pricing and process image
478
+ print(f"Found existing product ID {existing_product_id} for '{name}' from '{store}' - updating pricing and processing image")
479
+
480
+ # Check if dates need fixing
481
+ original_start = product.get("promoStartDate")
482
+ original_end = product.get("promoEndDate")
483
+
484
+ if (original_start is None or
485
+ original_start == "1970-01-01T00:00:00Z" or
486
+ original_end is None or
487
+ original_end == "1970-01-01T00:00:00Z"):
488
+ date_fixes += 1
489
+
490
+ # Process pricing
491
+ pricing_success = self.process_product_pricing(
492
+ product_id=existing_product_id,
493
+ store_name=store,
494
+ start_date=product.get("promoStartDate"),
495
+ end_date=product.get("promoEndDate"),
496
+ promo_price=product.get("promoPrice"),
497
+ regular_price=product.get("regularPrice")
498
+ )
499
+
500
+ # Process image if available (using sync wrapper)
501
+ image_success = False
502
+ if picture_id:
503
+ image_success = self.process_product_image_sync(picture_id, existing_product_id)
504
+ if image_success:
505
+ images_processed += 1
506
+ print(f"🖼️ Successfully processed image for: {name}")
507
+ else:
508
+ images_failed += 1
509
+ print(f"🖼️ Failed to process image for: {name}")
510
+
511
+ if pricing_success:
512
+ successfully_processed += 1
513
+ automatically_adjusted += 1
514
+ print(f"✅ Automatically adjusted pricing for: {name}")
515
+ else:
516
+ failed_pricing_updates += 1
517
+ print(f"❌ Failed to update pricing for: {name}")
518
+ else:
519
+ # Product not in dictionary - proceed with normal upsert to promo_products
520
+ formatted_promo_product = {
521
+ "store": store,
522
+ "picture_id": product.get("pictureId"),
523
+ "name": name,
524
+ "description": product.get("description", ""),
525
+ "promo_start_date": product.get("promoStartDate"),
526
+ "promo_end_date": product.get("promoEndDate"),
527
+ "regular_price": product.get("regularPrice"),
528
+ "promo_price": product.get("promoPrice"),
529
+ "last_updated": timestamp
530
+ }
531
+
532
+ # Check if product exists in promo_products
533
+ result = self.supabase.table("promo_products").select("*") \
534
+ .eq("store", store) \
535
+ .eq("name", name) \
536
+ .execute()
537
+
538
+ if result.data and len(result.data) > 0:
539
+ # Update existing promo product
540
+ record_id = result.data[0]["id"]
541
+ self.supabase.table("promo_products") \
542
+ .update(formatted_promo_product) \
543
+ .eq("id", record_id) \
544
+ .execute()
545
+ print(f"🔄 Updated existing promo product: {name}")
546
+ else:
547
+ # Insert new promo product
548
+ self.supabase.table("promo_products") \
549
+ .insert(formatted_promo_product) \
550
+ .execute()
551
+ print(f"➕ Inserted new promo product: {name}")
552
+
553
+ successfully_processed += 1
554
+ upserted_to_promo += 1
555
+
556
+ # Print progress periodically
557
+ total_processed = successfully_processed + failed_pricing_updates
558
+ if total_processed % 50 == 0:
559
+ print(f"Processed {total_processed} / {len(products)} products so far...")
560
+
561
+ except Exception as e:
562
+ print(f"Failed to process product '{name}' from '{store}': {str(e)}")
563
+ continue
564
+
565
+ # Detailed summary logging
566
+ total_processed = successfully_processed + failed_pricing_updates
567
+ print(f"\n{'='*60}")
568
+ print(f"SCRAPING PROCESS SUMMARY")
569
+ print(f"{'='*60}")
570
+ print(f"📊 Total products processed: {len(products)}")
571
+ print(f"✅ Successfully processed: {successfully_processed}")
572
+ print(f"🔧 Automatically adjusted (existing products): {automatically_adjusted}")
573
+ print(f"📋 Upserted to promo_products table: {upserted_to_promo}")
574
+ print(f"⚠️ Failed pricing updates: {failed_pricing_updates}")
575
+ print(f"🖼️ Images successfully processed: {images_processed}")
576
+ print(f"🖼️ Images failed to process: {images_failed}")
577
+ print(f"📅 Invalid dates fixed: {date_fixes}")
578
+ print(f"❌ Failed to process: {len(products) - total_processed}")
579
+ print(f"{'='*60}")
580
+
581
+ if automatically_adjusted > 0:
582
+ print(f"🎯 {automatically_adjusted} products were found in the dictionary and had their pricing automatically updated across all stores in their respective chains.")
583
+
584
+ if images_processed > 0:
585
+ print(f"🖼️ {images_processed} product images were successfully processed and updated.")
586
+
587
+ if images_failed > 0:
588
+ print(f"⚠️ {images_failed} product images failed to process.")
589
+
590
+ if date_fixes > 0:
591
+ print(f"📅 {date_fixes} products had invalid dates (null/1970) that were automatically corrected.")
592
+
593
+ if upserted_to_promo > 0:
594
+ print(f"📝 {upserted_to_promo} products were added/updated in the temporary promo_products table for manual review.")
595
+
596
+ if failed_pricing_updates > 0:
597
+ print(f"⚠️ {failed_pricing_updates} products had dictionary matches but failed pricing updates (likely due to API limits).")
598
+
599
+ print(f"{'='*60}\n")
600
+
601
+ return successfully_processed
requirements.txt CHANGED
@@ -9,4 +9,5 @@ ultralytics
9
  python-multipart
10
  google-cloud-vision
11
  python-dotenv
12
- supabase
 
 
9
  python-multipart
10
  google-cloud-vision
11
  python-dotenv
12
+ supabase
13
+ rembg
utils/image_processing.py CHANGED
@@ -1,6 +1,15 @@
1
  import numpy as np
2
  import cv2
3
  from fastapi import UploadFile, HTTPException
 
 
 
 
 
 
 
 
 
4
 
5
  async def read_image_file(file: UploadFile) -> np.ndarray:
6
  """Read and process an image file from FastAPI UploadFile"""
@@ -14,3 +23,196 @@ async def read_image_file(file: UploadFile) -> np.ndarray:
14
  raise HTTPException(400, "Invalid image data")
15
 
16
  return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import numpy as np
2
  import cv2
3
  from fastapi import UploadFile, HTTPException
4
+ from rembg import remove
5
+ import time
6
+ import uuid
7
+ from typing import Tuple, Optional
8
+ from db.supabase_client import SupabaseClient
9
+
10
+ # Initialize Supabase client
11
+ supabase = SupabaseClient().get_client()
12
+
13
 
14
  async def read_image_file(file: UploadFile) -> np.ndarray:
15
  """Read and process an image file from FastAPI UploadFile"""
 
23
  raise HTTPException(400, "Invalid image data")
24
 
25
  return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
26
+
27
+
28
+ def remove_background(image_bytes: bytes) -> bytes:
29
+ """Remove white background from image using rembg"""
30
+ try:
31
+ return remove(image_bytes,
32
+ alpha_matting=True,
33
+ alpha_matting_background_threshold=5,
34
+ alpha_matting_foreground_threshold=220,
35
+ alpha_matting_erode_size=5)
36
+ except Exception as e:
37
+ print(f"Error removing background: {str(e)}")
38
+ raise Exception(f"Background removal error: {str(e)}")
39
+
40
+
41
+ def upscale_image(image_bytes: bytes, scale_factor: int = 2) -> bytes:
42
+ """Upscale image using OpenCV"""
43
+ try:
44
+ # Create a numpy array from the image bytes
45
+ nparr = np.frombuffer(image_bytes, np.uint8)
46
+ img = cv2.imdecode(nparr, cv2.IMREAD_UNCHANGED)
47
+
48
+ # Handle images with alpha channel
49
+ if len(img.shape) > 2 and img.shape[2] == 4:
50
+ # Split channels
51
+ b, g, r, a = cv2.split(img)
52
+
53
+ # Scale RGB channels
54
+ rgb_channels = cv2.merge([b, g, r])
55
+ scaled_rgb = cv2.resize(rgb_channels, None, fx=scale_factor, fy=scale_factor,
56
+ interpolation=cv2.INTER_CUBIC)
57
+
58
+ # Scale alpha channel separately
59
+ scaled_alpha = cv2.resize(a, None, fx=scale_factor, fy=scale_factor,
60
+ interpolation=cv2.INTER_CUBIC)
61
+
62
+ # Merge channels back together
63
+ scaled_img = cv2.merge([
64
+ scaled_rgb[:, :, 0],
65
+ scaled_rgb[:, :, 1],
66
+ scaled_rgb[:, :, 2],
67
+ scaled_alpha
68
+ ])
69
+ else:
70
+ # Regular RGB image
71
+ scaled_img = cv2.resize(img, None, fx=scale_factor, fy=scale_factor,
72
+ interpolation=cv2.INTER_CUBIC)
73
+
74
+ # Encode the image back to bytes
75
+ success, buffer = cv2.imencode('.png', scaled_img)
76
+ if not success:
77
+ raise Exception("Failed to encode upscaled image")
78
+
79
+ return buffer.tobytes()
80
+ except Exception as e:
81
+ print(f"Error upscaling image: {str(e)}")
82
+ raise Exception(f"Image upscaling error: {str(e)}")
83
+
84
+
85
+ async def process_product_image(
86
+ file: UploadFile,
87
+ remove_bg: bool = True,
88
+ upscale: bool = True,
89
+ scale_factor: int = 2,
90
+ process_order: str = "remove_first"
91
+ ) -> Tuple[bytes, str]:
92
+ """Process a product image with background removal and upscaling"""
93
+ # Read the file content
94
+ content = await file.read()
95
+ file.file.seek(0) # Reset file pointer for potential reuse
96
+
97
+ # Create a descriptive filename with timestamp for uniqueness
98
+ timestamp = int(time.time())
99
+ original_filename = file.filename.split('.')
100
+ base_name = original_filename[0] if len(original_filename) > 0 else 'product'
101
+ extension = 'png' # Always use PNG to preserve transparency
102
+
103
+ # Process the image based on the parameters and order
104
+ processed_content = content
105
+
106
+ if process_order == "remove_first" and remove_bg and upscale:
107
+ processed_content = remove_background(processed_content)
108
+ processed_content = upscale_image(processed_content, scale_factor)
109
+ elif process_order == "upscale_first" and remove_bg and upscale:
110
+ processed_content = upscale_image(processed_content, scale_factor)
111
+ processed_content = remove_background(processed_content)
112
+ elif remove_bg:
113
+ processed_content = remove_background(processed_content)
114
+ elif upscale:
115
+ processed_content = upscale_image(processed_content, scale_factor)
116
+
117
+ # Create descriptive filename with processing info
118
+ processed_filename = f"{base_name}_{'nobg' if remove_bg else ''}_{'upx' + str(scale_factor) if upscale else ''}_{timestamp}.{extension}"
119
+
120
+ return processed_content, processed_filename
121
+
122
+
123
+ async def upload_processed_image(
124
+ processed_image: bytes,
125
+ filename: str,
126
+ bucket: str = "product-images"
127
+ ) -> Tuple[str, str]:
128
+ """
129
+ Upload a processed image to Supabase Storage
130
+
131
+ Returns:
132
+ Tuple[str, str]: (image_path, image_url)
133
+ """
134
+ # Generate a unique ID for the image
135
+ image_id = str(uuid.uuid4())
136
+ image_path = f"{image_id}_{filename}"
137
+
138
+ # Upload the processed image to Supabase Storage
139
+ supabase.storage.from_(bucket).upload(
140
+ file=processed_image,
141
+ path=image_path,
142
+ file_options={"content-type": "image/png", "upsert": "true"}
143
+ )
144
+
145
+ # Get the public URL for the uploaded image
146
+ image_url = supabase.storage.from_(bucket).get_public_url(image_path)
147
+
148
+ return image_path, image_url
149
+
150
+ async def update_product_image(product_id: str, image_url: str) -> dict[str, any]:
151
+ """
152
+ Update the product_image field for a product
153
+
154
+ Returns:
155
+ Dict[str, Any]: The updated product data
156
+ """
157
+ if not product_id:
158
+ raise ValueError("Product ID is required")
159
+
160
+ result = supabase.table("products").update({
161
+ "product_image": image_url
162
+ }).eq("product_id", product_id).execute()
163
+
164
+ if not result.data:
165
+ raise Exception(f"Failed to update product {product_id}")
166
+
167
+ return result.data[0]
168
+
169
+ async def process_and_store_product_image(
170
+ file: UploadFile,
171
+ remove_bg: bool = True,
172
+ upscale: bool = True,
173
+ scale_factor: int = 2,
174
+ process_order: str = "remove_first",
175
+ product_id: Optional[str] = None
176
+ ) -> dict[str, any]:
177
+ """
178
+ Complete workflow for processing a product image and storing it
179
+
180
+ This function:
181
+ 1. Processes the image (remove background, upscale)
182
+ 2. Uploads it to storage
183
+ 3. Updates the product record if product_id is provided
184
+
185
+ Returns:
186
+ Dict[str, Any]: Result with status, urls, and processing info
187
+ """
188
+ # Process the image
189
+ processed_image, filename = await process_product_image(
190
+ file,
191
+ remove_bg=remove_bg,
192
+ upscale=upscale,
193
+ scale_factor=scale_factor,
194
+ process_order=process_order
195
+ )
196
+
197
+ # Upload to storage
198
+ image_path, image_url = await upload_processed_image(processed_image, filename)
199
+
200
+ # Update product record if needed
201
+ product_data = None
202
+ if product_id:
203
+ product_data = await update_product_image(product_id, image_url)
204
+
205
+ # Return comprehensive result
206
+ return {
207
+ "status": "success",
208
+ "message": "Image processed successfully",
209
+ "image_url": image_url,
210
+ "image_path": image_path,
211
+ "product_data": product_data,
212
+ "processing": {
213
+ "background_removed": remove_bg,
214
+ "upscaled": upscale,
215
+ "scale_factor": scale_factor if upscale else None,
216
+ "process_order": process_order
217
+ }
218
+ }