# api.py from flask import Blueprint, request, jsonify, current_app, redirect, Response, url_for from bson.objectid import ObjectId from datetime import datetime from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity from .extensions import bcrypt, mongo from .xero_client import make_zoho_api_request from .xero_utils import trigger_invoice_creation, trigger_contact_creation,sync_user_approval_from_zoho from .email_utils import send_order_confirmation_email, send_registration_email, send_registration_admin_notification, send_login_notification_email, send_cart_reminder_email from .general_utils import get_next_order_serial api_bp = Blueprint('api', __name__) # --- NEW: Endpoint to serve images stored in MongoDB --- @api_bp.route('/product_image/') def get_product_image(product_id): """ Fetches image data stored as binary in the products collection. """ try: product = mongo.db.products.find_one( {'_id': ObjectId(product_id)}, {'image_data': 1, 'image_content_type': 1} # Projection to get only needed fields ) if product and 'image_data' in product and product['image_data'] is not None: # Clean the content_type string to ensure it's a valid MIME type for browsers. content_type = product.get('image_content_type', 'image/jpeg') mime_type = content_type.split(';')[0].strip() # Serve the binary data with the correct mimetype return Response(product['image_data'], mimetype=mime_type) else: # Return a 404 Not Found if the product or its image data doesn't exist return jsonify({"msg": "Image not found"}), 404 except Exception as e: current_app.logger.error(f"Error serving image for product_id {product_id}: {e}") return jsonify({"msg": "Error serving image"}), 500 @api_bp.route('/products', methods=['GET']) def get_products(): # --- OPTIMIZATION: Use projection to exclude large binary image data --- # We only need to know if 'image_data' exists, not its content. This dramatically # reduces the data transferred from MongoDB to the Flask app, speeding up the response. products_cursor = mongo.db.products.find({}, {'image_data': 0}) products_list = [] # Re-fetch the documents that have image_data to check for existence # This is a bit of a workaround because we can't check for existence with projection easily # A better long-term solution would be a boolean field like `has_binary_image`. # For now, we'll check based on the projected data. If a product *might* have an image, we can assume it does. # The logic below is slightly adjusted. A product document will still have the key 'image_data' if it was not projected out. # The previous code was fine, but this makes it explicit that we are avoiding the large field. products_cursor = mongo.db.products.find( {}, # Exclude the large image_data field from the initial query {'image_data': 0, 'image_content_type': 0} ) # Get a set of IDs for products that DO have binary image data in a separate, fast query products_with_images = { str(p['_id']) for p in mongo.db.products.find( {'image_data': {'$exists': True, '$ne': None}}, {'_id': 1} # Only fetch the ID ) } products_list = [] for p in products_cursor: product_id_str = str(p['_id']) image_url = None # Check against our pre-fetched set of IDs if product_id_str in products_with_images: image_url = url_for('api.get_product_image', product_id=product_id_str, _external=True) # Fallback to the stored URL elif p.get('image_url'): image_url = p.get('image_url') products_list.append({ 'id': product_id_str, 'name': p.get('name'), 'category': p.get('category'), 'modes': p.get('modes'), 'image_url': image_url, 'description': p.get('description', '') }) return jsonify(products_list) # ... (The rest of your api.py file remains unchanged) @api_bp.route('/sync_xero_users', methods=['GET']) def sync_xero_users(): sync_user_approval_from_zoho() return "✅" # @api_bp.route('/clear') # def clear_all(): # mongo.db.users.delete_many({}) # mongo.db.orders.delete_many({}) # return "✅" @api_bp.route('/register', methods=['POST']) def register(): data = request.get_json() email = data.get('email') password = data.get('password') company_name = data.get('businessName') if not all([email, password, company_name]): return jsonify({"msg": "Missing required fields: Email, Password, and Business Name"}), 400 if mongo.db.users.find_one({'email': email}): return jsonify({"msg": "A user with this email already exists"}), 409 hashed_password = bcrypt.generate_password_hash(password).decode('utf-8') user_document = data.copy() user_document['password'] = hashed_password user_document['company_name'] = company_name user_document['is_approved'] = False user_document['is_admin'] = False mongo.db.users.insert_one(user_document) trigger_contact_creation(data) try: send_registration_email(data) send_registration_admin_notification(data) # Send notification to admin except Exception as e: current_app.logger.error(f"Failed to send registration emails for {email}: {e}") return jsonify({"msg": "Registration successful! Your application is being processed."}), 201 @api_bp.route('/login', methods=['POST']) def login(): data = request.get_json() email, password = data.get('email'), data.get('password') user = mongo.db.users.find_one({'email': email}) if user and user.get('password') and bcrypt.check_password_hash(user['password'], password): if not user.get('is_approved', False): return jsonify({"msg": "Account pending approval"}), 403 try: send_login_notification_email(user) except Exception as e: current_app.logger.error(f"Failed to send login notification email to {email}: {e}") access_token = create_access_token(identity=email) return jsonify(access_token=access_token, email=user['email'], companyName=user['businessName'],contactPerson=user.get('contactPerson', '')) , 200 return jsonify({"msg": "Bad email or password"}), 401 @api_bp.route('/profile', methods=['GET']) @jwt_required() def get_user_profile(): user_email = get_jwt_identity() user = mongo.db.users.find_one({'email': user_email}) if not user: return jsonify({"msg": "User not found"}), 404 profile_data = { 'deliveryAddress': user.get('businessAddress', ''), 'mobileNumber': user.get('phoneNumber', '') } return jsonify(profile_data), 200 @api_bp.route('/cart', methods=['GET', 'POST']) @jwt_required() def handle_cart(): user_email = get_jwt_identity() if request.method == 'GET': # --- OPTIMIZATION: Use MongoDB Aggregation Pipeline to fetch cart and products in one go --- pipeline = [ # 1. Find the user's cart {'$match': {'user_email': user_email}}, # 2. Deconstruct the items array to process each item {'$unwind': '$items'}, # 3. Convert string productId to ObjectId for lookup {'$addFields': {'productId_obj': {'$toObjectId': '$items.productId'}}}, # 4. Join with the products collection ($lookup is like a JOIN) { '$lookup': { 'from': 'products', 'localField': 'productId_obj', 'foreignField': '_id', 'as': 'productDetails' } }, # 5. Deconstruct the resulting productDetails array (it will have 1 element) {'$unwind': '$productDetails'}, # 6. Re-shape the document to match the frontend's expected format { '$project': { '_id': 0, 'deliveryDate': '$deliveryDate', 'item': { 'quantity': '$items.quantity', 'mode': '$items.mode', 'product': { 'id': {'$toString': '$productDetails._id'}, 'name': '$productDetails.name', 'modes': '$productDetails.modes', 'price': {'$getField': {'field': '$items.mode', 'input': '$productDetails.modes.price'}}, 'image_url': { '$cond': { 'if': {'$and': [ {'$ne': ['$productDetails.image_data', None]}, {'$ne': ['$productDetails.image_data', ""]} ]}, 'then': url_for('api.get_product_image', product_id=str(ObjectId()), _external=True).replace(str(ObjectId()),""), # Placeholder for url construction 'else': '$productDetails.image_url' } } } } } }, # 7. Dynamically construct the image URL { '$addFields': { "item.product.image_url": { '$cond': { 'if': {'$ne': ["$item.product.image_url", None]}, 'then': { '$concat': [ request.host_url.rstrip('/'), '/api/product_image/', "$item.product.id" ] }, 'else': None } } } }, # 8. Group all items back into a single cart document { '$group': { '_id': '$_id', 'deliveryDate': {'$first': '$deliveryDate'}, 'items': {'$push': '$item'} } } ] result = list(mongo.db.carts.aggregate(pipeline)) if not result: return jsonify({'items': [], 'deliveryDate': None}) return jsonify(result[0]) if request.method == 'POST': data = request.get_json() update_doc = { 'user_email': user_email, 'updated_at': datetime.utcnow() } if 'items' in data: sanitized_items = [] for item in data['items']: try: if not all(k in item for k in ['productId', 'quantity', 'mode']) or item['quantity'] is None: continue mode = item.get('mode') quantity = item.get('quantity') if mode == 'weight': numeric_quantity = float(quantity) else: numeric_quantity = int(float(quantity)) if numeric_quantity < 0: continue sanitized_items.append({ 'productId': item['productId'], 'quantity': numeric_quantity, 'mode': mode }) except (ValueError, TypeError): return jsonify({"msg": f"Invalid quantity format for item."}), 400 update_doc['items'] = sanitized_items if 'deliveryDate' in data: update_doc['deliveryDate'] = data['deliveryDate'] mongo.db.carts.update_one( {'user_email': user_email}, {'$set': update_doc}, upsert=True ) return jsonify({"msg": "Cart updated successfully"}) @api_bp.route('/orders//download_invoice', methods=['GET']) @jwt_required() def download_invoice(serial_no): user_email = get_jwt_identity() order = mongo.db.orders.find_one({'serial_no': int(serial_no), 'user_email': user_email}) if not order: return jsonify({"msg": "Order not found or access denied"}), 404 try: invoices_response, _ = make_zoho_api_request('GET', '/invoices', params={'reference_number': serial_no}) if not invoices_response or not invoices_response.get('invoices'): return jsonify({"msg": "Invoice not found in our billing system."}), 404 invoice_id = invoices_response['invoices'][0].get('invoice_id') if not invoice_id: return jsonify({"msg": "Could not identify the invoice in our billing system."}), 404 pdf_content, headers = make_zoho_api_request('GET', f'/invoices/{invoice_id}', params={'accept': 'pdf'}) if not pdf_content: return jsonify({"msg": "Failed to download the invoice PDF from our billing system."}), 500 return Response( pdf_content, mimetype='application/pdf', headers={ "Content-Disposition": f"attachment; filename=invoice-{serial_no}.pdf", "Content-Type": "application/pdf" } ) except Exception as e: current_app.logger.error(f"Error downloading invoice {serial_no} from Zoho: {e}") return jsonify({"msg": "An internal error occurred while fetching the invoice."}), 500 @api_bp.route('/orders', methods=['GET', 'POST']) @jwt_required() def handle_orders(): user_email = get_jwt_identity() if request.method == 'POST': cart = mongo.db.carts.find_one({'user_email': user_email}) if not cart or not cart.get('items'): return jsonify({"msg": "Your cart is empty"}), 400 data = request.get_json() if not all([data.get('deliveryDate'), data.get('deliveryAddress'), data.get('mobileNumber')]): return jsonify({"msg": "Missing delivery information"}), 400 user = mongo.db.users.find_one({'email': user_email}) if not user: return jsonify({"msg": "User not found"}), 404 order_doc = { 'user_email': user_email, 'items': cart['items'], 'delivery_date': data['deliveryDate'], 'delivery_address': data['deliveryAddress'], 'mobile_number': data['mobileNumber'], 'additional_info': data.get('additionalInfo'), 'total_amount': data.get('totalAmount'), 'status': 'pending', 'created_at': datetime.utcnow() } order_doc['serial_no'] = get_next_order_serial() order_id = mongo.db.orders.insert_one(order_doc).inserted_id order_doc['_id'] = order_id order_details_for_xero = { "order_id": order_doc['serial_no'], "user_email": user_email, "items": cart['items'], "delivery_address": data['deliveryAddress'], "mobile_number": data['mobileNumber'],"deliverydate":data["deliveryDate"],'additional_info': data.get('additionalInfo') } trigger_invoice_creation(order_details_for_xero) try: product_ids = [ObjectId(item['productId']) for item in cart['items']] products_map = {str(p['_id']): p for p in mongo.db.products.find({'_id': {'$in': product_ids}})} order_doc['populated_items'] = [{ "name": products_map.get(item['productId'], {}).get('name', 'N/A'), "quantity": item['quantity'], "mode": item.get('mode', 'pieces') } for item in cart['items']] send_order_confirmation_email(order_doc, user) except Exception as e: current_app.logger.error(f"Failed to send confirmation email for order {order_id}: {e}") mongo.db.carts.delete_one({'user_email': user_email}) return jsonify({"msg": "Order placed successfully! You will be redirected shortly to the Orders Page!", "orderId": str(order_id)}), 201 if request.method == 'GET': user_orders_cursor = mongo.db.orders.find({'user_email': user_email}).sort('created_at', -1) user_orders = list(user_orders_cursor) if not user_orders: return jsonify([]) # Fetch status from DB, or from Zoho if not present in DB. for order in user_orders: # If status is present in our DB, use it and skip the API call. if 'zoho_status' in order: order['status'] = order['zoho_status'] continue # If status is not in DB, fetch from Zoho, update DB, and then use it. live_status = 'pending' try: serial_no = order.get('serial_no') if serial_no: invoices_response, _ = make_zoho_api_request('GET', '/invoices', params={'reference_number': serial_no}) if invoices_response and invoices_response.get('invoices'): invoice = invoices_response['invoices'][0] zoho_api_status = invoice.get('status') if zoho_api_status == 'draft': live_status = 'pending' elif zoho_api_status == 'sent': live_status = 'Processing' elif zoho_api_status == 'paid': live_status = 'Completed' elif zoho_api_status == 'void': live_status = 'cancelled' # Save the newly fetched status to MongoDB for future requests mongo.db.orders.update_one( {'_id': order['_id']}, {'$set': {'zoho_status': live_status}} ) except Exception as e: current_app.logger.error(f"Could not fetch Zoho invoice status for order {order.get('serial_no')}: {e}") order['status'] = live_status # --- OPTIMIZATION: Use a single aggregation to populate product details for all orders --- pipeline = [ {'$match': {'user_email': user_email}}, {'$sort': {'created_at': -1}}, {'$unwind': '$items'}, {'$addFields': {'productId_obj': {'$toObjectId': '$items.productId'}}}, { '$lookup': { 'from': 'products', 'localField': 'productId_obj', 'foreignField': '_id', 'as': 'productDetails' } }, {'$unwind': '$productDetails'}, { '$group': { '_id': '$_id', 'items': {'$push': { 'quantity': '$items.quantity', 'mode': '$items.mode', 'price': {'$let': {'vars': {'mode_details': {'$getField': {'field': '$items.mode', 'input': '$productDetails.modes'}}}, 'in': '$$mode_details.price'}}, 'product': { 'id': {'$toString': '$productDetails._id'}, 'name': '$productDetails.name', 'modes': '$productDetails.modes', 'image_url': { '$cond': { 'if': {'$ifNull': ['$productDetails.image_data', False]}, 'then': {'$concat': [request.host_url.rstrip('/'), '/api/product_image/', {'$toString': '$productDetails._id'}]}, 'else': '$productDetails.image_url' } } } }}, # Carry over all original order fields 'doc': {'$first': '$$ROOT'} } }, { '$replaceRoot': { 'newRoot': { '$mergeObjects': ['$doc', {'items': '$items'}] } } }, {'$sort': {'created_at': -1}} ] populated_orders = list(mongo.db.orders.aggregate(pipeline)) # Merge the live status back into the populated orders status_map = {str(order['_id']): order['status'] for order in user_orders} for order in populated_orders: order_id_str = str(order['_id']) order['status'] = status_map.get(order_id_str, 'pending') order['_id'] = order_id_str # Convert ObjectId to string for JSON order['created_at'] = order['created_at'].isoformat() order['delivery_date'] = order['delivery_date'] if isinstance(order['delivery_date'], str) else order['delivery_date'].isoformat() return jsonify(populated_orders) @api_bp.route('/orders/', methods=['GET']) @jwt_required() def get_order(order_id): user_email = get_jwt_identity() try: order = mongo.db.orders.find_one({'_id': ObjectId(order_id), 'user_email': user_email}) if not order: return jsonify({"msg": "Order not found or access denied"}), 404 order['_id'] = str(order['_id']) return jsonify(order), 200 except Exception as e: return jsonify({"msg": f"Invalid Order ID format: {e}"}), 400 @api_bp.route('/orders/', methods=['PUT']) @jwt_required() def update_order(order_id): user_email = get_jwt_identity() order = mongo.db.orders.find_one({'_id': ObjectId(order_id), 'user_email': user_email}) if not order: return jsonify({"msg": "Order not found or access denied"}), 404 if order.get('status') not in ['pending', 'confirmed']: return jsonify({"msg": f"Order with status '{order.get('status')}' cannot be modified."}), 400 cart = mongo.db.carts.find_one({'user_email': user_email}) if not cart or not cart.get('items'): return jsonify({"msg": "Cannot update with an empty cart. Please add items."}), 400 data = request.get_json() update_doc = { 'items': cart['items'], 'delivery_date': data['deliveryDate'], 'delivery_address': data['deliveryAddress'], 'mobile_number': data['mobileNumber'], 'additional_info': data.get('additionalInfo'), 'total_amount': data.get('totalAmount'), 'updated_at': datetime.utcnow() } mongo.db.orders.update_one({'_id': ObjectId(order_id)}, {'$set': update_doc}) mongo.db.carts.delete_one({'user_email': user_email}) return jsonify({"msg": "Order updated successfully!", "orderId": order_id}), 200 @api_bp.route('/orders//cancel', methods=['POST']) @jwt_required() def cancel_order(order_id): user_email = get_jwt_identity() order = mongo.db.orders.find_one({'_id': ObjectId(order_id), 'user_email': user_email}) if not order: return jsonify({"msg": "Order not found or access denied"}), 404 serial_no = order.get('serial_no') if not serial_no: return jsonify({"msg": "Cannot cancel order without a billing reference."}), 400 try: # Find the corresponding invoice in Zoho invoices_response, _ = make_zoho_api_request('GET', '/invoices', params={'reference_number': serial_no}) if not invoices_response or not invoices_response.get('invoices'): return jsonify({"msg": "Invoice not found in our billing system. Cannot cancel."}), 404 invoice = invoices_response['invoices'][0] invoice_id = invoice.get('invoice_id') zoho_status = invoice.get('status') # The order can only be cancelled if the invoice is a draft if zoho_status != 'draft': return jsonify({"msg": "This order cannot be cancelled as it is already being processed."}), 400 # Proceed to void the invoice in Zoho void_response, _ = make_zoho_api_request('POST', f'/invoices/{invoice_id}/status/void') if not void_response: return jsonify({"msg": "Failed to cancel the order in the billing system."}), 500 # If Zoho void was successful, update our local DB status mongo.db.orders.update_one( {'_id': ObjectId(order_id)}, {'$set': {'status': 'cancelled', 'updated_at': datetime.utcnow()}} ) return jsonify({"msg": "Order has been cancelled."}), 200 except Exception as e: current_app.logger.error(f"Error cancelling order {order_id} and voiding Zoho invoice: {e}") return jsonify({"msg": "An internal error occurred while cancelling the order."}), 500 @api_bp.route('/sendmail', methods=['GET']) def send_cart_reminders(): try: carts_with_items = list(mongo.db.carts.find({'items': {'$exists': True, '$ne': []}})) if not carts_with_items: return jsonify({"msg": "No users with pending items in cart."}), 200 user_emails = [cart['user_email'] for cart in carts_with_items] all_product_ids = { ObjectId(item['productId']) for cart in carts_with_items for item in cart.get('items', []) } users_cursor = mongo.db.users.find({'email': {'$in': user_emails}}) products_cursor = mongo.db.products.find({'_id': {'$in': list(all_product_ids)}}) users_map = {user['email']: user for user in users_cursor} products_map = {str(prod['_id']): prod for prod in products_cursor} emails_sent_count = 0 for cart in carts_with_items: user = users_map.get(cart['user_email']) if not user: current_app.logger.warning(f"Cart found for non-existent user: {cart['user_email']}") continue populated_items = [] for item in cart.get('items', []): product_details = products_map.get(item['productId']) if product_details: populated_items.append({ 'product': { 'id': str(product_details['_id']), 'name': product_details.get('name'), }, 'quantity': item['quantity'] }) if populated_items: try: send_cart_reminder_email(user, populated_items) emails_sent_count += 1 except Exception as e: current_app.logger.error(f"Failed to send cart reminder to {user['email']}: {e}") return jsonify({"msg": f"Cart reminder process finished. Emails sent to {emails_sent_count} users."}), 200 except Exception as e: current_app.logger.error(f"Error in /sendmail endpoint: {e}") return jsonify({"msg": "An internal error occurred while sending reminders."}), 500 @api_bp.route('/admin/users/approve/', methods=['POST']) @jwt_required() def approve_user(user_id): mongo.db.users.update_one({'_id': ObjectId(user_id)}, {'$set': {'is_approved': True}}) return jsonify({"msg": f"User {user_id} approved"}) @api_bp.route('/request-item', methods=['POST']) @jwt_required() def request_item(): user_email = get_jwt_identity() data = request.get_json() if not data or not data.get('details'): return jsonify({"msg": "Item details are required."}), 400 details = data.get('details').strip() if not details: return jsonify({"msg": "Item details cannot be empty."}), 400 try: user = mongo.db.users.find_one({'email': user_email}, {'company_name': 1}) company_name = user.get('company_name', 'N/A') if user else 'N/A' request_doc = { 'user_email': user_email, 'company_name': company_name, 'details': details, 'status': 'new', 'requested_at': datetime.utcnow() } mongo.db.item_requests.insert_one(request_doc) return jsonify({"msg": "Your item request has been submitted. We will look into it!"}), 201 except Exception as e: current_app.logger.error(f"Error processing item request for {user_email}: {e}") return jsonify({"msg": "An internal server error occurred."}), 500