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__) @zoho_bp.context_processor def inject_zoho_status(): """Makes the Zoho token status available to all templates.""" return dict(is_zoho_token_available=is_zoho_token_available) @zoho_bp.route("/WEBUI") 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" ) @zoho_bp.route("/login") 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) @zoho_bp.route("/callback") 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')) @zoho_bp.route("/logout") def logout(): clear_zoho_token() flash("You have been logged out from Zoho.", "info") return redirect(url_for("zoho.index")) @zoho_bp.route("/api/inventory") # @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_bp.route("/api/inventory/stream") # @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') @zoho_bp.route("/api/edit_inventory", methods=["GET", "POST"]) 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)