Spaces:
Running
Running
import logging | |
import traceback | |
import urllib.parse | |
import json | |
from flask import Blueprint, render_template, jsonify, request, flash, redirect, url_for, current_app, session, Response, stream_with_context | |
from pymongo import UpdateOne | |
from collections import defaultdict | |
from bson.binary import Binary | |
import random | |
import requests | |
# This part of the code assumes the Zoho client functions are in a file named `xero_client.py` | |
from .xero_client import ( | |
zoho_token_required, | |
is_zoho_token_available, | |
initialize_zoho_sdk, | |
clear_zoho_token, | |
make_zoho_api_request | |
) | |
from .extensions import mongo | |
from utils import jsonify as jsonify_custom | |
from search_engine import search_and_filter_images, categorise | |
zoho_bp = Blueprint('zoho', __name__) | |
logger = logging.getLogger(__name__) | |
def inject_zoho_status(): | |
"""Makes the Zoho token status available to all templates.""" | |
return dict(is_zoho_token_available=is_zoho_token_available) | |
def index(): | |
"""Renders the home page with a welcome message.""" | |
welcome_message = """ | |
Welcome to the Zoho Books Integration Dashboard. | |
Use the navigation bar above to: | |
- Sync Inventory: Pull all items from Zoho, find images, and save to our database. | |
- Edit Inventory: Manually override image URLs for specific products. | |
Your current Zoho Token Status is: {} | |
""".format("Available" if is_zoho_token_available() else "Not Available. Please login.") | |
return render_template( | |
"code.html", | |
title="Home", | |
sub_title="Welcome to the Zoho Integration", | |
code=welcome_message, | |
language="text" | |
) | |
def login(): | |
config = current_app.config | |
params = { | |
'scope': 'ZohoBooks.fullaccess.all', | |
'client_id': config['ZOHO_CLIENT_ID'], | |
'response_type': 'code', | |
'access_type': 'offline', | |
'redirect_uri': config['ZOHO_REDIRECT_URL'], | |
# Force consent so that Zoho returns a refresh token when possible | |
'prompt': 'consent' | |
} | |
accounts_url = 'https://accounts.zohocloud.ca/oauth/v2/auth' | |
auth_url = f"{accounts_url}?{urllib.parse.urlencode(params)}" | |
return redirect(auth_url) | |
def oauth_callback(): | |
grant_token = request.args.get('code') | |
accounts_server = request.args.get('accounts-server') | |
if not grant_token: | |
flash("Authorization failed: No grant token received from Zoho.", "error") | |
return redirect(url_for('zoho.index')) | |
try: | |
initialize_zoho_sdk(grant_token=grant_token, accounts_server_url=accounts_server) | |
flash("Successfully authenticated with Zoho Books!", "success") | |
next_url = session.pop('next_url', url_for('zoho.index')) | |
return redirect(next_url) | |
except Exception as e: | |
logger.error(f"Error during Zoho OAuth callback: {e}", exc_info=True) | |
flash(f"An error occurred during authentication: {e}", "error") | |
return redirect(url_for('zoho.index')) | |
def logout(): | |
clear_zoho_token() | |
flash("You have been logged out from Zoho.", "info") | |
return redirect(url_for("zoho.index")) | |
# @zoho_token_required | |
def inventory_sync_page(): | |
"""Renders the page that will display the sync progress bar.""" | |
return render_template("inventory_sync.html", title="Inventory Sync") | |
# @zoho_token_required | |
def fetch_inventory_stream(): | |
""" | |
Performs the inventory sync and streams progress updates to the client. | |
""" | |
def generate_sync_updates(): | |
try: | |
yield f"data: {json.dumps({'progress': 0, 'message': 'Starting synchronization...'})}\n\n" | |
# Step 1: Fetch all items from Zoho | |
all_zoho_items = [] | |
page = 1 | |
has_more_pages = True | |
while has_more_pages: | |
yield f"data: {json.dumps({'progress': 5, 'message': f'Fetching page {page} of items from Zoho...'})}\n\n" | |
params = {'page': page, 'per_page': 200} | |
response_data, _ = make_zoho_api_request('GET', '/items', params=params) | |
if not response_data: break | |
page_items = response_data.get('items', []) | |
if not page_items: break | |
all_zoho_items.extend(item for item in page_items if item.get('status') == 'active') | |
has_more_pages = response_data.get('page_context', {}).get('has_more_page', False) | |
page += 1 | |
total_items = len(all_zoho_items) | |
yield f"data: {json.dumps({'progress': 15, 'message': f'Found {total_items} active items. Grouping by product name...'})}\n\n" | |
# Step 2: Group items by name | |
grouped_products = defaultdict(lambda: { | |
'modes': {}, 'description': '', 'zoho_id_for_image': None, 'name': None, 'category': None | |
}) | |
for item in all_zoho_items: | |
name = item.get('name') | |
unit = item.get('unit', 'piece').lower() | |
if not grouped_products[name]['description'] and item.get('description'): grouped_products[name]['description'] = item.get('description') | |
if not grouped_products[name]['category'] and item.get('cf_type'): grouped_products[name]['category'] = item.get('cf_type') | |
if not grouped_products[name]['zoho_id_for_image'] and item.get('image_document_id'): grouped_products[name]['zoho_id_for_image'] = item.get('item_id') | |
grouped_products[name]['name'] = name | |
grouped_products[name]['modes'][unit] = {'price': float(item.get('rate', 0.0)), 'zoho_id': item.get('item_id'), 'sku': item.get('sku')} | |
# Step 3: Process and sync with MongoDB | |
db_products_map = {p['name']: p for p in mongo.db.products.find({}, {'name': 1})} | |
products_to_insert, bulk_update_ops = [], [] | |
total_to_process = len(grouped_products) | |
for i, (name, fetched_p) in enumerate(grouped_products.items()): | |
progress = 15 + int(((i + 1) / total_to_process) * 80) | |
yield f"data: {json.dumps({'progress': progress, 'message': f'Processing: {name}'})}\n\n" | |
image_data, image_content_type, fallback_image_url = None, None, None | |
zoho_image_id = fetched_p.get('zoho_id_for_image') | |
if zoho_image_id: | |
try: | |
content, headers = make_zoho_api_request('GET', f'/items/{zoho_image_id}/image') | |
if content and headers: | |
image_data = Binary(content) | |
image_content_type = headers.get('Content-Type', 'image/jpeg') | |
yield f"data: {json.dumps({'progress': progress, 'message': f'Downloaded existing image for {name} from Zoho.'})}\n\n" | |
except Exception: | |
yield f"data: {json.dumps({'progress': progress, 'message': f'Failed to get Zoho image for {name}. Will search online.'})}\n\n" | |
if not image_data: | |
yield f"data: {json.dumps({'progress': progress, 'message': f'Searching for new image for: {name}...'})}\n\n" | |
try: | |
results = search_and_filter_images(str(name)) | |
if results: | |
fallback_image_url = str(results[0]["image_url"]) | |
yield f"data: {json.dumps({'progress': progress, 'message': f'Downloading new image for: {name}...'})}\n\n" | |
response = requests.get(fallback_image_url, timeout=15) | |
response.raise_for_status() | |
image_content_from_url, content_type_from_url = response.content, response.headers.get('Content-Type', 'image/jpeg') | |
image_data, image_content_type = Binary(image_content_from_url), content_type_from_url | |
for mode_details in fetched_p['modes'].values(): | |
item_id = mode_details.get('zoho_id') | |
if item_id: | |
yield f"data: {json.dumps({'progress': progress, 'message': f'Uploading image for {name} to Zoho Item ID {item_id}...'})}\n\n" | |
files = {'image': (f"{name.replace(' ', '_')}.jpg", image_content_from_url, content_type_from_url)} | |
make_zoho_api_request('POST', f'/items/{item_id}/image', files=files) | |
else: | |
yield f"data: {json.dumps({'progress': progress, 'message': f'No image results found for: {name}'})}\n\n" | |
except Exception as e: | |
yield f"data: {json.dumps({'progress': progress, 'message': f'Error fetching new image for {name}: {e}'})}\n\n" | |
image_data, image_content_type = None, None | |
update_fields = { | |
"name": name, "modes": fetched_p["modes"], "description": fetched_p.get('description', ''), | |
"image_data": image_data, "image_content_type": image_content_type, "image_url": fallback_image_url, | |
"category": fetched_p.get('category') or str(categorise(name)), "code": random.randint(1,100000), | |
} | |
if not db_products_map: products_to_insert.append(update_fields) | |
else: bulk_update_ops.append(UpdateOne({'name': name}, {'$set': update_fields}, upsert=True)) | |
yield f"data: {json.dumps({'progress': 95, 'message': 'Finalizing database updates...'})}\n\n" | |
db_names, fetched_names = set(db_products_map.keys()), set(grouped_products.keys()) | |
names_to_delete = list(db_names - fetched_names) | |
if names_to_delete: mongo.db.products.delete_many({'name': {'$in': names_to_delete}}) | |
if products_to_insert: mongo.db.products.insert_many(products_to_insert) | |
if bulk_update_ops: mongo.db.products.bulk_write(bulk_update_ops) | |
summary = f"Sync complete. Inserted/Updated: {len(products_to_insert) + len(bulk_update_ops)}, Deleted: {len(names_to_delete)}" | |
final_code = jsonify_custom(list(grouped_products.values())) | |
yield f"data: {json.dumps({'progress': 100, 'message': summary, 'status': 'complete', 'final_code': final_code})}\n\n" | |
except Exception as e: | |
logger.error("Inventory sync stream failed: %s", e, exc_info=True) | |
error_message = f"Error during sync: {e}\n{traceback.format_exc()}" | |
yield f"data: {json.dumps({'progress': 100, 'message': error_message, 'status': 'error'})}\n\n" | |
return Response(stream_with_context(generate_sync_updates()), mimetype='text/event-stream') | |
def edit_inventory(): | |
if request.method == "POST": | |
product_name, new_image_url = request.form.get("product_name"), request.form.get("image_url") | |
if not product_name or not new_image_url: | |
flash("Product name and a new image URL are required.", "error") | |
else: | |
result = mongo.db.products.update_one( | |
{"name": product_name}, {"$set": {"image_url": new_image_url, "image_data": None, "image_content_type": None}} | |
) | |
flash(f"Image for '{product_name}' updated successfully!" if result.matched_count else f"Product '{product_name}' not found.", "success" if result.matched_count else "error") | |
return redirect(url_for("zoho.edit_inventory")) | |
products = list(mongo.db.products.find({}, {"_id": 0, "name": 1, "image_url": 1}).sort("name", 1)) | |
return render_template("edit_inventory.html", title="Edit Inventory Image", products=products) | |