made multi tenant

This commit is contained in:
2025-06-19 14:25:42 -04:00
parent f041266ac3
commit 705f2859fa
11 changed files with 473 additions and 157 deletions

View File

@@ -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('/<tenant:tenant_id>/')
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('/<tenant:tenant_id>/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('/<tenant:tenant_id>/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('/<tenant:tenant_id>/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/<path:filename>', methods=['GET'])
def serve_image(filename):
@app.route('/<tenant:tenant_id>/images/<path:filename>', 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/<path:filename>', methods=['GET'])
def serve_barcode(filename):
@app.route('/<tenant:tenant_id>/barcodes/<path:filename>', 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('/<tenant:tenant_id>/admin/', 'admin.index', admin_index)
app.add_url_rule('/<tenant:tenant_id>/admin/add', 'admin.add_product', add_product, methods=['GET', 'POST'])
app.add_url_rule('/<tenant:tenant_id>/admin/edit/<product_id>', 'admin.edit_product', edit_product, methods=['GET', 'POST'])
app.add_url_rule('/<tenant:tenant_id>/admin/delete/<product_id>', 'admin.delete_product', delete_product, methods=['POST'])
app.add_url_rule('/<tenant:tenant_id>/admin/view/<product_id>', 'admin.view_product', view_product)
app.add_url_rule('/<tenant:tenant_id>/admin/generate_barcode/<product_id>/<code_type>', 'admin.generate_barcode', generate_barcode)
app.add_url_rule('/<tenant:tenant_id>/admin/generate_barcode_page/<product_id>', 'admin.generate_barcode_page', generate_barcode_page)
app.add_url_rule('/<tenant:tenant_id>/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('/<tenant:tenant_id>/api/', 'api.api_index', api_index)
# Catch-all route for admin without tenant - redirect to home
@app.route('/admin/')
@app.route('/admin/<path:path>')
def redirect_admin_to_home(path=None):
return redirect('/')
if __name__ == '__main__':
# Initialize database
database.init_database()

View File

@@ -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
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

View File

@@ -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/<product_id>', 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/<product_id>', 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/<product_id>')
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/<product_id>/<code_type>')
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/<product_id>')
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)

View File

@@ -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})

View File

@@ -10,7 +10,7 @@
<h2>Add New Product</h2>
</div>
<div class="card-body">
<form action="{{ url_for('admin.add_product') }}" method="POST" enctype="multipart/form-data">
<form action="{{ url_for('admin.add_product', tenant_id=tenant_id) }}" method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="product_id" class="form-label">Product ID *</label>
<div class="input-group">
@@ -47,7 +47,7 @@
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('admin.index') }}" class="btn btn-secondary">Cancel</a>
<a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Add Product</button>
</div>
</form>

View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage Credentials - {{ tenant.name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('admin.index', tenant_id=tenant_id) }}">{{ tenant.name }} Admin</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.index', tenant_id=tenant_id) }}">Products</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="{{ url_for('admin.manage_credentials', tenant_id=tenant_id) }}">Credentials</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/">Switch Tenant</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container mt-5">
<h1>Manage Login Credentials</h1>
<p class="text-muted">These credentials will be used for KCAP authentication at <code>/{{ tenant_id }}/login</code></p>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="alert alert-info alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3>Current Credentials</h3>
</div>
<div class="card-body">
<p><strong>Username:</strong> {{ tenant.username }}</p>
<p><strong>Password:</strong> {{ tenant.password }}</p>
<p class="text-muted small">Note: In production, passwords should be encrypted and not displayed.</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3>Update Credentials</h3>
</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" value="{{ tenant.username }}" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Enter new password" required>
</div>
<button type="submit" class="btn btn-primary">Update Credentials</button>
<a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
</div>
</div>
<div class="mt-4">
<h3>Testing Instructions</h3>
<p>To test the login endpoint with these credentials:</p>
<pre class="bg-light p-3"><code>curl -u {{ tenant.username }}:{{ tenant.password }} http://{{ request.host }}/{{ tenant_id }}/login</code></pre>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -10,7 +10,7 @@
<h2>Edit Product</h2>
</div>
<div class="card-body">
<form action="{{ url_for('admin.edit_product', product_id=product_id) }}" method="POST" enctype="multipart/form-data">
<form action="{{ url_for('admin.edit_product', tenant_id=tenant_id, product_id=product_id) }}" method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="product_id" class="form-label">Product ID</label>
<input type="text" class="form-control" id="product_id" value="{{ product_id }}" disabled>
@@ -65,7 +65,7 @@
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('admin.index') }}" class="btn btn-secondary">Cancel</a>
<a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Update Product</button>
</div>
</form>

