From 791aa5a8d0ae9a5014732070843f5c96930b3d20 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Wed, 14 May 2025 15:05:52 -0400 Subject: [PATCH 01/19] project cleanup --- RULES.md | 20 ++++++++ app_compatibility.py | 112 ------------------------------------------- 2 files changed, 20 insertions(+), 112 deletions(-) create mode 100644 RULES.md delete mode 100644 app_compatibility.py diff --git a/RULES.md b/RULES.md new file mode 100644 index 0000000..e64941e --- /dev/null +++ b/RULES.md @@ -0,0 +1,20 @@ +# AI Agent Coding Rules + +## 1. Code Length Limits +- **Python files (`.py`)**: Maximum 600 lines per file. +- **JavaScript/HTML files (`.js`, `.html`)**: Maximum 500 lines per file. + +## 2. Architecture +- Follow the **MVC (Model-View-Controller)** pattern for all new features and refactoring. + - **Models**: Handle data and business logic. + - **Views**: Manage presentation and user interface. + - **Controllers**: Process input, interact with models, and render views. + +## 3. Coding Practices +- No guessing: All code must be based on clear requirements or existing patterns in the codebase. +- Avoid unnecessary complexity and keep code readable. +- Document any non-obvious logic with concise comments. + +## 4. General +- Ensure all code changes comply with these rules before merging or deploying. +- If a rule cannot be followed, document the reason clearly in the code and notify the team. diff --git a/app_compatibility.py b/app_compatibility.py deleted file mode 100644 index 8dcbdc5..0000000 --- a/app_compatibility.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -This file is a compatibility wrapper for the main app.py -It addresses the Werkzeug import issue by patching the necessary functions -""" -import sys - -# Add compatibility for url_quote before importing Flask -try: - # First try normal import - from werkzeug.urls import url_quote -except ImportError: - # If it fails, create a shim for the missing function - import werkzeug - from werkzeug.urls import quote as url_quote - # Patch the werkzeug.urls module - werkzeug.urls.url_quote = url_quote - sys.modules['werkzeug.urls'].url_quote = url_quote - -# Now import Flask normally -from flask import Flask, jsonify, request, send_file, render_template, flash -import os -import json -from admin import admin_bp - -app = Flask(__name__) -app.config['STATIC_FOLDER'] = './static' -app.config['SECRET_KEY'] = 'dev-key-for-demo-only' - -# Register blueprints -app.register_blueprint(admin_bp) - -# Load product data from products.json -def load_products(): - try: - with open('static/products.json', 'r') as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return {} - -@app.route('/') -def index(): - return render_template('index.html') - -@app.route('/login', methods=['GET']) -def login(): - # Just return 200 for this example, assuming no auth needed - return '', 200 - -@app.route('/arcontentfields', methods=['GET']) -def get_ar_content_fields(): - # Return a fixed set of attributes - fields = [ - {"fieldName": "_id", "label": "Item ID", "editable": "false", "fieldType": "TEXT"}, - {"fieldName": "_price", "label": "Sale Price", "editable": "true", "fieldType": "TEXT"}, - {"fieldName": "_image", "label": "Image", "editable": "false", "fieldType": "IMAGE_URI"} - ] - return jsonify(fields), 200 - -@app.route('/arinfo', methods=['GET']) -def get_ar_info(): - barcode = request.args.get('barcode') - products = load_products() - if not barcode or barcode not in products: - return jsonify({"error": "Item not found"}), 404 - return jsonify(products[barcode]), 200 - -@app.route('/images/', methods=['GET']) -def serve_image(filename): - image_path = os.path.join(app.config['STATIC_FOLDER'], 'images', filename) - if os.path.exists(image_path): - return send_file(image_path) - return jsonify({"error": "Image not found"}), 404 - -@app.route('/barcodes/', methods=['GET']) -def serve_barcode(filename): - barcode_path = os.path.join(app.config['STATIC_FOLDER'], 'barcodes', filename) - if os.path.exists(barcode_path): - return send_file(barcode_path) - return jsonify({"error": "Barcode not found"}), 404 - -if __name__ == '__main__': - # Print helpful information - print("=" * 80) - print("KCAP Demo Server") - print("=" * 80) - print("This version includes compatibility fixes for Werkzeug/Flask version mismatches.") - print("If you encounter dependency errors, please run: pip install -r requirements.txt") - print("=" * 80) - - # Ensure the required directories exist - os.makedirs(os.path.join(app.config['STATIC_FOLDER'], 'images'), exist_ok=True) - os.makedirs(os.path.join(app.config['STATIC_FOLDER'], 'barcodes'), exist_ok=True) - - # If products.json doesn't exist, create it with initial data - products_file = os.path.join(app.config['STATIC_FOLDER'], 'products.json') - if not os.path.exists(products_file): - initial_data = { - "123456": [ - {"fieldName": "_id", "label": "Item ID", "value": "123456", "editable": "false", "fieldType": "TEXT"}, - {"fieldName": "_price", "label": "Sale Price", "value": "$49.99", "editable": "true", "fieldType": "TEXT"}, - {"fieldName": "_image", "label": "Image", "value": "/images/123456.png", "editable": "false", "fieldType": "IMAGE_URI"} - ], - "789012": [ - {"fieldName": "_id", "label": "Item ID", "value": "789012", "editable": "false", "fieldType": "TEXT"}, - {"fieldName": "_price", "label": "Sale Price", "value": "$59.99", "editable": "true", "fieldType": "TEXT"}, - {"fieldName": "_image", "label": "Image", "value": "/images/789012.png", "editable": "false", "fieldType": "IMAGE_URI"} - ] - } - with open(products_file, 'w') as f: - json.dump(initial_data, f, indent=2) - - app.run(port=5555, host="0.0.0.0", debug=True) From bd7bf7bf82cc833f27d2daa91f6a2800e2111c01 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Wed, 14 May 2025 15:17:12 -0400 Subject: [PATCH 02/19] organizing the project --- .gitignore | 2 +- admin.py => src/admin.py | 7 ++++--- app.py => src/app.py | 16 +++++++++------- barcode_generator.py => src/barcode_generator.py | 0 .../templates}/admin/add_product.html | 0 .../templates}/admin/edit_product.html | 0 .../templates}/admin/generate_barcode.html | 0 {templates => src/templates}/admin/index.html | 0 {templates => src/templates}/index.html | 0 {templates => src/templates}/layout.html | 0 10 files changed, 14 insertions(+), 11 deletions(-) rename admin.py => src/admin.py (97%) rename app.py => src/app.py (82%) rename barcode_generator.py => src/barcode_generator.py (100%) rename {templates => src/templates}/admin/add_product.html (100%) rename {templates => src/templates}/admin/edit_product.html (100%) rename {templates => src/templates}/admin/generate_barcode.html (100%) rename {templates => src/templates}/admin/index.html (100%) rename {templates => src/templates}/index.html (100%) rename {templates => src/templates}/layout.html (100%) diff --git a/.gitignore b/.gitignore index 580f074..471b22d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ venv/ desktop.ini -static/ +data/ __pycache__/ *.pyc *.pyo diff --git a/admin.py b/src/admin.py similarity index 97% rename from admin.py rename to src/admin.py index 5d316a5..92d222a 100644 --- a/admin.py +++ b/src/admin.py @@ -19,9 +19,10 @@ except ImportError: admin_bp = Blueprint('admin', __name__, url_prefix='/admin') -PRODUCTS_FILE = 'static/products.json' -UPLOAD_FOLDER = 'static/images' -BARCODE_FOLDER = 'static/barcodes' +DATA_FOLDER = os.path.join(os.path.dirname(__file__), 'data') +PRODUCTS_FILE = os.path.join(DATA_FOLDER, 'products.json') +UPLOAD_FOLDER = os.path.join(DATA_FOLDER, 'images') +BARCODE_FOLDER = os.path.join(DATA_FOLDER, 'barcodes') ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} def allowed_file(filename): diff --git a/app.py b/src/app.py similarity index 82% rename from app.py rename to src/app.py index 5c09c3d..cc4b60a 100644 --- a/app.py +++ b/src/app.py @@ -4,7 +4,8 @@ import json from admin import admin_bp app = Flask(__name__) -app.config['STATIC_FOLDER'] = './static' +# Set DATA_FOLDER to the absolute path of the data directory inside src +app.config['DATA_FOLDER'] = os.path.join(os.path.dirname(__file__), 'data') app.config['SECRET_KEY'] = 'dev-key-for-demo-only' # Register blueprints @@ -13,7 +14,8 @@ app.register_blueprint(admin_bp) # Load product data from products.json def load_products(): try: - with open('static/products.json', 'r') as f: + products_file = os.path.join(app.config['DATA_FOLDER'], 'products.json') + with open(products_file, 'r') as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {} @@ -47,25 +49,25 @@ def get_ar_info(): @app.route('/images/', methods=['GET']) def serve_image(filename): - image_path = os.path.join(app.config['STATIC_FOLDER'], 'images', filename) + image_path = os.path.join(app.config['DATA_FOLDER'], 'images', filename) if os.path.exists(image_path): return send_file(image_path) return jsonify({"error": "Image not found"}), 404 @app.route('/barcodes/', methods=['GET']) def serve_barcode(filename): - barcode_path = os.path.join(app.config['STATIC_FOLDER'], 'barcodes', filename) + barcode_path = os.path.join(app.config['DATA_FOLDER'], 'barcodes', filename) if os.path.exists(barcode_path): return send_file(barcode_path) return jsonify({"error": "Barcode not found"}), 404 if __name__ == '__main__': # Ensure the required directories exist - os.makedirs(os.path.join(app.config['STATIC_FOLDER'], 'images'), exist_ok=True) - os.makedirs(os.path.join(app.config['STATIC_FOLDER'], 'barcodes'), exist_ok=True) + os.makedirs(os.path.join(app.config['DATA_FOLDER'], 'images'), exist_ok=True) + os.makedirs(os.path.join(app.config['DATA_FOLDER'], 'barcodes'), exist_ok=True) # If products.json doesn't exist, create it with initial data - products_file = os.path.join(app.config['STATIC_FOLDER'], 'products.json') + products_file = os.path.join(app.config['DATA_FOLDER'], 'products.json') if not os.path.exists(products_file): initial_data = { "123456": [ diff --git a/barcode_generator.py b/src/barcode_generator.py similarity index 100% rename from barcode_generator.py rename to src/barcode_generator.py diff --git a/templates/admin/add_product.html b/src/templates/admin/add_product.html similarity index 100% rename from templates/admin/add_product.html rename to src/templates/admin/add_product.html diff --git a/templates/admin/edit_product.html b/src/templates/admin/edit_product.html similarity index 100% rename from templates/admin/edit_product.html rename to src/templates/admin/edit_product.html diff --git a/templates/admin/generate_barcode.html b/src/templates/admin/generate_barcode.html similarity index 100% rename from templates/admin/generate_barcode.html rename to src/templates/admin/generate_barcode.html diff --git a/templates/admin/index.html b/src/templates/admin/index.html similarity index 100% rename from templates/admin/index.html rename to src/templates/admin/index.html diff --git a/templates/index.html b/src/templates/index.html similarity index 100% rename from templates/index.html rename to src/templates/index.html diff --git a/templates/layout.html b/src/templates/layout.html similarity index 100% rename from templates/layout.html rename to src/templates/layout.html From f78f5ce9f9ed1f6a78089040e7ca640e169ab9b8 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Wed, 14 May 2025 15:30:27 -0400 Subject: [PATCH 03/19] refactoring project --- src/admin.py | 2 + src/app.py | 4 +- src/barcode_generator.py | 185 --------------------- src/routes/__init__.py | 1 + src/routes/admin.py | 284 +++++++++++++++++++++++++++++++++ src/routes/api.py | 8 + src/utils/barcode_generator.py | 93 +++++++++++ 7 files changed, 391 insertions(+), 186 deletions(-) delete mode 100644 src/barcode_generator.py create mode 100644 src/routes/__init__.py create mode 100644 src/routes/admin.py create mode 100644 src/routes/api.py create mode 100644 src/utils/barcode_generator.py diff --git a/src/admin.py b/src/admin.py index 92d222a..8c293e7 100644 --- a/src/admin.py +++ b/src/admin.py @@ -283,3 +283,5 @@ def generate_barcode_page(product_id): product_id=product_id, product=product_data, dependencies=dependency_status) + +# Remove or replace this file, as admin.py has been moved to routes/admin.py diff --git a/src/app.py b/src/app.py index cc4b60a..7ca4a6a 100644 --- a/src/app.py +++ b/src/app.py @@ -1,7 +1,8 @@ from flask import Flask, jsonify, request, send_file, render_template, flash import os import json -from admin import admin_bp +from routes.admin import admin_bp +from routes.api import api_bp app = Flask(__name__) # Set DATA_FOLDER to the absolute path of the data directory inside src @@ -10,6 +11,7 @@ app.config['SECRET_KEY'] = 'dev-key-for-demo-only' # Register blueprints app.register_blueprint(admin_bp) +app.register_blueprint(api_bp) # Load product data from products.json def load_products(): diff --git a/src/barcode_generator.py b/src/barcode_generator.py deleted file mode 100644 index 3235960..0000000 --- a/src/barcode_generator.py +++ /dev/null @@ -1,185 +0,0 @@ -import os -import io -from PIL import Image, ImageDraw, ImageFont - -# Try to import barcode libraries, provide fallback if not available -try: - import qrcode - QRCODE_AVAILABLE = True -except ImportError: - QRCODE_AVAILABLE = False - -try: - from barcode import EAN13, Code128 - from barcode.writer import ImageWriter - BARCODE_AVAILABLE = True -except ImportError: - BARCODE_AVAILABLE = False - -class BarcodeGenerator: - """ - A utility class for generating QR codes and barcodes. - """ - - @staticmethod - def check_dependencies(): - """ - Check if all required dependencies are installed. - - Returns: - dict: Status of each dependency - """ - return { - 'qrcode': QRCODE_AVAILABLE, - 'barcode': BARCODE_AVAILABLE, - 'pillow': True # PIL/Pillow is imported directly at the top level - } - - @staticmethod - def generate_qr_code(data, size=10, border=4): - """ - Generates a QR code as a PIL Image object. - - Args: - data (str): The data to encode in the QR code - size (int): The size of the QR code (1-40) - border (int): The border size of the QR code - - Returns: - PIL.Image: The generated QR code image or a placeholder image if qrcode is not available - """ - if not QRCODE_AVAILABLE: - return BarcodeGenerator._generate_placeholder("QR Code Unavailable\nPlease install 'qrcode' package") - - qr = qrcode.QRCode( - version=size, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=border, - ) - qr.add_data(data) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - return img - - @staticmethod - def generate_ean13_barcode(data): - """ - Generates an EAN-13 barcode as a PIL Image object. - - Args: - data (str): The data to encode in the barcode (must be 12 digits) - - Returns: - PIL.Image: The generated barcode image or a placeholder image if barcode is not available - """ - if not BARCODE_AVAILABLE: - return BarcodeGenerator._generate_placeholder("EAN-13 Unavailable\nPlease install 'python-barcode' package") - - # Pad data to 12 digits if needed - if len(data) < 12: - data = data.zfill(12) - elif len(data) > 12: - data = data[:12] - - # Create a BytesIO object to temporarily store the image - buffer = io.BytesIO() - EAN13(data, writer=ImageWriter()).write(buffer) - - # Create a PIL Image from the BytesIO object - buffer.seek(0) - image = Image.open(buffer) - - return image - - @staticmethod - def generate_code128_barcode(data): - """ - Generates a Code 128 barcode as a PIL Image object. - - Args: - data (str): The data to encode in the barcode - - Returns: - PIL.Image: The generated barcode image or a placeholder image if barcode is not available - """ - if not BARCODE_AVAILABLE: - return BarcodeGenerator._generate_placeholder("Code 128 Unavailable\nPlease install 'python-barcode' package") - - # Create a BytesIO object to temporarily store the image - buffer = io.BytesIO() - Code128(data, writer=ImageWriter()).write(buffer) - - # Create a PIL Image from the BytesIO object - buffer.seek(0) - image = Image.open(buffer) - - return image - - @staticmethod - def _generate_placeholder(message): - """ - Generates a placeholder image with a message. - - Args: - message (str): Message to display on the placeholder - - Returns: - PIL.Image: The generated placeholder image - """ - # Create a placeholder image - img = Image.new('RGB', (300, 150), color=(255, 255, 255)) - d = ImageDraw.Draw(img) - - # Draw a border - d.rectangle([0, 0, 299, 149], outline=(0, 0, 0), width=2) - - # Add text - try: - # Try to use a default font - font = ImageFont.truetype("arial.ttf", 20) - except IOError: - # Fallback to default font - font = ImageFont.load_default() - - # Center the text - lines = message.split('\n') - y_offset = 40 - for line in lines: - text_width = d.textlength(line, font=font) - d.text(((300 - text_width) // 2, y_offset), line, font=font, fill=(0, 0, 0)) - y_offset += 30 - - return img - - @staticmethod - def save_image(image, path): - """ - Saves a PIL Image to the specified path. - - Args: - image (PIL.Image): The image to save - path (str): The path to save the image to - - Returns: - str: The path where the image was saved - """ - image.save(path) - return path - - @staticmethod - def get_image_as_bytes(image, format='PNG'): - """ - Converts a PIL Image to bytes. - - Args: - image (PIL.Image): The image to convert - format (str): The format to save the image as - - Returns: - bytes: The image as bytes - """ - buffer = io.BytesIO() - image.save(buffer, format=format) - return buffer.getvalue() diff --git a/src/routes/__init__.py b/src/routes/__init__.py new file mode 100644 index 0000000..ab331dc --- /dev/null +++ b/src/routes/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left blank to make 'routes' a package. diff --git a/src/routes/admin.py b/src/routes/admin.py new file mode 100644 index 0000000..668be09 --- /dev/null +++ b/src/routes/admin.py @@ -0,0 +1,284 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file +import os +import json +import uuid +import io +from werkzeug.utils import secure_filename + +# Import barcode generator class, but handle the case if it fails +try: + from utils.barcode_generator import BarcodeGenerator + BARCODE_GENERATOR_AVAILABLE = True +except ImportError: + BARCODE_GENERATOR_AVAILABLE = False + class BarcodeGenerator: + @staticmethod + def check_dependencies(): + return {'qrcode': False, 'barcode': False, 'pillow': False} + +admin_bp = Blueprint('admin', __name__, url_prefix='/admin') + +DATA_FOLDER = os.path.join(os.path.dirname(__file__), '../data') +PRODUCTS_FILE = os.path.join(DATA_FOLDER, 'products.json') +UPLOAD_FOLDER = os.path.join(DATA_FOLDER, 'images') +BARCODE_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'data', 'barcodes')) +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +def load_products(): + try: + with open(PRODUCTS_FILE, 'r') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + +def save_products(products): + with open(PRODUCTS_FILE, 'w') as f: + json.dump(products, f, indent=2) + +@admin_bp.route('/') +def index(): + products = load_products() + return render_template('admin/index.html', products=products) + +@admin_bp.route('/add', methods=['GET', 'POST']) +def add_product(): + if request.method == 'POST': + # Get form data + product_id = request.form.get('product_id') + name = request.form.get('name') + price = request.form.get('price') + + # Basic validation + if not product_id or not name or not price: + flash('Product ID, Name, and Price are required fields.') + return render_template('admin/add_product.html') + + # Check if product ID already exists + products = load_products() + if product_id in products: + flash('Product ID already exists.') + return render_template('admin/add_product.html') + + # Handle image upload + image_filename = f"{product_id}.png" # Default name + image_path = f"/images/{image_filename}" + + if 'image' in request.files: + file = request.files['image'] + if file and file.filename and allowed_file(file.filename): + extension = file.filename.rsplit('.', 1)[1].lower() + image_filename = f"{product_id}.{extension}" + image_path = f"/images/{image_filename}" + file.save(os.path.join(UPLOAD_FOLDER, image_filename)) + + # Create product data structure + product_data = [ + {"fieldName": "_id", "label": "Item ID", "value": product_id, "editable": "false", "fieldType": "TEXT"}, + {"fieldName": "_name", "label": "Product Name", "value": name, "editable": "true", "fieldType": "TEXT"}, + {"fieldName": "_price", "label": "Sale Price", "value": f"${price}", "editable": "true", "fieldType": "TEXT"}, + {"fieldName": "_image", "label": "Image", "value": image_path, "editable": "false", "fieldType": "IMAGE_URI"} + ] + + # Save to products.json + products[product_id] = product_data + save_products(products) + + flash('Product added successfully!') + return redirect(url_for('admin.index')) + + return render_template('admin/add_product.html') + +@admin_bp.route('/edit/', methods=['GET', 'POST']) +def edit_product(product_id): + products = load_products() + + if product_id not in products: + flash('Product not found.') + return redirect(url_for('admin.index')) + + if request.method == 'POST': + # Get form data + name = request.form.get('name') + price = request.form.get('price') + + # Basic validation + if not name or not price: + flash('Name and Price are required fields.') + return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id]) + + # Update name and price + for field in products[product_id]: + if field["fieldName"] == "_name": + field["value"] = name + elif field["fieldName"] == "_price": + field["value"] = f"${price}" + + # Handle image upload + if 'image' in request.files and request.files['image'].filename: + file = request.files['image'] + if allowed_file(file.filename): + # Find current image path + current_image = None + for field in products[product_id]: + if field["fieldName"] == "_image": + current_image = field["value"] + + if current_image: + # Get current filename + current_filename = os.path.basename(current_image) + # Delete old file if it exists and is different + old_path = os.path.join(UPLOAD_FOLDER, current_filename) + if os.path.exists(old_path): + os.remove(old_path) + + # Save new image + extension = file.filename.rsplit('.', 1)[1].lower() + image_filename = f"{product_id}.{extension}" + image_path = f"/images/{image_filename}" + file.save(os.path.join(UPLOAD_FOLDER, image_filename)) + + # Update image path + for field in products[product_id]: + if field["fieldName"] == "_image": + field["value"] = image_path + + # Save to products.json + save_products(products) + + flash('Product updated successfully!') + return redirect(url_for('admin.index')) + + return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id]) + +@admin_bp.route('/delete/', methods=['POST']) +def delete_product(product_id): + products = load_products() + + if product_id not in products: + flash('Product not found.') + return redirect(url_for('admin.index')) + + # Find image path + image_path = None + for field in products[product_id]: + if field["fieldName"] == "_image": + image_path = field["value"] + + # Delete image file + if image_path: + image_filename = os.path.basename(image_path) + image_file_path = os.path.join(UPLOAD_FOLDER, image_filename) + if os.path.exists(image_file_path): + os.remove(image_file_path) + + # Delete product from dict + del products[product_id] + + # Save updated products + save_products(products) + + flash('Product deleted successfully!') + return redirect(url_for('admin.index')) + +@admin_bp.route('/view/') +def view_product(product_id): + products = load_products() + + if product_id not in products: + flash('Product not found.') + return redirect(url_for('admin.index')) + + return render_template('admin/view_product.html', product_id=product_id, product=products[product_id]) + +@admin_bp.route('/generate_barcode//') +def generate_barcode(product_id, code_type): + """Generate and serve a barcode or QR code for a product""" + # Make sure the barcodes directory exists + os.makedirs(BARCODE_FOLDER, exist_ok=True) + + products = load_products() + if product_id not in products: + return jsonify({"error": "Product not found"}), 404 + + # Check if barcode generator is available + if not BARCODE_GENERATOR_AVAILABLE: + # Create a simple error image + from PIL import Image, ImageDraw, ImageFont + img = Image.new('RGB', (300, 150), color=(255, 255, 255)) + d = ImageDraw.Draw(img) + d.rectangle([0, 0, 299, 149], outline=(0, 0, 0), width=2) + d.text((25, 65), "Barcode generation unavailable", fill=(0, 0, 0)) + + # Save and serve the error image + image_path = os.path.join(BARCODE_FOLDER, f"{product_id}_{code_type}_error.png") + img.save(image_path) + return send_file(image_path, mimetype='image/png') + + generator = BarcodeGenerator() + + # Content for the code - use the product ID + data = product_id + + # Base filename for the saved code + base_filename = f"{product_id}_{code_type}" + image_path = os.path.join(BARCODE_FOLDER, f"{base_filename}.png") + + # Generate the requested code type + if code_type == 'qrcode': + # Generate QR code with product URL + product_url = request.host_url.rstrip('/') + f"/arinfo?barcode={product_id}" + img = generator.generate_qr_code(product_url) + generator.save_image(img, image_path) + + elif code_type == 'ean13': + # For EAN-13, make sure the product ID is numeric + numeric_id = ''.join(filter(str.isdigit, product_id)) + img = generator.generate_ean13_barcode(numeric_id) + generator.save_image(img, image_path) + + elif code_type == 'code128': + img = generator.generate_code128_barcode(data) + generator.save_image(img, image_path) + + else: + return jsonify({"error": "Invalid code type"}), 400 + + # Return the image directly + return send_file(image_path, mimetype='image/png') + +@admin_bp.route('/generate_barcode_page/') +def generate_barcode_page(product_id): + """Show a page with different barcode options for a product""" + products = load_products() + + if product_id not in products: + flash('Product not found.') + return redirect(url_for('admin.index')) + + # Extract product data + product_data = {} + for field in products[product_id]: + field_name = field["fieldName"][1:] if field["fieldName"].startswith('_') else field["fieldName"] + product_data[field_name] = field["value"] + + # Add product ID + product_data['id'] = product_id + + # Check if barcode generation is available + dependency_status = {} + if BARCODE_GENERATOR_AVAILABLE: + dependency_status = BarcodeGenerator.check_dependencies() + else: + dependency_status = { + 'qrcode': False, + 'barcode': False, + 'pillow': False + } + + return render_template('admin/generate_barcode.html', + product_id=product_id, + product=product_data, + dependencies=dependency_status) diff --git a/src/routes/api.py b/src/routes/api.py new file mode 100644 index 0000000..8014fa2 --- /dev/null +++ b/src/routes/api.py @@ -0,0 +1,8 @@ +# API routes blueprint scaffold +from flask import Blueprint, jsonify + +api_bp = Blueprint('api', __name__, url_prefix='/api') + +@api_bp.route('/') +def api_index(): + return jsonify({'message': 'API Home'}) diff --git a/src/utils/barcode_generator.py b/src/utils/barcode_generator.py new file mode 100644 index 0000000..017cf38 --- /dev/null +++ b/src/utils/barcode_generator.py @@ -0,0 +1,93 @@ +# Utility for barcode generation +import os +import io +from PIL import Image, ImageDraw, ImageFont + +try: + import qrcode + QRCODE_AVAILABLE = True +except ImportError: + QRCODE_AVAILABLE = False + +try: + from barcode import EAN13, Code128 + from barcode.writer import ImageWriter + BARCODE_AVAILABLE = True +except ImportError: + BARCODE_AVAILABLE = False + +class BarcodeGenerator: + @staticmethod + def check_dependencies(): + return { + 'qrcode': QRCODE_AVAILABLE, + 'barcode': BARCODE_AVAILABLE, + 'pillow': True + } + + @staticmethod + def generate_qr_code(data, size=10, border=4): + if not QRCODE_AVAILABLE: + return BarcodeGenerator._generate_placeholder("QR Code Unavailable\nPlease install 'qrcode' package") + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=size, + border=border, + ) + qr.add_data(data) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white").convert('RGB') + return img + + @staticmethod + def generate_ean13_barcode(data): + if not BARCODE_AVAILABLE: + return BarcodeGenerator._generate_placeholder("EAN-13 Unavailable\nPlease install 'python-barcode' package") + # EAN-13 requires 12 digits (13th is checksum, auto-calculated) + numeric = ''.join(filter(str.isdigit, str(data))) + if len(numeric) < 12: + numeric = numeric.zfill(12) + elif len(numeric) > 12: + numeric = numeric[:12] + ean = EAN13(numeric, writer=ImageWriter()) + output = io.BytesIO() + ean.write(output) + output.seek(0) + img = Image.open(output) + return img + + @staticmethod + def generate_code128_barcode(data): + if not BARCODE_AVAILABLE: + return BarcodeGenerator._generate_placeholder("Code 128 Unavailable\nPlease install 'python-barcode' package") + code128 = Code128(str(data), writer=ImageWriter()) + output = io.BytesIO() + code128.write(output) + output.seek(0) + img = Image.open(output) + return img + + @staticmethod + def _generate_placeholder(message): + img = Image.new('RGB', (300, 150), color=(255, 255, 255)) + d = ImageDraw.Draw(img) + d.rectangle([0, 0, 299, 149], outline=(0, 0, 0), width=2) + # Optionally, add multiline text + lines = message.split('\n') + y = 60 + for line in lines: + d.text((20, y), line, fill=(0, 0, 0)) + y += 20 + return img + + @staticmethod + def save_image(image, path): + image.save(path, format='PNG') + + @staticmethod + def get_image_as_bytes(image, format='PNG'): + buf = io.BytesIO() + image.save(buf, format=format) + buf.seek(0) + return buf.read() From f041266ac3e75ddb4d6424b1076b2bbf7f783aa1 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 19 Jun 2025 11:57:47 -0400 Subject: [PATCH 04/19] adding authentication and image support --- .gitignore | 1 + src/app.py | 184 +++++++++++++++++++++++------- src/database.py | 268 ++++++++++++++++++++++++++++++++++++++++++++ src/routes/admin.py | 153 +++++++++---------------- 4 files changed, 468 insertions(+), 138 deletions(-) create mode 100644 src/database.py diff --git a/.gitignore b/.gitignore index 471b22d..e6ea77e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ *.pyc *.pyo *.pyd +.claude/ \ No newline at end of file diff --git a/src/app.py b/src/app.py index 7ca4a6a..d46944d 100644 --- a/src/app.py +++ b/src/app.py @@ -1,8 +1,14 @@ -from flask import Flask, jsonify, request, send_file, render_template, flash +from flask import Flask, jsonify, request, send_file, render_template, flash, Response import os -import json +import base64 from routes.admin import admin_bp from routes.api import api_bp +import database +import qrcode +import barcode +from barcode.writer import ImageWriter +from io import BytesIO +import shutil app = Flask(__name__) # Set DATA_FOLDER to the absolute path of the data directory inside src @@ -13,23 +19,41 @@ app.config['SECRET_KEY'] = 'dev-key-for-demo-only' app.register_blueprint(admin_bp) app.register_blueprint(api_bp) -# Load product data from products.json +# Load product data from database def load_products(): - try: - products_file = os.path.join(app.config['DATA_FOLDER'], 'products.json') - with open(products_file, 'r') as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return {} + return database.get_all_products() @app.route('/') def index(): return render_template('index.html') +def check_basic_auth(auth_header): + """Validate Basic authentication credentials""" + if not auth_header or not auth_header.startswith('Basic '): + return False + + try: + # Decode the base64 credentials + credentials = base64.b64decode(auth_header[6:]).decode('utf-8') + username, password = credentials.split(':', 1) + + # Simple hardcoded credentials - in production, use secure storage + # You can change these credentials as needed + if username == 'admin' and password == 'knox123': + return True + except Exception: + pass + + return False + @app.route('/login', methods=['GET']) def login(): - # Just return 200 for this example, assuming no auth needed - return '', 200 + auth_header = request.headers.get('Authorization') + + if check_basic_auth(auth_header): + return jsonify({"status": "success", "message": "Authentication successful"}), 200 + + return jsonify({"error": "Unauthorized"}), 401 @app.route('/arcontentfields', methods=['GET']) def get_ar_content_fields(): @@ -45,45 +69,129 @@ def get_ar_content_fields(): def get_ar_info(): barcode = request.args.get('barcode') products = load_products() - if not barcode or barcode not in products: - return jsonify({"error": "Item not found"}), 404 - return jsonify(products[barcode]), 200 + + # Helper function to convert relative image paths to absolute URLs + def make_absolute_urls(product_fields): + # Create absolute URL for image fields + for field in product_fields: + if field['fieldName'] == '_image' and field['value']: + # If it's already an absolute URL, leave it as is + if not field['value'].startswith('http'): + # Build absolute URL using request host + field['value'] = f"{request.url_root.rstrip('/')}{field['value']}" + return product_fields + + # If barcode is provided, return specific product + if barcode: + if barcode in products: + product_data = make_absolute_urls(products[barcode]) + response = jsonify(product_data) + response.headers['Access-Control-Allow-Origin'] = '*' + return response, 200 + return jsonify({"error": "Product not found"}), 404 + + # Return all products if no barcode specified + # Convert all products to have absolute URLs + all_products = {} + for product_id, fields in products.items(): + all_products[product_id] = make_absolute_urls(fields) + response = jsonify(all_products) + response.headers['Access-Control-Allow-Origin'] = '*' + return response, 200 @app.route('/images/', methods=['GET']) def serve_image(filename): + # Log the request for debugging + app.logger.info(f"Image requested: {filename}") + + # Extract product ID from filename (e.g., "123456.jpg" -> "123456") + product_id = os.path.splitext(filename)[0] + + # Get image from database + image_data = database.get_product_image(product_id) + if image_data: + image_bytes, mime_type = image_data + response = Response(image_bytes, mimetype=mime_type) + # Add CORS headers to allow cross-origin requests + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Cache-Control'] = 'public, max-age=3600' + app.logger.info(f"Serving image from database: {product_id} ({len(image_bytes)} bytes)") + return response + + # Fallback to file system for backward compatibility image_path = os.path.join(app.config['DATA_FOLDER'], 'images', filename) if os.path.exists(image_path): - return send_file(image_path) + app.logger.info(f"Serving image from filesystem: {image_path}") + response = send_file(image_path) + response.headers['Access-Control-Allow-Origin'] = '*' + return response + + app.logger.warning(f"Image not found: {filename}") return jsonify({"error": "Image not found"}), 404 @app.route('/barcodes/', methods=['GET']) def serve_barcode(filename): - barcode_path = os.path.join(app.config['DATA_FOLDER'], 'barcodes', filename) - if os.path.exists(barcode_path): - return send_file(barcode_path) - return jsonify({"error": "Barcode not found"}), 404 + # Parse filename to extract product_id and barcode type + # Expected format: {product_id}_{type}.png + name_parts = os.path.splitext(filename)[0].split('_') + if len(name_parts) < 2: + return jsonify({"error": "Invalid barcode filename format"}), 400 + + product_id = '_'.join(name_parts[:-1]) # Handle product IDs with underscores + code_type = name_parts[-1].lower() + + # Generate barcode dynamically + try: + buffer = BytesIO() + + if code_type == 'qr': + # Generate QR code + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(f'http://{request.host}/arinfo?barcode={product_id}') + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + img.save(buffer, format='PNG') + + elif code_type == 'ean13': + # Generate EAN-13 barcode + # Convert product_id to numeric format if needed + numeric_id = ''.join(filter(str.isdigit, product_id)) + if not numeric_id: + numeric_id = str(abs(hash(product_id)) % 1000000000000)[:12] + else: + numeric_id = numeric_id[:12].zfill(12) + + EAN = barcode.get_barcode_class('ean13') + ean = EAN(numeric_id, writer=ImageWriter()) + ean.write(buffer) + + elif code_type == 'code128': + # Generate Code 128 barcode + CODE128 = barcode.get_barcode_class('code128') + code = CODE128(product_id, writer=ImageWriter()) + code.write(buffer) + + else: + return jsonify({"error": "Unsupported barcode type"}), 400 + + buffer.seek(0) + return Response(buffer.getvalue(), mimetype='image/png') + + except Exception as e: + app.logger.error(f"Barcode generation error: {str(e)}") + return jsonify({"error": "Failed to generate barcode"}), 500 if __name__ == '__main__': - # Ensure the required directories exist - os.makedirs(os.path.join(app.config['DATA_FOLDER'], 'images'), exist_ok=True) - os.makedirs(os.path.join(app.config['DATA_FOLDER'], 'barcodes'), exist_ok=True) + # Initialize database + database.init_database() - # If products.json doesn't exist, create it with initial data + # Migrate existing data from JSON if needed products_file = os.path.join(app.config['DATA_FOLDER'], 'products.json') - if not os.path.exists(products_file): - initial_data = { - "123456": [ - {"fieldName": "_id", "label": "Item ID", "value": "123456", "editable": "false", "fieldType": "TEXT"}, - {"fieldName": "_price", "label": "Sale Price", "value": "$49.99", "editable": "true", "fieldType": "TEXT"}, - {"fieldName": "_image", "label": "Image", "value": "/images/123456.png", "editable": "false", "fieldType": "IMAGE_URI"} - ], - "789012": [ - {"fieldName": "_id", "label": "Item ID", "value": "789012", "editable": "false", "fieldType": "TEXT"}, - {"fieldName": "_price", "label": "Sale Price", "value": "$59.99", "editable": "true", "fieldType": "TEXT"}, - {"fieldName": "_image", "label": "Image", "value": "/images/789012.png", "editable": "false", "fieldType": "IMAGE_URI"} - ] - } - with open(products_file, 'w') as f: - json.dump(initial_data, f, indent=2) + if os.path.exists(products_file): + print("Migrating data from products.json to SQLite...") + database.migrate_from_json() + # Optionally rename the JSON file to indicate it's been migrated + shutil.move(products_file, products_file + '.migrated') + print("Migration complete.") app.run(port=5555, host="0.0.0.0", debug=True) diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..cf4d7c4 --- /dev/null +++ b/src/database.py @@ -0,0 +1,268 @@ +import sqlite3 +import json +import os +from contextlib import contextmanager +from typing import Dict, List, Optional, Any +import base64 + +DATABASE_PATH = os.path.join(os.path.dirname(__file__), 'data', 'products.db') + +@contextmanager +def get_db(): + """Context manager for database connections""" + conn = sqlite3.connect(DATABASE_PATH) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + +def init_database(): + """Initialize the database with required tables""" + os.makedirs(os.path.dirname(DATABASE_PATH), exist_ok=True) + + with get_db() as conn: + cursor = conn.cursor() + + # Create products table with image_data as BLOB + cursor.execute(''' + CREATE TABLE IF NOT EXISTS products ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + price TEXT, + inventory INTEGER, + image_data BLOB, + image_mime_type TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Create product_fields table for extensible fields + cursor.execute(''' + CREATE TABLE IF NOT EXISTS product_fields ( + product_id TEXT, + field_name TEXT, + label TEXT, + value TEXT, + editable TEXT, + field_type TEXT, + FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE, + PRIMARY KEY (product_id, field_name) + ) + ''') + + conn.commit() + +def migrate_from_json(): + """Migrate existing JSON data to SQLite""" + json_path = os.path.join(os.path.dirname(__file__), 'data', 'products.json') + + if not os.path.exists(json_path): + return + + with open(json_path, 'r') as f: + products_data = json.load(f) + + with get_db() as conn: + cursor = conn.cursor() + + for product_id, fields in products_data.items(): + # Extract core fields + name = '' + price = '' + inventory = None + image_path = '' + + for field in fields: + if field['fieldName'] == '_name': + name = field['value'] + elif field['fieldName'] == '_price': + price = field['value'] + elif field['fieldName'] == '_inventory': + inventory = int(field['value']) if field['value'] else None + elif field['fieldName'] == '_image': + image_path = field['value'] + + # Read image data if exists + image_data = None + image_mime_type = None + if image_path: + # Extract filename from path like '/images/filename.jpg' + filename = image_path.split('/')[-1] + full_image_path = os.path.join(os.path.dirname(__file__), 'data', 'images', filename) + + if os.path.exists(full_image_path): + with open(full_image_path, 'rb') as img_file: + image_data = img_file.read() + # Determine mime type from extension + ext = os.path.splitext(filename)[1].lower() + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp' + } + image_mime_type = mime_types.get(ext, 'image/jpeg') + + # Insert into products table + cursor.execute(''' + INSERT OR REPLACE INTO products (id, name, price, inventory, image_data, image_mime_type) + VALUES (?, ?, ?, ?, ?, ?) + ''', (product_id, name, price, inventory, image_data, image_mime_type)) + + # Insert all fields into product_fields table + for field in fields: + cursor.execute(''' + INSERT OR REPLACE INTO product_fields + (product_id, field_name, label, value, editable, field_type) + VALUES (?, ?, ?, ?, ?, ?) + ''', ( + product_id, + field['fieldName'], + field['label'], + field['value'], + field.get('editable', 'true'), + field.get('fieldType', 'TEXT') + )) + + conn.commit() + +def get_all_products() -> Dict[str, List[Dict[str, Any]]]: + """Get all products in the legacy format""" + with get_db() as conn: + cursor = conn.cursor() + + # Get all product IDs + cursor.execute('SELECT id FROM products') + product_ids = [row['id'] for row in cursor.fetchall()] + + result = {} + for product_id in product_ids: + # Get all fields for this product + cursor.execute(''' + SELECT field_name, label, value, editable, field_type + FROM product_fields + WHERE product_id = ? + ORDER BY field_name + ''', (product_id,)) + + fields = [] + for row in cursor.fetchall(): + fields.append({ + 'fieldName': row['field_name'], + 'label': row['label'], + 'value': row['value'], + 'editable': row['editable'], + 'fieldType': row['field_type'] + }) + + result[product_id] = fields + + return result + +def get_product(product_id: str) -> Optional[List[Dict[str, Any]]]: + """Get a single product by ID""" + with get_db() as conn: + cursor = conn.cursor() + + cursor.execute(''' + SELECT field_name, label, value, editable, field_type + FROM product_fields + WHERE product_id = ? + ORDER BY field_name + ''', (product_id,)) + + fields = [] + for row in cursor.fetchall(): + fields.append({ + 'fieldName': row['field_name'], + 'label': row['label'], + 'value': row['value'], + 'editable': row['editable'], + 'fieldType': row['field_type'] + }) + + return fields if fields else None + +def save_product(product_id: str, fields: List[Dict[str, Any]], image_data: Optional[bytes] = None, image_mime_type: Optional[str] = None): + """Save or update a product""" + with get_db() as conn: + cursor = conn.cursor() + + # Extract core fields + name = '' + price = '' + inventory = None + + for field in fields: + if field['fieldName'] == '_name': + name = field['value'] + elif field['fieldName'] == '_price': + price = field['value'] + elif field['fieldName'] == '_inventory': + inventory = int(field['value']) if field['value'] else None + + # Check if product exists + cursor.execute('SELECT id FROM products WHERE id = ?', (product_id,)) + exists = cursor.fetchone() is not None + + if exists: + # Update existing product + if image_data is not None: + cursor.execute(''' + UPDATE products + SET name = ?, price = ?, inventory = ?, image_data = ?, image_mime_type = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (name, price, inventory, image_data, image_mime_type, product_id)) + else: + cursor.execute(''' + UPDATE products + SET name = ?, price = ?, inventory = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (name, price, inventory, product_id)) + else: + # Insert new product + cursor.execute(''' + INSERT INTO products (id, name, price, inventory, image_data, image_mime_type) + VALUES (?, ?, ?, ?, ?, ?) + ''', (product_id, name, price, inventory, image_data, image_mime_type)) + + # Delete existing fields + cursor.execute('DELETE FROM product_fields WHERE product_id = ?', (product_id,)) + + # Insert all fields + for field in fields: + cursor.execute(''' + INSERT INTO product_fields + (product_id, field_name, label, value, editable, field_type) + VALUES (?, ?, ?, ?, ?, ?) + ''', ( + product_id, + field['fieldName'], + field['label'], + field['value'], + field.get('editable', 'true'), + field.get('fieldType', 'TEXT') + )) + + conn.commit() + +def delete_product(product_id: str): + """Delete a product""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM products WHERE id = ?', (product_id,)) + conn.commit() + +def get_product_image(product_id: str) -> Optional[tuple[bytes, str]]: + """Get product image data and mime type""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute('SELECT image_data, image_mime_type FROM products WHERE id = ?', (product_id,)) + row = cursor.fetchone() + + if row and row['image_data']: + return row['image_data'], row['image_mime_type'] + return None \ No newline at end of file diff --git a/src/routes/admin.py b/src/routes/admin.py index 668be09..fd862e3 100644 --- a/src/routes/admin.py +++ b/src/routes/admin.py @@ -4,6 +4,9 @@ import json import uuid import io from werkzeug.utils import secure_filename +import sys +sys.path.append(os.path.dirname(os.path.dirname(__file__))) +import database # Import barcode generator class, but handle the case if it fails try: @@ -28,15 +31,11 @@ def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS def load_products(): - try: - with open(PRODUCTS_FILE, 'r') as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return {} + return database.get_all_products() def save_products(products): - with open(PRODUCTS_FILE, 'w') as f: - json.dump(products, f, indent=2) + # This is now handled by database operations + pass @admin_bp.route('/') def index(): @@ -63,16 +62,27 @@ def add_product(): return render_template('admin/add_product.html') # Handle image upload - image_filename = f"{product_id}.png" # Default name - image_path = f"/images/{image_filename}" + image_data = None + image_mime_type = None + image_path = f"/images/{product_id}.png" # Default path for display if 'image' in request.files: file = request.files['image'] if file and file.filename and allowed_file(file.filename): extension = file.filename.rsplit('.', 1)[1].lower() - image_filename = f"{product_id}.{extension}" - image_path = f"/images/{image_filename}" - file.save(os.path.join(UPLOAD_FOLDER, image_filename)) + image_path = f"/images/{product_id}.{extension}" + + # Read image data + image_data = file.read() + + # Determine mime type + mime_types = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif' + } + image_mime_type = mime_types.get(extension, 'image/jpeg') # Create product data structure product_data = [ @@ -82,9 +92,8 @@ def add_product(): {"fieldName": "_image", "label": "Image", "value": image_path, "editable": "false", "fieldType": "IMAGE_URI"} ] - # Save to products.json - products[product_id] = product_data - save_products(products) + # Save to database + database.save_product(product_id, product_data, image_data, image_mime_type) flash('Product added successfully!') return redirect(url_for('admin.index')) @@ -117,36 +126,33 @@ def edit_product(product_id): field["value"] = f"${price}" # Handle image upload + image_data = None + image_mime_type = None + if 'image' in request.files and request.files['image'].filename: file = request.files['image'] if allowed_file(file.filename): - # Find current image path - current_image = None - for field in products[product_id]: - if field["fieldName"] == "_image": - current_image = field["value"] + # Read new image data + image_data = file.read() - if current_image: - # Get current filename - current_filename = os.path.basename(current_image) - # Delete old file if it exists and is different - old_path = os.path.join(UPLOAD_FOLDER, current_filename) - if os.path.exists(old_path): - os.remove(old_path) - - # Save new image + # Determine mime type extension = file.filename.rsplit('.', 1)[1].lower() - image_filename = f"{product_id}.{extension}" - image_path = f"/images/{image_filename}" - file.save(os.path.join(UPLOAD_FOLDER, image_filename)) + mime_types = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif' + } + image_mime_type = mime_types.get(extension, 'image/jpeg') - # Update image path + # Update image path in product data + image_path = f"/images/{product_id}.{extension}" for field in products[product_id]: if field["fieldName"] == "_image": field["value"] = image_path - # Save to products.json - save_products(products) + # Save to database + database.save_product(product_id, products[product_id], image_data, image_mime_type) flash('Product updated successfully!') return redirect(url_for('admin.index')) @@ -161,24 +167,8 @@ def delete_product(product_id): flash('Product not found.') return redirect(url_for('admin.index')) - # Find image path - image_path = None - for field in products[product_id]: - if field["fieldName"] == "_image": - image_path = field["value"] - - # Delete image file - if image_path: - image_filename = os.path.basename(image_path) - image_file_path = os.path.join(UPLOAD_FOLDER, image_filename) - if os.path.exists(image_file_path): - os.remove(image_file_path) - - # Delete product from dict - del products[product_id] - - # Save updated products - save_products(products) + # Delete product from database (image is stored in DB) + database.delete_product(product_id) flash('Product deleted successfully!') return redirect(url_for('admin.index')) @@ -195,59 +185,22 @@ def view_product(product_id): @admin_bp.route('/generate_barcode//') def generate_barcode(product_id, code_type): - """Generate and serve a barcode or QR code for a product""" - # Make sure the barcodes directory exists - os.makedirs(BARCODE_FOLDER, exist_ok=True) - + """Redirect to the main barcode generation endpoint""" products = load_products() if product_id not in products: return jsonify({"error": "Product not found"}), 404 - # Check if barcode generator is available - if not BARCODE_GENERATOR_AVAILABLE: - # Create a simple error image - from PIL import Image, ImageDraw, ImageFont - img = Image.new('RGB', (300, 150), color=(255, 255, 255)) - d = ImageDraw.Draw(img) - d.rectangle([0, 0, 299, 149], outline=(0, 0, 0), width=2) - d.text((25, 65), "Barcode generation unavailable", fill=(0, 0, 0)) - - # Save and serve the error image - image_path = os.path.join(BARCODE_FOLDER, f"{product_id}_{code_type}_error.png") - img.save(image_path) - return send_file(image_path, mimetype='image/png') + # Map code_type to the expected format for the main endpoint + type_mapping = { + 'qrcode': 'qr', + 'ean13': 'ean13', + 'code128': 'code128' + } - generator = BarcodeGenerator() + barcode_type = type_mapping.get(code_type, code_type) - # Content for the code - use the product ID - data = product_id - - # Base filename for the saved code - base_filename = f"{product_id}_{code_type}" - image_path = os.path.join(BARCODE_FOLDER, f"{base_filename}.png") - - # Generate the requested code type - if code_type == 'qrcode': - # Generate QR code with product URL - product_url = request.host_url.rstrip('/') + f"/arinfo?barcode={product_id}" - img = generator.generate_qr_code(product_url) - generator.save_image(img, image_path) - - elif code_type == 'ean13': - # For EAN-13, make sure the product ID is numeric - numeric_id = ''.join(filter(str.isdigit, product_id)) - img = generator.generate_ean13_barcode(numeric_id) - generator.save_image(img, image_path) - - elif code_type == 'code128': - img = generator.generate_code128_barcode(data) - generator.save_image(img, image_path) - - else: - return jsonify({"error": "Invalid code type"}), 400 - - # Return the image directly - return send_file(image_path, mimetype='image/png') + # Redirect to the main barcode endpoint which generates dynamically + return redirect(f'/barcodes/{product_id}_{barcode_type}.png') @admin_bp.route('/generate_barcode_page/') def generate_barcode_page(product_id): From 705f2859faaba3cc22cd239740f1f34c8f296632 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 19 Jun 2025 14:25:42 -0400 Subject: [PATCH 05/19] made multi tenant --- src/app.py | 102 ++++++++---- src/database.py | 193 ++++++++++++++++------ src/routes/admin.py | 100 ++++++----- src/routes/api.py | 11 +- src/templates/admin/add_product.html | 4 +- src/templates/admin/credentials.html | 92 +++++++++++ src/templates/admin/edit_product.html | 4 +- src/templates/admin/generate_barcode.html | 14 +- src/templates/admin/index.html | 17 +- src/templates/index.html | 18 +- src/templates/tenant_selection.html | 75 +++++++++ 11 files changed, 473 insertions(+), 157 deletions(-) create mode 100644 src/templates/admin/credentials.html create mode 100644 src/templates/tenant_selection.html diff --git a/src/app.py b/src/app.py index d46944d..a154996 100644 --- a/src/app.py +++ b/src/app.py @@ -1,34 +1,43 @@ -from flask import Flask, jsonify, request, send_file, render_template, flash, Response +from flask import Flask, jsonify, request, send_file, render_template, flash, Response, redirect import os import base64 -from routes.admin import admin_bp -from routes.api import api_bp import database import qrcode import barcode from barcode.writer import ImageWriter from io import BytesIO import shutil +from werkzeug.routing import BaseConverter app = Flask(__name__) # Set DATA_FOLDER to the absolute path of the data directory inside src app.config['DATA_FOLDER'] = os.path.join(os.path.dirname(__file__), 'data') app.config['SECRET_KEY'] = 'dev-key-for-demo-only' -# Register blueprints -app.register_blueprint(admin_bp) -app.register_blueprint(api_bp) +# Custom converter for tenant IDs +class TenantConverter(BaseConverter): + regex = '[a-zA-Z0-9_-]+' + +app.url_map.converters['tenant'] = TenantConverter # Load product data from database -def load_products(): - return database.get_all_products() +def load_products(tenant_id=None): + return database.get_all_products(tenant_id) @app.route('/') def index(): - return render_template('index.html') + # Show a tenant selection page or redirect to default + tenants = database.get_all_tenants() + return render_template('tenant_selection.html', tenants=tenants) -def check_basic_auth(auth_header): - """Validate Basic authentication credentials""" +@app.route('//') +def tenant_index(tenant_id): + # Auto-create tenant if it doesn't exist + tenant = database.get_or_create_tenant(tenant_id) + return render_template('index.html', tenant=tenant) + +def check_basic_auth(auth_header, tenant_id): + """Validate Basic authentication credentials for a tenant""" if not auth_header or not auth_header.startswith('Basic '): return False @@ -37,27 +46,27 @@ def check_basic_auth(auth_header): credentials = base64.b64decode(auth_header[6:]).decode('utf-8') username, password = credentials.split(':', 1) - # Simple hardcoded credentials - in production, use secure storage - # You can change these credentials as needed - if username == 'admin' and password == 'knox123': + # Get tenant credentials from database + tenant = database.get_or_create_tenant(tenant_id) + if username == tenant['username'] and password == tenant['password']: return True except Exception: pass return False -@app.route('/login', methods=['GET']) -def login(): +@app.route('//login', methods=['GET']) +def login(tenant_id): auth_header = request.headers.get('Authorization') - if check_basic_auth(auth_header): + if check_basic_auth(auth_header, tenant_id): return jsonify({"status": "success", "message": "Authentication successful"}), 200 return jsonify({"error": "Unauthorized"}), 401 -@app.route('/arcontentfields', methods=['GET']) -def get_ar_content_fields(): - # Return a fixed set of attributes +@app.route('//arcontentfields', methods=['GET']) +def get_ar_content_fields(tenant_id): + # Return a fixed set of attributes (tenant_id could be used for custom fields in the future) fields = [ {"fieldName": "_id", "label": "Item ID", "editable": "false", "fieldType": "TEXT"}, {"fieldName": "_price", "label": "Sale Price", "editable": "true", "fieldType": "TEXT"}, @@ -65,10 +74,10 @@ def get_ar_content_fields(): ] return jsonify(fields), 200 -@app.route('/arinfo', methods=['GET']) -def get_ar_info(): +@app.route('//arinfo', methods=['GET']) +def get_ar_info(tenant_id): barcode = request.args.get('barcode') - products = load_products() + products = load_products(tenant_id) # Helper function to convert relative image paths to absolute URLs def make_absolute_urls(product_fields): @@ -77,8 +86,8 @@ def get_ar_info(): if field['fieldName'] == '_image' and field['value']: # If it's already an absolute URL, leave it as is if not field['value'].startswith('http'): - # Build absolute URL using request host - field['value'] = f"{request.url_root.rstrip('/')}{field['value']}" + # Build absolute URL using request host with tenant + field['value'] = f"{request.url_root.rstrip('/')}/{tenant_id}{field['value']}" return product_fields # If barcode is provided, return specific product @@ -99,16 +108,16 @@ def get_ar_info(): response.headers['Access-Control-Allow-Origin'] = '*' return response, 200 -@app.route('/images/', methods=['GET']) -def serve_image(filename): +@app.route('//images/', methods=['GET']) +def serve_image(tenant_id, filename): # Log the request for debugging - app.logger.info(f"Image requested: {filename}") + app.logger.info(f"Image requested for tenant {tenant_id}: {filename}") # Extract product ID from filename (e.g., "123456.jpg" -> "123456") product_id = os.path.splitext(filename)[0] - # Get image from database - image_data = database.get_product_image(product_id) + # Get image from database for this tenant + image_data = database.get_product_image(product_id, tenant_id) if image_data: image_bytes, mime_type = image_data response = Response(image_bytes, mimetype=mime_type) @@ -129,8 +138,8 @@ def serve_image(filename): app.logger.warning(f"Image not found: {filename}") return jsonify({"error": "Image not found"}), 404 -@app.route('/barcodes/', methods=['GET']) -def serve_barcode(filename): +@app.route('//barcodes/', methods=['GET']) +def serve_barcode(tenant_id, filename): # Parse filename to extract product_id and barcode type # Expected format: {product_id}_{type}.png name_parts = os.path.splitext(filename)[0].split('_') @@ -145,9 +154,9 @@ def serve_barcode(filename): buffer = BytesIO() if code_type == 'qr': - # Generate QR code + # Generate QR code with tenant in URL qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(f'http://{request.host}/arinfo?barcode={product_id}') + qr.add_data(f'http://{request.host}/{tenant_id}/arinfo?barcode={product_id}') qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") img.save(buffer, format='PNG') @@ -181,6 +190,31 @@ def serve_barcode(filename): app.logger.error(f"Barcode generation error: {str(e)}") return jsonify({"error": "Failed to generate barcode"}), 500 +# Import the admin routes directly and register them with tenant support +from routes.admin import (index as admin_index, add_product, edit_product, + delete_product, view_product, generate_barcode, + generate_barcode_page, manage_credentials) + +# Register admin routes with tenant prefix +app.add_url_rule('//admin/', 'admin.index', admin_index) +app.add_url_rule('//admin/add', 'admin.add_product', add_product, methods=['GET', 'POST']) +app.add_url_rule('//admin/edit/', 'admin.edit_product', edit_product, methods=['GET', 'POST']) +app.add_url_rule('//admin/delete/', 'admin.delete_product', delete_product, methods=['POST']) +app.add_url_rule('//admin/view/', 'admin.view_product', view_product) +app.add_url_rule('//admin/generate_barcode//', 'admin.generate_barcode', generate_barcode) +app.add_url_rule('//admin/generate_barcode_page/', 'admin.generate_barcode_page', generate_barcode_page) +app.add_url_rule('//admin/credentials', 'admin.manage_credentials', manage_credentials, methods=['GET', 'POST']) + +# Register API routes with tenant prefix +from routes.api import api_index +app.add_url_rule('//api/', 'api.api_index', api_index) + +# Catch-all route for admin without tenant - redirect to home +@app.route('/admin/') +@app.route('/admin/') +def redirect_admin_to_home(path=None): + return redirect('/') + if __name__ == '__main__': # Initialize database database.init_database() diff --git a/src/database.py b/src/database.py index cf4d7c4..a759c82 100644 --- a/src/database.py +++ b/src/database.py @@ -24,38 +24,54 @@ def init_database(): with get_db() as conn: cursor = conn.cursor() - # Create products table with image_data as BLOB + # Create tenants table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS tenants ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + username TEXT, + password TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Create products table with tenant_id cursor.execute(''' CREATE TABLE IF NOT EXISTS products ( - id TEXT PRIMARY KEY, + id TEXT, + tenant_id TEXT NOT NULL, name TEXT NOT NULL, price TEXT, inventory INTEGER, image_data BLOB, image_mime_type TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, tenant_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE ) ''') - # Create product_fields table for extensible fields + # Create product_fields table with tenant_id cursor.execute(''' CREATE TABLE IF NOT EXISTS product_fields ( product_id TEXT, + tenant_id TEXT, field_name TEXT, label TEXT, value TEXT, editable TEXT, field_type TEXT, - FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE, - PRIMARY KEY (product_id, field_name) + FOREIGN KEY (product_id, tenant_id) REFERENCES products(id, tenant_id) ON DELETE CASCADE, + PRIMARY KEY (product_id, tenant_id, field_name) ) ''') conn.commit() def migrate_from_json(): - """Migrate existing JSON data to SQLite""" + """Migrate existing JSON data to SQLite with default tenant""" json_path = os.path.join(os.path.dirname(__file__), 'data', 'products.json') if not os.path.exists(json_path): @@ -67,6 +83,13 @@ def migrate_from_json(): with get_db() as conn: cursor = conn.cursor() + # Create default tenant if not exists + default_tenant_id = 'default' + cursor.execute(''' + INSERT OR IGNORE INTO tenants (id, name, username, password) + VALUES (?, ?, ?, ?) + ''', (default_tenant_id, 'Default', 'admin', 'admin')) + for product_id, fields in products_data.items(): # Extract core fields name = '' @@ -106,20 +129,21 @@ def migrate_from_json(): } image_mime_type = mime_types.get(ext, 'image/jpeg') - # Insert into products table + # Insert into products table with tenant_id cursor.execute(''' - INSERT OR REPLACE INTO products (id, name, price, inventory, image_data, image_mime_type) - VALUES (?, ?, ?, ?, ?, ?) - ''', (product_id, name, price, inventory, image_data, image_mime_type)) + INSERT OR REPLACE INTO products (id, tenant_id, name, price, inventory, image_data, image_mime_type) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', (product_id, default_tenant_id, name, price, inventory, image_data, image_mime_type)) - # Insert all fields into product_fields table + # Insert all fields into product_fields table with tenant_id for field in fields: cursor.execute(''' INSERT OR REPLACE INTO product_fields - (product_id, field_name, label, value, editable, field_type) - VALUES (?, ?, ?, ?, ?, ?) + (product_id, tenant_id, field_name, label, value, editable, field_type) + VALUES (?, ?, ?, ?, ?, ?, ?) ''', ( product_id, + default_tenant_id, field['fieldName'], field['label'], field['value'], @@ -129,24 +153,35 @@ def migrate_from_json(): conn.commit() -def get_all_products() -> Dict[str, List[Dict[str, Any]]]: - """Get all products in the legacy format""" +def get_all_products(tenant_id: str = None) -> Dict[str, List[Dict[str, Any]]]: + """Get all products for a tenant in the legacy format""" with get_db() as conn: cursor = conn.cursor() - # Get all product IDs - cursor.execute('SELECT id FROM products') + # Get all product IDs for the tenant + if tenant_id: + cursor.execute('SELECT id FROM products WHERE tenant_id = ?', (tenant_id,)) + else: + cursor.execute('SELECT id FROM products') product_ids = [row['id'] for row in cursor.fetchall()] result = {} for product_id in product_ids: # Get all fields for this product - cursor.execute(''' - SELECT field_name, label, value, editable, field_type - FROM product_fields - WHERE product_id = ? - ORDER BY field_name - ''', (product_id,)) + if tenant_id: + cursor.execute(''' + SELECT field_name, label, value, editable, field_type + FROM product_fields + WHERE product_id = ? AND tenant_id = ? + ORDER BY field_name + ''', (product_id, tenant_id)) + else: + cursor.execute(''' + SELECT field_name, label, value, editable, field_type + FROM product_fields + WHERE product_id = ? + ORDER BY field_name + ''', (product_id,)) fields = [] for row in cursor.fetchall(): @@ -162,17 +197,17 @@ def get_all_products() -> Dict[str, List[Dict[str, Any]]]: return result -def get_product(product_id: str) -> Optional[List[Dict[str, Any]]]: - """Get a single product by ID""" +def get_product(product_id: str, tenant_id: str) -> Optional[List[Dict[str, Any]]]: + """Get a single product by ID and tenant""" with get_db() as conn: cursor = conn.cursor() cursor.execute(''' SELECT field_name, label, value, editable, field_type FROM product_fields - WHERE product_id = ? + WHERE product_id = ? AND tenant_id = ? ORDER BY field_name - ''', (product_id,)) + ''', (product_id, tenant_id)) fields = [] for row in cursor.fetchall(): @@ -186,8 +221,8 @@ def get_product(product_id: str) -> Optional[List[Dict[str, Any]]]: return fields if fields else None -def save_product(product_id: str, fields: List[Dict[str, Any]], image_data: Optional[bytes] = None, image_mime_type: Optional[str] = None): - """Save or update a product""" +def save_product(product_id: str, tenant_id: str, fields: List[Dict[str, Any]], image_data: Optional[bytes] = None, image_mime_type: Optional[str] = None): + """Save or update a product for a tenant""" with get_db() as conn: cursor = conn.cursor() @@ -205,7 +240,7 @@ def save_product(product_id: str, fields: List[Dict[str, Any]], image_data: Opti inventory = int(field['value']) if field['value'] else None # Check if product exists - cursor.execute('SELECT id FROM products WHERE id = ?', (product_id,)) + cursor.execute('SELECT id FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id)) exists = cursor.fetchone() is not None if exists: @@ -214,32 +249,33 @@ def save_product(product_id: str, fields: List[Dict[str, Any]], image_data: Opti cursor.execute(''' UPDATE products SET name = ?, price = ?, inventory = ?, image_data = ?, image_mime_type = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - ''', (name, price, inventory, image_data, image_mime_type, product_id)) + WHERE id = ? AND tenant_id = ? + ''', (name, price, inventory, image_data, image_mime_type, product_id, tenant_id)) else: cursor.execute(''' UPDATE products SET name = ?, price = ?, inventory = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - ''', (name, price, inventory, product_id)) + WHERE id = ? AND tenant_id = ? + ''', (name, price, inventory, product_id, tenant_id)) else: # Insert new product cursor.execute(''' - INSERT INTO products (id, name, price, inventory, image_data, image_mime_type) - VALUES (?, ?, ?, ?, ?, ?) - ''', (product_id, name, price, inventory, image_data, image_mime_type)) + INSERT INTO products (id, tenant_id, name, price, inventory, image_data, image_mime_type) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', (product_id, tenant_id, name, price, inventory, image_data, image_mime_type)) # Delete existing fields - cursor.execute('DELETE FROM product_fields WHERE product_id = ?', (product_id,)) + cursor.execute('DELETE FROM product_fields WHERE product_id = ? AND tenant_id = ?', (product_id, tenant_id)) # Insert all fields for field in fields: cursor.execute(''' INSERT INTO product_fields - (product_id, field_name, label, value, editable, field_type) - VALUES (?, ?, ?, ?, ?, ?) + (product_id, tenant_id, field_name, label, value, editable, field_type) + VALUES (?, ?, ?, ?, ?, ?, ?) ''', ( product_id, + tenant_id, field['fieldName'], field['label'], field['value'], @@ -249,20 +285,81 @@ def save_product(product_id: str, fields: List[Dict[str, Any]], image_data: Opti conn.commit() -def delete_product(product_id: str): - """Delete a product""" +def delete_product(product_id: str, tenant_id: str): + """Delete a product for a tenant""" with get_db() as conn: cursor = conn.cursor() - cursor.execute('DELETE FROM products WHERE id = ?', (product_id,)) + cursor.execute('DELETE FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id)) conn.commit() -def get_product_image(product_id: str) -> Optional[tuple[bytes, str]]: - """Get product image data and mime type""" +def get_product_image(product_id: str, tenant_id: str) -> Optional[tuple[bytes, str]]: + """Get product image data and mime type for a tenant""" with get_db() as conn: cursor = conn.cursor() - cursor.execute('SELECT image_data, image_mime_type FROM products WHERE id = ?', (product_id,)) + cursor.execute('SELECT image_data, image_mime_type FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id)) row = cursor.fetchone() if row and row['image_data']: return row['image_data'], row['image_mime_type'] - return None \ No newline at end of file + return None + +def get_or_create_tenant(tenant_id: str, username: str = None, password: str = None) -> Dict[str, Any]: + """Get or create a tenant""" + with get_db() as conn: + cursor = conn.cursor() + + # Check if tenant exists + cursor.execute('SELECT id, name, username, password FROM tenants WHERE id = ?', (tenant_id,)) + row = cursor.fetchone() + + if row: + return { + 'id': row['id'], + 'name': row['name'], + 'username': row['username'], + 'password': row['password'] + } + else: + # Create new tenant with default credentials + default_username = username or 'admin' + default_password = password or 'admin' + cursor.execute(''' + INSERT INTO tenants (id, name, username, password) + VALUES (?, ?, ?, ?) + ''', (tenant_id, tenant_id.title(), default_username, default_password)) + conn.commit() + + return { + 'id': tenant_id, + 'name': tenant_id.title(), + 'username': default_username, + 'password': default_password + } + +def update_tenant_credentials(tenant_id: str, username: str, password: str): + """Update tenant credentials""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE tenants + SET username = ?, password = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (username, password, tenant_id)) + conn.commit() + +def get_all_tenants() -> List[Dict[str, Any]]: + """Get all tenants""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute('SELECT id, name, username, created_at FROM tenants ORDER BY created_at DESC') + + tenants = [] + for row in cursor.fetchall(): + tenants.append({ + 'id': row['id'], + 'name': row['name'], + 'username': row['username'], + 'created_at': row['created_at'] + }) + + return tenants \ No newline at end of file diff --git a/src/routes/admin.py b/src/routes/admin.py index fd862e3..cf49501 100644 --- a/src/routes/admin.py +++ b/src/routes/admin.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file +from flask import render_template, request, redirect, url_for, flash, jsonify, send_file import os import json import uuid @@ -19,8 +19,6 @@ except ImportError: def check_dependencies(): return {'qrcode': False, 'barcode': False, 'pillow': False} -admin_bp = Blueprint('admin', __name__, url_prefix='/admin') - DATA_FOLDER = os.path.join(os.path.dirname(__file__), '../data') PRODUCTS_FILE = os.path.join(DATA_FOLDER, 'products.json') UPLOAD_FOLDER = os.path.join(DATA_FOLDER, 'images') @@ -30,20 +28,19 @@ ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS -def load_products(): - return database.get_all_products() +def load_products(tenant_id): + return database.get_all_products(tenant_id) def save_products(products): # This is now handled by database operations pass -@admin_bp.route('/') -def index(): - products = load_products() - return render_template('admin/index.html', products=products) +def index(tenant_id): + products = load_products(tenant_id) + tenant = database.get_or_create_tenant(tenant_id) + return render_template('admin/index.html', products=products, tenant=tenant) -@admin_bp.route('/add', methods=['GET', 'POST']) -def add_product(): +def add_product(tenant_id): if request.method == 'POST': # Get form data product_id = request.form.get('product_id') @@ -53,13 +50,13 @@ def add_product(): # Basic validation if not product_id or not name or not price: flash('Product ID, Name, and Price are required fields.') - return render_template('admin/add_product.html') + return render_template('admin/add_product.html', tenant_id=tenant_id) # Check if product ID already exists - products = load_products() + products = load_products(tenant_id) if product_id in products: flash('Product ID already exists.') - return render_template('admin/add_product.html') + return render_template('admin/add_product.html', tenant_id=tenant_id) # Handle image upload image_data = None @@ -93,20 +90,19 @@ def add_product(): ] # Save to database - database.save_product(product_id, product_data, image_data, image_mime_type) + database.save_product(product_id, tenant_id, product_data, image_data, image_mime_type) flash('Product added successfully!') - return redirect(url_for('admin.index')) + return redirect(url_for('admin.index', tenant_id=tenant_id)) - return render_template('admin/add_product.html') + return render_template('admin/add_product.html', tenant_id=tenant_id) -@admin_bp.route('/edit/', methods=['GET', 'POST']) -def edit_product(product_id): - products = load_products() +def edit_product(tenant_id, product_id): + products = load_products(tenant_id) if product_id not in products: flash('Product not found.') - return redirect(url_for('admin.index')) + return redirect(url_for('admin.index', tenant_id=tenant_id)) if request.method == 'POST': # Get form data @@ -116,7 +112,7 @@ def edit_product(product_id): # Basic validation if not name or not price: flash('Name and Price are required fields.') - return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id]) + return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id) # Update name and price for field in products[product_id]: @@ -152,41 +148,38 @@ def edit_product(product_id): field["value"] = image_path # Save to database - database.save_product(product_id, products[product_id], image_data, image_mime_type) + database.save_product(product_id, tenant_id, products[product_id], image_data, image_mime_type) flash('Product updated successfully!') - return redirect(url_for('admin.index')) + return redirect(url_for('admin.index', tenant_id=tenant_id)) - return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id]) + return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id) -@admin_bp.route('/delete/', methods=['POST']) -def delete_product(product_id): - products = load_products() +def delete_product(tenant_id, product_id): + products = load_products(tenant_id) if product_id not in products: flash('Product not found.') - return redirect(url_for('admin.index')) + return redirect(url_for('admin.index', tenant_id=tenant_id)) # Delete product from database (image is stored in DB) - database.delete_product(product_id) + database.delete_product(product_id, tenant_id) flash('Product deleted successfully!') - return redirect(url_for('admin.index')) + return redirect(url_for('admin.index', tenant_id=tenant_id)) -@admin_bp.route('/view/') -def view_product(product_id): - products = load_products() +def view_product(tenant_id, product_id): + products = load_products(tenant_id) if product_id not in products: flash('Product not found.') - return redirect(url_for('admin.index')) + return redirect(url_for('admin.index', tenant_id=tenant_id)) - return render_template('admin/view_product.html', product_id=product_id, product=products[product_id]) + return render_template('admin/view_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id) -@admin_bp.route('/generate_barcode//') -def generate_barcode(product_id, code_type): +def generate_barcode(tenant_id, product_id, code_type): """Redirect to the main barcode generation endpoint""" - products = load_products() + products = load_products(tenant_id) if product_id not in products: return jsonify({"error": "Product not found"}), 404 @@ -200,16 +193,15 @@ def generate_barcode(product_id, code_type): barcode_type = type_mapping.get(code_type, code_type) # Redirect to the main barcode endpoint which generates dynamically - return redirect(f'/barcodes/{product_id}_{barcode_type}.png') + return redirect(f'/{tenant_id}/barcodes/{product_id}_{barcode_type}.png') -@admin_bp.route('/generate_barcode_page/') -def generate_barcode_page(product_id): +def generate_barcode_page(tenant_id, product_id): """Show a page with different barcode options for a product""" - products = load_products() + products = load_products(tenant_id) if product_id not in products: flash('Product not found.') - return redirect(url_for('admin.index')) + return redirect(url_for('admin.index', tenant_id=tenant_id)) # Extract product data product_data = {} @@ -234,4 +226,22 @@ def generate_barcode_page(product_id): return render_template('admin/generate_barcode.html', product_id=product_id, product=product_data, - dependencies=dependency_status) + dependencies=dependency_status, + tenant_id=tenant_id) + +def manage_credentials(tenant_id): + """Manage tenant login credentials""" + tenant = database.get_or_create_tenant(tenant_id) + + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + + if username and password: + database.update_tenant_credentials(tenant_id, username, password) + flash('Credentials updated successfully!') + return redirect(url_for('admin.index', tenant_id=tenant_id)) + else: + flash('Username and password are required.') + + return render_template('admin/credentials.html', tenant=tenant, tenant_id=tenant_id) diff --git a/src/routes/api.py b/src/routes/api.py index 8014fa2..285d1c8 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -1,8 +1,5 @@ -# API routes blueprint scaffold -from flask import Blueprint, jsonify +# API routes scaffold +from flask import jsonify -api_bp = Blueprint('api', __name__, url_prefix='/api') - -@api_bp.route('/') -def api_index(): - return jsonify({'message': 'API Home'}) +def api_index(tenant_id): + return jsonify({'message': 'API Home', 'tenant': tenant_id}) diff --git a/src/templates/admin/add_product.html b/src/templates/admin/add_product.html index 41a2c7d..2efc1fa 100644 --- a/src/templates/admin/add_product.html +++ b/src/templates/admin/add_product.html @@ -10,7 +10,7 @@

