made multi tenant
This commit is contained in:
102
src/app.py
102
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 os
|
||||||
import base64
|
import base64
|
||||||
from routes.admin import admin_bp
|
|
||||||
from routes.api import api_bp
|
|
||||||
import database
|
import database
|
||||||
import qrcode
|
import qrcode
|
||||||
import barcode
|
import barcode
|
||||||
from barcode.writer import ImageWriter
|
from barcode.writer import ImageWriter
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import shutil
|
import shutil
|
||||||
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
# Set DATA_FOLDER to the absolute path of the data directory inside src
|
# 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['DATA_FOLDER'] = os.path.join(os.path.dirname(__file__), 'data')
|
||||||
app.config['SECRET_KEY'] = 'dev-key-for-demo-only'
|
app.config['SECRET_KEY'] = 'dev-key-for-demo-only'
|
||||||
|
|
||||||
# Register blueprints
|
# Custom converter for tenant IDs
|
||||||
app.register_blueprint(admin_bp)
|
class TenantConverter(BaseConverter):
|
||||||
app.register_blueprint(api_bp)
|
regex = '[a-zA-Z0-9_-]+'
|
||||||
|
|
||||||
|
app.url_map.converters['tenant'] = TenantConverter
|
||||||
|
|
||||||
# Load product data from database
|
# Load product data from database
|
||||||
def load_products():
|
def load_products(tenant_id=None):
|
||||||
return database.get_all_products()
|
return database.get_all_products(tenant_id)
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
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):
|
@app.route('/<tenant:tenant_id>/')
|
||||||
"""Validate Basic authentication credentials"""
|
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 '):
|
if not auth_header or not auth_header.startswith('Basic '):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -37,27 +46,27 @@ def check_basic_auth(auth_header):
|
|||||||
credentials = base64.b64decode(auth_header[6:]).decode('utf-8')
|
credentials = base64.b64decode(auth_header[6:]).decode('utf-8')
|
||||||
username, password = credentials.split(':', 1)
|
username, password = credentials.split(':', 1)
|
||||||
|
|
||||||
# Simple hardcoded credentials - in production, use secure storage
|
# Get tenant credentials from database
|
||||||
# You can change these credentials as needed
|
tenant = database.get_or_create_tenant(tenant_id)
|
||||||
if username == 'admin' and password == 'knox123':
|
if username == tenant['username'] and password == tenant['password']:
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@app.route('/login', methods=['GET'])
|
@app.route('/<tenant:tenant_id>/login', methods=['GET'])
|
||||||
def login():
|
def login(tenant_id):
|
||||||
auth_header = request.headers.get('Authorization')
|
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({"status": "success", "message": "Authentication successful"}), 200
|
||||||
|
|
||||||
return jsonify({"error": "Unauthorized"}), 401
|
return jsonify({"error": "Unauthorized"}), 401
|
||||||
|
|
||||||
@app.route('/arcontentfields', methods=['GET'])
|
@app.route('/<tenant:tenant_id>/arcontentfields', methods=['GET'])
|
||||||
def get_ar_content_fields():
|
def get_ar_content_fields(tenant_id):
|
||||||
# Return a fixed set of attributes
|
# Return a fixed set of attributes (tenant_id could be used for custom fields in the future)
|
||||||
fields = [
|
fields = [
|
||||||
{"fieldName": "_id", "label": "Item ID", "editable": "false", "fieldType": "TEXT"},
|
{"fieldName": "_id", "label": "Item ID", "editable": "false", "fieldType": "TEXT"},
|
||||||
{"fieldName": "_price", "label": "Sale Price", "editable": "true", "fieldType": "TEXT"},
|
{"fieldName": "_price", "label": "Sale Price", "editable": "true", "fieldType": "TEXT"},
|
||||||
@@ -65,10 +74,10 @@ def get_ar_content_fields():
|
|||||||
]
|
]
|
||||||
return jsonify(fields), 200
|
return jsonify(fields), 200
|
||||||
|
|
||||||
@app.route('/arinfo', methods=['GET'])
|
@app.route('/<tenant:tenant_id>/arinfo', methods=['GET'])
|
||||||
def get_ar_info():
|
def get_ar_info(tenant_id):
|
||||||
barcode = request.args.get('barcode')
|
barcode = request.args.get('barcode')
|
||||||
products = load_products()
|
products = load_products(tenant_id)
|
||||||
|
|
||||||
# Helper function to convert relative image paths to absolute URLs
|
# Helper function to convert relative image paths to absolute URLs
|
||||||
def make_absolute_urls(product_fields):
|
def make_absolute_urls(product_fields):
|
||||||
@@ -77,8 +86,8 @@ def get_ar_info():
|
|||||||
if field['fieldName'] == '_image' and field['value']:
|
if field['fieldName'] == '_image' and field['value']:
|
||||||
# If it's already an absolute URL, leave it as is
|
# If it's already an absolute URL, leave it as is
|
||||||
if not field['value'].startswith('http'):
|
if not field['value'].startswith('http'):
|
||||||
# Build absolute URL using request host
|
# Build absolute URL using request host with tenant
|
||||||
field['value'] = f"{request.url_root.rstrip('/')}{field['value']}"
|
field['value'] = f"{request.url_root.rstrip('/')}/{tenant_id}{field['value']}"
|
||||||
return product_fields
|
return product_fields
|
||||||
|
|
||||||
# If barcode is provided, return specific product
|
# If barcode is provided, return specific product
|
||||||
@@ -99,16 +108,16 @@ def get_ar_info():
|
|||||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||||
return response, 200
|
return response, 200
|
||||||
|
|
||||||
@app.route('/images/<path:filename>', methods=['GET'])
|
@app.route('/<tenant:tenant_id>/images/<path:filename>', methods=['GET'])
|
||||||
def serve_image(filename):
|
def serve_image(tenant_id, filename):
|
||||||
# Log the request for debugging
|
# 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")
|
# Extract product ID from filename (e.g., "123456.jpg" -> "123456")
|
||||||
product_id = os.path.splitext(filename)[0]
|
product_id = os.path.splitext(filename)[0]
|
||||||
|
|
||||||
# Get image from database
|
# Get image from database for this tenant
|
||||||
image_data = database.get_product_image(product_id)
|
image_data = database.get_product_image(product_id, tenant_id)
|
||||||
if image_data:
|
if image_data:
|
||||||
image_bytes, mime_type = image_data
|
image_bytes, mime_type = image_data
|
||||||
response = Response(image_bytes, mimetype=mime_type)
|
response = Response(image_bytes, mimetype=mime_type)
|
||||||
@@ -129,8 +138,8 @@ def serve_image(filename):
|
|||||||
app.logger.warning(f"Image not found: {filename}")
|
app.logger.warning(f"Image not found: {filename}")
|
||||||
return jsonify({"error": "Image not found"}), 404
|
return jsonify({"error": "Image not found"}), 404
|
||||||
|
|
||||||
@app.route('/barcodes/<path:filename>', methods=['GET'])
|
@app.route('/<tenant:tenant_id>/barcodes/<path:filename>', methods=['GET'])
|
||||||
def serve_barcode(filename):
|
def serve_barcode(tenant_id, filename):
|
||||||
# Parse filename to extract product_id and barcode type
|
# Parse filename to extract product_id and barcode type
|
||||||
# Expected format: {product_id}_{type}.png
|
# Expected format: {product_id}_{type}.png
|
||||||
name_parts = os.path.splitext(filename)[0].split('_')
|
name_parts = os.path.splitext(filename)[0].split('_')
|
||||||
@@ -145,9 +154,9 @@ def serve_barcode(filename):
|
|||||||
buffer = BytesIO()
|
buffer = BytesIO()
|
||||||
|
|
||||||
if code_type == 'qr':
|
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 = 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)
|
qr.make(fit=True)
|
||||||
img = qr.make_image(fill_color="black", back_color="white")
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
img.save(buffer, format='PNG')
|
img.save(buffer, format='PNG')
|
||||||
@@ -181,6 +190,31 @@ def serve_barcode(filename):
|
|||||||
app.logger.error(f"Barcode generation error: {str(e)}")
|
app.logger.error(f"Barcode generation error: {str(e)}")
|
||||||
return jsonify({"error": "Failed to generate barcode"}), 500
|
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__':
|
if __name__ == '__main__':
|
||||||
# Initialize database
|
# Initialize database
|
||||||
database.init_database()
|
database.init_database()
|
||||||
|
|||||||
193
src/database.py
193
src/database.py
@@ -24,38 +24,54 @@ def init_database():
|
|||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
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('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS products (
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
price TEXT,
|
price TEXT,
|
||||||
inventory INTEGER,
|
inventory INTEGER,
|
||||||
image_data BLOB,
|
image_data BLOB,
|
||||||
image_mime_type TEXT,
|
image_mime_type TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
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('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS product_fields (
|
CREATE TABLE IF NOT EXISTS product_fields (
|
||||||
product_id TEXT,
|
product_id TEXT,
|
||||||
|
tenant_id TEXT,
|
||||||
field_name TEXT,
|
field_name TEXT,
|
||||||
label TEXT,
|
label TEXT,
|
||||||
value TEXT,
|
value TEXT,
|
||||||
editable TEXT,
|
editable TEXT,
|
||||||
field_type TEXT,
|
field_type TEXT,
|
||||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
|
FOREIGN KEY (product_id, tenant_id) REFERENCES products(id, tenant_id) ON DELETE CASCADE,
|
||||||
PRIMARY KEY (product_id, field_name)
|
PRIMARY KEY (product_id, tenant_id, field_name)
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def migrate_from_json():
|
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')
|
json_path = os.path.join(os.path.dirname(__file__), 'data', 'products.json')
|
||||||
|
|
||||||
if not os.path.exists(json_path):
|
if not os.path.exists(json_path):
|
||||||
@@ -67,6 +83,13 @@ def migrate_from_json():
|
|||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
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():
|
for product_id, fields in products_data.items():
|
||||||
# Extract core fields
|
# Extract core fields
|
||||||
name = ''
|
name = ''
|
||||||
@@ -106,20 +129,21 @@ def migrate_from_json():
|
|||||||
}
|
}
|
||||||
image_mime_type = mime_types.get(ext, 'image/jpeg')
|
image_mime_type = mime_types.get(ext, 'image/jpeg')
|
||||||
|
|
||||||
# Insert into products table
|
# Insert into products table with tenant_id
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
INSERT OR REPLACE INTO products (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 (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
''', (product_id, name, price, inventory, image_data, image_mime_type))
|
''', (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:
|
for field in fields:
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
INSERT OR REPLACE INTO product_fields
|
INSERT OR REPLACE INTO product_fields
|
||||||
(product_id, field_name, label, value, editable, field_type)
|
(product_id, tenant_id, field_name, label, value, editable, field_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
''', (
|
''', (
|
||||||
product_id,
|
product_id,
|
||||||
|
default_tenant_id,
|
||||||
field['fieldName'],
|
field['fieldName'],
|
||||||
field['label'],
|
field['label'],
|
||||||
field['value'],
|
field['value'],
|
||||||
@@ -129,24 +153,35 @@ def migrate_from_json():
|
|||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def get_all_products() -> Dict[str, List[Dict[str, Any]]]:
|
def get_all_products(tenant_id: str = None) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
"""Get all products in the legacy format"""
|
"""Get all products for a tenant in the legacy format"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Get all product IDs
|
# Get all product IDs for the tenant
|
||||||
cursor.execute('SELECT id FROM products')
|
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()]
|
product_ids = [row['id'] for row in cursor.fetchall()]
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
for product_id in product_ids:
|
for product_id in product_ids:
|
||||||
# Get all fields for this product
|
# Get all fields for this product
|
||||||
cursor.execute('''
|
if tenant_id:
|
||||||
SELECT field_name, label, value, editable, field_type
|
cursor.execute('''
|
||||||
FROM product_fields
|
SELECT field_name, label, value, editable, field_type
|
||||||
WHERE product_id = ?
|
FROM product_fields
|
||||||
ORDER BY field_name
|
WHERE product_id = ? AND tenant_id = ?
|
||||||
''', (product_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 = []
|
fields = []
|
||||||
for row in cursor.fetchall():
|
for row in cursor.fetchall():
|
||||||
@@ -162,17 +197,17 @@ def get_all_products() -> Dict[str, List[Dict[str, Any]]]:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_product(product_id: str) -> Optional[List[Dict[str, Any]]]:
|
def get_product(product_id: str, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
|
||||||
"""Get a single product by ID"""
|
"""Get a single product by ID and tenant"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT field_name, label, value, editable, field_type
|
SELECT field_name, label, value, editable, field_type
|
||||||
FROM product_fields
|
FROM product_fields
|
||||||
WHERE product_id = ?
|
WHERE product_id = ? AND tenant_id = ?
|
||||||
ORDER BY field_name
|
ORDER BY field_name
|
||||||
''', (product_id,))
|
''', (product_id, tenant_id))
|
||||||
|
|
||||||
fields = []
|
fields = []
|
||||||
for row in cursor.fetchall():
|
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
|
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):
|
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"""
|
"""Save or update a product for a tenant"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
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
|
inventory = int(field['value']) if field['value'] else None
|
||||||
|
|
||||||
# Check if product exists
|
# 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
|
exists = cursor.fetchone() is not None
|
||||||
|
|
||||||
if exists:
|
if exists:
|
||||||
@@ -214,32 +249,33 @@ def save_product(product_id: str, fields: List[Dict[str, Any]], image_data: Opti
|
|||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
UPDATE products
|
UPDATE products
|
||||||
SET name = ?, price = ?, inventory = ?, image_data = ?, image_mime_type = ?, updated_at = CURRENT_TIMESTAMP
|
SET name = ?, price = ?, inventory = ?, image_data = ?, image_mime_type = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ? AND tenant_id = ?
|
||||||
''', (name, price, inventory, image_data, image_mime_type, product_id))
|
''', (name, price, inventory, image_data, image_mime_type, product_id, tenant_id))
|
||||||
else:
|
else:
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
UPDATE products
|
UPDATE products
|
||||||
SET name = ?, price = ?, inventory = ?, updated_at = CURRENT_TIMESTAMP
|
SET name = ?, price = ?, inventory = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ? AND tenant_id = ?
|
||||||
''', (name, price, inventory, product_id))
|
''', (name, price, inventory, product_id, tenant_id))
|
||||||
else:
|
else:
|
||||||
# Insert new product
|
# Insert new product
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
INSERT INTO products (id, name, price, inventory, image_data, image_mime_type)
|
INSERT INTO products (id, tenant_id, name, price, inventory, image_data, image_mime_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
''', (product_id, name, price, inventory, image_data, image_mime_type))
|
''', (product_id, tenant_id, name, price, inventory, image_data, image_mime_type))
|
||||||
|
|
||||||
# Delete existing fields
|
# 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
|
# Insert all fields
|
||||||
for field in fields:
|
for field in fields:
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
INSERT INTO product_fields
|
INSERT INTO product_fields
|
||||||
(product_id, field_name, label, value, editable, field_type)
|
(product_id, tenant_id, field_name, label, value, editable, field_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
''', (
|
''', (
|
||||||
product_id,
|
product_id,
|
||||||
|
tenant_id,
|
||||||
field['fieldName'],
|
field['fieldName'],
|
||||||
field['label'],
|
field['label'],
|
||||||
field['value'],
|
field['value'],
|
||||||
@@ -249,20 +285,81 @@ def save_product(product_id: str, fields: List[Dict[str, Any]], image_data: Opti
|
|||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def delete_product(product_id: str):
|
def delete_product(product_id: str, tenant_id: str):
|
||||||
"""Delete a product"""
|
"""Delete a product for a tenant"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
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()
|
conn.commit()
|
||||||
|
|
||||||
def get_product_image(product_id: str) -> Optional[tuple[bytes, str]]:
|
def get_product_image(product_id: str, tenant_id: str) -> Optional[tuple[bytes, str]]:
|
||||||
"""Get product image data and mime type"""
|
"""Get product image data and mime type for a tenant"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
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()
|
row = cursor.fetchone()
|
||||||
|
|
||||||
if row and row['image_data']:
|
if row and row['image_data']:
|
||||||
return row['image_data'], row['image_mime_type']
|
return row['image_data'], row['image_mime_type']
|
||||||
return None
|
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
|
||||||
@@ -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 os
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
@@ -19,8 +19,6 @@ except ImportError:
|
|||||||
def check_dependencies():
|
def check_dependencies():
|
||||||
return {'qrcode': False, 'barcode': False, 'pillow': False}
|
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')
|
DATA_FOLDER = os.path.join(os.path.dirname(__file__), '../data')
|
||||||
PRODUCTS_FILE = os.path.join(DATA_FOLDER, 'products.json')
|
PRODUCTS_FILE = os.path.join(DATA_FOLDER, 'products.json')
|
||||||
UPLOAD_FOLDER = os.path.join(DATA_FOLDER, 'images')
|
UPLOAD_FOLDER = os.path.join(DATA_FOLDER, 'images')
|
||||||
@@ -30,20 +28,19 @@ ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
|||||||
def allowed_file(filename):
|
def allowed_file(filename):
|
||||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
def load_products():
|
def load_products(tenant_id):
|
||||||
return database.get_all_products()
|
return database.get_all_products(tenant_id)
|
||||||
|
|
||||||
def save_products(products):
|
def save_products(products):
|
||||||
# This is now handled by database operations
|
# This is now handled by database operations
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@admin_bp.route('/')
|
def index(tenant_id):
|
||||||
def index():
|
products = load_products(tenant_id)
|
||||||
products = load_products()
|
tenant = database.get_or_create_tenant(tenant_id)
|
||||||
return render_template('admin/index.html', products=products)
|
return render_template('admin/index.html', products=products, tenant=tenant)
|
||||||
|
|
||||||
@admin_bp.route('/add', methods=['GET', 'POST'])
|
def add_product(tenant_id):
|
||||||
def add_product():
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
# Get form data
|
# Get form data
|
||||||
product_id = request.form.get('product_id')
|
product_id = request.form.get('product_id')
|
||||||
@@ -53,13 +50,13 @@ def add_product():
|
|||||||
# Basic validation
|
# Basic validation
|
||||||
if not product_id or not name or not price:
|
if not product_id or not name or not price:
|
||||||
flash('Product ID, Name, and Price are required fields.')
|
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
|
# Check if product ID already exists
|
||||||
products = load_products()
|
products = load_products(tenant_id)
|
||||||
if product_id in products:
|
if product_id in products:
|
||||||
flash('Product ID already exists.')
|
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
|
# Handle image upload
|
||||||
image_data = None
|
image_data = None
|
||||||
@@ -93,20 +90,19 @@ def add_product():
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Save to database
|
# 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!')
|
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(tenant_id, product_id):
|
||||||
def edit_product(product_id):
|
products = load_products(tenant_id)
|
||||||
products = load_products()
|
|
||||||
|
|
||||||
if product_id not in products:
|
if product_id not in products:
|
||||||
flash('Product not found.')
|
flash('Product not found.')
|
||||||
return redirect(url_for('admin.index'))
|
return redirect(url_for('admin.index', tenant_id=tenant_id))
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
# Get form data
|
# Get form data
|
||||||
@@ -116,7 +112,7 @@ def edit_product(product_id):
|
|||||||
# Basic validation
|
# Basic validation
|
||||||
if not name or not price:
|
if not name or not price:
|
||||||
flash('Name and Price are required fields.')
|
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
|
# Update name and price
|
||||||
for field in products[product_id]:
|
for field in products[product_id]:
|
||||||
@@ -152,41 +148,38 @@ def edit_product(product_id):
|
|||||||
field["value"] = image_path
|
field["value"] = image_path
|
||||||
|
|
||||||
# Save to database
|
# 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!')
|
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(tenant_id, product_id):
|
||||||
def delete_product(product_id):
|
products = load_products(tenant_id)
|
||||||
products = load_products()
|
|
||||||
|
|
||||||
if product_id not in products:
|
if product_id not in products:
|
||||||
flash('Product not found.')
|
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)
|
# 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!')
|
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(tenant_id, product_id):
|
||||||
def view_product(product_id):
|
products = load_products(tenant_id)
|
||||||
products = load_products()
|
|
||||||
|
|
||||||
if product_id not in products:
|
if product_id not in products:
|
||||||
flash('Product not found.')
|
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(tenant_id, product_id, code_type):
|
||||||
def generate_barcode(product_id, code_type):
|
|
||||||
"""Redirect to the main barcode generation endpoint"""
|
"""Redirect to the main barcode generation endpoint"""
|
||||||
products = load_products()
|
products = load_products(tenant_id)
|
||||||
if product_id not in products:
|
if product_id not in products:
|
||||||
return jsonify({"error": "Product not found"}), 404
|
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)
|
barcode_type = type_mapping.get(code_type, code_type)
|
||||||
|
|
||||||
# Redirect to the main barcode endpoint which generates dynamically
|
# 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(tenant_id, product_id):
|
||||||
def generate_barcode_page(product_id):
|
|
||||||
"""Show a page with different barcode options for a product"""
|
"""Show a page with different barcode options for a product"""
|
||||||
products = load_products()
|
products = load_products(tenant_id)
|
||||||
|
|
||||||
if product_id not in products:
|
if product_id not in products:
|
||||||
flash('Product not found.')
|
flash('Product not found.')
|
||||||
return redirect(url_for('admin.index'))
|
return redirect(url_for('admin.index', tenant_id=tenant_id))
|
||||||
|
|
||||||
# Extract product data
|
# Extract product data
|
||||||
product_data = {}
|
product_data = {}
|
||||||
@@ -234,4 +226,22 @@ def generate_barcode_page(product_id):
|
|||||||
return render_template('admin/generate_barcode.html',
|
return render_template('admin/generate_barcode.html',
|
||||||
product_id=product_id,
|
product_id=product_id,
|
||||||
product=product_data,
|
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)
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
# API routes blueprint scaffold
|
# API routes scaffold
|
||||||
from flask import Blueprint, jsonify
|
from flask import jsonify
|
||||||
|
|
||||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
def api_index(tenant_id):
|
||||||
|
return jsonify({'message': 'API Home', 'tenant': tenant_id})
|
||||||
@api_bp.route('/')
|
|
||||||
def api_index():
|
|
||||||
return jsonify({'message': 'API Home'})
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<h2>Add New Product</h2>
|
<h2>Add New Product</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<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">
|
<div class="mb-3">
|
||||||
<label for="product_id" class="form-label">Product ID *</label>
|
<label for="product_id" class="form-label">Product ID *</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between">
|
<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>
|
<button type="submit" class="btn btn-primary">Add Product</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
92
src/templates/admin/credentials.html
Normal file
92
src/templates/admin/credentials.html
Normal 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>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<h2>Edit Product</h2>
|
<h2>Edit Product</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<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">
|
<div class="mb-3">
|
||||||
<label for="product_id" class="form-label">Product ID</label>
|
<label for="product_id" class="form-label">Product ID</label>
|
||||||
<input type="text" class="form-control" id="product_id" value="{{ product_id }}" disabled>
|
<input type="text" class="form-control" id="product_id" value="{{ product_id }}" disabled>
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between">
|
<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>
|
<button type="submit" class="btn btn-primary">Update Product</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h2>Generate Barcodes for Product: {{ product_id }}</h2>
|
<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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if not dependencies.qrcode or not dependencies.barcode or not dependencies.pillow %}
|
{% if not dependencies.qrcode or not dependencies.barcode or not dependencies.pillow %}
|
||||||
@@ -38,9 +38,9 @@
|
|||||||
<h3>QR Code</h3>
|
<h3>QR Code</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body text-center">
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,9 +51,9 @@
|
|||||||
<h3>EAN-13 Barcode</h3>
|
<h3>EAN-13 Barcode</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body text-center">
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,9 +64,9 @@
|
|||||||
<h3>Code 128 Barcode</h3>
|
<h3>Code 128 Barcode</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body text-center">
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block title %}Admin Dashboard - KCAP Demo Server{% endblock %}
|
{% block title %}Admin Dashboard - {{ tenant.name }} - KCAP Demo Server{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-md-12">
|
<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">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1>Product Management</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
{% if products %}
|
{% if products %}
|
||||||
@@ -50,8 +57,8 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex justify-content-center gap-2">
|
<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.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', product_id=product_id) }}" class="btn btn-sm btn-outline-success">Barcodes</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"
|
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||||
data-product-id="{{ product_id }}"
|
data-product-id="{{ product_id }}"
|
||||||
@@ -59,7 +66,7 @@
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="jumbotron">
|
<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>
|
<p class="lead">A simple Flask API for demonstrating AR content retrieval for barcode scanning applications.</p>
|
||||||
<hr class="my-4">
|
<hr class="my-4">
|
||||||
<p>This server simulates the Knox Capture API for AR overlays and includes endpoints for managing product attributes.</p>
|
<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>
|
<h2>API Endpoints</h2>
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<strong>Login:</strong> <code>GET /login</code>
|
<strong>Login:</strong> <code>GET {% if tenant %}/{{ tenant.id }}{% endif %}/login</code>
|
||||||
<p>Simulates a simple login with a 200 response (no authentication required).</p>
|
<p>Authenticates using Basic Auth with tenant-specific credentials.</p>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item">
|
<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>
|
<p>Returns a list of available attributes (e.g., item ID, price, image URI).</p>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item">
|
<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>
|
<p>Returns product details for a given barcode, including image URLs.</p>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<strong>Static Image Server:</strong> <code>GET /images/123456.png</code>
|
<strong>Static Image Server:</strong> <code>GET {% if tenant %}/{{ tenant.id }}{% endif %}/images/123456.png</code>
|
||||||
<p>Serves image files from the <code>static/images/</code> directory.</p>
|
<p>Serves product images stored in the database.</p>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<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>
|
<a href="/admin" class="btn btn-primary btn-lg">Go to Admin Interface</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
75
src/templates/tenant_selection.html
Normal file
75
src/templates/tenant_selection.html
Normal 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>
|
||||||
Reference in New Issue
Block a user