View File

@@ -8,7 +8,7 @@
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h2>Generate Barcodes for Product: {{ product_id }}</h2>
<a href="{{ url_for('admin.index') }}" class="btn btn-outline-secondary">Back to Products</a>
<a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-outline-secondary">Back to Products</a>
</div>
<div class="card-body">
{% if not dependencies.qrcode or not dependencies.barcode or not dependencies.pillow %}
@@ -38,9 +38,9 @@
<h3>QR Code</h3>
</div>
<div class="card-body text-center">
<img src="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='qrcode') }}" alt="QR Code" class="img-fluid mb-3" style="max-width: 250px;">
<img src="{{ url_for('admin.generate_barcode', tenant_id=tenant_id, product_id=product_id, code_type='qrcode') }}" alt="QR Code" class="img-fluid mb-3" style="max-width: 250px;">
<p class="text-muted">QR Code contains a link to the product AR info.</p>
<a href="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='qrcode') }}" class="btn btn-primary" download="product_{{ product_id }}_qrcode.png">Download QR Code</a>
<a href="{{ url_for('admin.generate_barcode', tenant_id=tenant_id, product_id=product_id, code_type='qrcode') }}" class="btn btn-primary" download="product_{{ product_id }}_qrcode.png">Download QR Code</a>
</div>
</div>
</div>
@@ -51,9 +51,9 @@
<h3>EAN-13 Barcode</h3>
</div>
<div class="card-body text-center">
<img src="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='ean13') }}" alt="EAN-13 Barcode" class="img-fluid mb-3" style="max-width: 250px;">
<img src="{{ url_for('admin.generate_barcode', tenant_id=tenant_id, product_id=product_id, code_type='ean13') }}" alt="EAN-13 Barcode" class="img-fluid mb-3" style="max-width: 250px;">
<p class="text-muted">Standard EAN-13 barcode format.</p>
<a href="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='ean13') }}" class="btn btn-primary" download="product_{{ product_id }}_ean13.png">Download EAN-13</a>
<a href="{{ url_for('admin.generate_barcode', tenant_id=tenant_id, product_id=product_id, code_type='ean13') }}" class="btn btn-primary" download="product_{{ product_id }}_ean13.png">Download EAN-13</a>
</div>
</div>
</div>
@@ -64,9 +64,9 @@
<h3>Code 128 Barcode</h3>
</div>
<div class="card-body text-center">
<img src="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='code128') }}" alt="Code 128 Barcode" class="img-fluid mb-3" style="max-width: 250px;">
<img src="{{ url_for('admin.generate_barcode', tenant_id=tenant_id, product_id=product_id, code_type='code128') }}" alt="Code 128 Barcode" class="img-fluid mb-3" style="max-width: 250px;">
<p class="text-muted">High-density alphanumeric barcode.</p>
<a href="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='code128') }}" class="btn btn-primary" download="product_{{ product_id }}_code128.png">Download Code 128</a>
<a href="{{ url_for('admin.generate_barcode', tenant_id=tenant_id, product_id=product_id, code_type='code128') }}" class="btn btn-primary" download="product_{{ product_id }}_code128.png">Download Code 128</a>
</div>
</div>
</div>

View File

@@ -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 %}
<div class="row mt-4">
<div class="col-md-12">
<div class="alert alert-info">
<strong>Current Tenant:</strong> {{ tenant.name }} (ID: {{ tenant.id }})
<div class="mt-2">
<a href="{{ url_for('admin.manage_credentials', tenant_id=tenant.id) }}" class="btn btn-sm btn-secondary">Manage Credentials</a>
<a href="/" class="btn btn-sm btn-outline-secondary">Switch Tenant</a>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Product Management</h1>
<a href="{{ url_for('admin.add_product') }}" class="btn btn-primary">Add New Product</a>
<a href="{{ url_for('admin.add_product', tenant_id=tenant.id) }}" class="btn btn-primary">Add New Product</a>
</div>
{% if products %}
@@ -50,8 +57,8 @@
</td>
<td>
<div class="d-flex justify-content-center gap-2">
<a href="{{ url_for('admin.edit_product', product_id=product_id) }}" class="btn btn-sm btn-outline-primary">Edit</a>
<a href="{{ url_for('admin.generate_barcode_page', product_id=product_id) }}" class="btn btn-sm btn-outline-success">Barcodes</a>
<a href="{{ url_for('admin.edit_product', tenant_id=tenant.id, product_id=product_id) }}" class="btn btn-sm btn-outline-primary">Edit</a>
<a href="{{ url_for('admin.generate_barcode_page', tenant_id=tenant.id, product_id=product_id) }}" class="btn btn-sm btn-outline-success">Barcodes</a>
<button type="button" class="btn btn-sm btn-outline-danger"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-product-id="{{ product_id }}"
@@ -59,7 +66,7 @@
Delete
</button>
</div>
<form id="delete-form-{{ product_id }}" action="{{ url_for('admin.delete_product', product_id=product_id) }}" method="POST" style="display: none;">
<form id="delete-form-{{ product_id }}" action="{{ url_for('admin.delete_product', tenant_id=tenant.id, product_id=product_id) }}" method="POST" style="display: none;">
</form>
</td>
</tr>

