Spaces:
Running
Running
# 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 --- | |
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 | |
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) | |
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 "✅" | |
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 | |
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 | |
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 | |
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"}) | |
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 | |
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) | |
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 | |
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 | |
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 | |
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 | |
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"}) | |
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 |