Add New Product

-
+
@@ -47,7 +47,7 @@
- Cancel + Cancel
diff --git a/src/templates/admin/credentials.html b/src/templates/admin/credentials.html new file mode 100644 index 0000000..63984ff --- /dev/null +++ b/src/templates/admin/credentials.html @@ -0,0 +1,92 @@ + + + + + + Manage Credentials - {{ tenant.name }} + + + + + +
+

Manage Login Credentials

+

These credentials will be used for KCAP authentication at /{{ tenant_id }}/login

+ + {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+
+

Current Credentials

+
+
+

Username: {{ tenant.username }}

+

Password: {{ tenant.password }}

+

Note: In production, passwords should be encrypted and not displayed.

+
+
+
+
+
+
+

Update Credentials

+
+
+
+
+ + +
+
+ + +
+ + Cancel +
+
+
+
+
+ +
+

Testing Instructions

+

To test the login endpoint with these credentials:

+
curl -u {{ tenant.username }}:{{ tenant.password }} http://{{ request.host }}/{{ tenant_id }}/login
+
+
+ + + + \ No newline at end of file diff --git a/src/templates/admin/edit_product.html b/src/templates/admin/edit_product.html index 19b9264..f085b0e 100644 --- a/src/templates/admin/edit_product.html +++ b/src/templates/admin/edit_product.html @@ -10,7 +10,7 @@

Edit Product

-
+
@@ -65,7 +65,7 @@
- Cancel + Cancel
diff --git a/src/templates/admin/generate_barcode.html b/src/templates/admin/generate_barcode.html index db9824c..0615bf5 100644 --- a/src/templates/admin/generate_barcode.html +++ b/src/templates/admin/generate_barcode.html @@ -8,7 +8,7 @@

Generate Barcodes for Product: {{ product_id }}

- Back to Products + Back to Products
{% if not dependencies.qrcode or not dependencies.barcode or not dependencies.pillow %} @@ -38,9 +38,9 @@

QR Code

- QR Code + QR Code

QR Code contains a link to the product AR info.

- Download QR Code + Download QR Code
@@ -51,9 +51,9 @@

EAN-13 Barcode

- EAN-13 Barcode + EAN-13 Barcode

Standard EAN-13 barcode format.

- Download EAN-13 + Download EAN-13
@@ -64,9 +64,9 @@

Code 128 Barcode

- Code 128 Barcode + Code 128 Barcode

High-density alphanumeric barcode.

- Download Code 128 + Download Code 128
diff --git a/src/templates/admin/index.html b/src/templates/admin/index.html index f0b1c0b..52079f8 100644 --- a/src/templates/admin/index.html +++ b/src/templates/admin/index.html @@ -1,13 +1,20 @@ {% extends "layout.html" %} -{% block title %}Admin Dashboard - KCAP Demo Server{% endblock %} +{% block title %}Admin Dashboard - {{ tenant.name }} - KCAP Demo Server{% endblock %} {% block content %}
+
+ Current Tenant: {{ tenant.name }} (ID: {{ tenant.id }}) + +

Product Management

- Add New Product + Add New Product
{% if products %} @@ -50,8 +57,8 @@ - diff --git a/src/templates/index.html b/src/templates/index.html index ebbb744..86cc56a 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -6,7 +6,7 @@
-

KCAP Demo Server

+

KCAP Demo Server{% if tenant %} - {{ tenant.name }}{% endif %}

A simple Flask API for demonstrating AR content retrieval for barcode scanning applications.


This server simulates the Knox Capture API for AR overlays and includes endpoints for managing product attributes.

@@ -14,25 +14,29 @@

API Endpoints

  • - Login: GET /login -

    Simulates a simple login with a 200 response (no authentication required).

    + Login: GET {% if tenant %}/{{ tenant.id }}{% endif %}/login +

    Authenticates using Basic Auth with tenant-specific credentials.

  • - Content Fields: GET /arcontentfields + Content Fields: GET {% if tenant %}/{{ tenant.id }}{% endif %}/arcontentfields

    Returns a list of available attributes (e.g., item ID, price, image URI).

  • - AR Info: GET /arinfo?barcode=123456 + AR Info: GET {% if tenant %}/{{ tenant.id }}{% endif %}/arinfo?barcode=123456

    Returns product details for a given barcode, including image URLs.

  • - Static Image Server: GET /images/123456.png -

    Serves image files from the static/images/ directory.

    + Static Image Server: GET {% if tenant %}/{{ tenant.id }}{% endif %}/images/123456.png +

    Serves product images stored in the database.

+ {% if tenant %} + Go to Admin Interface + {% else %} Go to Admin Interface + {% endif %}
diff --git a/src/templates/tenant_selection.html b/src/templates/tenant_selection.html new file mode 100644 index 0000000..1cb4c5f --- /dev/null +++ b/src/templates/tenant_selection.html @@ -0,0 +1,75 @@ + + + + + + Select or Create Tenant - KCAP Demo Server + + + +
+

KCAP Demo Server - Multi-Tenant

+ +
+
+
+
+

Select an Existing Tenant

+
+
+ {% if tenants %} + + {% else %} +

No tenants exist yet. Create one below!

+ {% endif %} +
+
+ +
+
+

Create a New Tenant

+
+
+
+
+ + + Only letters, numbers, hyphens, and underscores allowed +
+ +
+
+
+ +
+

+ Note: When you access a tenant URL directly (e.g., /my-tenant/), + it will be automatically created with default credentials (admin/admin). +

+
+
+
+
+ + + + \ No newline at end of file From ac07f5b7c849dde96aed059c66d66677d566354c Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 19 Jun 2025 14:35:13 -0400 Subject: [PATCH 06/19] ability to delete a tenant --- src/app.py | 17 ++++++++++- src/database.py | 42 +++++++++++++++++++++++++-- src/templates/tenant_selection.html | 45 ++++++++++++++++++++++++----- 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/src/app.py b/src/app.py index a154996..b172660 100644 --- a/src/app.py +++ b/src/app.py @@ -30,10 +30,20 @@ def index(): tenants = database.get_all_tenants() return render_template('tenant_selection.html', tenants=tenants) +@app.route('/tenant//delete', methods=['POST']) +def delete_tenant(tenant_id): + """Delete a tenant and all its data""" + database.delete_tenant(tenant_id) + flash(f'Tenant "{tenant_id}" has been deleted successfully.', 'success') + return redirect('/') + @app.route('//') def tenant_index(tenant_id): # Auto-create tenant if it doesn't exist tenant = database.get_or_create_tenant(tenant_id) + if tenant is None: + # Reserved tenant ID or invalid + return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404 return render_template('index.html', tenant=tenant) def check_basic_auth(auth_header, tenant_id): @@ -48,7 +58,7 @@ def check_basic_auth(auth_header, tenant_id): # Get tenant credentials from database tenant = database.get_or_create_tenant(tenant_id) - if username == tenant['username'] and password == tenant['password']: + if tenant and username == tenant['username'] and password == tenant['password']: return True except Exception: pass @@ -228,4 +238,9 @@ if __name__ == '__main__': shutil.move(products_file, products_file + '.migrated') print("Migration complete.") + # Clean up any accidentally created reserved tenants + deleted_count = database.cleanup_reserved_tenants() + if deleted_count > 0: + print(f"Cleaned up {deleted_count} reserved tenant(s)") + app.run(port=5555, host="0.0.0.0", debug=True) diff --git a/src/database.py b/src/database.py index a759c82..81f530f 100644 --- a/src/database.py +++ b/src/database.py @@ -7,6 +7,13 @@ import base64 DATABASE_PATH = os.path.join(os.path.dirname(__file__), 'data', 'products.db') +# Reserved tenant IDs that cannot be used +RESERVED_TENANT_IDS = { + 'admin', 'api', 'login', 'logout', 'arcontentfields', 'arinfo', + 'images', 'barcodes', 'static', 'assets', 'js', 'css', 'tenant', + 'auth', 'oauth', 'callback', 'webhook', 'health', 'status' +} + @contextmanager def get_db(): """Context manager for database connections""" @@ -303,8 +310,12 @@ def get_product_image(product_id: str, tenant_id: str) -> Optional[tuple[bytes, return row['image_data'], row['image_mime_type'] return None -def get_or_create_tenant(tenant_id: str, username: str = None, password: str = None) -> Dict[str, Any]: +def get_or_create_tenant(tenant_id: str, username: str = None, password: str = None) -> Optional[Dict[str, Any]]: """Get or create a tenant""" + # Check if tenant_id is reserved + if tenant_id.lower() in RESERVED_TENANT_IDS: + return None + with get_db() as conn: cursor = conn.cursor() @@ -362,4 +373,31 @@ def get_all_tenants() -> List[Dict[str, Any]]: 'created_at': row['created_at'] }) - return tenants \ No newline at end of file + return tenants + +def delete_tenant(tenant_id: str): + """Delete a tenant and all associated data""" + with get_db() as conn: + cursor = conn.cursor() + # Due to ON DELETE CASCADE, this will also delete all products and product_fields + cursor.execute('DELETE FROM tenants WHERE id = ?', (tenant_id,)) + conn.commit() + +def cleanup_reserved_tenants(): + """Remove any tenants that were accidentally created with reserved IDs""" + with get_db() as conn: + cursor = conn.cursor() + # Get all tenants + cursor.execute('SELECT id FROM tenants') + tenants = cursor.fetchall() + + deleted_count = 0 + for tenant in tenants: + tenant_id = tenant['id'] + if tenant_id.lower() in RESERVED_TENANT_IDS: + cursor.execute('DELETE FROM tenants WHERE id = ?', (tenant_id,)) + deleted_count += 1 + print(f"Deleted reserved tenant: {tenant_id}") + + conn.commit() + return deleted_count \ No newline at end of file diff --git a/src/templates/tenant_selection.html b/src/templates/tenant_selection.html index 1cb4c5f..427672a 100644 --- a/src/templates/tenant_selection.html +++ b/src/templates/tenant_selection.html @@ -10,6 +10,17 @@

KCAP Demo Server - Multi-Tenant

+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+ \ No newline at end of file From 000c054734669ffcee603448af1f1c3d19d38dbe Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 19 Jun 2025 15:46:22 -0400 Subject: [PATCH 07/19] fixing field edits and adding custom fileds --- src/app.py | 64 +++-- src/database.py | 156 +++++++++++- src/routes/admin.py | 346 ++++++++++++++++++++------ src/templates/admin/add_product.html | 60 +++-- src/templates/admin/ar_fields.html | 170 +++++++++++++ src/templates/admin/edit_product.html | 114 +++++---- src/templates/admin/index.html | 1 + src/templates/index.html | 61 +++-- 8 files changed, 792 insertions(+), 180 deletions(-) create mode 100644 src/templates/admin/ar_fields.html diff --git a/src/app.py b/src/app.py index b172660..1ab0ce9 100644 --- a/src/app.py +++ b/src/app.py @@ -76,12 +76,8 @@ def login(tenant_id): @app.route('//arcontentfields', methods=['GET']) def get_ar_content_fields(tenant_id): - # Return a fixed set of attributes (tenant_id could be used for custom fields in the future) - fields = [ - {"fieldName": "_id", "label": "Item ID", "editable": "false", "fieldType": "TEXT"}, - {"fieldName": "_price", "label": "Sale Price", "editable": "true", "fieldType": "TEXT"}, - {"fieldName": "_image", "label": "Image", "editable": "false", "fieldType": "IMAGE_URI"} - ] + # Get custom fields defined for this tenant + fields = database.get_custom_ar_fields(tenant_id) return jsonify(fields), 200 @app.route('//arinfo', methods=['GET']) @@ -89,31 +85,39 @@ def get_ar_info(tenant_id): barcode = request.args.get('barcode') products = load_products(tenant_id) - # Helper function to convert relative image paths to absolute URLs - def make_absolute_urls(product_fields): - # Create absolute URL for image fields + # Get custom AR fields for this tenant + custom_fields = database.get_custom_ar_fields(tenant_id) + custom_field_names = [f['fieldName'] for f in custom_fields] + + # Helper function to convert relative image paths to absolute URLs and filter fields + def filter_and_process_fields(product_fields): + # Filter to only include fields defined in custom AR fields + filtered_fields = [] for field in product_fields: - if field['fieldName'] == '_image' and field['value']: - # If it's already an absolute URL, leave it as is - if not field['value'].startswith('http'): - # Build absolute URL using request host with tenant - field['value'] = f"{request.url_root.rstrip('/')}/{tenant_id}{field['value']}" - return product_fields + if field['fieldName'] in custom_field_names: + # Create absolute URL for image fields + if field['fieldName'] == '_image' and field['value']: + # If it's already an absolute URL, leave it as is + if not field['value'].startswith('http'): + # Build absolute URL using request host with tenant + field['value'] = f"{request.url_root.rstrip('/')}/{tenant_id}{field['value']}" + filtered_fields.append(field) + return filtered_fields # If barcode is provided, return specific product if barcode: if barcode in products: - product_data = make_absolute_urls(products[barcode]) + product_data = filter_and_process_fields(products[barcode]) response = jsonify(product_data) response.headers['Access-Control-Allow-Origin'] = '*' return response, 200 return jsonify({"error": "Product not found"}), 404 # Return all products if no barcode specified - # Convert all products to have absolute URLs + # Convert all products to have absolute URLs and filter fields all_products = {} for product_id, fields in products.items(): - all_products[product_id] = make_absolute_urls(fields) + all_products[product_id] = filter_and_process_fields(fields) response = jsonify(all_products) response.headers['Access-Control-Allow-Origin'] = '*' return response, 200 @@ -123,7 +127,26 @@ def serve_image(tenant_id, filename): # Log the request for debugging app.logger.info(f"Image requested for tenant {tenant_id}: {filename}") - # Extract product ID from filename (e.g., "123456.jpg" -> "123456") + # Check if filename contains field name (e.g., "123456_thumbnail.jpg") + base_name = os.path.splitext(filename)[0] + + if '_' in base_name: + # Split to get product_id and field_name + parts = base_name.split('_', 1) + product_id = parts[0] + field_name = '_' + parts[1] if len(parts) > 1 else '_image' + + # Try to get field-specific image + image_data = database.get_product_image_by_field(product_id, tenant_id, field_name) + if image_data: + image_bytes, mime_type = image_data + response = Response(image_bytes, mimetype=mime_type) + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Cache-Control'] = 'public, max-age=3600' + app.logger.info(f"Serving field-specific image: {product_id}/{field_name} ({len(image_bytes)} bytes)") + return response + + # Try standard image lookup (backward compatibility) product_id = os.path.splitext(filename)[0] # Get image from database for this tenant @@ -203,7 +226,7 @@ def serve_barcode(tenant_id, filename): # Import the admin routes directly and register them with tenant support from routes.admin import (index as admin_index, add_product, edit_product, delete_product, view_product, generate_barcode, - generate_barcode_page, manage_credentials) + generate_barcode_page, manage_credentials, manage_ar_fields) # Register admin routes with tenant prefix app.add_url_rule('//admin/', 'admin.index', admin_index) @@ -214,6 +237,7 @@ app.add_url_rule('//admin/view/', 'admin.view_prod app.add_url_rule('//admin/generate_barcode//', 'admin.generate_barcode', generate_barcode) app.add_url_rule('//admin/generate_barcode_page/', 'admin.generate_barcode_page', generate_barcode_page) app.add_url_rule('//admin/credentials', 'admin.manage_credentials', manage_credentials, methods=['GET', 'POST']) +app.add_url_rule('//admin/ar_fields', 'admin.manage_ar_fields', manage_ar_fields, methods=['GET', 'POST']) # Register API routes with tenant prefix from routes.api import api_index diff --git a/src/database.py b/src/database.py index 81f530f..d8692ec 100644 --- a/src/database.py +++ b/src/database.py @@ -75,6 +75,38 @@ def init_database(): ) ''') + # Create custom_ar_fields table for tenant-specific AR field definitions + cursor.execute(''' + CREATE TABLE IF NOT EXISTS custom_ar_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id TEXT NOT NULL, + field_name TEXT NOT NULL, + label TEXT NOT NULL, + field_type TEXT NOT NULL, + editable TEXT DEFAULT 'true', + display_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + UNIQUE(tenant_id, field_name) + ) + ''') + + # Create product_images table for storing multiple images per product + cursor.execute(''' + CREATE TABLE IF NOT EXISTS product_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + field_name TEXT NOT NULL, + image_data BLOB, + image_mime_type TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (product_id, tenant_id) REFERENCES products(id, tenant_id) ON DELETE CASCADE, + UNIQUE(product_id, tenant_id, field_name) + ) + ''') + conn.commit() def migrate_from_json(): @@ -340,6 +372,9 @@ def get_or_create_tenant(tenant_id: str, username: str = None, password: str = N ''', (tenant_id, tenant_id.title(), default_username, default_password)) conn.commit() + # Initialize default AR fields for the new tenant + init_default_ar_fields(tenant_id) + return { 'id': tenant_id, 'name': tenant_id.title(), @@ -400,4 +435,123 @@ def cleanup_reserved_tenants(): print(f"Deleted reserved tenant: {tenant_id}") conn.commit() - return deleted_count \ No newline at end of file + return deleted_count + +def get_custom_ar_fields(tenant_id: str) -> List[Dict[str, Any]]: + """Get custom AR fields for a tenant""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT id, field_name, label, field_type, editable, display_order + FROM custom_ar_fields + WHERE tenant_id = ? + ORDER BY display_order, field_name + ''', (tenant_id,)) + + fields = [] + for row in cursor.fetchall(): + fields.append({ + 'id': row['id'], + 'fieldName': row['field_name'], + 'label': row['label'], + 'fieldType': row['field_type'], + 'editable': row['editable'], + 'displayOrder': row['display_order'] + }) + + return fields + +def save_custom_ar_field(tenant_id: str, field_data: Dict[str, Any]) -> int: + """Save or update a custom AR field""" + with get_db() as conn: + cursor = conn.cursor() + + if 'id' in field_data: + # Update existing field + cursor.execute(''' + UPDATE custom_ar_fields + SET field_name = ?, label = ?, field_type = ?, editable = ?, + display_order = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND tenant_id = ? + ''', ( + field_data['fieldName'], + field_data['label'], + field_data['fieldType'], + field_data.get('editable', 'true'), + field_data.get('displayOrder', 0), + field_data['id'], + tenant_id + )) + conn.commit() + return field_data['id'] + else: + # Insert new field + cursor.execute(''' + INSERT INTO custom_ar_fields + (tenant_id, field_name, label, field_type, editable, display_order) + VALUES (?, ?, ?, ?, ?, ?) + ''', ( + tenant_id, + field_data['fieldName'], + field_data['label'], + field_data['fieldType'], + field_data.get('editable', 'true'), + field_data.get('displayOrder', 0) + )) + conn.commit() + return cursor.lastrowid + +def delete_custom_ar_field(tenant_id: str, field_id: int): + """Delete a custom AR field""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + DELETE FROM custom_ar_fields + WHERE id = ? AND tenant_id = ? + ''', (field_id, tenant_id)) + conn.commit() + +def init_default_ar_fields(tenant_id: str): + """Initialize default AR fields for a new tenant""" + default_fields = [ + {"fieldName": "_id", "label": "Item ID", "fieldType": "TEXT", "editable": "false", "displayOrder": 1}, + {"fieldName": "_price", "label": "Sale Price", "fieldType": "TEXT", "editable": "true", "displayOrder": 2}, + {"fieldName": "_image", "label": "Image", "fieldType": "IMAGE_URI", "editable": "false", "displayOrder": 3} + ] + + for field in default_fields: + save_custom_ar_field(tenant_id, field) + +def save_product_image(product_id: str, tenant_id: str, field_name: str, image_data: bytes, mime_type: str): + """Save an image for a specific field of a product""" + with get_db() as conn: + cursor = conn.cursor() + + # Delete existing image for this field if any + cursor.execute(''' + DELETE FROM product_images + WHERE product_id = ? AND tenant_id = ? AND field_name = ? + ''', (product_id, tenant_id, field_name)) + + # Insert new image + cursor.execute(''' + INSERT INTO product_images (product_id, tenant_id, field_name, image_data, image_mime_type) + VALUES (?, ?, ?, ?, ?) + ''', (product_id, tenant_id, field_name, image_data, mime_type)) + + conn.commit() + +def get_product_image_by_field(product_id: str, tenant_id: str, field_name: str) -> Optional[tuple[bytes, str]]: + """Get image data for a specific field of a product""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT image_data, image_mime_type + FROM product_images + WHERE product_id = ? AND tenant_id = ? AND field_name = ? + ''', (product_id, tenant_id, field_name)) + + row = cursor.fetchone() + if row and row['image_data']: + return row['image_data'], row['image_mime_type'] + return None \ No newline at end of file diff --git a/src/routes/admin.py b/src/routes/admin.py index cf49501..7bb087e 100644 --- a/src/routes/admin.py +++ b/src/routes/admin.py @@ -41,119 +41,264 @@ def index(tenant_id): return render_template('admin/index.html', products=products, tenant=tenant) def add_product(tenant_id): + # Get custom AR fields for this tenant + custom_fields = database.get_custom_ar_fields(tenant_id) + if request.method == 'POST': # Get form data product_id = request.form.get('product_id') - name = request.form.get('name') - price = request.form.get('price') # Basic validation - if not product_id or not name or not price: - flash('Product ID, Name, and Price are required fields.') - return render_template('admin/add_product.html', tenant_id=tenant_id) + if not product_id: + flash('Product ID is required.') + return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields) # Check if product ID already exists products = load_products(tenant_id) if product_id in products: flash('Product ID already exists.') - return render_template('admin/add_product.html', tenant_id=tenant_id) + return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields) - # Handle image upload + # Handle backward compatibility image upload image_data = None image_mime_type = None - image_path = f"/images/{product_id}.png" # Default path for display - if 'image' in request.files: - file = request.files['image'] - if file and file.filename and allowed_file(file.filename): - extension = file.filename.rsplit('.', 1)[1].lower() - image_path = f"/images/{product_id}.{extension}" - - # Read image data - image_data = file.read() - - # Determine mime type - mime_types = { - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'png': 'image/png', - 'gif': 'image/gif' - } - image_mime_type = mime_types.get(extension, 'image/jpeg') + # Create product data structure based on custom fields + product_data = [] - # Create product data structure - product_data = [ - {"fieldName": "_id", "label": "Item ID", "value": product_id, "editable": "false", "fieldType": "TEXT"}, - {"fieldName": "_name", "label": "Product Name", "value": name, "editable": "true", "fieldType": "TEXT"}, - {"fieldName": "_price", "label": "Sale Price", "value": f"${price}", "editable": "true", "fieldType": "TEXT"}, - {"fieldName": "_image", "label": "Image", "value": image_path, "editable": "false", "fieldType": "IMAGE_URI"} - ] + # Always include _id field + product_data.append({ + "fieldName": "_id", + "label": "Item ID", + "value": product_id, + "editable": "false", + "fieldType": "TEXT" + }) + + # Process images after product is saved + images_to_save = [] + + # Add other fields based on custom configuration + for field in custom_fields: + field_name = field['fieldName'] + if field_name == '_id': + continue # Already added + elif field['fieldType'] == 'IMAGE_URI': + # Check if image was uploaded for this field + image_field_name = f'image_{field_name}' + if image_field_name in request.files: + file = request.files[image_field_name] + if file and file.filename and allowed_file(file.filename): + # Read image data + file.seek(0) + img_data = file.read() + + # Determine mime type + extension = file.filename.rsplit('.', 1)[1].lower() + mime_types = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif' + } + mime_type = mime_types.get(extension, 'image/jpeg') + + # Store for later saving + images_to_save.append({ + 'field_name': field_name, + 'data': img_data, + 'mime_type': mime_type + }) + + # Set the image URL path + image_path = f"/images/{product_id}_{field_name}.{extension}" + + # For backward compatibility with _image field + if field_name == '_image': + image_data = img_data + image_mime_type = mime_type + else: + image_path = "" + else: + image_path = "" + + product_data.append({ + "fieldName": field_name, + "label": field['label'], + "value": image_path, + "editable": field['editable'], + "fieldType": field['fieldType'] + }) + else: + # Get value from form + value = request.form.get(f'field_{field_name}', '') + product_data.append({ + "fieldName": field_name, + "label": field['label'], + "value": value, + "editable": field['editable'], + "fieldType": field['fieldType'] + }) + + # Also save fields that aren't in custom fields (for backward compatibility) + if '_name' not in [f['fieldName'] for f in custom_fields]: + name = request.form.get('name', '') + if name: + product_data.append({ + "fieldName": "_name", + "label": "Product Name", + "value": name, + "editable": "true", + "fieldType": "TEXT" + }) + + if '_price' not in [f['fieldName'] for f in custom_fields]: + price = request.form.get('price', '') + if price: + product_data.append({ + "fieldName": "_price", + "label": "Sale Price", + "value": f"${price}", + "editable": "true", + "fieldType": "TEXT" + }) # Save to database database.save_product(product_id, tenant_id, product_data, image_data, image_mime_type) + # Save additional images + for img in images_to_save: + database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type']) + flash('Product added successfully!') return redirect(url_for('admin.index', tenant_id=tenant_id)) - return render_template('admin/add_product.html', tenant_id=tenant_id) + return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields) def edit_product(tenant_id, product_id): products = load_products(tenant_id) + custom_fields = database.get_custom_ar_fields(tenant_id) if product_id not in products: flash('Product not found.') return redirect(url_for('admin.index', tenant_id=tenant_id)) if request.method == 'POST': - # Get form data - name = request.form.get('name') - price = request.form.get('price') - - # Basic validation - if not name or not price: - flash('Name and Price are required fields.') - return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id) - - # Update name and price - for field in products[product_id]: - if field["fieldName"] == "_name": - field["value"] = name - elif field["fieldName"] == "_price": - field["value"] = f"${price}" - - # Handle image upload + # Handle backward compatibility image upload image_data = None image_mime_type = None - if 'image' in request.files and request.files['image'].filename: - file = request.files['image'] - if allowed_file(file.filename): - # Read new image data - image_data = file.read() + # Process images after product is saved + images_to_save = [] + + # Update fields based on form data + updated_product = [] + + # Always include _id field + for field in products[product_id]: + if field["fieldName"] == "_id": + updated_product.append(field) + break + + # Process each custom field + for custom_field in custom_fields: + field_name = custom_field['fieldName'] + if field_name == '_id': + continue - # Determine mime type - extension = file.filename.rsplit('.', 1)[1].lower() - mime_types = { - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'png': 'image/png', - 'gif': 'image/gif' + # Find existing field data + existing_field = None + for field in products[product_id]: + if field["fieldName"] == field_name: + existing_field = field.copy() + break + + if not existing_field: + existing_field = { + "fieldName": field_name, + "label": custom_field['label'], + "value": "", + "editable": custom_field['editable'], + "fieldType": custom_field['fieldType'] } - image_mime_type = mime_types.get(extension, 'image/jpeg') - - # Update image path in product data - image_path = f"/images/{product_id}.{extension}" + + if custom_field['fieldType'] == 'IMAGE_URI': + # Check if new image was uploaded + image_field_name = f'image_{field_name}' + if image_field_name in request.files and request.files[image_field_name].filename: + file = request.files[image_field_name] + if allowed_file(file.filename): + # Read image data + file.seek(0) + img_data = file.read() + + # Determine mime type + extension = file.filename.rsplit('.', 1)[1].lower() + mime_types = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif' + } + mime_type = mime_types.get(extension, 'image/jpeg') + + # Store for later saving + images_to_save.append({ + 'field_name': field_name, + 'data': img_data, + 'mime_type': mime_type + }) + + # Update image path + existing_field["value"] = f"/images/{product_id}_{field_name}.{extension}" + + # For backward compatibility with _image field + if field_name == '_image': + image_data = img_data + image_mime_type = mime_type + else: + # Get value from form + value = request.form.get(f'field_{field_name}', '') + if field_name == '_price' and value and not value.startswith('$'): + value = f"${value}" + existing_field["value"] = value + + updated_product.append(existing_field) + + # Handle backward compatibility fields + if '_name' not in [f['fieldName'] for f in custom_fields]: + name = request.form.get('name', '') + if name: for field in products[product_id]: - if field["fieldName"] == "_image": - field["value"] = image_path + if field["fieldName"] == "_name": + field["value"] = name + updated_product.append(field) + break + + if '_price' not in [f['fieldName'] for f in custom_fields]: + price = request.form.get('price', '') + if price: + for field in products[product_id]: + if field["fieldName"] == "_price": + field["value"] = f"${price}" + updated_product.append(field) + break # Save to database - database.save_product(product_id, tenant_id, products[product_id], image_data, image_mime_type) + database.save_product(product_id, tenant_id, updated_product, image_data, image_mime_type) + + # Save additional images + for img in images_to_save: + database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type']) flash('Product updated successfully!') return redirect(url_for('admin.index', tenant_id=tenant_id)) - return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id) + return render_template('admin/edit_product.html', + product_id=product_id, + product=products[product_id], + tenant_id=tenant_id, + custom_fields=custom_fields) def delete_product(tenant_id, product_id): products = load_products(tenant_id) @@ -245,3 +390,64 @@ def manage_credentials(tenant_id): flash('Username and password are required.') return render_template('admin/credentials.html', tenant=tenant, tenant_id=tenant_id) + +def manage_ar_fields(tenant_id): + """Manage custom AR content fields""" + tenant = database.get_or_create_tenant(tenant_id) + + if request.method == 'POST': + action = request.form.get('action') + + if action == 'add': + field_data = { + 'fieldName': request.form.get('fieldName'), + 'label': request.form.get('label'), + 'fieldType': request.form.get('fieldType'), + 'editable': request.form.get('editable', 'true'), + 'displayOrder': int(request.form.get('displayOrder', 0)) + } + + if field_data['fieldName'] and field_data['label']: + database.save_custom_ar_field(tenant_id, field_data) + flash('Custom field added successfully!') + else: + flash('Field name and label are required.') + + elif action == 'delete': + field_id = request.form.get('field_id') + if field_id: + database.delete_custom_ar_field(tenant_id, int(field_id)) + flash('Custom field deleted successfully!') + + elif action == 'update': + field_data = { + 'id': int(request.form.get('field_id')), + 'fieldName': request.form.get('fieldName'), + 'label': request.form.get('label'), + 'fieldType': request.form.get('fieldType'), + 'editable': request.form.get('editable', 'true'), + 'displayOrder': int(request.form.get('displayOrder', 0)) + } + + if field_data['fieldName'] and field_data['label']: + database.save_custom_ar_field(tenant_id, field_data) + flash('Custom field updated successfully!') + else: + flash('Field name and label are required.') + + return redirect(url_for('admin.manage_ar_fields', tenant_id=tenant_id)) + + # Get existing custom fields + custom_fields = database.get_custom_ar_fields(tenant_id) + + # Available field types + field_types = [ + 'TEXT', + 'IMAGE_URI' + ] + + return render_template('admin/ar_fields.html', + tenant=tenant, + tenant_id=tenant_id, + custom_fields=custom_fields, + field_types=field_types) diff --git a/src/templates/admin/add_product.html b/src/templates/admin/add_product.html index 2efc1fa..099495a 100644 --- a/src/templates/admin/add_product.html +++ b/src/templates/admin/add_product.html @@ -22,29 +22,49 @@
Must be unique. This will be used as the barcode for AR content.
-
- - -
Enter a descriptive name for the product.
-
+ {% for field in custom_fields %} + {% if field.fieldName != '_id' %} +
+ + {% if field.fieldType == 'IMAGE_URI' %} + +
Supported formats: .png, .jpg, .jpeg, .gif
+
+ +
+ {% elif field.fieldType == 'TEXT' %} + {% if field.fieldName == '_price' %} +
+ $ + +
+ {% else %} + + {% endif %} + {% else %} + + {% endif %} +
+ {% endif %} + {% endfor %} + + {% if not custom_fields|selectattr('fieldName', 'equalto', '_name')|list %}
- + + +
+ {% endif %} + + {% if not custom_fields|selectattr('fieldName', 'equalto', '_price')|list %} +
+
$ - -
-
Enter the price in decimal format (e.g., 49.99)
-
- -
- - -
Supported formats: .png, .jpg, .jpeg, .gif
-
- +
+ {% endif %}
Cancel @@ -59,8 +79,8 @@ {% block extra_js %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/templates/admin/ar_fields.html b/src/templates/admin/ar_fields.html new file mode 100644 index 0000000..b49f93b --- /dev/null +++ b/src/templates/admin/ar_fields.html @@ -0,0 +1,170 @@ +{% extends "layout.html" %} + +{% block title %}Manage AR Fields - KCAP Admin{% endblock %} + +{% block content %} +
+
+

Manage AR Content Fields

+

Configure the fields that will be returned by the /arcontentfields and /arinfo endpoints.

+
+ Back to Admin +
+ +
+
+
Current AR Fields
+
+
+ {% if custom_fields %} + + + + + + + + + + + + + {% for field in custom_fields %} + + + + + + + + + {% endfor %} + +
Field NameLabelTypeEditableDisplay OrderActions
{{ field.fieldName }}{{ field.label }}{{ field.fieldType }}{{ field.editable }}{{ field.displayOrder }} + +
+ + + +
+
+ {% else %} +

No custom fields defined. Default fields will be used.

+ {% endif %} +
+
+ +
+
+
Add/Edit AR Field
+
+
+
+ + + +
+ + + Use underscore prefix for system fields (e.g., _id, _price) + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/src/templates/admin/edit_product.html b/src/templates/admin/edit_product.html index f085b0e..e7f85ad 100644 --- a/src/templates/admin/edit_product.html +++ b/src/templates/admin/edit_product.html @@ -17,52 +17,80 @@
Product ID cannot be changed.
-
- + {% for field in custom_fields %} + {% if field.fieldName != '_id' %} +
+ + + {% set current_value = '' %} + {% for prod_field in product %} + {% if prod_field.fieldName == field.fieldName %} + {% set current_value = prod_field.value %} + {% endif %} + {% endfor %} + + {% if field.fieldType == 'IMAGE_URI' %} + +
Leave empty to keep the current image.
+ + {% if current_value %} +
+ +
+ Current image +
+
+ {% endif %} + +
+ + +
+ {% elif field.fieldType == 'TEXT' %} + {% if field.fieldName == '_price' %} +
+ $ + {% set price_value = current_value|replace('$', '') %} + +
+ {% else %} + + {% endif %} + {% else %} + + {% endif %} +
+ {% endif %} + {% endfor %} + + + {% if not custom_fields|selectattr('fieldName', 'equalto', '_name')|list %} {% for field in product %} {% if field.fieldName == '_name' %} - +
+ + +
{% endif %} {% endfor %} -
Enter a descriptive name for the product.
-
+ {% endif %} -
- -
- $ - {% for field in product %} - {% if field.fieldName == '_price' %} + {% if not custom_fields|selectattr('fieldName', 'equalto', '_price')|list %} + {% for field in product %} + {% if field.fieldName == '_price' %} +
+ +
+ $ {% set price_value = field.value|replace('$', '') %} - - {% endif %} - {% endfor %} -
-
Enter the price in decimal format (e.g., 49.99)
-
- -
- - -
Leave empty to keep the current image.
- -
- - {% for field in product %} - {% if field.fieldName == '_image' %} -
- Current product image -
- {% endif %} - {% endfor %} -
- -
- - -
-
+ +
+
+ {% endif %} + {% endfor %} + {% endif %}
Cancel @@ -77,8 +105,8 @@ {% block extra_js %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/templates/admin/index.html b/src/templates/admin/index.html index 52079f8..4371b28 100644 --- a/src/templates/admin/index.html +++ b/src/templates/admin/index.html @@ -9,6 +9,7 @@ Current Tenant: {{ tenant.name }} (ID: {{ tenant.id }})
diff --git a/src/templates/index.html b/src/templates/index.html index 86cc56a..b20730a 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -7,36 +7,45 @@

KCAP Demo Server{% if tenant %} - {{ tenant.name }}{% endif %}

-

A simple Flask API for demonstrating AR content retrieval for barcode scanning applications.

+

Manage your product catalog and AR content for barcode scanning applications.


-

This server simulates the Knox Capture API for AR overlays and includes endpoints for managing product attributes.

+
-

API Endpoints

-
    -
  • - Login: GET {% if tenant %}/{{ tenant.id }}{% endif %}/login -

    Authenticates using Basic Auth with tenant-specific credentials.

    -
  • -
  • - Content Fields: GET {% if tenant %}/{{ tenant.id }}{% endif %}/arcontentfields -

    Returns a list of available attributes (e.g., item ID, price, image URI).

    -
  • -
  • - AR Info: GET {% if tenant %}/{{ tenant.id }}{% endif %}/arinfo?barcode=123456 -

    Returns product details for a given barcode, including image URLs.

    -
  • -
  • - Static Image Server: GET {% if tenant %}/{{ tenant.id }}{% endif %}/images/123456.png -

    Serves product images stored in the database.

    -
  • -
+

Quick Actions

+
+
+
+
+
Product Management
+

Add, edit, and manage your product catalog with custom fields.

+ {% if tenant %} + Manage Products + {% endif %} +
+
+
+
+
+
+
AR Field Configuration
+

Customize the fields returned by AR content endpoints.

+ {% if tenant %} + Configure AR Fields + {% endif %} +
+
+
+
+
- {% if tenant %} - Go to Admin Interface - {% else %} - Go to Admin Interface - {% endif %} +

Getting Started

+
    +
  1. Configure your AR fields to define what information is returned for products
  2. +
  3. Add products to your catalog with the configured fields
  4. +
  5. Generate barcodes for your products
  6. +
  7. Use the AR endpoints in your scanning application
  8. +
From e744afbe26c66f5878ee2e630db6789e0f55a556 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 19 Jun 2025 16:03:50 -0400 Subject: [PATCH 08/19] adding server settings --- src/app.py | 23 ++++++++-- src/database.py | 70 +++++++++++++++++++++++++++-- src/templates/settings.html | 52 +++++++++++++++++++++ src/templates/tenant_selection.html | 2 + 4 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 src/templates/settings.html diff --git a/src/app.py b/src/app.py index 1ab0ce9..f94377d 100644 --- a/src/app.py +++ b/src/app.py @@ -28,7 +28,8 @@ def load_products(tenant_id=None): def index(): # Show a tenant selection page or redirect to default tenants = database.get_all_tenants() - return render_template('tenant_selection.html', tenants=tenants) + server_url = database.get_server_url() + return render_template('tenant_selection.html', tenants=tenants, server_url=server_url) @app.route('/tenant//delete', methods=['POST']) def delete_tenant(tenant_id): @@ -37,6 +38,22 @@ def delete_tenant(tenant_id): flash(f'Tenant "{tenant_id}" has been deleted successfully.', 'success') return redirect('/') +@app.route('/settings', methods=['GET', 'POST']) +def settings(): + if request.method == 'POST': + server_url = request.form.get('server_url', '').strip() + if server_url: + # Remove trailing slash for consistency + server_url = server_url.rstrip('/') + database.set_setting('server_url', server_url) + flash('Server settings updated successfully.', 'success') + else: + flash('Please provide a valid server URL.', 'error') + return redirect('/settings') + + server_url = database.get_server_url() + return render_template('settings.html', server_url=server_url) + @app.route('//') def tenant_index(tenant_id): # Auto-create tenant if it doesn't exist @@ -56,8 +73,8 @@ def check_basic_auth(auth_header, tenant_id): credentials = base64.b64decode(auth_header[6:]).decode('utf-8') username, password = credentials.split(':', 1) - # Get tenant credentials from database - tenant = database.get_or_create_tenant(tenant_id) + # Get tenant credentials from database (without creating) + tenant = database.get_tenant(tenant_id) if tenant and username == tenant['username'] and password == tenant['password']: return True except Exception: diff --git a/src/database.py b/src/database.py index d8692ec..836f9a6 100644 --- a/src/database.py +++ b/src/database.py @@ -107,6 +107,16 @@ def init_database(): ) ''') + # Create settings table for server configuration + cursor.execute(''' + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + conn.commit() def migrate_from_json(): @@ -342,10 +352,34 @@ def get_product_image(product_id: str, tenant_id: str) -> Optional[tuple[bytes, return row['image_data'], row['image_mime_type'] return None +def get_tenant(tenant_id: str) -> Optional[Dict[str, Any]]: + """Get a tenant without creating it""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + + with get_db() as conn: + cursor = conn.cursor() + + # Check if tenant exists + cursor.execute('SELECT id, name, username, password FROM tenants WHERE id = ?', (tenant_id,)) + row = cursor.fetchone() + + if row: + return { + 'id': row['id'], + 'name': row['name'], + 'username': row['username'], + 'password': row['password'] + } + return None + def get_or_create_tenant(tenant_id: str, username: str = None, password: str = None) -> Optional[Dict[str, Any]]: """Get or create a tenant""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + # Check if tenant_id is reserved - if tenant_id.lower() in RESERVED_TENANT_IDS: + if tenant_id in RESERVED_TENANT_IDS: return None with get_db() as conn: @@ -366,10 +400,12 @@ def get_or_create_tenant(tenant_id: str, username: str = None, password: str = N # Create new tenant with default credentials default_username = username or 'admin' default_password = password or 'admin' + # Preserve original casing for display name + display_name = tenant_id.replace('-', ' ').replace('_', ' ').title() cursor.execute(''' INSERT INTO tenants (id, name, username, password) VALUES (?, ?, ?, ?) - ''', (tenant_id, tenant_id.title(), default_username, default_password)) + ''', (tenant_id, display_name, default_username, default_password)) conn.commit() # Initialize default AR fields for the new tenant @@ -377,7 +413,7 @@ def get_or_create_tenant(tenant_id: str, username: str = None, password: str = N return { 'id': tenant_id, - 'name': tenant_id.title(), + 'name': display_name, 'username': default_username, 'password': default_password } @@ -554,4 +590,30 @@ def get_product_image_by_field(product_id: str, tenant_id: str, field_name: str) row = cursor.fetchone() if row and row['image_data']: return row['image_data'], row['image_mime_type'] - return None \ No newline at end of file + return None + +def get_setting(key: str, default_value: str = None) -> Optional[str]: + """Get a setting value by key""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute('SELECT value FROM settings WHERE key = ?', (key,)) + row = cursor.fetchone() + + if row: + return row['value'] + return default_value + +def set_setting(key: str, value: str): + """Set a setting value""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO settings (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP + ''', (key, value, value)) + conn.commit() + +def get_server_url() -> str: + """Get the configured server URL or return a default""" + return get_setting('server_url', 'http://localhost:5000') \ No newline at end of file diff --git a/src/templates/settings.html b/src/templates/settings.html new file mode 100644 index 0000000..0cc6697 --- /dev/null +++ b/src/templates/settings.html @@ -0,0 +1,52 @@ + + + + + + Server Settings - KCAP Demo Server + + + +
+

Server Settings

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+
+

Server Configuration

+
+
+
+
+ + + + This URL will be used for generating Knox Capture AR Template URLs. + Include the protocol (http:// or https://) and port if needed. + +
+ + Back to Home +
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/templates/tenant_selection.html b/src/templates/tenant_selection.html index 427672a..4dd29ed 100644 --- a/src/templates/tenant_selection.html +++ b/src/templates/tenant_selection.html @@ -38,6 +38,7 @@ {{ tenant.created_at }}

Username: {{ tenant.username }}

+

Knox Capture AR Template URL: {{ server_url }}/{{ tenant.id }}/

ID: {{ tenant.id }}
@@ -73,6 +74,7 @@ Note: When you access a tenant URL directly (e.g., /my-tenant/), it will be automatically created with default credentials (admin/admin).

+ Server Settings
From 9f33e1aeb68dc4556eda1575158f65b566209f6c Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 19 Jun 2025 16:08:18 -0400 Subject: [PATCH 09/19] case insensitivity --- src/app.py | 9 +++++ src/database.py | 59 ++++++++++++++++++++++++++++- src/templates/tenant_selection.html | 7 +++- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/app.py b/src/app.py index f94377d..af741a4 100644 --- a/src/app.py +++ b/src/app.py @@ -8,6 +8,7 @@ from barcode.writer import ImageWriter from io import BytesIO import shutil from werkzeug.routing import BaseConverter +from functools import wraps app = Flask(__name__) # Set DATA_FOLDER to the absolute path of the data directory inside src @@ -17,6 +18,14 @@ app.config['SECRET_KEY'] = 'dev-key-for-demo-only' # Custom converter for tenant IDs class TenantConverter(BaseConverter): regex = '[a-zA-Z0-9_-]+' + + def to_python(self, value): + # Convert to lowercase when parsing from URL + return value.lower() + + def to_url(self, value): + # Convert to lowercase when generating URLs + return value.lower() app.url_map.converters['tenant'] = TenantConverter diff --git a/src/database.py b/src/database.py index 836f9a6..01c71d4 100644 --- a/src/database.py +++ b/src/database.py @@ -204,6 +204,10 @@ def migrate_from_json(): def get_all_products(tenant_id: str = None) -> Dict[str, List[Dict[str, Any]]]: """Get all products for a tenant in the legacy format""" + # Normalize tenant_id to lowercase if provided + if tenant_id: + tenant_id = tenant_id.lower() + with get_db() as conn: cursor = conn.cursor() @@ -248,6 +252,9 @@ def get_all_products(tenant_id: str = None) -> Dict[str, List[Dict[str, Any]]]: def get_product(product_id: str, tenant_id: str) -> Optional[List[Dict[str, Any]]]: """Get a single product by ID and tenant""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + with get_db() as conn: cursor = conn.cursor() @@ -272,6 +279,9 @@ def get_product(product_id: str, tenant_id: str) -> Optional[List[Dict[str, Any] def save_product(product_id: str, tenant_id: str, fields: List[Dict[str, Any]], image_data: Optional[bytes] = None, image_mime_type: Optional[str] = None): """Save or update a product for a tenant""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + with get_db() as conn: cursor = conn.cursor() @@ -336,6 +346,9 @@ def save_product(product_id: str, tenant_id: str, fields: List[Dict[str, Any]], def delete_product(product_id: str, tenant_id: str): """Delete a product for a tenant""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + with get_db() as conn: cursor = conn.cursor() cursor.execute('DELETE FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id)) @@ -343,6 +356,9 @@ def delete_product(product_id: str, tenant_id: str): def get_product_image(product_id: str, tenant_id: str) -> Optional[tuple[bytes, str]]: """Get product image data and mime type for a tenant""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + with get_db() as conn: cursor = conn.cursor() cursor.execute('SELECT image_data, image_mime_type FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id)) @@ -420,6 +436,9 @@ def get_or_create_tenant(tenant_id: str, username: str = None, password: str = N def update_tenant_credentials(tenant_id: str, username: str, password: str): """Update tenant credentials""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + with get_db() as conn: cursor = conn.cursor() cursor.execute(''' @@ -448,6 +467,9 @@ def get_all_tenants() -> List[Dict[str, Any]]: def delete_tenant(tenant_id: str): """Delete a tenant and all associated data""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + with get_db() as conn: cursor = conn.cursor() # Due to ON DELETE CASCADE, this will also delete all products and product_fields @@ -475,6 +497,9 @@ def cleanup_reserved_tenants(): def get_custom_ar_fields(tenant_id: str) -> List[Dict[str, Any]]: """Get custom AR fields for a tenant""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + with get_db() as conn: cursor = conn.cursor() cursor.execute(''' @@ -499,6 +524,9 @@ def get_custom_ar_fields(tenant_id: str) -> List[Dict[str, Any]]: def save_custom_ar_field(tenant_id: str, field_data: Dict[str, Any]) -> int: """Save or update a custom AR field""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + with get_db() as conn: cursor = conn.cursor() @@ -539,6 +567,9 @@ def save_custom_ar_field(tenant_id: str, field_data: Dict[str, Any]) -> int: def delete_custom_ar_field(tenant_id: str, field_id: int): """Delete a custom AR field""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + with get_db() as conn: cursor = conn.cursor() cursor.execute(''' @@ -549,17 +580,38 @@ def delete_custom_ar_field(tenant_id: str, field_id: int): def init_default_ar_fields(tenant_id: str): """Initialize default AR fields for a new tenant""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + default_fields = [ {"fieldName": "_id", "label": "Item ID", "fieldType": "TEXT", "editable": "false", "displayOrder": 1}, {"fieldName": "_price", "label": "Sale Price", "fieldType": "TEXT", "editable": "true", "displayOrder": 2}, {"fieldName": "_image", "label": "Image", "fieldType": "IMAGE_URI", "editable": "false", "displayOrder": 3} ] - for field in default_fields: - save_custom_ar_field(tenant_id, field) + with get_db() as conn: + cursor = conn.cursor() + for field in default_fields: + # Use INSERT OR IGNORE to avoid duplicate key errors + cursor.execute(''' + INSERT OR IGNORE INTO custom_ar_fields + (tenant_id, field_name, label, field_type, editable, display_order) + VALUES (?, ?, ?, ?, ?, ?) + ''', ( + tenant_id, + field['fieldName'], + field['label'], + field['fieldType'], + field.get('editable', 'true'), + field.get('displayOrder', 0) + )) + conn.commit() def save_product_image(product_id: str, tenant_id: str, field_name: str, image_data: bytes, mime_type: str): """Save an image for a specific field of a product""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + with get_db() as conn: cursor = conn.cursor() @@ -579,6 +631,9 @@ def save_product_image(product_id: str, tenant_id: str, field_name: str, image_d def get_product_image_by_field(product_id: str, tenant_id: str, field_name: str) -> Optional[tuple[bytes, str]]: """Get image data for a specific field of a product""" + # Normalize tenant_id to lowercase + tenant_id = tenant_id.lower() + with get_db() as conn: cursor = conn.cursor() cursor.execute(''' diff --git a/src/templates/tenant_selection.html b/src/templates/tenant_selection.html index 4dd29ed..5e2012f 100644 --- a/src/templates/tenant_selection.html +++ b/src/templates/tenant_selection.html @@ -90,11 +90,14 @@ e.preventDefault(); const tenantId = document.getElementById('tenantId').value; if (tenantId) { - if (reservedIds.includes(tenantId.toLowerCase())) { + // Convert to lowercase for consistency + const normalizedId = tenantId.toLowerCase(); + if (reservedIds.includes(normalizedId)) { alert(`"${tenantId}" is a reserved name and cannot be used as a tenant ID.`); return; } - window.location.href = '/' + tenantId + '/'; + // Use the normalized (lowercase) ID in the URL + window.location.href = '/' + normalizedId + '/'; } }); From 6dc276a2925d45fa0bb7be8201605632e9af8115 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Wed, 25 Jun 2025 18:11:12 -0400 Subject: [PATCH 10/19] fixing missing barcodes --- src/app.py | 49 +++++++++- src/templates/admin/generate_barcode.html | 4 +- src/templates/index.html | 89 ++++++++++++------ src/templates/tenant_settings.html | 104 ++++++++++++++++++++++ 4 files changed, 216 insertions(+), 30 deletions(-) create mode 100644 src/templates/tenant_settings.html diff --git a/src/app.py b/src/app.py index af741a4..2842682 100644 --- a/src/app.py +++ b/src/app.py @@ -70,7 +70,46 @@ def tenant_index(tenant_id): if tenant is None: # Reserved tenant ID or invalid return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404 - return render_template('index.html', tenant=tenant) + + # Load products for this tenant + products = load_products(tenant_id) + + return render_template('index.html', tenant=tenant, products=products) + +@app.route('//settings') +def tenant_settings(tenant_id): + # Get tenant + tenant = database.get_or_create_tenant(tenant_id) + if tenant is None: + return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404 + + # Get custom AR fields for this tenant + custom_fields = database.get_custom_ar_fields(tenant_id) + + # Get server URL for API endpoint display + server_url = database.get_server_url() + + return render_template('tenant_settings.html', tenant=tenant, custom_fields=custom_fields, server_url=server_url) + +@app.route('//settings/credentials', methods=['POST']) +def update_tenant_credentials(tenant_id): + # Get tenant + tenant = database.get_tenant(tenant_id) + if tenant is None: + return jsonify({"error": "Tenant not found"}), 404 + + username = request.form.get('username', '').strip() + password = request.form.get('password', '').strip() + + if not username: + flash('Username is required.', 'error') + return redirect(f'/{tenant_id}/settings') + + # Update credentials + database.update_tenant_credentials(tenant_id, username, password if password else None) + + flash('Credentials updated successfully.', 'success') + return redirect(f'/{tenant_id}/settings') def check_basic_auth(auth_header, tenant_id): """Validate Basic authentication credentials for a tenant""" @@ -119,10 +158,14 @@ def get_ar_info(tenant_id): def filter_and_process_fields(product_fields): # Filter to only include fields defined in custom AR fields filtered_fields = [] + + # Get field types for all custom fields + field_types = {f['fieldName']: f['fieldType'] for f in custom_fields} + for field in product_fields: if field['fieldName'] in custom_field_names: - # Create absolute URL for image fields - if field['fieldName'] == '_image' and field['value']: + # Create absolute URL for IMAGE_URI fields + if field_types.get(field['fieldName']) == 'IMAGE_URI' and field['value']: # If it's already an absolute URL, leave it as is if not field['value'].startswith('http'): # Build absolute URL using request host with tenant diff --git a/src/templates/admin/generate_barcode.html b/src/templates/admin/generate_barcode.html index 0615bf5..19a0d8c 100644 --- a/src/templates/admin/generate_barcode.html +++ b/src/templates/admin/generate_barcode.html @@ -86,8 +86,8 @@ diff --git a/src/templates/index.html b/src/templates/index.html index b20730a..43ccb8f 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -5,37 +5,76 @@ {% block content %}
-
+
+

KCAP Demo Server{% if tenant %} - {{ tenant.name }}{% endif %}

Manage your product catalog and AR content for barcode scanning applications.


-

Quick Actions

-
-
-
-
-
Product Management
-

Add, edit, and manage your product catalog with custom fields.

- {% if tenant %} - Manage Products - {% endif %} -
-
-
-
-
-
-
AR Field Configuration
-

Customize the fields returned by AR content endpoints.

- {% if tenant %} - Configure AR Fields - {% endif %} -
-
-
+ + + {% if products %} +
+ + + + + + + + + + + {% for product_id, product_data in products.items() %} + + + + + + + {% endfor %} + +
Product IDNamePriceActions
{{ product_id }} + {% for field in product_data %} + {% if field.fieldName == '_name' %} + {{ field.value }} + {% endif %} + {% endfor %} + + {% for field in product_data %} + {% if field.fieldName == '_price' %} + {{ field.value }} + {% endif %} + {% endfor %} + + + Edit + + + Barcodes + +
+
+ + {% else %} +
+ No products available yet. Click "Add New Product" to get started. +
+ {% endif %}
diff --git a/src/templates/tenant_settings.html b/src/templates/tenant_settings.html new file mode 100644 index 0000000..d6270ef --- /dev/null +++ b/src/templates/tenant_settings.html @@ -0,0 +1,104 @@ +{% extends "layout.html" %} + +{% block title %}Settings - {{ tenant.name }} - KCAP Demo Server{% endblock %} + +{% block content %} +
+
+
+

Settings - {{ tenant.name }}

+ + Back to Dashboard + +
+ +
+ +
+
+
+
Device Authentication
+
+
+

Configure the username and password for device authentication.

+ +
+ + +
+
+ + +
Leave blank to keep current password
+
+ + +
+
+
+ + +
+
+
+
Custom AR Content Fields
+
+
+

Manage the fields that are returned in the AR content API.

+ + Manage AR Fields + + + {% if custom_fields %} +
+
Current Fields:
+
    + {% for field in custom_fields %} +
  • +
    + {{ field.label }} + ({{ field.fieldName }}) +
    + {{ field.fieldType }} +
  • + {% endfor %} +
+
+ {% endif %} +
+
+
+
+ + +
+
+
+
+
Tenant Information
+
+
+
+
Tenant ID:
+
{{ tenant.id }}
+ +
Tenant Name:
+
{{ tenant.name }}
+ +
API Endpoint:
+
{{ server_url }}/{{ tenant.id }}/arinfo
+ +
Created:
+
{{ tenant.created_at }}
+
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file From 92eb45fc8ca2cf00638453b14306ba6b7da820f1 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Mon, 20 Oct 2025 16:45:58 -0400 Subject: [PATCH 11/19] Fix product image not saving/displaying in tenant admin Fixed two issues with product images: 1. Images weren't being preserved when editing other product fields - added logic to keep existing image value when no new image is uploaded 2. Image URLs had double underscores (e.g., /images/123__image.jpg) due to field names starting with underscore - now strips leading underscore from field name when constructing image paths --- src/routes/admin.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/routes/admin.py b/src/routes/admin.py index 7bb087e..82c692c 100644 --- a/src/routes/admin.py +++ b/src/routes/admin.py @@ -110,8 +110,9 @@ def add_product(tenant_id): 'mime_type': mime_type }) - # Set the image URL path - image_path = f"/images/{product_id}_{field_name}.{extension}" + # Set the image URL path (strip leading underscore from field_name to avoid double underscores) + field_suffix = field_name[1:] if field_name.startswith('_') else field_name + image_path = f"/images/{product_id}_{field_suffix}.{extension}" # For backward compatibility with _image field if field_name == '_image': @@ -231,7 +232,7 @@ def edit_product(tenant_id, product_id): # Read image data file.seek(0) img_data = file.read() - + # Determine mime type extension = file.filename.rsplit('.', 1)[1].lower() mime_types = { @@ -241,21 +242,23 @@ def edit_product(tenant_id, product_id): 'gif': 'image/gif' } mime_type = mime_types.get(extension, 'image/jpeg') - + # Store for later saving images_to_save.append({ 'field_name': field_name, 'data': img_data, 'mime_type': mime_type }) - - # Update image path - existing_field["value"] = f"/images/{product_id}_{field_name}.{extension}" - + + # Update image path (strip leading underscore from field_name to avoid double underscores) + field_suffix = field_name[1:] if field_name.startswith('_') else field_name + existing_field["value"] = f"/images/{product_id}_{field_suffix}.{extension}" + # For backward compatibility with _image field if field_name == '_image': image_data = img_data image_mime_type = mime_type + # If no new image uploaded, keep the existing value (already in existing_field) else: # Get value from form value = request.form.get(f'field_{field_name}', '') From f781cdf602c6ee8e593ee9f779c6ace20d5e752f Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Mon, 20 Oct 2025 17:11:26 -0400 Subject: [PATCH 12/19] Add dynamic custom field display and remove redundant admin routes Major improvements to tenant management: 1. Dynamic field display - Product lists now automatically show all custom fields based on tenant configuration (e.g., inventory, custom attributes) 2. Fixed Jinja2 template scoping issue - Product field values now correctly display in edit forms using selectattr filter 3. Simplified URL structure - Removed redundant /tenant/admin/ routes, all product management now at /tenant/ root 4. Enhanced tenant landing page - Added delete functionality with confirmation modal, shows all custom fields except images 5. Updated all routes and redirects to use simplified paths (/tenant/add, /tenant/edit, etc.) This consolidates the interface so /tenant/ serves as the main dashboard with full product management capabilities, while /tenant/settings handles configuration. --- src/app.py | 30 +++---- src/routes/admin.py | 37 ++++----- src/templates/admin/add_product.html | 4 +- src/templates/admin/edit_product.html | 17 ++-- src/templates/admin/generate_barcode.html | 2 +- src/templates/admin/index.html | 58 +++++++------- src/templates/index.html | 97 +++++++++++++++++------ 7 files changed, 142 insertions(+), 103 deletions(-) diff --git a/src/app.py b/src/app.py index 2842682..456a73a 100644 --- a/src/app.py +++ b/src/app.py @@ -70,11 +70,12 @@ def tenant_index(tenant_id): if tenant is None: # Reserved tenant ID or invalid return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404 - + # Load products for this tenant products = load_products(tenant_id) - - return render_template('index.html', tenant=tenant, products=products) + custom_fields = database.get_custom_ar_fields(tenant_id) + + return render_template('index.html', tenant=tenant, products=products, custom_fields=custom_fields) @app.route('//settings') def tenant_settings(tenant_id): @@ -292,21 +293,16 @@ def serve_barcode(tenant_id, filename): app.logger.error(f"Barcode generation error: {str(e)}") return jsonify({"error": "Failed to generate barcode"}), 500 -# Import the admin routes directly and register them with tenant support -from routes.admin import (index as admin_index, add_product, edit_product, - delete_product, view_product, generate_barcode, - generate_barcode_page, manage_credentials, manage_ar_fields) +# Import product management routes +from routes.admin import (add_product, edit_product, delete_product, + generate_barcode, generate_barcode_page) -# Register admin routes with tenant prefix -app.add_url_rule('//admin/', 'admin.index', admin_index) -app.add_url_rule('//admin/add', 'admin.add_product', add_product, methods=['GET', 'POST']) -app.add_url_rule('//admin/edit/', 'admin.edit_product', edit_product, methods=['GET', 'POST']) -app.add_url_rule('//admin/delete/', 'admin.delete_product', delete_product, methods=['POST']) -app.add_url_rule('//admin/view/', 'admin.view_product', view_product) -app.add_url_rule('//admin/generate_barcode//', 'admin.generate_barcode', generate_barcode) -app.add_url_rule('//admin/generate_barcode_page/', 'admin.generate_barcode_page', generate_barcode_page) -app.add_url_rule('//admin/credentials', 'admin.manage_credentials', manage_credentials, methods=['GET', 'POST']) -app.add_url_rule('//admin/ar_fields', 'admin.manage_ar_fields', manage_ar_fields, methods=['GET', 'POST']) +# Register product management routes (remove /admin/ from paths) +app.add_url_rule('//add', 'admin.add_product', add_product, methods=['GET', 'POST']) +app.add_url_rule('//edit/', 'admin.edit_product', edit_product, methods=['GET', 'POST']) +app.add_url_rule('//delete/', 'admin.delete_product', delete_product, methods=['POST']) +app.add_url_rule('//generate_barcode//', 'admin.generate_barcode', generate_barcode) +app.add_url_rule('//generate_barcode_page/', 'admin.generate_barcode_page', generate_barcode_page) # Register API routes with tenant prefix from routes.api import api_index diff --git a/src/routes/admin.py b/src/routes/admin.py index 82c692c..1b24498 100644 --- a/src/routes/admin.py +++ b/src/routes/admin.py @@ -38,7 +38,8 @@ def save_products(products): def index(tenant_id): products = load_products(tenant_id) tenant = database.get_or_create_tenant(tenant_id) - return render_template('admin/index.html', products=products, tenant=tenant) + custom_fields = database.get_custom_ar_fields(tenant_id) + return render_template('admin/index.html', products=products, tenant=tenant, custom_fields=custom_fields) def add_product(tenant_id): # Get custom AR fields for this tenant @@ -172,26 +173,26 @@ def add_product(tenant_id): database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type']) flash('Product added successfully!') - return redirect(url_for('admin.index', tenant_id=tenant_id)) + return redirect(f'/{tenant_id}/') return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields) def edit_product(tenant_id, product_id): products = load_products(tenant_id) custom_fields = database.get_custom_ar_fields(tenant_id) - + if product_id not in products: flash('Product not found.') - return redirect(url_for('admin.index', tenant_id=tenant_id)) + return redirect(f'/{tenant_id}/') if request.method == 'POST': # Handle backward compatibility image upload image_data = None image_mime_type = None - + # Process images after product is saved images_to_save = [] - + # Update fields based on form data updated_product = [] @@ -295,7 +296,7 @@ def edit_product(tenant_id, product_id): database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type']) flash('Product updated successfully!') - return redirect(url_for('admin.index', tenant_id=tenant_id)) + return redirect(f'/{tenant_id}/') return render_template('admin/edit_product.html', product_id=product_id, @@ -305,24 +306,24 @@ def edit_product(tenant_id, product_id): def delete_product(tenant_id, product_id): products = load_products(tenant_id) - + if product_id not in products: flash('Product not found.') - return redirect(url_for('admin.index', tenant_id=tenant_id)) - + return redirect(f'/{tenant_id}/') + # Delete product from database (image is stored in DB) database.delete_product(product_id, tenant_id) - + flash('Product deleted successfully!') - return redirect(url_for('admin.index', tenant_id=tenant_id)) + return redirect(f'/{tenant_id}/') def view_product(tenant_id, product_id): products = load_products(tenant_id) - + if product_id not in products: flash('Product not found.') - return redirect(url_for('admin.index', tenant_id=tenant_id)) - + return redirect(f'/{tenant_id}/') + return render_template('admin/view_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id) def generate_barcode(tenant_id, product_id, code_type): @@ -346,10 +347,10 @@ def generate_barcode(tenant_id, product_id, code_type): def generate_barcode_page(tenant_id, product_id): """Show a page with different barcode options for a product""" products = load_products(tenant_id) - + if product_id not in products: flash('Product not found.') - return redirect(url_for('admin.index', tenant_id=tenant_id)) + return redirect(f'/{tenant_id}/') # Extract product data product_data = {} @@ -388,7 +389,7 @@ def manage_credentials(tenant_id): if username and password: database.update_tenant_credentials(tenant_id, username, password) flash('Credentials updated successfully!') - return redirect(url_for('admin.index', tenant_id=tenant_id)) + return redirect(f'/{tenant_id}/') else: flash('Username and password are required.') diff --git a/src/templates/admin/add_product.html b/src/templates/admin/add_product.html index 099495a..d38843b 100644 --- a/src/templates/admin/add_product.html +++ b/src/templates/admin/add_product.html @@ -10,7 +10,7 @@

Add New Product

-
+
@@ -67,7 +67,7 @@ {% endif %}
- Cancel + Cancel
diff --git a/src/templates/admin/edit_product.html b/src/templates/admin/edit_product.html index e7f85ad..16873bb 100644 --- a/src/templates/admin/edit_product.html +++ b/src/templates/admin/edit_product.html @@ -10,7 +10,7 @@

Edit Product

-
+
@@ -21,14 +21,11 @@ {% if field.fieldName != '_id' %}
- - {% set current_value = '' %} - {% for prod_field in product %} - {% if prod_field.fieldName == field.fieldName %} - {% set current_value = prod_field.value %} - {% endif %} - {% endfor %} - + + {# Find current value using selectattr filter #} + {% set matching_fields = product|selectattr('fieldName', 'equalto', field.fieldName)|list %} + {% set current_value = matching_fields[0].value if matching_fields else '' %} + {% if field.fieldType == 'IMAGE_URI' %}
Leave empty to keep the current image.
@@ -93,7 +90,7 @@ {% endif %}
- Cancel + Cancel
diff --git a/src/templates/admin/generate_barcode.html b/src/templates/admin/generate_barcode.html index 19a0d8c..dd94df4 100644 --- a/src/templates/admin/generate_barcode.html +++ b/src/templates/admin/generate_barcode.html @@ -8,7 +8,7 @@

Generate Barcodes for Product: {{ product_id }}

- Back to Products + Back to Products
{% if not dependencies.qrcode or not dependencies.barcode or not dependencies.pillow %} diff --git a/src/templates/admin/index.html b/src/templates/admin/index.html index 4371b28..d005c1c 100644 --- a/src/templates/admin/index.html +++ b/src/templates/admin/index.html @@ -22,46 +22,44 @@ - - - - - + + {% for custom_field in custom_fields %} + {% if custom_field.fieldName != '_id' %} + + {% endif %} + {% endfor %} + {% for product_id, product_data in products.items() %} - - - + {% for custom_field in custom_fields %} + {% if custom_field.fieldName != '_id' %} + + {% endif %} + {% endfor %} - - + {% for custom_field in custom_fields %} + {% if custom_field.fieldName != '_id' and custom_field.fieldType != 'IMAGE_URI' %} + + {% endif %} + {% endfor %} @@ -38,45 +41,42 @@ {% for product_id, product_data in products.items() %} + {% for custom_field in custom_fields %} + {% if custom_field.fieldName != '_id' and custom_field.fieldType != 'IMAGE_URI' %} + + {% endif %} + {% endfor %} - - {% endfor %}
Product IDNamePriceImageActionsProduct ID{{ custom_field.label }}Actions
{{ product_id }} - {% for field in product_data %} - {% if field.fieldName == '_name' %} - {{ field.value }} - {% endif %} - {% endfor %} - - {% for field in product_data %} - {% if field.fieldName == '_price' %} - {{ field.value }} - {% endif %} - {% endfor %} - - {% for field in product_data %} - {% if field.fieldName == '_image' %} -
- Product image -
- {% endif %} - {% endfor %} -
+ {% for field in product_data %} + {% if field.fieldName == custom_field.fieldName %} + {% if custom_field.fieldType == 'IMAGE_URI' %} + {% if field.value %} +
+ {{ custom_field.label }} +
+ {% endif %} + {% else %} + {{ field.value }} + {% endif %} + {% endif %} + {% endfor %} +
Edit Barcodes -
Product IDNamePrice{{ custom_field.label }}Actions
{{ product_id }} + {% set matching_fields = product_data|selectattr('fieldName', 'equalto', custom_field.fieldName)|list %} + {{ matching_fields[0].value if matching_fields else '' }} + - {% for field in product_data %} - {% if field.fieldName == '_name' %} - {{ field.value }} - {% endif %} - {% endfor %} - - {% for field in product_data %} - {% if field.fieldName == '_price' %} - {{ field.value }} - {% endif %} - {% endfor %} - - + Edit - + Barcodes + +
- {% else %}
No products available yet. Click "Add New Product" to get started.
{% endif %}
- +

Getting Started

    @@ -89,4 +89,51 @@
+ + + +{% endblock %} + +{% block extra_js %} + {% endblock %} From ccac3134f3579a58868c974a5878e8de5d73fdf1 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Mon, 20 Oct 2025 17:17:58 -0400 Subject: [PATCH 13/19] Fix image display and add AR fields management route Fixes: 1. Image display - Images now properly display on tenant landing page and edit forms by prepending tenant ID to image paths (/tenant/images/... instead of /images/...) 2. AR fields route - Added /tenant/ar_fields route (without /admin/ prefix) for managing custom AR content fields 3. Template updates - Updated all image src attributes to include tenant prefix for proper routing 4. Navigation - Updated AR fields back button to return to settings page instead of non-existent admin index Images are now visible in: - Product list on tenant landing page - Product edit forms showing current images - Admin index (if accessed) --- src/app.py | 3 ++- src/templates/admin/ar_fields.html | 2 +- src/templates/admin/edit_product.html | 2 +- src/templates/admin/index.html | 2 +- src/templates/index.html | 14 +++++++++++--- src/templates/tenant_settings.html | 2 +- 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/app.py b/src/app.py index 456a73a..9ba7d84 100644 --- a/src/app.py +++ b/src/app.py @@ -295,7 +295,7 @@ def serve_barcode(tenant_id, filename): # Import product management routes from routes.admin import (add_product, edit_product, delete_product, - generate_barcode, generate_barcode_page) + generate_barcode, generate_barcode_page, manage_ar_fields) # Register product management routes (remove /admin/ from paths) app.add_url_rule('//add', 'admin.add_product', add_product, methods=['GET', 'POST']) @@ -303,6 +303,7 @@ app.add_url_rule('//edit/', 'admin.edit_product', app.add_url_rule('//delete/', 'admin.delete_product', delete_product, methods=['POST']) app.add_url_rule('//generate_barcode//', 'admin.generate_barcode', generate_barcode) app.add_url_rule('//generate_barcode_page/', 'admin.generate_barcode_page', generate_barcode_page) +app.add_url_rule('//ar_fields', 'admin.manage_ar_fields', manage_ar_fields, methods=['GET', 'POST']) # Register API routes with tenant prefix from routes.api import api_index diff --git a/src/templates/admin/ar_fields.html b/src/templates/admin/ar_fields.html index b49f93b..2b26339 100644 --- a/src/templates/admin/ar_fields.html +++ b/src/templates/admin/ar_fields.html @@ -8,7 +8,7 @@

Manage AR Content Fields

Configure the fields that will be returned by the /arcontentfields and /arinfo endpoints.

- Back to Admin + Back to Settings
diff --git a/src/templates/admin/edit_product.html b/src/templates/admin/edit_product.html index 16873bb..a2f3680 100644 --- a/src/templates/admin/edit_product.html +++ b/src/templates/admin/edit_product.html @@ -34,7 +34,7 @@
- Current image + Current image
{% endif %} diff --git a/src/templates/admin/index.html b/src/templates/admin/index.html index d005c1c..fb3fc22 100644 --- a/src/templates/admin/index.html +++ b/src/templates/admin/index.html @@ -43,7 +43,7 @@ {% if custom_field.fieldType == 'IMAGE_URI' %} {% if field.value %}
- {{ custom_field.label }} + {{ custom_field.label }}
{% endif %} {% else %} diff --git a/src/templates/index.html b/src/templates/index.html index b03129a..7f394b3 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -30,7 +30,7 @@ Product ID {% for custom_field in custom_fields %} - {% if custom_field.fieldName != '_id' and custom_field.fieldType != 'IMAGE_URI' %} + {% if custom_field.fieldName != '_id' %} {{ custom_field.label }} {% endif %} {% endfor %} @@ -42,10 +42,18 @@ {{ product_id }} {% for custom_field in custom_fields %} - {% if custom_field.fieldName != '_id' and custom_field.fieldType != 'IMAGE_URI' %} + {% if custom_field.fieldName != '_id' %} {% set matching_fields = product_data|selectattr('fieldName', 'equalto', custom_field.fieldName)|list %} - {{ matching_fields[0].value if matching_fields else '' }} + {% if custom_field.fieldType == 'IMAGE_URI' %} + {% if matching_fields and matching_fields[0].value %} +
+ {{ custom_field.label }} +
+ {% endif %} + {% else %} + {{ matching_fields[0].value if matching_fields else '' }} + {% endif %} {% endif %} {% endfor %} diff --git a/src/templates/tenant_settings.html b/src/templates/tenant_settings.html index d6270ef..c39a0f8 100644 --- a/src/templates/tenant_settings.html +++ b/src/templates/tenant_settings.html @@ -49,7 +49,7 @@

Manage the fields that are returned in the AR content API.

- + Manage AR Fields From 253058b45b619289967608f0538feffb23b5075c Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Mon, 20 Oct 2025 17:40:04 -0400 Subject: [PATCH 14/19] Add QR code functionality for AR template URLs - Add QR code icon next to each tenant's AR template URL on main page - Add modal popup to display dynamically generated QR codes - Add routes for QR code generation (/qrcode/template and /qrcode/arinfo) - Include Bootstrap Icons for QR code icon display --- src/app.py | 100 ++++++++++++++++++++++++++-- src/templates/tenant_selection.html | 35 +++++++++- src/templates/tenant_settings.html | 29 +++++++- 3 files changed, 154 insertions(+), 10 deletions(-) diff --git a/src/app.py b/src/app.py index 9ba7d84..7228860 100644 --- a/src/app.py +++ b/src/app.py @@ -146,23 +146,64 @@ def get_ar_content_fields(tenant_id): fields = database.get_custom_ar_fields(tenant_id) return jsonify(fields), 200 -@app.route('//arinfo', methods=['GET']) +@app.route('//arinfo', methods=['GET', 'POST']) def get_ar_info(tenant_id): barcode = request.args.get('barcode') products = load_products(tenant_id) - + # Get custom AR fields for this tenant custom_fields = database.get_custom_ar_fields(tenant_id) custom_field_names = [f['fieldName'] for f in custom_fields] - + + # Handle POST request - update product fields + if request.method == 'POST': + if not barcode: + return jsonify({"error": "Barcode parameter required"}), 400 + + if barcode not in products: + return jsonify({"error": "Product not found"}), 404 + + try: + # Get the updated fields from the request body + updated_fields = request.get_json() + + if not isinstance(updated_fields, list): + return jsonify({"error": "Request body must be an array of fields"}), 400 + + # Get current product data + current_product = products[barcode] + + # Update only the editable fields + for updated_field in updated_fields: + field_name = updated_field.get('fieldName') + new_value = updated_field.get('value') + + # Find the field in the current product and update it + for field in current_product: + if field['fieldName'] == field_name: + # Check if the field is editable + if field.get('editable') == 'true': + field['value'] = new_value + break + + # Save the updated product to database + database.save_product(barcode, tenant_id, current_product) + + app.logger.info(f"Updated product {barcode} for tenant {tenant_id}") + return jsonify({"success": True}), 200 + + except Exception as e: + app.logger.error(f"Error updating product: {str(e)}") + return jsonify({"error": "Failed to update product"}), 500 + # Helper function to convert relative image paths to absolute URLs and filter fields def filter_and_process_fields(product_fields): # Filter to only include fields defined in custom AR fields filtered_fields = [] - + # Get field types for all custom fields field_types = {f['fieldName']: f['fieldType'] for f in custom_fields} - + for field in product_fields: if field['fieldName'] in custom_field_names: # Create absolute URL for IMAGE_URI fields @@ -173,7 +214,8 @@ def get_ar_info(tenant_id): field['value'] = f"{request.url_root.rstrip('/')}/{tenant_id}{field['value']}" filtered_fields.append(field) return filtered_fields - + + # Handle GET request - return product data # If barcode is provided, return specific product if barcode: if barcode in products: @@ -182,7 +224,7 @@ def get_ar_info(tenant_id): response.headers['Access-Control-Allow-Origin'] = '*' return response, 200 return jsonify({"error": "Product not found"}), 404 - + # Return all products if no barcode specified # Convert all products to have absolute URLs and filter fields all_products = {} @@ -241,6 +283,50 @@ def serve_image(tenant_id, filename): app.logger.warning(f"Image not found: {filename}") return jsonify({"error": "Image not found"}), 404 +@app.route('//qrcode/template', methods=['GET']) +def generate_template_qr_code(tenant_id): + """Generate QR code for the AR Template URL""" + try: + # Get server URL from settings + server_url = database.get_server_url() + template_url = f"{server_url}/{tenant_id}/" + + # Generate QR code + buffer = BytesIO() + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(template_url) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + img.save(buffer, format='PNG') + buffer.seek(0) + + return Response(buffer.getvalue(), mimetype='image/png') + except Exception as e: + app.logger.error(f"QR code generation error: {str(e)}") + return jsonify({"error": "Failed to generate QR code"}), 500 + +@app.route('//qrcode/arinfo', methods=['GET']) +def generate_ar_qr_code(tenant_id): + """Generate QR code for the AR API endpoint""" + try: + # Get server URL from settings + server_url = database.get_server_url() + ar_url = f"{server_url}/{tenant_id}/arinfo" + + # Generate QR code + buffer = BytesIO() + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(ar_url) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + img.save(buffer, format='PNG') + buffer.seek(0) + + return Response(buffer.getvalue(), mimetype='image/png') + except Exception as e: + app.logger.error(f"QR code generation error: {str(e)}") + return jsonify({"error": "Failed to generate QR code"}), 500 + @app.route('//barcodes/', methods=['GET']) def serve_barcode(tenant_id, filename): # Parse filename to extract product_id and barcode type diff --git a/src/templates/tenant_selection.html b/src/templates/tenant_selection.html index 5e2012f..14ee745 100644 --- a/src/templates/tenant_selection.html +++ b/src/templates/tenant_selection.html @@ -5,6 +5,7 @@ Select or Create Tenant - KCAP Demo Server +
@@ -38,9 +39,15 @@ {{ tenant.created_at }}

Username: {{ tenant.username }}

-

Knox Capture AR Template URL: {{ server_url }}/{{ tenant.id }}/

+

+ Knox Capture AR Template URL: + {{ server_url }}/{{ tenant.id }}/ +

ID: {{ tenant.id }} +
@@ -79,7 +86,31 @@
- + + + {% for tenant in tenants %} + + {% endfor %} + +{% endblock %} diff --git a/src/templates/admin/index.html b/src/templates/admin/index.html index fb3fc22..bfac565 100644 --- a/src/templates/admin/index.html +++ b/src/templates/admin/index.html @@ -10,6 +10,9 @@
diff --git a/src/templates/index.html b/src/templates/index.html index 65e1a0f..714a997 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -18,9 +18,14 @@
{% if products %} From f97808c7b38cecbc5a50b18d34bae3ad42ac5bd2 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Mon, 20 Oct 2025 18:11:16 -0400 Subject: [PATCH 18/19] Refactor application to MVC architecture with Flask blueprints Major architectural refactoring to improve code organization, maintainability, and scalability: - Implement MVC pattern with clear separation of concerns - Organize code into blueprints for different functional areas (main, tenant, admin, api) - Create models layer to encapsulate all database operations - Create services layer for business logic (auth, product, barcode) - Implement application factory pattern for better testability - Add environment-based configuration management - Remove old monolithic app.py and consolidate routes New structure: - app/models/ - Database operations (Tenant, Product, ARField, Settings) - app/services/ - Business logic (Auth, Product, Barcode services) - app/blueprints/ - Controllers organized by function - app/config.py - Environment configurations - run.py - Application entry point All routes remain backward compatible. No changes to API or templates functionality. --- PLANNING.md | 55 -- RULES.md | 20 - TASK.md | 50 -- src/admin.py | 287 ------- src/app.py | 441 ----------- src/app/__init__.py | 49 ++ src/app/blueprints/__init__.py | 6 + src/app/blueprints/admin/__init__.py | 5 + .../blueprints/admin/routes.py} | 244 +++--- src/app/blueprints/api/__init__.py | 5 + .../api.py => app/blueprints/api/routes.py} | 4 +- src/app/blueprints/main/__init__.py | 5 + src/app/blueprints/main/routes.py | 27 + src/app/blueprints/tenant/__init__.py | 5 + src/app/blueprints/tenant/routes.py | 224 ++++++ src/app/config.py | 43 ++ src/app/models/__init__.py | 6 + src/app/models/ar_field.py | 84 +++ src/app/models/base.py | 111 +++ src/app/models/product.py | 192 +++++ src/app/models/settings.py | 30 + src/app/models/tenant.py | 98 +++ src/app/services/__init__.py | 5 + src/app/services/auth_service.py | 25 + src/app/services/barcode_service.py | 58 ++ src/app/services/product_service.py | 56 ++ .../templates/admin/add_product.html | 0 .../templates/admin/all_barcodes.html | 0 src/{ => app}/templates/admin/ar_fields.html | 0 .../templates/admin/credentials.html | 0 .../templates/admin/edit_product.html | 0 src/{ => app}/templates/admin/index.html | 0 src/{ => app}/templates/index.html | 0 src/{ => app}/templates/layout.html | 0 src/{ => app}/templates/settings.html | 0 src/{ => app}/templates/tenant_selection.html | 0 src/{ => app}/templates/tenant_settings.html | 0 src/{ => app}/utils/barcode_generator.py | 0 src/database.py | 706 ------------------ src/routes/__init__.py | 1 - src/run.py | 20 + 41 files changed, 1147 insertions(+), 1715 deletions(-) delete mode 100644 PLANNING.md delete mode 100644 RULES.md delete mode 100644 TASK.md delete mode 100644 src/admin.py delete mode 100644 src/app.py create mode 100644 src/app/__init__.py create mode 100644 src/app/blueprints/__init__.py create mode 100644 src/app/blueprints/admin/__init__.py rename src/{routes/admin.py => app/blueprints/admin/routes.py} (66%) create mode 100644 src/app/blueprints/api/__init__.py rename src/{routes/api.py => app/blueprints/api/routes.py} (63%) create mode 100644 src/app/blueprints/main/__init__.py create mode 100644 src/app/blueprints/main/routes.py create mode 100644 src/app/blueprints/tenant/__init__.py create mode 100644 src/app/blueprints/tenant/routes.py create mode 100644 src/app/config.py create mode 100644 src/app/models/__init__.py create mode 100644 src/app/models/ar_field.py create mode 100644 src/app/models/base.py create mode 100644 src/app/models/product.py create mode 100644 src/app/models/settings.py create mode 100644 src/app/models/tenant.py create mode 100644 src/app/services/__init__.py create mode 100644 src/app/services/auth_service.py create mode 100644 src/app/services/barcode_service.py create mode 100644 src/app/services/product_service.py rename src/{ => app}/templates/admin/add_product.html (100%) rename src/{ => app}/templates/admin/all_barcodes.html (100%) rename src/{ => app}/templates/admin/ar_fields.html (100%) rename src/{ => app}/templates/admin/credentials.html (100%) rename src/{ => app}/templates/admin/edit_product.html (100%) rename src/{ => app}/templates/admin/index.html (100%) rename src/{ => app}/templates/index.html (100%) rename src/{ => app}/templates/layout.html (100%) rename src/{ => app}/templates/settings.html (100%) rename src/{ => app}/templates/tenant_selection.html (100%) rename src/{ => app}/templates/tenant_settings.html (100%) rename src/{ => app}/utils/barcode_generator.py (100%) delete mode 100644 src/database.py delete mode 100644 src/routes/__init__.py create mode 100644 src/run.py diff --git a/PLANNING.md b/PLANNING.md deleted file mode 100644 index 1640d40..0000000 --- a/PLANNING.md +++ /dev/null @@ -1,55 +0,0 @@ -# Project Planning and Code Guidelines - -## General Code Guidelines - -* **Modular Design:** Use Object-Oriented Programming (OOP) where possible to separate concerns and promote code reuse. -* **File Size Limit:** No single Python file should exceed **600 lines** to improve readability and maintainability. -* **Commenting:** - - * Use clear, concise comments to explain complex logic. - * Include function and class docstrings where appropriate. - * Use in-line comments sparingly and only when the logic is not immediately obvious. -* **Code Structure:** - - * Follow the Model-View-Controller (MVC) pattern where applicable. - * Group related classes and functions into separate modules for clarity. -* **Error Handling:** Implement robust error handling with meaningful messages and fallback behavior where possible. -* **Readability:** - - * Use meaningful variable and function names. - * Avoid deeply nested loops and conditionals where possible. - -## API Design Guidelines - -* **RESTful Endpoints:** Use RESTful conventions for all API endpoints. -* **Status Codes:** Return appropriate HTTP status codes (e.g., 200 for success, 404 for not found, 500 for server error). -* **Error Responses:** Provide consistent and descriptive error messages. -* **Data Validation:** Validate all incoming data to prevent unexpected behavior and security issues. -* **Unified API and UI:** For demo purposes, both the API endpoints and admin UI should be served by the same Flask application. - -## File Organization - -* **Static Files:** Store images in the `static/images/` directory. -* **Product Data:** Store product metadata in `products.json`. -* **Configuration Files:** Use a dedicated `config/` directory for environment-specific settings. -* **Templates:** Store HTML templates in the `templates/` directory, with admin-specific templates in `templates/admin/`. - -## Documentation Guidelines - -* **Keep Documentation Updated:** Update TASK.md as features are implemented to track progress. -* **Readme First:** README.md should always reflect the current state of the project and provide clear setup instructions. -* **Code Comments:** Keep code comments in sync with implementation changes. -* **API Documentation:** Document all API endpoints, including request/response formats. - -## Security Guidelines - -* **No Hardcoded Credentials:** Avoid hardcoding sensitive information like passwords or API keys. -* **Input Sanitization:** Validate and sanitize all user input to prevent injection attacks. -* **Minimal Permissions:** Run the application with the least required privileges. - -## Future Considerations - -* **Scalability:** Plan for the potential migration to a database if the product catalog grows. -* **API Rate Limiting:** Consider adding rate limiting for public APIs. -* **Monitoring and Logging:** Implement basic logging for audit trails and error diagnosis. -* **Deployment Automation:** Use Docker or other containerization for easy deployment and scaling. diff --git a/RULES.md b/RULES.md deleted file mode 100644 index e64941e..0000000 --- a/RULES.md +++ /dev/null @@ -1,20 +0,0 @@ -# AI Agent Coding Rules - -## 1. Code Length Limits -- **Python files (`.py`)**: Maximum 600 lines per file. -- **JavaScript/HTML files (`.js`, `.html`)**: Maximum 500 lines per file. - -## 2. Architecture -- Follow the **MVC (Model-View-Controller)** pattern for all new features and refactoring. - - **Models**: Handle data and business logic. - - **Views**: Manage presentation and user interface. - - **Controllers**: Process input, interact with models, and render views. - -## 3. Coding Practices -- No guessing: All code must be based on clear requirements or existing patterns in the codebase. -- Avoid unnecessary complexity and keep code readable. -- Document any non-obvious logic with concise comments. - -## 4. General -- Ensure all code changes comply with these rules before merging or deploying. -- If a rule cannot be followed, document the reason clearly in the code and notify the team. diff --git a/TASK.md b/TASK.md deleted file mode 100644 index 6368793..0000000 --- a/TASK.md +++ /dev/null @@ -1,50 +0,0 @@ -# Task List - Flask AR API Demo - -## Phase 1: Basic Admin UI ✅ - -* [x] Create a simple admin UI (no authentication required for demo purposes). -* [x] Allow adding, updating, and deleting products. -* [x] Store all product data in a `products.json` file for persistence. -* [x] Store images in the `static/images/` directory. -* [x] Implement basic form validation for product fields (ID, price, image). -* [x] Add a file upload feature for product images. - -## Phase 2: API Improvements - -* [x] Update the `/arinfo` endpoint to read from `products.json` instead of the in-memory dictionary. -* [ ] Add support for updating product attributes via the API. -* [x] Implement error handling and input validation. -* [ ] Optimize image loading for better performance. - -## Phase 3: UI Enhancements - -* [x] Add a responsive design for mobile and tablet support. -* [x] Include image previews in the admin UI. -* [ ] Add sorting and search capabilities for the product list. -* [x] Add barcode and QR code generation for products. - -## Implementation Notes - -* The APIs and admin UI have been implemented as a single server for demonstration purposes. -* The API endpoints and admin interface are now served by the same Flask application. -* Bootstrap 5 has been used for responsive UI design. -* Basic validation has been implemented for product forms. -* Flash messages provide user feedback for actions. -* Barcode generation features support three formats: - * QR Code: Links directly to the product's AR information endpoint - * EAN-13: Standard barcode format for retail products - * Code 128: High-density alphanumeric barcode -* Generated barcodes can be previewed, downloaded individually, or printed all at once. -* Barcodes are dynamically generated and stored in the `static/barcodes/` directory. - -## Future Ideas - -* [ ] Write basic unit tests for the API. -* [ ] Dockerize the application for easier deployment. -* [ ] Add a basic CI/CD pipeline (e.g., GitHub Actions or GitLab CI). -* [ ] Add user authentication for the admin UI. -* [ ] Implement role-based access control (RBAC). -* [ ] Add analytics for product views and updates. -* [ ] Integrate with a database for larger product catalogs. -* [ ] Add bulk import/export functionality for products. -* [ ] Implement a mobile-friendly scanner interface for testing the AR functionality. diff --git a/src/admin.py b/src/admin.py deleted file mode 100644 index 8c293e7..0000000 --- a/src/admin.py +++ /dev/null @@ -1,287 +0,0 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file -import os -import json -import uuid -import io -from werkzeug.utils import secure_filename - -# Import barcode generator class, but handle the case if it fails -try: - from barcode_generator import BarcodeGenerator - BARCODE_GENERATOR_AVAILABLE = True -except ImportError: - BARCODE_GENERATOR_AVAILABLE = False - # Create a stub class for graceful degradation - class BarcodeGenerator: - @staticmethod - def check_dependencies(): - return {'qrcode': False, 'barcode': False, 'pillow': False} - -admin_bp = Blueprint('admin', __name__, url_prefix='/admin') - -DATA_FOLDER = os.path.join(os.path.dirname(__file__), 'data') -PRODUCTS_FILE = os.path.join(DATA_FOLDER, 'products.json') -UPLOAD_FOLDER = os.path.join(DATA_FOLDER, 'images') -BARCODE_FOLDER = os.path.join(DATA_FOLDER, 'barcodes') -ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} - -def allowed_file(filename): - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS - -def load_products(): - try: - with open(PRODUCTS_FILE, 'r') as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return {} - -def save_products(products): - with open(PRODUCTS_FILE, 'w') as f: - json.dump(products, f, indent=2) - -@admin_bp.route('/') -def index(): - products = load_products() - return render_template('admin/index.html', products=products) - -@admin_bp.route('/add', methods=['GET', 'POST']) -def add_product(): - if request.method == 'POST': - # Get form data - product_id = request.form.get('product_id') - name = request.form.get('name') - price = request.form.get('price') - - # Basic validation - if not product_id or not name or not price: - flash('Product ID, Name, and Price are required fields.') - return render_template('admin/add_product.html') - - # Check if product ID already exists - products = load_products() - if product_id in products: - flash('Product ID already exists.') - return render_template('admin/add_product.html') - - # Handle image upload - image_filename = f"{product_id}.png" # Default name - image_path = f"/images/{image_filename}" - - if 'image' in request.files: - file = request.files['image'] - if file and file.filename and allowed_file(file.filename): - extension = file.filename.rsplit('.', 1)[1].lower() - image_filename = f"{product_id}.{extension}" - image_path = f"/images/{image_filename}" - file.save(os.path.join(UPLOAD_FOLDER, image_filename)) - - # Create product data structure - product_data = [ - {"fieldName": "_id", "label": "Item ID", "value": product_id, "editable": "false", "fieldType": "TEXT"}, - {"fieldName": "_name", "label": "Product Name", "value": name, "editable": "true", "fieldType": "TEXT"}, - {"fieldName": "_price", "label": "Sale Price", "value": f"${price}", "editable": "true", "fieldType": "TEXT"}, - {"fieldName": "_image", "label": "Image", "value": image_path, "editable": "false", "fieldType": "IMAGE_URI"} - ] - - # Save to products.json - products[product_id] = product_data - save_products(products) - - flash('Product added successfully!') - return redirect(url_for('admin.index')) - - return render_template('admin/add_product.html') - -@admin_bp.route('/edit/', methods=['GET', 'POST']) -def edit_product(product_id): - products = load_products() - - if product_id not in products: - flash('Product not found.') - return redirect(url_for('admin.index')) - - if request.method == 'POST': - # Get form data - name = request.form.get('name') - price = request.form.get('price') - - # Basic validation - if not name or not price: - flash('Name and Price are required fields.') - return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id]) - - # Update name and price - for field in products[product_id]: - if field["fieldName"] == "_name": - field["value"] = name - elif field["fieldName"] == "_price": - field["value"] = f"${price}" - - # Handle image upload - if 'image' in request.files and request.files['image'].filename: - file = request.files['image'] - if allowed_file(file.filename): - # Find current image path - current_image = None - for field in products[product_id]: - if field["fieldName"] == "_image": - current_image = field["value"] - - if current_image: - # Get current filename - current_filename = os.path.basename(current_image) - # Delete old file if it exists and is different - old_path = os.path.join(UPLOAD_FOLDER, current_filename) - if os.path.exists(old_path): - os.remove(old_path) - - # Save new image - extension = file.filename.rsplit('.', 1)[1].lower() - image_filename = f"{product_id}.{extension}" - image_path = f"/images/{image_filename}" - file.save(os.path.join(UPLOAD_FOLDER, image_filename)) - - # Update image path - for field in products[product_id]: - if field["fieldName"] == "_image": - field["value"] = image_path - - # Save to products.json - save_products(products) - - flash('Product updated successfully!') - return redirect(url_for('admin.index')) - - return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id]) - -@admin_bp.route('/delete/', methods=['POST']) -def delete_product(product_id): - products = load_products() - - if product_id not in products: - flash('Product not found.') - return redirect(url_for('admin.index')) - - # Find image path - image_path = None - for field in products[product_id]: - if field["fieldName"] == "_image": - image_path = field["value"] - - # Delete image file - if image_path: - image_filename = os.path.basename(image_path) - image_file_path = os.path.join(UPLOAD_FOLDER, image_filename) - if os.path.exists(image_file_path): - os.remove(image_file_path) - - # Delete product from dict - del products[product_id] - - # Save updated products - save_products(products) - - flash('Product deleted successfully!') - return redirect(url_for('admin.index')) - -@admin_bp.route('/view/') -def view_product(product_id): - products = load_products() - - if product_id not in products: - flash('Product not found.') - return redirect(url_for('admin.index')) - - return render_template('admin/view_product.html', product_id=product_id, product=products[product_id]) - -@admin_bp.route('/generate_barcode//') -def generate_barcode(product_id, code_type): - """Generate and serve a barcode or QR code for a product""" - # Make sure the barcodes directory exists - os.makedirs(BARCODE_FOLDER, exist_ok=True) - - products = load_products() - if product_id not in products: - return jsonify({"error": "Product not found"}), 404 - - # Check if barcode generator is available - if not BARCODE_GENERATOR_AVAILABLE: - # Create a simple error image - from PIL import Image, ImageDraw, ImageFont - img = Image.new('RGB', (300, 150), color=(255, 255, 255)) - d = ImageDraw.Draw(img) - d.rectangle([0, 0, 299, 149], outline=(0, 0, 0), width=2) - d.text((25, 65), "Barcode generation unavailable", fill=(0, 0, 0)) - - # Save and serve the error image - image_path = os.path.join(BARCODE_FOLDER, f"{product_id}_{code_type}_error.png") - img.save(image_path) - return send_file(image_path, mimetype='image/png') - - generator = BarcodeGenerator() - - # Content for the code - use the product ID - data = product_id - - # Base filename for the saved code - base_filename = f"{product_id}_{code_type}" - image_path = os.path.join(BARCODE_FOLDER, f"{base_filename}.png") - - # Generate the requested code type - if code_type == 'qrcode': - # Generate QR code with product URL - product_url = request.host_url.rstrip('/') + f"/arinfo?barcode={product_id}" - img = generator.generate_qr_code(product_url) - generator.save_image(img, image_path) - - elif code_type == 'ean13': - # For EAN-13, make sure the product ID is numeric - numeric_id = ''.join(filter(str.isdigit, product_id)) - img = generator.generate_ean13_barcode(numeric_id) - generator.save_image(img, image_path) - - elif code_type == 'code128': - img = generator.generate_code128_barcode(data) - generator.save_image(img, image_path) - - else: - return jsonify({"error": "Invalid code type"}), 400 - - # Return the image directly - return send_file(image_path, mimetype='image/png') - -@admin_bp.route('/generate_barcode_page/') -def generate_barcode_page(product_id): - """Show a page with different barcode options for a product""" - products = load_products() - - if product_id not in products: - flash('Product not found.') - return redirect(url_for('admin.index')) - - # Extract product data - product_data = {} - for field in products[product_id]: - field_name = field["fieldName"][1:] if field["fieldName"].startswith('_') else field["fieldName"] - product_data[field_name] = field["value"] - - # Add product ID - product_data['id'] = product_id - - # Check if barcode generation is available - dependency_status = {} - if BARCODE_GENERATOR_AVAILABLE: - dependency_status = BarcodeGenerator.check_dependencies() - else: - dependency_status = { - 'qrcode': False, - 'barcode': False, - 'pillow': False - } - - return render_template('admin/generate_barcode.html', - product_id=product_id, - product=product_data, - dependencies=dependency_status) - -# Remove or replace this file, as admin.py has been moved to routes/admin.py diff --git a/src/app.py b/src/app.py deleted file mode 100644 index 47b2446..0000000 --- a/src/app.py +++ /dev/null @@ -1,441 +0,0 @@ -from flask import Flask, jsonify, request, send_file, render_template, flash, Response, redirect -import os -import base64 -import database -import qrcode -import barcode -from barcode.writer import ImageWriter -from io import BytesIO -import shutil -from werkzeug.routing import BaseConverter -from functools import wraps - -app = Flask(__name__) -# Set DATA_FOLDER to the absolute path of the data directory inside src -app.config['DATA_FOLDER'] = os.path.join(os.path.dirname(__file__), 'data') -app.config['SECRET_KEY'] = 'dev-key-for-demo-only' - -# Custom converter for tenant IDs -class TenantConverter(BaseConverter): - regex = '[a-zA-Z0-9_-]+' - - def to_python(self, value): - # Convert to lowercase when parsing from URL - return value.lower() - - def to_url(self, value): - # Convert to lowercase when generating URLs - return value.lower() - -app.url_map.converters['tenant'] = TenantConverter - -# Load product data from database -def load_products(tenant_id=None): - return database.get_all_products(tenant_id) - -@app.route('/') -def index(): - # Show a tenant selection page or redirect to default - tenants = database.get_all_tenants() - server_url = database.get_server_url() - return render_template('tenant_selection.html', tenants=tenants, server_url=server_url) - -@app.route('/tenant//delete', methods=['POST']) -def delete_tenant(tenant_id): - """Delete a tenant and all its data""" - database.delete_tenant(tenant_id) - flash(f'Tenant "{tenant_id}" has been deleted successfully.', 'success') - return redirect('/') - -@app.route('/settings', methods=['GET', 'POST']) -def settings(): - if request.method == 'POST': - server_url = request.form.get('server_url', '').strip() - if server_url: - # Remove trailing slash for consistency - server_url = server_url.rstrip('/') - database.set_setting('server_url', server_url) - flash('Server settings updated successfully.', 'success') - else: - flash('Please provide a valid server URL.', 'error') - return redirect('/settings') - - server_url = database.get_server_url() - return render_template('settings.html', server_url=server_url) - -@app.route('//') -def tenant_index(tenant_id): - # Auto-create tenant if it doesn't exist - tenant = database.get_or_create_tenant(tenant_id) - if tenant is None: - # Reserved tenant ID or invalid - return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404 - - # Load products for this tenant - products = load_products(tenant_id) - custom_fields = database.get_custom_ar_fields(tenant_id) - - return render_template('index.html', tenant=tenant, products=products, custom_fields=custom_fields) - -@app.route('//settings') -def tenant_settings(tenant_id): - # Get tenant - tenant = database.get_or_create_tenant(tenant_id) - if tenant is None: - return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404 - - # Get custom AR fields for this tenant - custom_fields = database.get_custom_ar_fields(tenant_id) - - # Get server URL for API endpoint display - server_url = database.get_server_url() - - return render_template('tenant_settings.html', tenant=tenant, custom_fields=custom_fields, server_url=server_url) - -@app.route('//settings/credentials', methods=['POST']) -def update_tenant_credentials(tenant_id): - # Get tenant - tenant = database.get_tenant(tenant_id) - if tenant is None: - return jsonify({"error": "Tenant not found"}), 404 - - username = request.form.get('username', '').strip() - password = request.form.get('password', '').strip() - - if not username: - flash('Username is required.', 'error') - return redirect(f'/{tenant_id}/settings') - - # Update credentials - database.update_tenant_credentials(tenant_id, username, password if password else None) - - flash('Credentials updated successfully.', 'success') - return redirect(f'/{tenant_id}/settings') - -@app.route('//settings/barcode', methods=['POST']) -def update_tenant_barcode_type(tenant_id): - # Get tenant - tenant = database.get_tenant(tenant_id) - if tenant is None: - return jsonify({"error": "Tenant not found"}), 404 - - barcode_type = request.form.get('barcode_type', '').strip() - - if not barcode_type: - flash('Barcode type is required.', 'error') - return redirect(f'/{tenant_id}/settings') - - # Update barcode type - database.update_tenant_barcode_type(tenant_id, barcode_type) - - flash('Barcode type updated successfully.', 'success') - return redirect(f'/{tenant_id}/settings') - -def check_basic_auth(auth_header, tenant_id): - """Validate Basic authentication credentials for a tenant""" - if not auth_header or not auth_header.startswith('Basic '): - return False - - try: - # Decode the base64 credentials - credentials = base64.b64decode(auth_header[6:]).decode('utf-8') - username, password = credentials.split(':', 1) - - # Get tenant credentials from database (without creating) - tenant = database.get_tenant(tenant_id) - if tenant and username == tenant['username'] and password == tenant['password']: - return True - except Exception: - pass - - return False - -@app.route('//login', methods=['GET']) -def login(tenant_id): - auth_header = request.headers.get('Authorization') - - if check_basic_auth(auth_header, tenant_id): - return jsonify({"status": "success", "message": "Authentication successful"}), 200 - - return jsonify({"error": "Unauthorized"}), 401 - -@app.route('//arcontentfields', methods=['GET']) -def get_ar_content_fields(tenant_id): - # Get custom fields defined for this tenant - fields = database.get_custom_ar_fields(tenant_id) - return jsonify(fields), 200 - -@app.route('//arinfo', methods=['GET', 'POST']) -def get_ar_info(tenant_id): - barcode = request.args.get('barcode') - products = load_products(tenant_id) - - # Get custom AR fields for this tenant - custom_fields = database.get_custom_ar_fields(tenant_id) - custom_field_names = [f['fieldName'] for f in custom_fields] - - # Handle POST request - update product fields - if request.method == 'POST': - if not barcode: - return jsonify({"error": "Barcode parameter required"}), 400 - - if barcode not in products: - return jsonify({"error": "Product not found"}), 404 - - try: - # Get the updated fields from the request body - updated_fields = request.get_json() - - if not isinstance(updated_fields, list): - return jsonify({"error": "Request body must be an array of fields"}), 400 - - # Get current product data - current_product = products[barcode] - - # Update only the editable fields - for updated_field in updated_fields: - field_name = updated_field.get('fieldName') - new_value = updated_field.get('value') - - # Find the field in the current product and update it - for field in current_product: - if field['fieldName'] == field_name: - # Check if the field is editable - if field.get('editable') == 'true': - field['value'] = new_value - break - - # Save the updated product to database - database.save_product(barcode, tenant_id, current_product) - - app.logger.info(f"Updated product {barcode} for tenant {tenant_id}") - return jsonify({"success": True}), 200 - - except Exception as e: - app.logger.error(f"Error updating product: {str(e)}") - return jsonify({"error": "Failed to update product"}), 500 - - # Helper function to convert relative image paths to absolute URLs and filter fields - def filter_and_process_fields(product_fields): - # Filter to only include fields defined in custom AR fields - filtered_fields = [] - - # Get field types for all custom fields - field_types = {f['fieldName']: f['fieldType'] for f in custom_fields} - - for field in product_fields: - if field['fieldName'] in custom_field_names: - # Create absolute URL for IMAGE_URI fields - if field_types.get(field['fieldName']) == 'IMAGE_URI' and field['value']: - # If it's already an absolute URL, leave it as is - if not field['value'].startswith('http'): - # Build absolute URL using request host with tenant - field['value'] = f"{request.url_root.rstrip('/')}/{tenant_id}{field['value']}" - filtered_fields.append(field) - return filtered_fields - - # Handle GET request - return product data - # If barcode is provided, return specific product - if barcode: - if barcode in products: - product_data = filter_and_process_fields(products[barcode]) - response = jsonify(product_data) - response.headers['Access-Control-Allow-Origin'] = '*' - return response, 200 - return jsonify({"error": "Product not found"}), 404 - - # Return all products if no barcode specified - # Convert all products to have absolute URLs and filter fields - all_products = {} - for product_id, fields in products.items(): - all_products[product_id] = filter_and_process_fields(fields) - response = jsonify(all_products) - response.headers['Access-Control-Allow-Origin'] = '*' - return response, 200 - -@app.route('//images/', methods=['GET']) -def serve_image(tenant_id, filename): - # Log the request for debugging - app.logger.info(f"Image requested for tenant {tenant_id}: {filename}") - - # Check if filename contains field name (e.g., "123456_thumbnail.jpg") - base_name = os.path.splitext(filename)[0] - - if '_' in base_name: - # Split to get product_id and field_name - parts = base_name.split('_', 1) - product_id = parts[0] - field_name = '_' + parts[1] if len(parts) > 1 else '_image' - - # Try to get field-specific image - image_data = database.get_product_image_by_field(product_id, tenant_id, field_name) - if image_data: - image_bytes, mime_type = image_data - response = Response(image_bytes, mimetype=mime_type) - response.headers['Access-Control-Allow-Origin'] = '*' - response.headers['Cache-Control'] = 'public, max-age=3600' - app.logger.info(f"Serving field-specific image: {product_id}/{field_name} ({len(image_bytes)} bytes)") - return response - - # Try standard image lookup (backward compatibility) - product_id = os.path.splitext(filename)[0] - - # Get image from database for this tenant - image_data = database.get_product_image(product_id, tenant_id) - if image_data: - image_bytes, mime_type = image_data - response = Response(image_bytes, mimetype=mime_type) - # Add CORS headers to allow cross-origin requests - response.headers['Access-Control-Allow-Origin'] = '*' - response.headers['Cache-Control'] = 'public, max-age=3600' - app.logger.info(f"Serving image from database: {product_id} ({len(image_bytes)} bytes)") - return response - - # Fallback to file system for backward compatibility - image_path = os.path.join(app.config['DATA_FOLDER'], 'images', filename) - if os.path.exists(image_path): - app.logger.info(f"Serving image from filesystem: {image_path}") - response = send_file(image_path) - response.headers['Access-Control-Allow-Origin'] = '*' - return response - - app.logger.warning(f"Image not found: {filename}") - return jsonify({"error": "Image not found"}), 404 - -@app.route('//qrcode/template', methods=['GET']) -def generate_template_qr_code(tenant_id): - """Generate QR code for the AR Template URL""" - try: - # Get server URL from settings - server_url = database.get_server_url() - template_url = f"{server_url}/{tenant_id}/" - - # Generate QR code - buffer = BytesIO() - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(template_url) - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white") - img.save(buffer, format='PNG') - buffer.seek(0) - - return Response(buffer.getvalue(), mimetype='image/png') - except Exception as e: - app.logger.error(f"QR code generation error: {str(e)}") - return jsonify({"error": "Failed to generate QR code"}), 500 - -@app.route('//qrcode/arinfo', methods=['GET']) -def generate_ar_qr_code(tenant_id): - """Generate QR code for the AR API endpoint""" - try: - # Get server URL from settings - server_url = database.get_server_url() - ar_url = f"{server_url}/{tenant_id}/arinfo" - - # Generate QR code - buffer = BytesIO() - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(ar_url) - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white") - img.save(buffer, format='PNG') - buffer.seek(0) - - return Response(buffer.getvalue(), mimetype='image/png') - except Exception as e: - app.logger.error(f"QR code generation error: {str(e)}") - return jsonify({"error": "Failed to generate QR code"}), 500 - -@app.route('//barcodes/', methods=['GET']) -def serve_barcode(tenant_id, filename): - # Parse filename to extract product_id and barcode type - # Expected format: {product_id}_{type}.png - name_parts = os.path.splitext(filename)[0].split('_') - if len(name_parts) < 2: - return jsonify({"error": "Invalid barcode filename format"}), 400 - - product_id = '_'.join(name_parts[:-1]) # Handle product IDs with underscores - code_type = name_parts[-1].lower() - - # Generate barcode dynamically - try: - buffer = BytesIO() - - if code_type == 'qr': - # Generate QR code with tenant in URL - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(f'http://{request.host}/{tenant_id}/arinfo?barcode={product_id}') - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white") - img.save(buffer, format='PNG') - - elif code_type == 'ean13': - # Generate EAN-13 barcode - # Convert product_id to numeric format if needed - numeric_id = ''.join(filter(str.isdigit, product_id)) - if not numeric_id: - numeric_id = str(abs(hash(product_id)) % 1000000000000)[:12] - else: - numeric_id = numeric_id[:12].zfill(12) - - EAN = barcode.get_barcode_class('ean13') - ean = EAN(numeric_id, writer=ImageWriter()) - ean.write(buffer) - - elif code_type == 'code128': - # Generate Code 128 barcode - CODE128 = barcode.get_barcode_class('code128') - code = CODE128(product_id, writer=ImageWriter()) - code.write(buffer) - - else: - return jsonify({"error": "Unsupported barcode type"}), 400 - - buffer.seek(0) - return Response(buffer.getvalue(), mimetype='image/png') - - except Exception as e: - app.logger.error(f"Barcode generation error: {str(e)}") - return jsonify({"error": "Failed to generate barcode"}), 500 - -# Import product management routes -from routes.admin import (add_product, edit_product, delete_product, - generate_barcode, manage_ar_fields, view_all_barcodes) - -# Register product management routes (remove /admin/ from paths) -app.add_url_rule('//add', 'admin.add_product', add_product, methods=['GET', 'POST']) -app.add_url_rule('//edit/', 'admin.edit_product', edit_product, methods=['GET', 'POST']) -app.add_url_rule('//delete/', 'admin.delete_product', delete_product, methods=['POST']) -app.add_url_rule('//generate_barcode//', 'admin.generate_barcode', generate_barcode) -app.add_url_rule('//ar_fields', 'admin.manage_ar_fields', manage_ar_fields, methods=['GET', 'POST']) -app.add_url_rule('//barcodes', 'admin.view_all_barcodes', view_all_barcodes, methods=['GET']) - -# Register API routes with tenant prefix -from routes.api import api_index -app.add_url_rule('//api/', 'api.api_index', api_index) - -# Catch-all route for admin without tenant - redirect to home -@app.route('/admin/') -@app.route('/admin/') -def redirect_admin_to_home(path=None): - return redirect('/') - -if __name__ == '__main__': - # Initialize database - database.init_database() - - # Migrate existing data from JSON if needed - products_file = os.path.join(app.config['DATA_FOLDER'], 'products.json') - if os.path.exists(products_file): - print("Migrating data from products.json to SQLite...") - database.migrate_from_json() - # Optionally rename the JSON file to indicate it's been migrated - shutil.move(products_file, products_file + '.migrated') - print("Migration complete.") - - # Clean up any accidentally created reserved tenants - deleted_count = database.cleanup_reserved_tenants() - if deleted_count > 0: - print(f"Cleaned up {deleted_count} reserved tenant(s)") - - app.run(port=5555, host="0.0.0.0", debug=True) diff --git a/src/app/__init__.py b/src/app/__init__.py new file mode 100644 index 0000000..fbf2ea3 --- /dev/null +++ b/src/app/__init__.py @@ -0,0 +1,49 @@ +import os +from flask import Flask, redirect +from werkzeug.routing import BaseConverter + +def create_app(config_name='development'): + """Application factory pattern""" + app = Flask(__name__) + + # Load configuration + from app.config import config + app.config.from_object(config[config_name]) + + # Ensure data folder exists + os.makedirs(app.config['DATA_FOLDER'], exist_ok=True) + + # Custom converter for tenant IDs + class TenantConverter(BaseConverter): + regex = '[a-zA-Z0-9_-]+' + + def to_python(self, value): + # Convert to lowercase when parsing from URL + return value.lower() + + def to_url(self, value): + # Convert to lowercase when generating URLs + return value.lower() + + app.url_map.converters['tenant'] = TenantConverter + + # Initialize database + with app.app_context(): + from app.models.base import init_database + init_database() + + # Register blueprints + from app.blueprints import main_bp, tenant_bp, admin_bp, api_bp + + app.register_blueprint(main_bp) + app.register_blueprint(tenant_bp) + app.register_blueprint(admin_bp) + app.register_blueprint(api_bp) + + # Catch-all route for admin without tenant - redirect to home + @app.route('/admin/') + @app.route('/admin/') + def redirect_admin_to_home(path=None): + return redirect('/') + + return app diff --git a/src/app/blueprints/__init__.py b/src/app/blueprints/__init__.py new file mode 100644 index 0000000..c82d1f5 --- /dev/null +++ b/src/app/blueprints/__init__.py @@ -0,0 +1,6 @@ +from .main import main_bp +from .tenant import tenant_bp +from .admin import admin_bp +from .api import api_bp + +__all__ = ['main_bp', 'tenant_bp', 'admin_bp', 'api_bp'] diff --git a/src/app/blueprints/admin/__init__.py b/src/app/blueprints/admin/__init__.py new file mode 100644 index 0000000..27197e0 --- /dev/null +++ b/src/app/blueprints/admin/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +admin_bp = Blueprint('admin', __name__, url_prefix='/') + +from . import routes diff --git a/src/routes/admin.py b/src/app/blueprints/admin/routes.py similarity index 66% rename from src/routes/admin.py rename to src/app/blueprints/admin/routes.py index f8a5d47..8ad63d1 100644 --- a/src/routes/admin.py +++ b/src/app/blueprints/admin/routes.py @@ -1,85 +1,60 @@ -from flask import render_template, request, redirect, url_for, flash, jsonify -import os -import sys -sys.path.append(os.path.dirname(os.path.dirname(__file__))) -import database - -DATA_FOLDER = os.path.join(os.path.dirname(__file__), '../data') -PRODUCTS_FILE = os.path.join(DATA_FOLDER, 'products.json') -UPLOAD_FOLDER = os.path.join(DATA_FOLDER, 'images') -BARCODE_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'data', 'barcodes')) -ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} - -def allowed_file(filename): - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS - -def load_products(tenant_id): - return database.get_all_products(tenant_id) - -def save_products(products): - # This is now handled by database operations - pass - -def index(tenant_id): - products = load_products(tenant_id) - tenant = database.get_or_create_tenant(tenant_id) - custom_fields = database.get_custom_ar_fields(tenant_id) - return render_template('admin/index.html', products=products, tenant=tenant, custom_fields=custom_fields) +from flask import render_template, request, redirect, url_for, flash, jsonify, current_app +from . import admin_bp +from app.models import TenantModel, ProductModel, ARFieldModel +from app.services import ProductService +@admin_bp.route('/add', methods=['GET', 'POST']) def add_product(tenant_id): - # Get custom AR fields for this tenant - custom_fields = database.get_custom_ar_fields(tenant_id) - + """Add a new product""" + custom_fields = ARFieldModel.get_all(tenant_id) + if request.method == 'POST': - # Get form data product_id = request.form.get('product_id') - - # Basic validation + if not product_id: flash('Product ID is required.') return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields) - + # Check if product ID already exists - products = load_products(tenant_id) + products = ProductModel.get_all(tenant_id) if product_id in products: flash('Product ID already exists.') return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields) - + # Handle backward compatibility image upload image_data = None image_mime_type = None - + # Create product data structure based on custom fields product_data = [] - + # Always include _id field product_data.append({ - "fieldName": "_id", - "label": "Item ID", - "value": product_id, - "editable": "false", + "fieldName": "_id", + "label": "Item ID", + "value": product_id, + "editable": "false", "fieldType": "TEXT" }) - + # Process images after product is saved images_to_save = [] - + # Add other fields based on custom configuration for field in custom_fields: field_name = field['fieldName'] if field_name == '_id': - continue # Already added - elif field['fieldType'] == 'IMAGE_URI': - # Check if image was uploaded for this field + continue + + if field['fieldType'] == 'IMAGE_URI': image_field_name = f'image_{field_name}' if image_field_name in request.files: file = request.files[image_field_name] - if file and file.filename and allowed_file(file.filename): - # Read image data + allowed_extensions = current_app.config['ALLOWED_EXTENSIONS'] + if file and file.filename and ProductService.allowed_file(file.filename, allowed_extensions): file.seek(0) img_data = file.read() - - # Determine mime type + extension = file.filename.rsplit('.', 1)[1].lower() mime_types = { 'jpg': 'image/jpeg', @@ -88,19 +63,16 @@ def add_product(tenant_id): 'gif': 'image/gif' } mime_type = mime_types.get(extension, 'image/jpeg') - - # Store for later saving + images_to_save.append({ 'field_name': field_name, 'data': img_data, 'mime_type': mime_type }) - - # Set the image URL path (strip leading underscore from field_name to avoid double underscores) + field_suffix = field_name[1:] if field_name.startswith('_') else field_name image_path = f"/images/{product_id}_{field_suffix}.{extension}" - - # For backward compatibility with _image field + if field_name == '_image': image_data = img_data image_mime_type = mime_type @@ -108,7 +80,7 @@ def add_product(tenant_id): image_path = "" else: image_path = "" - + product_data.append({ "fieldName": field_name, "label": field['label'], @@ -117,7 +89,6 @@ def add_product(tenant_id): "fieldType": field['fieldType'] }) else: - # Get value from form value = request.form.get(f'field_{field_name}', '') product_data.append({ "fieldName": field_name, @@ -126,8 +97,8 @@ def add_product(tenant_id): "editable": field['editable'], "fieldType": field['fieldType'] }) - - # Also save fields that aren't in custom fields (for backward compatibility) + + # Handle backward compatibility fields if '_name' not in [f['fieldName'] for f in custom_fields]: name = request.form.get('name', '') if name: @@ -138,7 +109,7 @@ def add_product(tenant_id): "editable": "true", "fieldType": "TEXT" }) - + if '_price' not in [f['fieldName'] for f in custom_fields]: price = request.form.get('price', '') if price: @@ -149,57 +120,56 @@ def add_product(tenant_id): "editable": "true", "fieldType": "TEXT" }) - + # Save to database - database.save_product(product_id, tenant_id, product_data, image_data, image_mime_type) - + ProductModel.save(product_id, tenant_id, product_data, image_data, image_mime_type) + # Save additional images for img in images_to_save: - database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type']) - + ProductModel.save_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type']) + flash('Product added successfully!') return redirect(f'/{tenant_id}/') - + return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields) +@admin_bp.route('/edit/', methods=['GET', 'POST']) def edit_product(tenant_id, product_id): - products = load_products(tenant_id) - custom_fields = database.get_custom_ar_fields(tenant_id) + """Edit an existing product""" + products = ProductModel.get_all(tenant_id) + custom_fields = ARFieldModel.get_all(tenant_id) if product_id not in products: flash('Product not found.') return redirect(f'/{tenant_id}/') - + if request.method == 'POST': - # Handle backward compatibility image upload image_data = None image_mime_type = None - - # Process images after product is saved images_to_save = [] # Update fields based on form data updated_product = [] - + # Always include _id field for field in products[product_id]: if field["fieldName"] == "_id": updated_product.append(field) break - + # Process each custom field for custom_field in custom_fields: field_name = custom_field['fieldName'] if field_name == '_id': continue - + # Find existing field data existing_field = None for field in products[product_id]: if field["fieldName"] == field_name: existing_field = field.copy() break - + if not existing_field: existing_field = { "fieldName": field_name, @@ -208,18 +178,16 @@ def edit_product(tenant_id, product_id): "editable": custom_field['editable'], "fieldType": custom_field['fieldType'] } - + if custom_field['fieldType'] == 'IMAGE_URI': - # Check if new image was uploaded image_field_name = f'image_{field_name}' if image_field_name in request.files and request.files[image_field_name].filename: file = request.files[image_field_name] - if allowed_file(file.filename): - # Read image data + allowed_extensions = current_app.config['ALLOWED_EXTENSIONS'] + if ProductService.allowed_file(file.filename, allowed_extensions): file.seek(0) img_data = file.read() - # Determine mime type extension = file.filename.rsplit('.', 1)[1].lower() mime_types = { 'jpg': 'image/jpeg', @@ -229,31 +197,26 @@ def edit_product(tenant_id, product_id): } mime_type = mime_types.get(extension, 'image/jpeg') - # Store for later saving images_to_save.append({ 'field_name': field_name, 'data': img_data, 'mime_type': mime_type }) - # Update image path (strip leading underscore from field_name to avoid double underscores) field_suffix = field_name[1:] if field_name.startswith('_') else field_name existing_field["value"] = f"/images/{product_id}_{field_suffix}.{extension}" - # For backward compatibility with _image field if field_name == '_image': image_data = img_data image_mime_type = mime_type - # If no new image uploaded, keep the existing value (already in existing_field) else: - # Get value from form value = request.form.get(f'field_{field_name}', '') if field_name == '_price' and value and not value.startswith('$'): value = f"${value}" existing_field["value"] = value - + updated_product.append(existing_field) - + # Handle backward compatibility fields if '_name' not in [f['fieldName'] for f in custom_fields]: name = request.form.get('name', '') @@ -263,7 +226,7 @@ def edit_product(tenant_id, product_id): field["value"] = name updated_product.append(field) break - + if '_price' not in [f['fieldName'] for f in custom_fields]: price = request.form.get('price', '') if price: @@ -272,52 +235,43 @@ def edit_product(tenant_id, product_id): field["value"] = f"${price}" updated_product.append(field) break - + # Save to database - database.save_product(product_id, tenant_id, updated_product, image_data, image_mime_type) - + ProductModel.save(product_id, tenant_id, updated_product, image_data, image_mime_type) + # Save additional images for img in images_to_save: - database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type']) - + ProductModel.save_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type']) + flash('Product updated successfully!') return redirect(f'/{tenant_id}/') - - return render_template('admin/edit_product.html', - product_id=product_id, - product=products[product_id], + + return render_template('admin/edit_product.html', + product_id=product_id, + product=products[product_id], tenant_id=tenant_id, custom_fields=custom_fields) +@admin_bp.route('/delete/', methods=['POST']) def delete_product(tenant_id, product_id): - products = load_products(tenant_id) + """Delete a product""" + products = ProductModel.get_all(tenant_id) if product_id not in products: flash('Product not found.') return redirect(f'/{tenant_id}/') - # Delete product from database (image is stored in DB) - database.delete_product(product_id, tenant_id) - + ProductModel.delete(product_id, tenant_id) flash('Product deleted successfully!') return redirect(f'/{tenant_id}/') -def view_product(tenant_id, product_id): - products = load_products(tenant_id) - - if product_id not in products: - flash('Product not found.') - return redirect(f'/{tenant_id}/') - - return render_template('admin/view_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id) - +@admin_bp.route('/generate_barcode//') def generate_barcode(tenant_id, product_id, code_type): """Redirect to the main barcode generation endpoint""" - products = load_products(tenant_id) + products = ProductModel.get_all(tenant_id) if product_id not in products: return jsonify({"error": "Product not found"}), 404 - # Map code_type to the expected format for the main endpoint type_mapping = { 'qrcode': 'qr', 'ean13': 'ean13', @@ -325,16 +279,14 @@ def generate_barcode(tenant_id, product_id, code_type): } barcode_type = type_mapping.get(code_type, code_type) - - # Redirect to the main barcode endpoint which generates dynamically return redirect(f'/{tenant_id}/barcodes/{product_id}_{barcode_type}.png') +@admin_bp.route('/barcodes', methods=['GET']) def view_all_barcodes(tenant_id): """Display all product barcodes for printing""" - products = load_products(tenant_id) - tenant = database.get_or_create_tenant(tenant_id) + products = ProductModel.get_all(tenant_id) + tenant = TenantModel.get_or_create(tenant_id) - # Get barcode type setting for this tenant barcode_type = tenant.get('barcode_type', 'qr') return render_template('admin/all_barcodes.html', @@ -342,30 +294,14 @@ def view_all_barcodes(tenant_id): tenant=tenant, barcode_type=barcode_type) -def manage_credentials(tenant_id): - """Manage tenant login credentials""" - tenant = database.get_or_create_tenant(tenant_id) - - if request.method == 'POST': - username = request.form.get('username') - password = request.form.get('password') - - if username and password: - database.update_tenant_credentials(tenant_id, username, password) - flash('Credentials updated successfully!') - return redirect(f'/{tenant_id}/') - else: - flash('Username and password are required.') - - return render_template('admin/credentials.html', tenant=tenant, tenant_id=tenant_id) - +@admin_bp.route('/ar_fields', methods=['GET', 'POST']) def manage_ar_fields(tenant_id): """Manage custom AR content fields""" - tenant = database.get_or_create_tenant(tenant_id) - + tenant = TenantModel.get_or_create(tenant_id) + if request.method == 'POST': action = request.form.get('action') - + if action == 'add': field_data = { 'fieldName': request.form.get('fieldName'), @@ -374,19 +310,19 @@ def manage_ar_fields(tenant_id): 'editable': request.form.get('editable', 'true'), 'displayOrder': int(request.form.get('displayOrder', 0)) } - + if field_data['fieldName'] and field_data['label']: - database.save_custom_ar_field(tenant_id, field_data) + ARFieldModel.save(tenant_id, field_data) flash('Custom field added successfully!') else: flash('Field name and label are required.') - + elif action == 'delete': field_id = request.form.get('field_id') if field_id: - database.delete_custom_ar_field(tenant_id, int(field_id)) + ARFieldModel.delete(tenant_id, int(field_id)) flash('Custom field deleted successfully!') - + elif action == 'update': field_data = { 'id': int(request.form.get('field_id')), @@ -396,26 +332,26 @@ def manage_ar_fields(tenant_id): 'editable': request.form.get('editable', 'true'), 'displayOrder': int(request.form.get('displayOrder', 0)) } - + if field_data['fieldName'] and field_data['label']: - database.save_custom_ar_field(tenant_id, field_data) + ARFieldModel.save(tenant_id, field_data) flash('Custom field updated successfully!') else: flash('Field name and label are required.') - + return redirect(url_for('admin.manage_ar_fields', tenant_id=tenant_id)) - + # Get existing custom fields - custom_fields = database.get_custom_ar_fields(tenant_id) - + custom_fields = ARFieldModel.get_all(tenant_id) + # Available field types field_types = [ 'TEXT', 'IMAGE_URI' ] - - return render_template('admin/ar_fields.html', - tenant=tenant, + + return render_template('admin/ar_fields.html', + tenant=tenant, tenant_id=tenant_id, custom_fields=custom_fields, field_types=field_types) diff --git a/src/app/blueprints/api/__init__.py b/src/app/blueprints/api/__init__.py new file mode 100644 index 0000000..2e6eae8 --- /dev/null +++ b/src/app/blueprints/api/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +api_bp = Blueprint('api', __name__, url_prefix='//api') + +from . import routes diff --git a/src/routes/api.py b/src/app/blueprints/api/routes.py similarity index 63% rename from src/routes/api.py rename to src/app/blueprints/api/routes.py index 285d1c8..1dad439 100644 --- a/src/routes/api.py +++ b/src/app/blueprints/api/routes.py @@ -1,5 +1,7 @@ -# API routes scaffold from flask import jsonify +from . import api_bp +@api_bp.route('/') def api_index(tenant_id): + """API home endpoint""" return jsonify({'message': 'API Home', 'tenant': tenant_id}) diff --git a/src/app/blueprints/main/__init__.py b/src/app/blueprints/main/__init__.py new file mode 100644 index 0000000..d4dcf69 --- /dev/null +++ b/src/app/blueprints/main/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +main_bp = Blueprint('main', __name__) + +from . import routes diff --git a/src/app/blueprints/main/routes.py b/src/app/blueprints/main/routes.py new file mode 100644 index 0000000..5c6b186 --- /dev/null +++ b/src/app/blueprints/main/routes.py @@ -0,0 +1,27 @@ +from flask import render_template, request, redirect, flash +from . import main_bp +from app.models import TenantModel, SettingsModel + +@main_bp.route('/') +def index(): + """Tenant selection page""" + tenants = TenantModel.get_all() + server_url = SettingsModel.get_server_url() + return render_template('tenant_selection.html', tenants=tenants, server_url=server_url) + +@main_bp.route('/settings', methods=['GET', 'POST']) +def settings(): + """Global settings page""" + if request.method == 'POST': + server_url = request.form.get('server_url', '').strip() + if server_url: + # Remove trailing slash for consistency + server_url = server_url.rstrip('/') + SettingsModel.set('server_url', server_url) + flash('Server settings updated successfully.', 'success') + else: + flash('Please provide a valid server URL.', 'error') + return redirect('/settings') + + server_url = SettingsModel.get_server_url() + return render_template('settings.html', server_url=server_url) diff --git a/src/app/blueprints/tenant/__init__.py b/src/app/blueprints/tenant/__init__.py new file mode 100644 index 0000000..9af6a7b --- /dev/null +++ b/src/app/blueprints/tenant/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +tenant_bp = Blueprint('tenant', __name__, url_prefix='/') + +from . import routes diff --git a/src/app/blueprints/tenant/routes.py b/src/app/blueprints/tenant/routes.py new file mode 100644 index 0000000..87698e8 --- /dev/null +++ b/src/app/blueprints/tenant/routes.py @@ -0,0 +1,224 @@ +from flask import render_template, request, redirect, flash, jsonify, Response, current_app +from . import tenant_bp +from app.models import TenantModel, ProductModel, ARFieldModel, SettingsModel +from app.services import AuthService, ProductService, BarcodeService +import os + +@tenant_bp.route('/') +def index(tenant_id): + """Tenant home page - product listing""" + tenant = TenantModel.get_or_create(tenant_id) + if tenant is None: + return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404 + + products = ProductModel.get_all(tenant_id) + custom_fields = ARFieldModel.get_all(tenant_id) + + return render_template('index.html', tenant=tenant, products=products, custom_fields=custom_fields) + +@tenant_bp.route('/delete', methods=['POST']) +def delete_tenant(tenant_id): + """Delete a tenant and all its data""" + TenantModel.delete(tenant_id) + flash(f'Tenant "{tenant_id}" has been deleted successfully.', 'success') + return redirect('/') + +@tenant_bp.route('/settings') +def settings(tenant_id): + """Tenant settings page""" + tenant = TenantModel.get_or_create(tenant_id) + if tenant is None: + return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404 + + custom_fields = ARFieldModel.get_all(tenant_id) + server_url = SettingsModel.get_server_url() + + return render_template('tenant_settings.html', tenant=tenant, custom_fields=custom_fields, server_url=server_url) + +@tenant_bp.route('/settings/credentials', methods=['POST']) +def update_credentials(tenant_id): + """Update tenant credentials""" + tenant = TenantModel.get_by_id(tenant_id) + if tenant is None: + return jsonify({"error": "Tenant not found"}), 404 + + username = request.form.get('username', '').strip() + password = request.form.get('password', '').strip() + + if not username: + flash('Username is required.', 'error') + return redirect(f'/{tenant_id}/settings') + + TenantModel.update_credentials(tenant_id, username, password if password else None) + flash('Credentials updated successfully.', 'success') + return redirect(f'/{tenant_id}/settings') + +@tenant_bp.route('/settings/barcode', methods=['POST']) +def update_barcode_type(tenant_id): + """Update tenant barcode type""" + tenant = TenantModel.get_by_id(tenant_id) + if tenant is None: + return jsonify({"error": "Tenant not found"}), 404 + + barcode_type = request.form.get('barcode_type', '').strip() + + if not barcode_type: + flash('Barcode type is required.', 'error') + return redirect(f'/{tenant_id}/settings') + + TenantModel.update_barcode_type(tenant_id, barcode_type) + flash('Barcode type updated successfully.', 'success') + return redirect(f'/{tenant_id}/settings') + +@tenant_bp.route('/login', methods=['GET']) +def login(tenant_id): + """Login endpoint for API authentication""" + auth_header = request.headers.get('Authorization') + + if AuthService.check_basic_auth(auth_header, tenant_id): + return jsonify({"status": "success", "message": "Authentication successful"}), 200 + + return jsonify({"error": "Unauthorized"}), 401 + +@tenant_bp.route('/arcontentfields', methods=['GET']) +def get_ar_content_fields(tenant_id): + """Get custom AR fields for tenant""" + fields = ARFieldModel.get_all(tenant_id) + return jsonify(fields), 200 + +@tenant_bp.route('/arinfo', methods=['GET', 'POST']) +def get_ar_info(tenant_id): + """Get or update AR product information""" + barcode = request.args.get('barcode') + + # Handle POST request - update product fields + if request.method == 'POST': + if not barcode: + return jsonify({"error": "Barcode parameter required"}), 400 + + product = ProductModel.get_by_id(barcode, tenant_id) + if not product: + return jsonify({"error": "Product not found"}), 404 + + try: + updated_fields = request.get_json() + if not isinstance(updated_fields, list): + return jsonify({"error": "Request body must be an array of fields"}), 400 + + # Update only the editable fields + for updated_field in updated_fields: + field_name = updated_field.get('fieldName') + new_value = updated_field.get('value') + + for field in product: + if field['fieldName'] == field_name: + if field.get('editable') == 'true': + field['value'] = new_value + break + + # Save the updated product + ProductModel.save(barcode, tenant_id, product) + current_app.logger.info(f"Updated product {barcode} for tenant {tenant_id}") + return jsonify({"success": True}), 200 + + except Exception as e: + current_app.logger.error(f"Error updating product: {str(e)}") + return jsonify({"error": "Failed to update product"}), 500 + + # Handle GET request - return product data + if barcode: + product_data = ProductService.get_product_filtered(barcode, tenant_id) + if product_data: + response = jsonify(product_data) + response.headers['Access-Control-Allow-Origin'] = '*' + return response, 200 + return jsonify({"error": "Product not found"}), 404 + + # Return all products if no barcode specified + all_products = ProductService.get_all_products_filtered(tenant_id) + response = jsonify(all_products) + response.headers['Access-Control-Allow-Origin'] = '*' + return response, 200 + +@tenant_bp.route('/images/', methods=['GET']) +def serve_image(tenant_id, filename): + """Serve product images""" + current_app.logger.info(f"Image requested for tenant {tenant_id}: {filename}") + + # Check if filename contains field name (e.g., "123456_thumbnail.jpg") + base_name = os.path.splitext(filename)[0] + + if '_' in base_name: + parts = base_name.split('_', 1) + product_id = parts[0] + field_name = '_' + parts[1] if len(parts) > 1 else '_image' + + # Try to get field-specific image + image_data = ProductModel.get_image_by_field(product_id, tenant_id, field_name) + if image_data: + image_bytes, mime_type = image_data + response = Response(image_bytes, mimetype=mime_type) + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Cache-Control'] = 'public, max-age=3600' + current_app.logger.info(f"Serving field-specific image: {product_id}/{field_name} ({len(image_bytes)} bytes)") + return response + + # Try standard image lookup (backward compatibility) + product_id = os.path.splitext(filename)[0] + image_data = ProductModel.get_image(product_id, tenant_id) + if image_data: + image_bytes, mime_type = image_data + response = Response(image_bytes, mimetype=mime_type) + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Cache-Control'] = 'public, max-age=3600' + current_app.logger.info(f"Serving image from database: {product_id} ({len(image_bytes)} bytes)") + return response + + current_app.logger.warning(f"Image not found: {filename}") + return jsonify({"error": "Image not found"}), 404 + +@tenant_bp.route('/qrcode/template', methods=['GET']) +def generate_template_qr_code(tenant_id): + """Generate QR code for the AR Template URL""" + try: + server_url = SettingsModel.get_server_url() + template_url = f"{server_url}/{tenant_id}/" + buffer = BarcodeService.generate_qr_code(template_url) + return Response(buffer.getvalue(), mimetype='image/png') + except Exception as e: + current_app.logger.error(f"QR code generation error: {str(e)}") + return jsonify({"error": "Failed to generate QR code"}), 500 + +@tenant_bp.route('/qrcode/arinfo', methods=['GET']) +def generate_ar_qr_code(tenant_id): + """Generate QR code for the AR API endpoint""" + try: + server_url = SettingsModel.get_server_url() + ar_url = f"{server_url}/{tenant_id}/arinfo" + buffer = BarcodeService.generate_qr_code(ar_url) + return Response(buffer.getvalue(), mimetype='image/png') + except Exception as e: + current_app.logger.error(f"QR code generation error: {str(e)}") + return jsonify({"error": "Failed to generate QR code"}), 500 + +@tenant_bp.route('/barcodes/', methods=['GET']) +def serve_barcode(tenant_id, filename): + """Serve dynamically generated barcodes""" + name_parts = os.path.splitext(filename)[0].split('_') + if len(name_parts) < 2: + return jsonify({"error": "Invalid barcode filename format"}), 400 + + product_id = '_'.join(name_parts[:-1]) + code_type = name_parts[-1].lower() + + try: + url = None + if code_type == 'qr': + url = f'http://{request.host}/{tenant_id}/arinfo?barcode={product_id}' + + buffer = BarcodeService.generate_barcode(product_id, code_type, url) + return Response(buffer.getvalue(), mimetype='image/png') + + except Exception as e: + current_app.logger.error(f"Barcode generation error: {str(e)}") + return jsonify({"error": "Failed to generate barcode"}), 500 diff --git a/src/app/config.py b/src/app/config.py new file mode 100644 index 0000000..0f22651 --- /dev/null +++ b/src/app/config.py @@ -0,0 +1,43 @@ +import os + +class Config: + """Base configuration""" + SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key-for-demo-only' + BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + DATA_FOLDER = os.path.join(BASE_DIR, 'data') + DATABASE_PATH = os.path.join(DATA_FOLDER, 'products.db') + + # Reserved tenant IDs that cannot be used + RESERVED_TENANT_IDS = { + 'admin', 'api', 'login', 'logout', 'arcontentfields', 'arinfo', + 'images', 'barcodes', 'static', 'assets', 'js', 'css', 'tenant', + 'auth', 'oauth', 'callback', 'webhook', 'health', 'status' + } + + # File upload settings + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} + MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size + +class DevelopmentConfig(Config): + """Development configuration""" + DEBUG = True + TESTING = False + +class ProductionConfig(Config): + """Production configuration""" + DEBUG = False + TESTING = False + +class TestingConfig(Config): + """Testing configuration""" + DEBUG = True + TESTING = True + DATABASE_PATH = ':memory:' + +# Configuration dictionary +config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'testing': TestingConfig, + 'default': DevelopmentConfig +} diff --git a/src/app/models/__init__.py b/src/app/models/__init__.py new file mode 100644 index 0000000..868b479 --- /dev/null +++ b/src/app/models/__init__.py @@ -0,0 +1,6 @@ +from .tenant import TenantModel +from .product import ProductModel +from .ar_field import ARFieldModel +from .settings import SettingsModel + +__all__ = ['TenantModel', 'ProductModel', 'ARFieldModel', 'SettingsModel'] diff --git a/src/app/models/ar_field.py b/src/app/models/ar_field.py new file mode 100644 index 0000000..8e63f7c --- /dev/null +++ b/src/app/models/ar_field.py @@ -0,0 +1,84 @@ +from typing import Dict, List, Any +from .base import get_db + +class ARFieldModel: + """Model for custom AR field operations""" + + @staticmethod + def get_all(tenant_id: str) -> List[Dict[str, Any]]: + """Get all custom AR fields for a tenant""" + tenant_id = tenant_id.lower() + + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT id, field_name, label, field_type, editable, display_order + FROM custom_ar_fields + WHERE tenant_id = ? + ORDER BY display_order, id + ''', (tenant_id,)) + + fields = [] + for row in cursor.fetchall(): + fields.append({ + 'id': row['id'], + 'fieldName': row['field_name'], + 'label': row['label'], + 'fieldType': row['field_type'], + 'editable': row['editable'], + 'displayOrder': row['display_order'] + }) + + return fields + + @staticmethod + def save(tenant_id: str, field_data: Dict[str, Any]): + """Save or update a custom AR field""" + tenant_id = tenant_id.lower() + + with get_db() as conn: + cursor = conn.cursor() + + if 'id' in field_data and field_data['id']: + # Update existing field + cursor.execute(''' + UPDATE custom_ar_fields + SET field_name = ?, label = ?, field_type = ?, editable = ?, + display_order = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND tenant_id = ? + ''', ( + field_data['fieldName'], + field_data['label'], + field_data['fieldType'], + field_data['editable'], + field_data['displayOrder'], + field_data['id'], + tenant_id + )) + else: + # Insert new field + cursor.execute(''' + INSERT INTO custom_ar_fields + (tenant_id, field_name, label, field_type, editable, display_order) + VALUES (?, ?, ?, ?, ?, ?) + ''', ( + tenant_id, + field_data['fieldName'], + field_data['label'], + field_data['fieldType'], + field_data['editable'], + field_data['displayOrder'] + )) + + conn.commit() + + @staticmethod + def delete(tenant_id: str, field_id: int): + """Delete a custom AR field""" + tenant_id = tenant_id.lower() + + with get_db() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM custom_ar_fields WHERE id = ? AND tenant_id = ?', + (field_id, tenant_id)) + conn.commit() diff --git a/src/app/models/base.py b/src/app/models/base.py new file mode 100644 index 0000000..6aa7ff6 --- /dev/null +++ b/src/app/models/base.py @@ -0,0 +1,111 @@ +import sqlite3 +from contextlib import contextmanager +from flask import current_app + +@contextmanager +def get_db(): + """Context manager for database connections""" + db_path = current_app.config['DATABASE_PATH'] + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + +def init_database(): + """Initialize the database with required tables""" + import os + db_path = current_app.config['DATABASE_PATH'] + os.makedirs(os.path.dirname(db_path), exist_ok=True) + + with get_db() as conn: + cursor = conn.cursor() + + # Create tenants table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS tenants ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + username TEXT, + password TEXT, + barcode_type TEXT DEFAULT 'qr', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Create products table with tenant_id + cursor.execute(''' + CREATE TABLE IF NOT EXISTS products ( + id TEXT, + tenant_id TEXT NOT NULL, + name TEXT NOT NULL, + price TEXT, + inventory INTEGER, + image_data BLOB, + image_mime_type TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, tenant_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE + ) + ''') + + # Create product_fields table with tenant_id + cursor.execute(''' + CREATE TABLE IF NOT EXISTS product_fields ( + product_id TEXT, + tenant_id TEXT, + field_name TEXT, + label TEXT, + value TEXT, + editable TEXT, + field_type TEXT, + FOREIGN KEY (product_id, tenant_id) REFERENCES products(id, tenant_id) ON DELETE CASCADE, + PRIMARY KEY (product_id, tenant_id, field_name) + ) + ''') + + # Create custom_ar_fields table for tenant-specific AR field definitions + cursor.execute(''' + CREATE TABLE IF NOT EXISTS custom_ar_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id TEXT NOT NULL, + field_name TEXT NOT NULL, + label TEXT NOT NULL, + field_type TEXT NOT NULL, + editable TEXT DEFAULT 'true', + display_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + UNIQUE(tenant_id, field_name) + ) + ''') + + # Create product_images table for storing multiple images per product + cursor.execute(''' + CREATE TABLE IF NOT EXISTS product_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + field_name TEXT NOT NULL, + image_data BLOB NOT NULL, + image_mime_type TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (product_id, tenant_id) REFERENCES products(id, tenant_id) ON DELETE CASCADE, + UNIQUE(product_id, tenant_id, field_name) + ) + ''') + + # Create settings table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + conn.commit() diff --git a/src/app/models/product.py b/src/app/models/product.py new file mode 100644 index 0000000..5d5be8b --- /dev/null +++ b/src/app/models/product.py @@ -0,0 +1,192 @@ +from typing import Dict, List, Optional, Any, Tuple +from .base import get_db + +class ProductModel: + """Model for product operations""" + + @staticmethod + def get_all(tenant_id: str) -> Dict[str, List[Dict[str, Any]]]: + """Get all products for a tenant in the legacy format""" + tenant_id = tenant_id.lower() + + with get_db() as conn: + cursor = conn.cursor() + + cursor.execute('SELECT id FROM products WHERE tenant_id = ?', (tenant_id,)) + product_ids = [row['id'] for row in cursor.fetchall()] + + result = {} + for product_id in product_ids: + cursor.execute(''' + SELECT field_name, label, value, editable, field_type + FROM product_fields + WHERE product_id = ? AND tenant_id = ? + ORDER BY field_name + ''', (product_id, tenant_id)) + + fields = [] + for row in cursor.fetchall(): + fields.append({ + 'fieldName': row['field_name'], + 'label': row['label'], + 'value': row['value'], + 'editable': row['editable'], + 'fieldType': row['field_type'] + }) + + result[product_id] = fields + + return result + + @staticmethod + def get_by_id(product_id: str, tenant_id: str) -> Optional[List[Dict[str, Any]]]: + """Get a single product by ID and tenant""" + tenant_id = tenant_id.lower() + + with get_db() as conn: + cursor = conn.cursor() + + cursor.execute(''' + SELECT field_name, label, value, editable, field_type + FROM product_fields + WHERE product_id = ? AND tenant_id = ? + ORDER BY field_name + ''', (product_id, tenant_id)) + + fields = [] + for row in cursor.fetchall(): + fields.append({ + 'fieldName': row['field_name'], + 'label': row['label'], + 'value': row['value'], + 'editable': row['editable'], + 'fieldType': row['field_type'] + }) + + return fields if fields else None + + @staticmethod + def save(product_id: str, tenant_id: str, fields: List[Dict[str, Any]], + image_data: Optional[bytes] = None, image_mime_type: Optional[str] = None): + """Save or update a product for a tenant""" + tenant_id = tenant_id.lower() + + with get_db() as conn: + cursor = conn.cursor() + + # Extract core fields + name = '' + price = '' + inventory = None + + for field in fields: + if field['fieldName'] == '_name': + name = field['value'] + elif field['fieldName'] == '_price': + price = field['value'] + elif field['fieldName'] == '_inventory': + inventory = int(field['value']) if field['value'] else None + + # Check if product exists + cursor.execute('SELECT id FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id)) + exists = cursor.fetchone() is not None + + if exists: + # Update existing product + if image_data is not None: + cursor.execute(''' + UPDATE products + SET name = ?, price = ?, inventory = ?, image_data = ?, image_mime_type = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND tenant_id = ? + ''', (name, price, inventory, image_data, image_mime_type, product_id, tenant_id)) + else: + cursor.execute(''' + UPDATE products + SET name = ?, price = ?, inventory = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND tenant_id = ? + ''', (name, price, inventory, product_id, tenant_id)) + else: + # Insert new product + cursor.execute(''' + INSERT INTO products (id, tenant_id, name, price, inventory, image_data, image_mime_type) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', (product_id, tenant_id, name, price, inventory, image_data, image_mime_type)) + + # Delete existing fields + cursor.execute('DELETE FROM product_fields WHERE product_id = ? AND tenant_id = ?', (product_id, tenant_id)) + + # Insert all fields + for field in fields: + cursor.execute(''' + INSERT INTO product_fields + (product_id, tenant_id, field_name, label, value, editable, field_type) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + product_id, + tenant_id, + field['fieldName'], + field['label'], + field['value'], + field.get('editable', 'true'), + field.get('fieldType', 'TEXT') + )) + + conn.commit() + + @staticmethod + def delete(product_id: str, tenant_id: str): + """Delete a product for a tenant""" + tenant_id = tenant_id.lower() + + with get_db() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id)) + conn.commit() + + @staticmethod + def get_image(product_id: str, tenant_id: str) -> Optional[Tuple[bytes, str]]: + """Get product image data and mime type for a tenant""" + tenant_id = tenant_id.lower() + + with get_db() as conn: + cursor = conn.cursor() + cursor.execute('SELECT image_data, image_mime_type FROM products WHERE id = ? AND tenant_id = ?', + (product_id, tenant_id)) + row = cursor.fetchone() + + if row and row['image_data']: + return row['image_data'], row['image_mime_type'] + return None + + @staticmethod + def save_image(product_id: str, tenant_id: str, field_name: str, + image_data: bytes, image_mime_type: str): + """Save an image for a specific product field""" + tenant_id = tenant_id.lower() + + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT OR REPLACE INTO product_images + (product_id, tenant_id, field_name, image_data, image_mime_type) + VALUES (?, ?, ?, ?, ?) + ''', (product_id, tenant_id, field_name, image_data, image_mime_type)) + conn.commit() + + @staticmethod + def get_image_by_field(product_id: str, tenant_id: str, field_name: str) -> Optional[Tuple[bytes, str]]: + """Get image for a specific product field""" + tenant_id = tenant_id.lower() + + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT image_data, image_mime_type + FROM product_images + WHERE product_id = ? AND tenant_id = ? AND field_name = ? + ''', (product_id, tenant_id, field_name)) + row = cursor.fetchone() + + if row: + return row['image_data'], row['image_mime_type'] + return None diff --git a/src/app/models/settings.py b/src/app/models/settings.py new file mode 100644 index 0000000..18dcc9f --- /dev/null +++ b/src/app/models/settings.py @@ -0,0 +1,30 @@ +from typing import Optional +from .base import get_db + +class SettingsModel: + """Model for application settings""" + + @staticmethod + def get(key: str, default: Optional[str] = None) -> Optional[str]: + """Get a setting value by key""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute('SELECT value FROM settings WHERE key = ?', (key,)) + row = cursor.fetchone() + return row['value'] if row else default + + @staticmethod + def set(key: str, value: str): + """Set a setting value""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ''', (key, value)) + conn.commit() + + @staticmethod + def get_server_url() -> str: + """Get server URL setting""" + return SettingsModel.get('server_url', 'http://localhost:5555') diff --git a/src/app/models/tenant.py b/src/app/models/tenant.py new file mode 100644 index 0000000..d8f92ee --- /dev/null +++ b/src/app/models/tenant.py @@ -0,0 +1,98 @@ +from .base import get_db +from flask import current_app + +class TenantModel: + """Model for tenant operations""" + + @staticmethod + def get_all(): + """Get all tenants""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM tenants ORDER BY created_at DESC') + rows = cursor.fetchall() + return [dict(row) for row in rows] + + @staticmethod + def get_by_id(tenant_id): + """Get tenant by ID""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM tenants WHERE id = ?', (tenant_id,)) + row = cursor.fetchone() + return dict(row) if row else None + + @staticmethod + def create(tenant_id, name=None): + """Create a new tenant""" + # Check if tenant_id is reserved + if tenant_id.lower() in current_app.config['RESERVED_TENANT_IDS']: + return None + + if name is None: + name = tenant_id + + with get_db() as conn: + cursor = conn.cursor() + try: + cursor.execute( + 'INSERT INTO tenants (id, name, username, password) VALUES (?, ?, ?, ?)', + (tenant_id, name, 'admin', 'password') + ) + conn.commit() + return TenantModel.get_by_id(tenant_id) + except Exception: + return None + + @staticmethod + def get_or_create(tenant_id): + """Get existing tenant or create new one""" + tenant = TenantModel.get_by_id(tenant_id) + if tenant: + return tenant + return TenantModel.create(tenant_id) + + @staticmethod + def update_credentials(tenant_id, username, password): + """Update tenant credentials""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE tenants SET username = ?, password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (username, password, tenant_id) + ) + conn.commit() + + @staticmethod + def update_barcode_type(tenant_id, barcode_type): + """Update tenant barcode type preference""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE tenants SET barcode_type = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (barcode_type, tenant_id) + ) + conn.commit() + + @staticmethod + def delete(tenant_id): + """Delete a tenant and all associated data""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM tenants WHERE id = ?', (tenant_id,)) + conn.commit() + + @staticmethod + def cleanup_reserved(): + """Clean up any accidentally created reserved tenants""" + reserved = current_app.config['RESERVED_TENANT_IDS'] + with get_db() as conn: + cursor = conn.cursor() + placeholders = ','.join('?' * len(reserved)) + cursor.execute( + f'DELETE FROM tenants WHERE LOWER(id) IN ({placeholders})', + tuple(reserved) + ) + deleted_count = cursor.rowcount + conn.commit() + return deleted_count diff --git a/src/app/services/__init__.py b/src/app/services/__init__.py new file mode 100644 index 0000000..f103c73 --- /dev/null +++ b/src/app/services/__init__.py @@ -0,0 +1,5 @@ +from .auth_service import AuthService +from .product_service import ProductService +from .barcode_service import BarcodeService + +__all__ = ['AuthService', 'ProductService', 'BarcodeService'] diff --git a/src/app/services/auth_service.py b/src/app/services/auth_service.py new file mode 100644 index 0000000..e92c59f --- /dev/null +++ b/src/app/services/auth_service.py @@ -0,0 +1,25 @@ +import base64 +from app.models import TenantModel + +class AuthService: + """Service for authentication logic""" + + @staticmethod + def check_basic_auth(auth_header: str, tenant_id: str) -> bool: + """Validate Basic authentication credentials for a tenant""" + if not auth_header or not auth_header.startswith('Basic '): + return False + + try: + # Decode the base64 credentials + credentials = base64.b64decode(auth_header[6:]).decode('utf-8') + username, password = credentials.split(':', 1) + + # Get tenant credentials from database (without creating) + tenant = TenantModel.get_by_id(tenant_id) + if tenant and username == tenant['username'] and password == tenant['password']: + return True + except Exception: + pass + + return False diff --git a/src/app/services/barcode_service.py b/src/app/services/barcode_service.py new file mode 100644 index 0000000..9369c0f --- /dev/null +++ b/src/app/services/barcode_service.py @@ -0,0 +1,58 @@ +import qrcode +import barcode +from barcode.writer import ImageWriter +from io import BytesIO + +class BarcodeService: + """Service for barcode generation""" + + @staticmethod + def generate_qr_code(data: str) -> BytesIO: + """Generate QR code image""" + buffer = BytesIO() + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(data) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + img.save(buffer, format='PNG') + buffer.seek(0) + return buffer + + @staticmethod + def generate_ean13(product_id: str) -> BytesIO: + """Generate EAN-13 barcode""" + buffer = BytesIO() + # Convert product_id to numeric format if needed + numeric_id = ''.join(filter(str.isdigit, product_id)) + if not numeric_id: + numeric_id = str(abs(hash(product_id)) % 1000000000000)[:12] + else: + numeric_id = numeric_id[:12].zfill(12) + + EAN = barcode.get_barcode_class('ean13') + ean = EAN(numeric_id, writer=ImageWriter()) + ean.write(buffer) + buffer.seek(0) + return buffer + + @staticmethod + def generate_code128(product_id: str) -> BytesIO: + """Generate Code 128 barcode""" + buffer = BytesIO() + CODE128 = barcode.get_barcode_class('code128') + code = CODE128(product_id, writer=ImageWriter()) + code.write(buffer) + buffer.seek(0) + return buffer + + @staticmethod + def generate_barcode(product_id: str, code_type: str, url: str = None) -> BytesIO: + """Generate barcode based on type""" + if code_type == 'qr': + return BarcodeService.generate_qr_code(url or product_id) + elif code_type == 'ean13': + return BarcodeService.generate_ean13(product_id) + elif code_type == 'code128': + return BarcodeService.generate_code128(product_id) + else: + raise ValueError(f"Unsupported barcode type: {code_type}") diff --git a/src/app/services/product_service.py b/src/app/services/product_service.py new file mode 100644 index 0000000..9a40ea8 --- /dev/null +++ b/src/app/services/product_service.py @@ -0,0 +1,56 @@ +from typing import Dict, List, Any +from app.models import ProductModel, ARFieldModel +from flask import request + +class ProductService: + """Service for product business logic""" + + @staticmethod + def filter_and_process_fields(product_fields: List[Dict[str, Any]], + tenant_id: str, + custom_fields: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Filter product fields to only include defined AR fields and process image URLs""" + custom_field_names = [f['fieldName'] for f in custom_fields] + field_types = {f['fieldName']: f['fieldType'] for f in custom_fields} + filtered_fields = [] + + for field in product_fields: + if field['fieldName'] in custom_field_names: + # Create absolute URL for IMAGE_URI fields + if field_types.get(field['fieldName']) == 'IMAGE_URI' and field['value']: + # If it's already an absolute URL, leave it as is + if not field['value'].startswith('http'): + # Build absolute URL using request host with tenant + field['value'] = f"{request.url_root.rstrip('/')}/{tenant_id}{field['value']}" + filtered_fields.append(field) + + return filtered_fields + + @staticmethod + def get_all_products_filtered(tenant_id: str) -> Dict[str, List[Dict[str, Any]]]: + """Get all products with filtered fields""" + products = ProductModel.get_all(tenant_id) + custom_fields = ARFieldModel.get_all(tenant_id) + + all_products = {} + for product_id, fields in products.items(): + all_products[product_id] = ProductService.filter_and_process_fields( + fields, tenant_id, custom_fields + ) + + return all_products + + @staticmethod + def get_product_filtered(product_id: str, tenant_id: str) -> List[Dict[str, Any]]: + """Get a single product with filtered fields""" + product = ProductModel.get_by_id(product_id, tenant_id) + if not product: + return None + + custom_fields = ARFieldModel.get_all(tenant_id) + return ProductService.filter_and_process_fields(product, tenant_id, custom_fields) + + @staticmethod + def allowed_file(filename: str, allowed_extensions: set) -> bool: + """Check if file extension is allowed""" + return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions diff --git a/src/templates/admin/add_product.html b/src/app/templates/admin/add_product.html similarity index 100% rename from src/templates/admin/add_product.html rename to src/app/templates/admin/add_product.html diff --git a/src/templates/admin/all_barcodes.html b/src/app/templates/admin/all_barcodes.html similarity index 100% rename from src/templates/admin/all_barcodes.html rename to src/app/templates/admin/all_barcodes.html diff --git a/src/templates/admin/ar_fields.html b/src/app/templates/admin/ar_fields.html similarity index 100% rename from src/templates/admin/ar_fields.html rename to src/app/templates/admin/ar_fields.html diff --git a/src/templates/admin/credentials.html b/src/app/templates/admin/credentials.html similarity index 100% rename from src/templates/admin/credentials.html rename to src/app/templates/admin/credentials.html diff --git a/src/templates/admin/edit_product.html b/src/app/templates/admin/edit_product.html similarity index 100% rename from src/templates/admin/edit_product.html rename to src/app/templates/admin/edit_product.html diff --git a/src/templates/admin/index.html b/src/app/templates/admin/index.html similarity index 100% rename from src/templates/admin/index.html rename to src/app/templates/admin/index.html diff --git a/src/templates/index.html b/src/app/templates/index.html similarity index 100% rename from src/templates/index.html rename to src/app/templates/index.html diff --git a/src/templates/layout.html b/src/app/templates/layout.html similarity index 100% rename from src/templates/layout.html rename to src/app/templates/layout.html diff --git a/src/templates/settings.html b/src/app/templates/settings.html similarity index 100% rename from src/templates/settings.html rename to src/app/templates/settings.html diff --git a/src/templates/tenant_selection.html b/src/app/templates/tenant_selection.html similarity index 100% rename from src/templates/tenant_selection.html rename to src/app/templates/tenant_selection.html diff --git a/src/templates/tenant_settings.html b/src/app/templates/tenant_settings.html similarity index 100% rename from src/templates/tenant_settings.html rename to src/app/templates/tenant_settings.html diff --git a/src/utils/barcode_generator.py b/src/app/utils/barcode_generator.py similarity index 100% rename from src/utils/barcode_generator.py rename to src/app/utils/barcode_generator.py diff --git a/src/database.py b/src/database.py deleted file mode 100644 index 33a60ef..0000000 --- a/src/database.py +++ /dev/null @@ -1,706 +0,0 @@ -import sqlite3 -import json -import os -from contextlib import contextmanager -from typing import Dict, List, Optional, Any -import base64 - -DATABASE_PATH = os.path.join(os.path.dirname(__file__), 'data', 'products.db') - -# Reserved tenant IDs that cannot be used -RESERVED_TENANT_IDS = { - 'admin', 'api', 'login', 'logout', 'arcontentfields', 'arinfo', - 'images', 'barcodes', 'static', 'assets', 'js', 'css', 'tenant', - 'auth', 'oauth', 'callback', 'webhook', 'health', 'status' -} - -@contextmanager -def get_db(): - """Context manager for database connections""" - conn = sqlite3.connect(DATABASE_PATH) - conn.row_factory = sqlite3.Row - try: - yield conn - finally: - conn.close() - -def init_database(): - """Initialize the database with required tables""" - os.makedirs(os.path.dirname(DATABASE_PATH), exist_ok=True) - - with get_db() as conn: - cursor = conn.cursor() - - # Create tenants table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS tenants ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - username TEXT, - password TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') - - # Create products table with tenant_id - cursor.execute(''' - CREATE TABLE IF NOT EXISTS products ( - id TEXT, - tenant_id TEXT NOT NULL, - name TEXT NOT NULL, - price TEXT, - inventory INTEGER, - image_data BLOB, - image_mime_type TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id, tenant_id), - FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE - ) - ''') - - # Create product_fields table with tenant_id - cursor.execute(''' - CREATE TABLE IF NOT EXISTS product_fields ( - product_id TEXT, - tenant_id TEXT, - field_name TEXT, - label TEXT, - value TEXT, - editable TEXT, - field_type TEXT, - FOREIGN KEY (product_id, tenant_id) REFERENCES products(id, tenant_id) ON DELETE CASCADE, - PRIMARY KEY (product_id, tenant_id, field_name) - ) - ''') - - # Create custom_ar_fields table for tenant-specific AR field definitions - cursor.execute(''' - CREATE TABLE IF NOT EXISTS custom_ar_fields ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - tenant_id TEXT NOT NULL, - field_name TEXT NOT NULL, - label TEXT NOT NULL, - field_type TEXT NOT NULL, - editable TEXT DEFAULT 'true', - display_order INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, - UNIQUE(tenant_id, field_name) - ) - ''') - - # Create product_images table for storing multiple images per product - cursor.execute(''' - CREATE TABLE IF NOT EXISTS product_images ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - product_id TEXT NOT NULL, - tenant_id TEXT NOT NULL, - field_name TEXT NOT NULL, - image_data BLOB, - image_mime_type TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (product_id, tenant_id) REFERENCES products(id, tenant_id) ON DELETE CASCADE, - UNIQUE(product_id, tenant_id, field_name) - ) - ''') - - # Create settings table for server configuration - cursor.execute(''' - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') - - # Add barcode_type column to tenants table if it doesn't exist - try: - cursor.execute('ALTER TABLE tenants ADD COLUMN barcode_type TEXT DEFAULT "code128"') - conn.commit() - except sqlite3.OperationalError: - # Column already exists - pass - - conn.commit() - -def migrate_from_json(): - """Migrate existing JSON data to SQLite with default tenant""" - json_path = os.path.join(os.path.dirname(__file__), 'data', 'products.json') - - if not os.path.exists(json_path): - return - - with open(json_path, 'r') as f: - products_data = json.load(f) - - with get_db() as conn: - cursor = conn.cursor() - - # Create default tenant if not exists - default_tenant_id = 'default' - cursor.execute(''' - INSERT OR IGNORE INTO tenants (id, name, username, password) - VALUES (?, ?, ?, ?) - ''', (default_tenant_id, 'Default', 'admin', 'admin')) - - for product_id, fields in products_data.items(): - # Extract core fields - name = '' - price = '' - inventory = None - image_path = '' - - for field in fields: - if field['fieldName'] == '_name': - name = field['value'] - elif field['fieldName'] == '_price': - price = field['value'] - elif field['fieldName'] == '_inventory': - inventory = int(field['value']) if field['value'] else None - elif field['fieldName'] == '_image': - image_path = field['value'] - - # Read image data if exists - image_data = None - image_mime_type = None - if image_path: - # Extract filename from path like '/images/filename.jpg' - filename = image_path.split('/')[-1] - full_image_path = os.path.join(os.path.dirname(__file__), 'data', 'images', filename) - - if os.path.exists(full_image_path): - with open(full_image_path, 'rb') as img_file: - image_data = img_file.read() - # Determine mime type from extension - ext = os.path.splitext(filename)[1].lower() - mime_types = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp' - } - image_mime_type = mime_types.get(ext, 'image/jpeg') - - # Insert into products table with tenant_id - cursor.execute(''' - INSERT OR REPLACE INTO products (id, tenant_id, name, price, inventory, image_data, image_mime_type) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', (product_id, default_tenant_id, name, price, inventory, image_data, image_mime_type)) - - # Insert all fields into product_fields table with tenant_id - for field in fields: - cursor.execute(''' - INSERT OR REPLACE INTO product_fields - (product_id, tenant_id, field_name, label, value, editable, field_type) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ( - product_id, - default_tenant_id, - field['fieldName'], - field['label'], - field['value'], - field.get('editable', 'true'), - field.get('fieldType', 'TEXT') - )) - - conn.commit() - -def get_all_products(tenant_id: str = None) -> Dict[str, List[Dict[str, Any]]]: - """Get all products for a tenant in the legacy format""" - # Normalize tenant_id to lowercase if provided - if tenant_id: - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - - # Get all product IDs for the tenant - if tenant_id: - cursor.execute('SELECT id FROM products WHERE tenant_id = ?', (tenant_id,)) - else: - cursor.execute('SELECT id FROM products') - product_ids = [row['id'] for row in cursor.fetchall()] - - result = {} - for product_id in product_ids: - # Get all fields for this product - if tenant_id: - cursor.execute(''' - SELECT field_name, label, value, editable, field_type - FROM product_fields - WHERE product_id = ? AND tenant_id = ? - ORDER BY field_name - ''', (product_id, tenant_id)) - else: - cursor.execute(''' - SELECT field_name, label, value, editable, field_type - FROM product_fields - WHERE product_id = ? - ORDER BY field_name - ''', (product_id,)) - - fields = [] - for row in cursor.fetchall(): - fields.append({ - 'fieldName': row['field_name'], - 'label': row['label'], - 'value': row['value'], - 'editable': row['editable'], - 'fieldType': row['field_type'] - }) - - result[product_id] = fields - - return result - -def get_product(product_id: str, tenant_id: str) -> Optional[List[Dict[str, Any]]]: - """Get a single product by ID and tenant""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - - cursor.execute(''' - SELECT field_name, label, value, editable, field_type - FROM product_fields - WHERE product_id = ? AND tenant_id = ? - ORDER BY field_name - ''', (product_id, tenant_id)) - - fields = [] - for row in cursor.fetchall(): - fields.append({ - 'fieldName': row['field_name'], - 'label': row['label'], - 'value': row['value'], - 'editable': row['editable'], - 'fieldType': row['field_type'] - }) - - return fields if fields else None - -def save_product(product_id: str, tenant_id: str, fields: List[Dict[str, Any]], image_data: Optional[bytes] = None, image_mime_type: Optional[str] = None): - """Save or update a product for a tenant""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - - # Extract core fields - name = '' - price = '' - inventory = None - - for field in fields: - if field['fieldName'] == '_name': - name = field['value'] - elif field['fieldName'] == '_price': - price = field['value'] - elif field['fieldName'] == '_inventory': - inventory = int(field['value']) if field['value'] else None - - # Check if product exists - cursor.execute('SELECT id FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id)) - exists = cursor.fetchone() is not None - - if exists: - # Update existing product - if image_data is not None: - cursor.execute(''' - UPDATE products - SET name = ?, price = ?, inventory = ?, image_data = ?, image_mime_type = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND tenant_id = ? - ''', (name, price, inventory, image_data, image_mime_type, product_id, tenant_id)) - else: - cursor.execute(''' - UPDATE products - SET name = ?, price = ?, inventory = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND tenant_id = ? - ''', (name, price, inventory, product_id, tenant_id)) - else: - # Insert new product - cursor.execute(''' - INSERT INTO products (id, tenant_id, name, price, inventory, image_data, image_mime_type) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', (product_id, tenant_id, name, price, inventory, image_data, image_mime_type)) - - # Delete existing fields - cursor.execute('DELETE FROM product_fields WHERE product_id = ? AND tenant_id = ?', (product_id, tenant_id)) - - # Insert all fields - for field in fields: - cursor.execute(''' - INSERT INTO product_fields - (product_id, tenant_id, field_name, label, value, editable, field_type) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ( - product_id, - tenant_id, - field['fieldName'], - field['label'], - field['value'], - field.get('editable', 'true'), - field.get('fieldType', 'TEXT') - )) - - conn.commit() - -def delete_product(product_id: str, tenant_id: str): - """Delete a product for a tenant""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - cursor.execute('DELETE FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id)) - conn.commit() - -def get_product_image(product_id: str, tenant_id: str) -> Optional[tuple[bytes, str]]: - """Get product image data and mime type for a tenant""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - cursor.execute('SELECT image_data, image_mime_type FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id)) - row = cursor.fetchone() - - if row and row['image_data']: - return row['image_data'], row['image_mime_type'] - return None - -def get_tenant(tenant_id: str) -> Optional[Dict[str, Any]]: - """Get a tenant without creating it""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - - # Check if tenant exists - cursor.execute('SELECT id, name, username, password, created_at, barcode_type FROM tenants WHERE id = ?', (tenant_id,)) - row = cursor.fetchone() - - if row: - return { - 'id': row['id'], - 'name': row['name'], - 'username': row['username'], - 'password': row['password'], - 'created_at': row['created_at'], - 'barcode_type': row['barcode_type'] or 'code128' - } - return None - -def get_or_create_tenant(tenant_id: str, username: str = None, password: str = None) -> Optional[Dict[str, Any]]: - """Get or create a tenant""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - # Check if tenant_id is reserved - if tenant_id in RESERVED_TENANT_IDS: - return None - - with get_db() as conn: - cursor = conn.cursor() - - # Check if tenant exists - cursor.execute('SELECT id, name, username, password, created_at, barcode_type FROM tenants WHERE id = ?', (tenant_id,)) - row = cursor.fetchone() - - if row: - return { - 'id': row['id'], - 'name': row['name'], - 'username': row['username'], - 'password': row['password'], - 'created_at': row['created_at'], - 'barcode_type': row['barcode_type'] or 'code128' - } - else: - # Create new tenant with default credentials - default_username = username or 'admin' - default_password = password or 'admin' - # Preserve original casing for display name - display_name = tenant_id.replace('-', ' ').replace('_', ' ').title() - cursor.execute(''' - INSERT INTO tenants (id, name, username, password) - VALUES (?, ?, ?, ?) - ''', (tenant_id, display_name, default_username, default_password)) - conn.commit() - - # Initialize default AR fields for the new tenant - init_default_ar_fields(tenant_id) - - # Get the created_at timestamp and barcode_type from the database - cursor.execute('SELECT created_at, barcode_type FROM tenants WHERE id = ?', (tenant_id,)) - created_row = cursor.fetchone() - - return { - 'id': tenant_id, - 'name': display_name, - 'username': default_username, - 'password': default_password, - 'created_at': created_row['created_at'] if created_row else None, - 'barcode_type': created_row['barcode_type'] if created_row else 'code128' - } - -def update_tenant_credentials(tenant_id: str, username: str, password: str): - """Update tenant credentials""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - cursor.execute(''' - UPDATE tenants - SET username = ?, password = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - ''', (username, password, tenant_id)) - conn.commit() - -def update_tenant_barcode_type(tenant_id: str, barcode_type: str): - """Update tenant barcode type""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - cursor.execute(''' - UPDATE tenants - SET barcode_type = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - ''', (barcode_type, tenant_id)) - conn.commit() - -def get_all_tenants() -> List[Dict[str, Any]]: - """Get all tenants""" - with get_db() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id, name, username, created_at FROM tenants ORDER BY created_at DESC') - - tenants = [] - for row in cursor.fetchall(): - tenants.append({ - 'id': row['id'], - 'name': row['name'], - 'username': row['username'], - 'created_at': row['created_at'] - }) - - return tenants - -def delete_tenant(tenant_id: str): - """Delete a tenant and all associated data""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - # Due to ON DELETE CASCADE, this will also delete all products and product_fields - cursor.execute('DELETE FROM tenants WHERE id = ?', (tenant_id,)) - conn.commit() - -def cleanup_reserved_tenants(): - """Remove any tenants that were accidentally created with reserved IDs""" - with get_db() as conn: - cursor = conn.cursor() - # Get all tenants - cursor.execute('SELECT id FROM tenants') - tenants = cursor.fetchall() - - deleted_count = 0 - for tenant in tenants: - tenant_id = tenant['id'] - if tenant_id.lower() in RESERVED_TENANT_IDS: - cursor.execute('DELETE FROM tenants WHERE id = ?', (tenant_id,)) - deleted_count += 1 - print(f"Deleted reserved tenant: {tenant_id}") - - conn.commit() - return deleted_count - -def get_custom_ar_fields(tenant_id: str) -> List[Dict[str, Any]]: - """Get custom AR fields for a tenant""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT id, field_name, label, field_type, editable, display_order - FROM custom_ar_fields - WHERE tenant_id = ? - ORDER BY display_order, field_name - ''', (tenant_id,)) - - fields = [] - for row in cursor.fetchall(): - fields.append({ - 'id': row['id'], - 'fieldName': row['field_name'], - 'label': row['label'], - 'fieldType': row['field_type'], - 'editable': row['editable'], - 'displayOrder': row['display_order'] - }) - - return fields - -def save_custom_ar_field(tenant_id: str, field_data: Dict[str, Any]) -> int: - """Save or update a custom AR field""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - - if 'id' in field_data: - # Update existing field - cursor.execute(''' - UPDATE custom_ar_fields - SET field_name = ?, label = ?, field_type = ?, editable = ?, - display_order = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND tenant_id = ? - ''', ( - field_data['fieldName'], - field_data['label'], - field_data['fieldType'], - field_data.get('editable', 'true'), - field_data.get('displayOrder', 0), - field_data['id'], - tenant_id - )) - conn.commit() - return field_data['id'] - else: - # Insert new field - cursor.execute(''' - INSERT INTO custom_ar_fields - (tenant_id, field_name, label, field_type, editable, display_order) - VALUES (?, ?, ?, ?, ?, ?) - ''', ( - tenant_id, - field_data['fieldName'], - field_data['label'], - field_data['fieldType'], - field_data.get('editable', 'true'), - field_data.get('displayOrder', 0) - )) - conn.commit() - return cursor.lastrowid - -def delete_custom_ar_field(tenant_id: str, field_id: int): - """Delete a custom AR field""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - cursor.execute(''' - DELETE FROM custom_ar_fields - WHERE id = ? AND tenant_id = ? - ''', (field_id, tenant_id)) - conn.commit() - -def init_default_ar_fields(tenant_id: str): - """Initialize default AR fields for a new tenant""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - default_fields = [ - {"fieldName": "_id", "label": "Item ID", "fieldType": "TEXT", "editable": "false", "displayOrder": 1}, - {"fieldName": "_price", "label": "Sale Price", "fieldType": "TEXT", "editable": "true", "displayOrder": 2}, - {"fieldName": "_image", "label": "Image", "fieldType": "IMAGE_URI", "editable": "false", "displayOrder": 3} - ] - - with get_db() as conn: - cursor = conn.cursor() - for field in default_fields: - # Use INSERT OR IGNORE to avoid duplicate key errors - cursor.execute(''' - INSERT OR IGNORE INTO custom_ar_fields - (tenant_id, field_name, label, field_type, editable, display_order) - VALUES (?, ?, ?, ?, ?, ?) - ''', ( - tenant_id, - field['fieldName'], - field['label'], - field['fieldType'], - field.get('editable', 'true'), - field.get('displayOrder', 0) - )) - conn.commit() - -def save_product_image(product_id: str, tenant_id: str, field_name: str, image_data: bytes, mime_type: str): - """Save an image for a specific field of a product""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - - # Delete existing image for this field if any - cursor.execute(''' - DELETE FROM product_images - WHERE product_id = ? AND tenant_id = ? AND field_name = ? - ''', (product_id, tenant_id, field_name)) - - # Insert new image - cursor.execute(''' - INSERT INTO product_images (product_id, tenant_id, field_name, image_data, image_mime_type) - VALUES (?, ?, ?, ?, ?) - ''', (product_id, tenant_id, field_name, image_data, mime_type)) - - conn.commit() - -def get_product_image_by_field(product_id: str, tenant_id: str, field_name: str) -> Optional[tuple[bytes, str]]: - """Get image data for a specific field of a product""" - # Normalize tenant_id to lowercase - tenant_id = tenant_id.lower() - - with get_db() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT image_data, image_mime_type - FROM product_images - WHERE product_id = ? AND tenant_id = ? AND field_name = ? - ''', (product_id, tenant_id, field_name)) - - row = cursor.fetchone() - if row and row['image_data']: - return row['image_data'], row['image_mime_type'] - return None - -def get_setting(key: str, default_value: str = None) -> Optional[str]: - """Get a setting value by key""" - with get_db() as conn: - cursor = conn.cursor() - cursor.execute('SELECT value FROM settings WHERE key = ?', (key,)) - row = cursor.fetchone() - - if row: - return row['value'] - return default_value - -def set_setting(key: str, value: str): - """Set a setting value""" - with get_db() as conn: - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO settings (key, value) - VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP - ''', (key, value, value)) - conn.commit() - -def get_server_url() -> str: - """Get the configured server URL or return a default""" - return get_setting('server_url', 'http://localhost:5000') \ No newline at end of file diff --git a/src/routes/__init__.py b/src/routes/__init__.py deleted file mode 100644 index ab331dc..0000000 --- a/src/routes/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# This file is intentionally left blank to make 'routes' a package. diff --git a/src/run.py b/src/run.py new file mode 100644 index 0000000..67328b1 --- /dev/null +++ b/src/run.py @@ -0,0 +1,20 @@ +import os +import shutil +from app import create_app +from app.models import TenantModel + +# Create application +app = create_app(os.environ.get('FLASK_ENV', 'development')) + +if __name__ == '__main__': + with app.app_context(): + # Migrate existing data from JSON if needed (backward compatibility) + # Note: This is for the old database.py migration - keeping for reference + # but in the new structure, this would be handled differently + + # Clean up any accidentally created reserved tenants + deleted_count = TenantModel.cleanup_reserved() + if deleted_count > 0: + print(f"Cleaned up {deleted_count} reserved tenant(s)") + + app.run(port=5555, host="0.0.0.0", debug=True) From 0888edaec53a93c51da8800a80cbaba8ab3ce95a Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Mon, 20 Oct 2025 18:16:45 -0400 Subject: [PATCH 19/19] Modernize tenant selection page with improved UX - Replace list view with responsive card grid layout for better scalability - Add real-time search functionality to filter tenants by name or ID - Move settings to fixed gear icon in top-right corner - Add new tenant button (plus icon) next to settings gear - Implement modal dialog for creating new tenants - Add hover effects and animations to tenant cards - Improve mobile responsiveness - Display tenant info more compactly (name, ID, username, date, AR URL) - Add "no results" message for empty search results The new layout handles 50+ tenants efficiently with better visual organization and easier navigation. --- src/app/templates/tenant_selection.html | 254 ++++++++++++++++++------ 1 file changed, 191 insertions(+), 63 deletions(-) diff --git a/src/app/templates/tenant_selection.html b/src/app/templates/tenant_selection.html index 14ee745..23f5371 100644 --- a/src/app/templates/tenant_selection.html +++ b/src/app/templates/tenant_selection.html @@ -6,10 +6,62 @@ Select or Create Tenant - KCAP Demo Server + -
-

KCAP Demo Server - Multi-Tenant

+ +
+ + + + +
+ +
+

KCAP Demo Server

{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} @@ -21,67 +73,102 @@ {% endfor %} {% endif %} {% endwith %} - -
-
-
-
-

Select an Existing Tenant

+ + +
+
+
+
+ + +
- + + +
+ {% if tenants %} + {% for tenant in tenants %} + - -
-
-

Create a New Tenant

-
-
-
-
- - - Only letters, numbers, hyphens, and underscores allowed -
- -
+ {% endfor %} + {% else %} +
+
+ +

No tenants exist yet. Create your first tenant!

- -
-

- Note: When you access a tenant URL directly (e.g., /my-tenant/), - it will be automatically created with default credentials (admin/admin). -

- Server Settings + {% endif %} +
+ + + +
+ + +