View File

@@ -6,7 +6,7 @@
<div class="row mt-4">
<div class="col-md-12">
<div class="jumbotron">
<h1 class="display-4">KCAP Demo Server</h1>
<h1 class="display-4">KCAP Demo Server{% if tenant %} - {{ tenant.name }}{% endif %}</h1>
<p class="lead">A simple Flask API for demonstrating AR content retrieval for barcode scanning applications.</p>
<hr class="my-4">
<p>This server simulates the Knox Capture API for AR overlays and includes endpoints for managing product attributes.</p>
@@ -14,25 +14,29 @@
<h2>API Endpoints</h2>
<ul class="list-group">
<li class="list-group-item">
<strong>Login:</strong> <code>GET /login</code>
<p>Simulates a simple login with a 200 response (no authentication required).</p>
<strong>Login:</strong> <code>GET {% if tenant %}/{{ tenant.id }}{% endif %}/login</code>
<p>Authenticates using Basic Auth with tenant-specific credentials.</p>
</li>
<li class="list-group-item">
<strong>Content Fields:</strong> <code>GET /arcontentfields</code>
<strong>Content Fields:</strong> <code>GET {% if tenant %}/{{ tenant.id }}{% endif %}/arcontentfields</code>
<p>Returns a list of available attributes (e.g., item ID, price, image URI).</p>
</li>
<li class="list-group-item">
<strong>AR Info:</strong> <code>GET /arinfo?barcode=123456</code>
<strong>AR Info:</strong> <code>GET {% if tenant %}/{{ tenant.id }}{% endif %}/arinfo?barcode=123456</code>
<p>Returns product details for a given barcode, including image URLs.</p>
</li>
<li class="list-group-item">
<strong>Static Image Server:</strong> <code>GET /images/123456.png</code>
<p>Serves image files from the <code>static/images/</code> directory.</p>
<strong>Static Image Server:</strong> <code>GET {% if tenant %}/{{ tenant.id }}{% endif %}/images/123456.png</code>
<p>Serves product images stored in the database.</p>
</li>
</ul>
</div>
<div class="mt-4">
{% if tenant %}
<a href="/{{ tenant.id }}/admin/" class="btn btn-primary btn-lg">Go to Admin Interface</a>
{% else %}
<a href="/admin" class="btn btn-primary btn-lg">Go to Admin Interface</a>
{% endif %}
</div>
</div>
</div>

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Select or Create Tenant - KCAP Demo Server</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1 class="text-center mb-4">KCAP Demo Server - Multi-Tenant</h1>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3>Select an Existing Tenant</h3>
</div>
<div class="card-body">
{% if tenants %}
<div class="list-group">
{% for tenant in tenants %}
<a href="/{{ tenant.id }}/" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ tenant.name }}</h5>
<small>{{ tenant.created_at }}</small>
</div>
<p class="mb-1">Username: {{ tenant.username }}</p>
<small>ID: {{ tenant.id }}</small>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-muted">No tenants exist yet. Create one below!</p>
{% endif %}
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h3>Create a New Tenant</h3>
</div>
<div class="card-body">
<form id="newTenantForm">
<div class="mb-3">
<label for="tenantId" class="form-label">Tenant ID</label>
<input type="text" class="form-control" id="tenantId" placeholder="e.g., demo-company" required pattern="[a-zA-Z0-9_-]+">
<small class="form-text text-muted">Only letters, numbers, hyphens, and underscores allowed</small>
</div>
<button type="submit" class="btn btn-primary">Create & Enter Tenant</button>
</form>
</div>
</div>
<div class="mt-4 text-center">
<p class="text-muted">
<strong>Note:</strong> When you access a tenant URL directly (e.g., /my-tenant/),
it will be automatically created with default credentials (admin/admin).
</p>
</div>
</div>
</div>
</div>
<script>
document.getElementById('newTenantForm').addEventListener('submit', function(e) {
e.preventDefault();
const tenantId = document.getElementById('tenantId').value;
if (tenantId) {
window.location.href = '/' + tenantId + '/';
}
});
</script>
</body>
</html>