Merge pull request #1 from mattintech/feature/customfileds
Add custom fields and barcode viewing features
This commit is contained in:
261
src/app.py
261
src/app.py
@@ -8,6 +8,7 @@ from barcode.writer import ImageWriter
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import shutil
|
import shutil
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
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
|
||||||
@@ -18,6 +19,14 @@ app.config['SECRET_KEY'] = 'dev-key-for-demo-only'
|
|||||||
class TenantConverter(BaseConverter):
|
class TenantConverter(BaseConverter):
|
||||||
regex = '[a-zA-Z0-9_-]+'
|
regex = '[a-zA-Z0-9_-]+'
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
# Convert to lowercase when parsing from URL
|
||||||
|
return value.lower()
|
||||||
|
|
||||||
|
def to_url(self, value):
|
||||||
|
# Convert to lowercase when generating URLs
|
||||||
|
return value.lower()
|
||||||
|
|
||||||
app.url_map.converters['tenant'] = TenantConverter
|
app.url_map.converters['tenant'] = TenantConverter
|
||||||
|
|
||||||
# Load product data from database
|
# Load product data from database
|
||||||
@@ -28,7 +37,8 @@ def load_products(tenant_id=None):
|
|||||||
def index():
|
def index():
|
||||||
# Show a tenant selection page or redirect to default
|
# Show a tenant selection page or redirect to default
|
||||||
tenants = database.get_all_tenants()
|
tenants = database.get_all_tenants()
|
||||||
return render_template('tenant_selection.html', tenants=tenants)
|
server_url = database.get_server_url()
|
||||||
|
return render_template('tenant_selection.html', tenants=tenants, server_url=server_url)
|
||||||
|
|
||||||
@app.route('/tenant/<tenant:tenant_id>/delete', methods=['POST'])
|
@app.route('/tenant/<tenant:tenant_id>/delete', methods=['POST'])
|
||||||
def delete_tenant(tenant_id):
|
def delete_tenant(tenant_id):
|
||||||
@@ -37,6 +47,22 @@ def delete_tenant(tenant_id):
|
|||||||
flash(f'Tenant "{tenant_id}" has been deleted successfully.', 'success')
|
flash(f'Tenant "{tenant_id}" has been deleted successfully.', 'success')
|
||||||
return redirect('/')
|
return redirect('/')
|
||||||
|
|
||||||
|
@app.route('/settings', methods=['GET', 'POST'])
|
||||||
|
def settings():
|
||||||
|
if request.method == 'POST':
|
||||||
|
server_url = request.form.get('server_url', '').strip()
|
||||||
|
if server_url:
|
||||||
|
# Remove trailing slash for consistency
|
||||||
|
server_url = server_url.rstrip('/')
|
||||||
|
database.set_setting('server_url', server_url)
|
||||||
|
flash('Server settings updated successfully.', 'success')
|
||||||
|
else:
|
||||||
|
flash('Please provide a valid server URL.', 'error')
|
||||||
|
return redirect('/settings')
|
||||||
|
|
||||||
|
server_url = database.get_server_url()
|
||||||
|
return render_template('settings.html', server_url=server_url)
|
||||||
|
|
||||||
@app.route('/<tenant:tenant_id>/')
|
@app.route('/<tenant:tenant_id>/')
|
||||||
def tenant_index(tenant_id):
|
def tenant_index(tenant_id):
|
||||||
# Auto-create tenant if it doesn't exist
|
# Auto-create tenant if it doesn't exist
|
||||||
@@ -44,7 +70,66 @@ def tenant_index(tenant_id):
|
|||||||
if tenant is None:
|
if tenant is None:
|
||||||
# Reserved tenant ID or invalid
|
# Reserved tenant ID or invalid
|
||||||
return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404
|
return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404
|
||||||
return render_template('index.html', tenant=tenant)
|
|
||||||
|
# Load products for this tenant
|
||||||
|
products = load_products(tenant_id)
|
||||||
|
custom_fields = database.get_custom_ar_fields(tenant_id)
|
||||||
|
|
||||||
|
return render_template('index.html', tenant=tenant, products=products, custom_fields=custom_fields)
|
||||||
|
|
||||||
|
@app.route('/<tenant:tenant_id>/settings')
|
||||||
|
def tenant_settings(tenant_id):
|
||||||
|
# Get tenant
|
||||||
|
tenant = database.get_or_create_tenant(tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404
|
||||||
|
|
||||||
|
# Get custom AR fields for this tenant
|
||||||
|
custom_fields = database.get_custom_ar_fields(tenant_id)
|
||||||
|
|
||||||
|
# Get server URL for API endpoint display
|
||||||
|
server_url = database.get_server_url()
|
||||||
|
|
||||||
|
return render_template('tenant_settings.html', tenant=tenant, custom_fields=custom_fields, server_url=server_url)
|
||||||
|
|
||||||
|
@app.route('/<tenant:tenant_id>/settings/credentials', methods=['POST'])
|
||||||
|
def update_tenant_credentials(tenant_id):
|
||||||
|
# Get tenant
|
||||||
|
tenant = database.get_tenant(tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
return jsonify({"error": "Tenant not found"}), 404
|
||||||
|
|
||||||
|
username = request.form.get('username', '').strip()
|
||||||
|
password = request.form.get('password', '').strip()
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
flash('Username is required.', 'error')
|
||||||
|
return redirect(f'/{tenant_id}/settings')
|
||||||
|
|
||||||
|
# Update credentials
|
||||||
|
database.update_tenant_credentials(tenant_id, username, password if password else None)
|
||||||
|
|
||||||
|
flash('Credentials updated successfully.', 'success')
|
||||||
|
return redirect(f'/{tenant_id}/settings')
|
||||||
|
|
||||||
|
@app.route('/<tenant:tenant_id>/settings/barcode', methods=['POST'])
|
||||||
|
def update_tenant_barcode_type(tenant_id):
|
||||||
|
# Get tenant
|
||||||
|
tenant = database.get_tenant(tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
return jsonify({"error": "Tenant not found"}), 404
|
||||||
|
|
||||||
|
barcode_type = request.form.get('barcode_type', '').strip()
|
||||||
|
|
||||||
|
if not barcode_type:
|
||||||
|
flash('Barcode type is required.', 'error')
|
||||||
|
return redirect(f'/{tenant_id}/settings')
|
||||||
|
|
||||||
|
# Update barcode type
|
||||||
|
database.update_tenant_barcode_type(tenant_id, barcode_type)
|
||||||
|
|
||||||
|
flash('Barcode type updated successfully.', 'success')
|
||||||
|
return redirect(f'/{tenant_id}/settings')
|
||||||
|
|
||||||
def check_basic_auth(auth_header, tenant_id):
|
def check_basic_auth(auth_header, tenant_id):
|
||||||
"""Validate Basic authentication credentials for a tenant"""
|
"""Validate Basic authentication credentials for a tenant"""
|
||||||
@@ -56,8 +141,8 @@ def check_basic_auth(auth_header, tenant_id):
|
|||||||
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)
|
||||||
|
|
||||||
# Get tenant credentials from database
|
# Get tenant credentials from database (without creating)
|
||||||
tenant = database.get_or_create_tenant(tenant_id)
|
tenant = database.get_tenant(tenant_id)
|
||||||
if tenant and username == tenant['username'] and password == tenant['password']:
|
if tenant and username == tenant['username'] and password == tenant['password']:
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -76,44 +161,94 @@ def login(tenant_id):
|
|||||||
|
|
||||||
@app.route('/<tenant:tenant_id>/arcontentfields', methods=['GET'])
|
@app.route('/<tenant:tenant_id>/arcontentfields', methods=['GET'])
|
||||||
def get_ar_content_fields(tenant_id):
|
def get_ar_content_fields(tenant_id):
|
||||||
# Return a fixed set of attributes (tenant_id could be used for custom fields in the future)
|
# Get custom fields defined for this tenant
|
||||||
fields = [
|
fields = database.get_custom_ar_fields(tenant_id)
|
||||||
{"fieldName": "_id", "label": "Item ID", "editable": "false", "fieldType": "TEXT"},
|
|
||||||
{"fieldName": "_price", "label": "Sale Price", "editable": "true", "fieldType": "TEXT"},
|
|
||||||
{"fieldName": "_image", "label": "Image", "editable": "false", "fieldType": "IMAGE_URI"}
|
|
||||||
]
|
|
||||||
return jsonify(fields), 200
|
return jsonify(fields), 200
|
||||||
|
|
||||||
@app.route('/<tenant:tenant_id>/arinfo', methods=['GET'])
|
@app.route('/<tenant:tenant_id>/arinfo', methods=['GET', 'POST'])
|
||||||
def get_ar_info(tenant_id):
|
def get_ar_info(tenant_id):
|
||||||
barcode = request.args.get('barcode')
|
barcode = request.args.get('barcode')
|
||||||
products = load_products(tenant_id)
|
products = load_products(tenant_id)
|
||||||
|
|
||||||
# Helper function to convert relative image paths to absolute URLs
|
# Get custom AR fields for this tenant
|
||||||
def make_absolute_urls(product_fields):
|
custom_fields = database.get_custom_ar_fields(tenant_id)
|
||||||
# Create absolute URL for image fields
|
custom_field_names = [f['fieldName'] for f in custom_fields]
|
||||||
|
|
||||||
|
# Handle POST request - update product fields
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not barcode:
|
||||||
|
return jsonify({"error": "Barcode parameter required"}), 400
|
||||||
|
|
||||||
|
if barcode not in products:
|
||||||
|
return jsonify({"error": "Product not found"}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the updated fields from the request body
|
||||||
|
updated_fields = request.get_json()
|
||||||
|
|
||||||
|
if not isinstance(updated_fields, list):
|
||||||
|
return jsonify({"error": "Request body must be an array of fields"}), 400
|
||||||
|
|
||||||
|
# Get current product data
|
||||||
|
current_product = products[barcode]
|
||||||
|
|
||||||
|
# Update only the editable fields
|
||||||
|
for updated_field in updated_fields:
|
||||||
|
field_name = updated_field.get('fieldName')
|
||||||
|
new_value = updated_field.get('value')
|
||||||
|
|
||||||
|
# Find the field in the current product and update it
|
||||||
|
for field in current_product:
|
||||||
|
if field['fieldName'] == field_name:
|
||||||
|
# Check if the field is editable
|
||||||
|
if field.get('editable') == 'true':
|
||||||
|
field['value'] = new_value
|
||||||
|
break
|
||||||
|
|
||||||
|
# Save the updated product to database
|
||||||
|
database.save_product(barcode, tenant_id, current_product)
|
||||||
|
|
||||||
|
app.logger.info(f"Updated product {barcode} for tenant {tenant_id}")
|
||||||
|
return jsonify({"success": True}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error updating product: {str(e)}")
|
||||||
|
return jsonify({"error": "Failed to update product"}), 500
|
||||||
|
|
||||||
|
# Helper function to convert relative image paths to absolute URLs and filter fields
|
||||||
|
def filter_and_process_fields(product_fields):
|
||||||
|
# Filter to only include fields defined in custom AR fields
|
||||||
|
filtered_fields = []
|
||||||
|
|
||||||
|
# Get field types for all custom fields
|
||||||
|
field_types = {f['fieldName']: f['fieldType'] for f in custom_fields}
|
||||||
|
|
||||||
for field in product_fields:
|
for field in product_fields:
|
||||||
if field['fieldName'] == '_image' and field['value']:
|
if field['fieldName'] in custom_field_names:
|
||||||
|
# Create absolute URL for IMAGE_URI fields
|
||||||
|
if field_types.get(field['fieldName']) == 'IMAGE_URI' and field['value']:
|
||||||
# If it's already an absolute URL, leave it as is
|
# If 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 with tenant
|
# Build absolute URL using request host with tenant
|
||||||
field['value'] = f"{request.url_root.rstrip('/')}/{tenant_id}{field['value']}"
|
field['value'] = f"{request.url_root.rstrip('/')}/{tenant_id}{field['value']}"
|
||||||
return product_fields
|
filtered_fields.append(field)
|
||||||
|
return filtered_fields
|
||||||
|
|
||||||
|
# Handle GET request - return product data
|
||||||
# If barcode is provided, return specific product
|
# If barcode is provided, return specific product
|
||||||
if barcode:
|
if barcode:
|
||||||
if barcode in products:
|
if barcode in products:
|
||||||
product_data = make_absolute_urls(products[barcode])
|
product_data = filter_and_process_fields(products[barcode])
|
||||||
response = jsonify(product_data)
|
response = jsonify(product_data)
|
||||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||||
return response, 200
|
return response, 200
|
||||||
return jsonify({"error": "Product not found"}), 404
|
return jsonify({"error": "Product not found"}), 404
|
||||||
|
|
||||||
# Return all products if no barcode specified
|
# Return all products if no barcode specified
|
||||||
# Convert all products to have absolute URLs
|
# Convert all products to have absolute URLs and filter fields
|
||||||
all_products = {}
|
all_products = {}
|
||||||
for product_id, fields in products.items():
|
for product_id, fields in products.items():
|
||||||
all_products[product_id] = make_absolute_urls(fields)
|
all_products[product_id] = filter_and_process_fields(fields)
|
||||||
response = jsonify(all_products)
|
response = jsonify(all_products)
|
||||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||||
return response, 200
|
return response, 200
|
||||||
@@ -123,7 +258,26 @@ def serve_image(tenant_id, filename):
|
|||||||
# Log the request for debugging
|
# Log the request for debugging
|
||||||
app.logger.info(f"Image requested for tenant {tenant_id}: {filename}")
|
app.logger.info(f"Image requested for tenant {tenant_id}: {filename}")
|
||||||
|
|
||||||
# Extract product ID from filename (e.g., "123456.jpg" -> "123456")
|
# Check if filename contains field name (e.g., "123456_thumbnail.jpg")
|
||||||
|
base_name = os.path.splitext(filename)[0]
|
||||||
|
|
||||||
|
if '_' in base_name:
|
||||||
|
# Split to get product_id and field_name
|
||||||
|
parts = base_name.split('_', 1)
|
||||||
|
product_id = parts[0]
|
||||||
|
field_name = '_' + parts[1] if len(parts) > 1 else '_image'
|
||||||
|
|
||||||
|
# Try to get field-specific image
|
||||||
|
image_data = database.get_product_image_by_field(product_id, tenant_id, field_name)
|
||||||
|
if image_data:
|
||||||
|
image_bytes, mime_type = image_data
|
||||||
|
response = Response(image_bytes, mimetype=mime_type)
|
||||||
|
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||||
|
response.headers['Cache-Control'] = 'public, max-age=3600'
|
||||||
|
app.logger.info(f"Serving field-specific image: {product_id}/{field_name} ({len(image_bytes)} bytes)")
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Try standard image lookup (backward compatibility)
|
||||||
product_id = os.path.splitext(filename)[0]
|
product_id = os.path.splitext(filename)[0]
|
||||||
|
|
||||||
# Get image from database for this tenant
|
# Get image from database for this tenant
|
||||||
@@ -148,6 +302,50 @@ def serve_image(tenant_id, 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('/<tenant:tenant_id>/qrcode/template', methods=['GET'])
|
||||||
|
def generate_template_qr_code(tenant_id):
|
||||||
|
"""Generate QR code for the AR Template URL"""
|
||||||
|
try:
|
||||||
|
# Get server URL from settings
|
||||||
|
server_url = database.get_server_url()
|
||||||
|
template_url = f"{server_url}/{tenant_id}/"
|
||||||
|
|
||||||
|
# Generate QR code
|
||||||
|
buffer = BytesIO()
|
||||||
|
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||||
|
qr.add_data(template_url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
img.save(buffer, format='PNG')
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
return Response(buffer.getvalue(), mimetype='image/png')
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"QR code generation error: {str(e)}")
|
||||||
|
return jsonify({"error": "Failed to generate QR code"}), 500
|
||||||
|
|
||||||
|
@app.route('/<tenant:tenant_id>/qrcode/arinfo', methods=['GET'])
|
||||||
|
def generate_ar_qr_code(tenant_id):
|
||||||
|
"""Generate QR code for the AR API endpoint"""
|
||||||
|
try:
|
||||||
|
# Get server URL from settings
|
||||||
|
server_url = database.get_server_url()
|
||||||
|
ar_url = f"{server_url}/{tenant_id}/arinfo"
|
||||||
|
|
||||||
|
# Generate QR code
|
||||||
|
buffer = BytesIO()
|
||||||
|
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||||
|
qr.add_data(ar_url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
img.save(buffer, format='PNG')
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
return Response(buffer.getvalue(), mimetype='image/png')
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"QR code generation error: {str(e)}")
|
||||||
|
return jsonify({"error": "Failed to generate QR code"}), 500
|
||||||
|
|
||||||
@app.route('/<tenant:tenant_id>/barcodes/<path:filename>', methods=['GET'])
|
@app.route('/<tenant:tenant_id>/barcodes/<path:filename>', methods=['GET'])
|
||||||
def serve_barcode(tenant_id, 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
|
||||||
@@ -200,20 +398,17 @@ def serve_barcode(tenant_id, 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
|
# Import product management routes
|
||||||
from routes.admin import (index as admin_index, add_product, edit_product,
|
from routes.admin import (add_product, edit_product, delete_product,
|
||||||
delete_product, view_product, generate_barcode,
|
generate_barcode, manage_ar_fields, view_all_barcodes)
|
||||||
generate_barcode_page, manage_credentials)
|
|
||||||
|
|
||||||
# Register admin routes with tenant prefix
|
# Register product management routes (remove /admin/ from paths)
|
||||||
app.add_url_rule('/<tenant:tenant_id>/admin/', 'admin.index', admin_index)
|
app.add_url_rule('/<tenant:tenant_id>/add', 'admin.add_product', add_product, methods=['GET', 'POST'])
|
||||||
app.add_url_rule('/<tenant:tenant_id>/admin/add', 'admin.add_product', add_product, methods=['GET', 'POST'])
|
app.add_url_rule('/<tenant:tenant_id>/edit/<product_id>', 'admin.edit_product', edit_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>/delete/<product_id>', 'admin.delete_product', delete_product, methods=['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>/generate_barcode/<product_id>/<code_type>', 'admin.generate_barcode', generate_barcode)
|
||||||
app.add_url_rule('/<tenant:tenant_id>/admin/view/<product_id>', 'admin.view_product', view_product)
|
app.add_url_rule('/<tenant:tenant_id>/ar_fields', 'admin.manage_ar_fields', manage_ar_fields, methods=['GET', 'POST'])
|
||||||
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>/barcodes', 'admin.view_all_barcodes', view_all_barcodes, methods=['GET'])
|
||||||
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
|
# Register API routes with tenant prefix
|
||||||
from routes.api import api_index
|
from routes.api import api_index
|
||||||
|
|||||||
323
src/database.py
323
src/database.py
@@ -75,6 +75,56 @@ def init_database():
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# Create custom_ar_fields table for tenant-specific AR field definitions
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_ar_fields (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
field_name TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
field_type TEXT NOT NULL,
|
||||||
|
editable TEXT DEFAULT 'true',
|
||||||
|
display_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(tenant_id, field_name)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Create product_images table for storing multiple images per product
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS product_images (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
product_id TEXT NOT NULL,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
field_name TEXT NOT NULL,
|
||||||
|
image_data BLOB,
|
||||||
|
image_mime_type TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (product_id, tenant_id) REFERENCES products(id, tenant_id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(product_id, tenant_id, field_name)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Create settings table for server configuration
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Add barcode_type column to tenants table if it doesn't exist
|
||||||
|
try:
|
||||||
|
cursor.execute('ALTER TABLE tenants ADD COLUMN barcode_type TEXT DEFAULT "code128"')
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# Column already exists
|
||||||
|
pass
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def migrate_from_json():
|
def migrate_from_json():
|
||||||
@@ -162,6 +212,10 @@ def migrate_from_json():
|
|||||||
|
|
||||||
def get_all_products(tenant_id: str = None) -> Dict[str, List[Dict[str, Any]]]:
|
def get_all_products(tenant_id: str = None) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
"""Get all products for a tenant in the legacy format"""
|
"""Get all products for a tenant in the legacy format"""
|
||||||
|
# Normalize tenant_id to lowercase if provided
|
||||||
|
if tenant_id:
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -206,6 +260,9 @@ def get_all_products(tenant_id: str = None) -> Dict[str, List[Dict[str, Any]]]:
|
|||||||
|
|
||||||
def get_product(product_id: str, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
|
def get_product(product_id: str, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
|
||||||
"""Get a single product by ID and tenant"""
|
"""Get a single product by ID and tenant"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -230,6 +287,9 @@ def get_product(product_id: str, tenant_id: str) -> Optional[List[Dict[str, Any]
|
|||||||
|
|
||||||
def save_product(product_id: str, tenant_id: str, fields: List[Dict[str, Any]], image_data: Optional[bytes] = None, image_mime_type: Optional[str] = None):
|
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"""
|
"""Save or update a product for a tenant"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -294,6 +354,9 @@ def save_product(product_id: str, tenant_id: str, fields: List[Dict[str, Any]],
|
|||||||
|
|
||||||
def delete_product(product_id: str, tenant_id: str):
|
def delete_product(product_id: str, tenant_id: str):
|
||||||
"""Delete a product for a tenant"""
|
"""Delete a product for a tenant"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute('DELETE FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id))
|
cursor.execute('DELETE FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id))
|
||||||
@@ -301,6 +364,9 @@ def delete_product(product_id: str, tenant_id: str):
|
|||||||
|
|
||||||
def get_product_image(product_id: str, tenant_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 for a tenant"""
|
"""Get product image data and mime type for a tenant"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute('SELECT image_data, image_mime_type FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id))
|
cursor.execute('SELECT image_data, image_mime_type FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id))
|
||||||
@@ -310,17 +376,16 @@ def get_product_image(product_id: str, tenant_id: str) -> Optional[tuple[bytes,
|
|||||||
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) -> Optional[Dict[str, Any]]:
|
def get_tenant(tenant_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get or create a tenant"""
|
"""Get a tenant without creating it"""
|
||||||
# Check if tenant_id is reserved
|
# Normalize tenant_id to lowercase
|
||||||
if tenant_id.lower() in RESERVED_TENANT_IDS:
|
tenant_id = tenant_id.lower()
|
||||||
return None
|
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Check if tenant exists
|
# Check if tenant exists
|
||||||
cursor.execute('SELECT id, name, username, password FROM tenants WHERE id = ?', (tenant_id,))
|
cursor.execute('SELECT id, name, username, password, created_at, barcode_type FROM tenants WHERE id = ?', (tenant_id,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
@@ -328,27 +393,70 @@ def get_or_create_tenant(tenant_id: str, username: str = None, password: str = N
|
|||||||
'id': row['id'],
|
'id': row['id'],
|
||||||
'name': row['name'],
|
'name': row['name'],
|
||||||
'username': row['username'],
|
'username': row['username'],
|
||||||
'password': row['password']
|
'password': row['password'],
|
||||||
|
'created_at': row['created_at'],
|
||||||
|
'barcode_type': row['barcode_type'] or 'code128'
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_or_create_tenant(tenant_id: str, username: str = None, password: str = None) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get or create a tenant"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
|
# Check if tenant_id is reserved
|
||||||
|
if tenant_id in RESERVED_TENANT_IDS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if tenant exists
|
||||||
|
cursor.execute('SELECT id, name, username, password, created_at, barcode_type FROM tenants WHERE id = ?', (tenant_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return {
|
||||||
|
'id': row['id'],
|
||||||
|
'name': row['name'],
|
||||||
|
'username': row['username'],
|
||||||
|
'password': row['password'],
|
||||||
|
'created_at': row['created_at'],
|
||||||
|
'barcode_type': row['barcode_type'] or 'code128'
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Create new tenant with default credentials
|
# Create new tenant with default credentials
|
||||||
default_username = username or 'admin'
|
default_username = username or 'admin'
|
||||||
default_password = password or 'admin'
|
default_password = password or 'admin'
|
||||||
|
# Preserve original casing for display name
|
||||||
|
display_name = tenant_id.replace('-', ' ').replace('_', ' ').title()
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
INSERT INTO tenants (id, name, username, password)
|
INSERT INTO tenants (id, name, username, password)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
''', (tenant_id, tenant_id.title(), default_username, default_password))
|
''', (tenant_id, display_name, default_username, default_password))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# Initialize default AR fields for the new tenant
|
||||||
|
init_default_ar_fields(tenant_id)
|
||||||
|
|
||||||
|
# Get the created_at timestamp and barcode_type from the database
|
||||||
|
cursor.execute('SELECT created_at, barcode_type FROM tenants WHERE id = ?', (tenant_id,))
|
||||||
|
created_row = cursor.fetchone()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': tenant_id,
|
'id': tenant_id,
|
||||||
'name': tenant_id.title(),
|
'name': display_name,
|
||||||
'username': default_username,
|
'username': default_username,
|
||||||
'password': default_password
|
'password': default_password,
|
||||||
|
'created_at': created_row['created_at'] if created_row else None,
|
||||||
|
'barcode_type': created_row['barcode_type'] if created_row else 'code128'
|
||||||
}
|
}
|
||||||
|
|
||||||
def update_tenant_credentials(tenant_id: str, username: str, password: str):
|
def update_tenant_credentials(tenant_id: str, username: str, password: str):
|
||||||
"""Update tenant credentials"""
|
"""Update tenant credentials"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
@@ -358,6 +466,20 @@ def update_tenant_credentials(tenant_id: str, username: str, password: str):
|
|||||||
''', (username, password, tenant_id))
|
''', (username, password, tenant_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
def update_tenant_barcode_type(tenant_id: str, barcode_type: str):
|
||||||
|
"""Update tenant barcode type"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
UPDATE tenants
|
||||||
|
SET barcode_type = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
''', (barcode_type, tenant_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
def get_all_tenants() -> List[Dict[str, Any]]:
|
def get_all_tenants() -> List[Dict[str, Any]]:
|
||||||
"""Get all tenants"""
|
"""Get all tenants"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
@@ -377,6 +499,9 @@ def get_all_tenants() -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
def delete_tenant(tenant_id: str):
|
def delete_tenant(tenant_id: str):
|
||||||
"""Delete a tenant and all associated data"""
|
"""Delete a tenant and all associated data"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
# Due to ON DELETE CASCADE, this will also delete all products and product_fields
|
# Due to ON DELETE CASCADE, this will also delete all products and product_fields
|
||||||
@@ -401,3 +526,181 @@ def cleanup_reserved_tenants():
|
|||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return deleted_count
|
return deleted_count
|
||||||
|
|
||||||
|
def get_custom_ar_fields(tenant_id: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Get custom AR fields for a tenant"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT id, field_name, label, field_type, editable, display_order
|
||||||
|
FROM custom_ar_fields
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
ORDER BY display_order, field_name
|
||||||
|
''', (tenant_id,))
|
||||||
|
|
||||||
|
fields = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
fields.append({
|
||||||
|
'id': row['id'],
|
||||||
|
'fieldName': row['field_name'],
|
||||||
|
'label': row['label'],
|
||||||
|
'fieldType': row['field_type'],
|
||||||
|
'editable': row['editable'],
|
||||||
|
'displayOrder': row['display_order']
|
||||||
|
})
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def save_custom_ar_field(tenant_id: str, field_data: Dict[str, Any]) -> int:
|
||||||
|
"""Save or update a custom AR field"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
if 'id' in field_data:
|
||||||
|
# Update existing field
|
||||||
|
cursor.execute('''
|
||||||
|
UPDATE custom_ar_fields
|
||||||
|
SET field_name = ?, label = ?, field_type = ?, editable = ?,
|
||||||
|
display_order = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND tenant_id = ?
|
||||||
|
''', (
|
||||||
|
field_data['fieldName'],
|
||||||
|
field_data['label'],
|
||||||
|
field_data['fieldType'],
|
||||||
|
field_data.get('editable', 'true'),
|
||||||
|
field_data.get('displayOrder', 0),
|
||||||
|
field_data['id'],
|
||||||
|
tenant_id
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
return field_data['id']
|
||||||
|
else:
|
||||||
|
# Insert new field
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO custom_ar_fields
|
||||||
|
(tenant_id, field_name, label, field_type, editable, display_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
tenant_id,
|
||||||
|
field_data['fieldName'],
|
||||||
|
field_data['label'],
|
||||||
|
field_data['fieldType'],
|
||||||
|
field_data.get('editable', 'true'),
|
||||||
|
field_data.get('displayOrder', 0)
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
def delete_custom_ar_field(tenant_id: str, field_id: int):
|
||||||
|
"""Delete a custom AR field"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
DELETE FROM custom_ar_fields
|
||||||
|
WHERE id = ? AND tenant_id = ?
|
||||||
|
''', (field_id, tenant_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def init_default_ar_fields(tenant_id: str):
|
||||||
|
"""Initialize default AR fields for a new tenant"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
|
default_fields = [
|
||||||
|
{"fieldName": "_id", "label": "Item ID", "fieldType": "TEXT", "editable": "false", "displayOrder": 1},
|
||||||
|
{"fieldName": "_price", "label": "Sale Price", "fieldType": "TEXT", "editable": "true", "displayOrder": 2},
|
||||||
|
{"fieldName": "_image", "label": "Image", "fieldType": "IMAGE_URI", "editable": "false", "displayOrder": 3}
|
||||||
|
]
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
for field in default_fields:
|
||||||
|
# Use INSERT OR IGNORE to avoid duplicate key errors
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT OR IGNORE INTO custom_ar_fields
|
||||||
|
(tenant_id, field_name, label, field_type, editable, display_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
tenant_id,
|
||||||
|
field['fieldName'],
|
||||||
|
field['label'],
|
||||||
|
field['fieldType'],
|
||||||
|
field.get('editable', 'true'),
|
||||||
|
field.get('displayOrder', 0)
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def save_product_image(product_id: str, tenant_id: str, field_name: str, image_data: bytes, mime_type: str):
|
||||||
|
"""Save an image for a specific field of a product"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Delete existing image for this field if any
|
||||||
|
cursor.execute('''
|
||||||
|
DELETE FROM product_images
|
||||||
|
WHERE product_id = ? AND tenant_id = ? AND field_name = ?
|
||||||
|
''', (product_id, tenant_id, field_name))
|
||||||
|
|
||||||
|
# Insert new image
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO product_images (product_id, tenant_id, field_name, image_data, image_mime_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
''', (product_id, tenant_id, field_name, image_data, mime_type))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def get_product_image_by_field(product_id: str, tenant_id: str, field_name: str) -> Optional[tuple[bytes, str]]:
|
||||||
|
"""Get image data for a specific field of a product"""
|
||||||
|
# Normalize tenant_id to lowercase
|
||||||
|
tenant_id = tenant_id.lower()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT image_data, image_mime_type
|
||||||
|
FROM product_images
|
||||||
|
WHERE product_id = ? AND tenant_id = ? AND field_name = ?
|
||||||
|
''', (product_id, tenant_id, field_name))
|
||||||
|
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row and row['image_data']:
|
||||||
|
return row['image_data'], row['image_mime_type']
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_setting(key: str, default_value: str = None) -> Optional[str]:
|
||||||
|
"""Get a setting value by key"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT value FROM settings WHERE key = ?', (key,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return row['value']
|
||||||
|
return default_value
|
||||||
|
|
||||||
|
def set_setting(key: str, value: str):
|
||||||
|
"""Set a setting value"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO settings (key, value)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
''', (key, value, value))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def get_server_url() -> str:
|
||||||
|
"""Get the configured server URL or return a default"""
|
||||||
|
return get_setting('server_url', 'http://localhost:5000')
|
||||||
@@ -1,24 +1,9 @@
|
|||||||
from flask import render_template, request, redirect, url_for, flash, jsonify, send_file
|
from flask import render_template, request, redirect, url_for, flash, jsonify
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
import io
|
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
import sys
|
import sys
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
||||||
import database
|
import database
|
||||||
|
|
||||||
# Import barcode generator class, but handle the case if it fails
|
|
||||||
try:
|
|
||||||
from utils.barcode_generator import BarcodeGenerator
|
|
||||||
BARCODE_GENERATOR_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
BARCODE_GENERATOR_AVAILABLE = False
|
|
||||||
class BarcodeGenerator:
|
|
||||||
@staticmethod
|
|
||||||
def check_dependencies():
|
|
||||||
return {'qrcode': False, 'barcode': False, 'pillow': False}
|
|
||||||
|
|
||||||
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')
|
||||||
@@ -38,98 +23,201 @@ def save_products(products):
|
|||||||
def index(tenant_id):
|
def index(tenant_id):
|
||||||
products = load_products(tenant_id)
|
products = load_products(tenant_id)
|
||||||
tenant = database.get_or_create_tenant(tenant_id)
|
tenant = database.get_or_create_tenant(tenant_id)
|
||||||
return render_template('admin/index.html', products=products, tenant=tenant)
|
custom_fields = database.get_custom_ar_fields(tenant_id)
|
||||||
|
return render_template('admin/index.html', products=products, tenant=tenant, custom_fields=custom_fields)
|
||||||
|
|
||||||
def add_product(tenant_id):
|
def add_product(tenant_id):
|
||||||
|
# Get custom AR fields for this tenant
|
||||||
|
custom_fields = database.get_custom_ar_fields(tenant_id)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
# Get form data
|
# Get form data
|
||||||
product_id = request.form.get('product_id')
|
product_id = request.form.get('product_id')
|
||||||
name = request.form.get('name')
|
|
||||||
price = request.form.get('price')
|
|
||||||
|
|
||||||
# Basic validation
|
# Basic validation
|
||||||
if not product_id or not name or not price:
|
if not product_id:
|
||||||
flash('Product ID, Name, and Price are required fields.')
|
flash('Product ID is required.')
|
||||||
return render_template('admin/add_product.html', tenant_id=tenant_id)
|
return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields)
|
||||||
|
|
||||||
# Check if product ID already exists
|
# Check if product ID already exists
|
||||||
products = load_products(tenant_id)
|
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', tenant_id=tenant_id)
|
return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields)
|
||||||
|
|
||||||
# Handle image upload
|
# Handle backward compatibility image upload
|
||||||
image_data = None
|
image_data = None
|
||||||
image_mime_type = None
|
image_mime_type = None
|
||||||
image_path = f"/images/{product_id}.png" # Default path for display
|
|
||||||
|
|
||||||
if 'image' in request.files:
|
# Create product data structure based on custom fields
|
||||||
file = request.files['image']
|
product_data = []
|
||||||
|
|
||||||
|
# Always include _id field
|
||||||
|
product_data.append({
|
||||||
|
"fieldName": "_id",
|
||||||
|
"label": "Item ID",
|
||||||
|
"value": product_id,
|
||||||
|
"editable": "false",
|
||||||
|
"fieldType": "TEXT"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Process images after product is saved
|
||||||
|
images_to_save = []
|
||||||
|
|
||||||
|
# Add other fields based on custom configuration
|
||||||
|
for field in custom_fields:
|
||||||
|
field_name = field['fieldName']
|
||||||
|
if field_name == '_id':
|
||||||
|
continue # Already added
|
||||||
|
elif field['fieldType'] == 'IMAGE_URI':
|
||||||
|
# Check if image was uploaded for this field
|
||||||
|
image_field_name = f'image_{field_name}'
|
||||||
|
if image_field_name in request.files:
|
||||||
|
file = request.files[image_field_name]
|
||||||
if file and file.filename and allowed_file(file.filename):
|
if file and file.filename and allowed_file(file.filename):
|
||||||
extension = file.filename.rsplit('.', 1)[1].lower()
|
|
||||||
image_path = f"/images/{product_id}.{extension}"
|
|
||||||
|
|
||||||
# Read image data
|
# Read image data
|
||||||
image_data = file.read()
|
file.seek(0)
|
||||||
|
img_data = file.read()
|
||||||
|
|
||||||
# Determine mime type
|
# Determine mime type
|
||||||
|
extension = file.filename.rsplit('.', 1)[1].lower()
|
||||||
mime_types = {
|
mime_types = {
|
||||||
'jpg': 'image/jpeg',
|
'jpg': 'image/jpeg',
|
||||||
'jpeg': 'image/jpeg',
|
'jpeg': 'image/jpeg',
|
||||||
'png': 'image/png',
|
'png': 'image/png',
|
||||||
'gif': 'image/gif'
|
'gif': 'image/gif'
|
||||||
}
|
}
|
||||||
image_mime_type = mime_types.get(extension, 'image/jpeg')
|
mime_type = mime_types.get(extension, 'image/jpeg')
|
||||||
|
|
||||||
# Create product data structure
|
# Store for later saving
|
||||||
product_data = [
|
images_to_save.append({
|
||||||
{"fieldName": "_id", "label": "Item ID", "value": product_id, "editable": "false", "fieldType": "TEXT"},
|
'field_name': field_name,
|
||||||
{"fieldName": "_name", "label": "Product Name", "value": name, "editable": "true", "fieldType": "TEXT"},
|
'data': img_data,
|
||||||
{"fieldName": "_price", "label": "Sale Price", "value": f"${price}", "editable": "true", "fieldType": "TEXT"},
|
'mime_type': mime_type
|
||||||
{"fieldName": "_image", "label": "Image", "value": image_path, "editable": "false", "fieldType": "IMAGE_URI"}
|
})
|
||||||
]
|
|
||||||
|
# Set the image URL path (strip leading underscore from field_name to avoid double underscores)
|
||||||
|
field_suffix = field_name[1:] if field_name.startswith('_') else field_name
|
||||||
|
image_path = f"/images/{product_id}_{field_suffix}.{extension}"
|
||||||
|
|
||||||
|
# For backward compatibility with _image field
|
||||||
|
if field_name == '_image':
|
||||||
|
image_data = img_data
|
||||||
|
image_mime_type = mime_type
|
||||||
|
else:
|
||||||
|
image_path = ""
|
||||||
|
else:
|
||||||
|
image_path = ""
|
||||||
|
|
||||||
|
product_data.append({
|
||||||
|
"fieldName": field_name,
|
||||||
|
"label": field['label'],
|
||||||
|
"value": image_path,
|
||||||
|
"editable": field['editable'],
|
||||||
|
"fieldType": field['fieldType']
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Get value from form
|
||||||
|
value = request.form.get(f'field_{field_name}', '')
|
||||||
|
product_data.append({
|
||||||
|
"fieldName": field_name,
|
||||||
|
"label": field['label'],
|
||||||
|
"value": value,
|
||||||
|
"editable": field['editable'],
|
||||||
|
"fieldType": field['fieldType']
|
||||||
|
})
|
||||||
|
|
||||||
|
# Also save fields that aren't in custom fields (for backward compatibility)
|
||||||
|
if '_name' not in [f['fieldName'] for f in custom_fields]:
|
||||||
|
name = request.form.get('name', '')
|
||||||
|
if name:
|
||||||
|
product_data.append({
|
||||||
|
"fieldName": "_name",
|
||||||
|
"label": "Product Name",
|
||||||
|
"value": name,
|
||||||
|
"editable": "true",
|
||||||
|
"fieldType": "TEXT"
|
||||||
|
})
|
||||||
|
|
||||||
|
if '_price' not in [f['fieldName'] for f in custom_fields]:
|
||||||
|
price = request.form.get('price', '')
|
||||||
|
if price:
|
||||||
|
product_data.append({
|
||||||
|
"fieldName": "_price",
|
||||||
|
"label": "Sale Price",
|
||||||
|
"value": f"${price}",
|
||||||
|
"editable": "true",
|
||||||
|
"fieldType": "TEXT"
|
||||||
|
})
|
||||||
|
|
||||||
# Save to database
|
# Save to database
|
||||||
database.save_product(product_id, tenant_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!')
|
# Save additional images
|
||||||
return redirect(url_for('admin.index', tenant_id=tenant_id))
|
for img in images_to_save:
|
||||||
|
database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type'])
|
||||||
|
|
||||||
return render_template('admin/add_product.html', tenant_id=tenant_id)
|
flash('Product added successfully!')
|
||||||
|
return redirect(f'/{tenant_id}/')
|
||||||
|
|
||||||
|
return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields)
|
||||||
|
|
||||||
def edit_product(tenant_id, product_id):
|
def edit_product(tenant_id, product_id):
|
||||||
products = load_products(tenant_id)
|
products = load_products(tenant_id)
|
||||||
|
custom_fields = database.get_custom_ar_fields(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', tenant_id=tenant_id))
|
return redirect(f'/{tenant_id}/')
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
# Get form data
|
# Handle backward compatibility image upload
|
||||||
name = request.form.get('name')
|
|
||||||
price = request.form.get('price')
|
|
||||||
|
|
||||||
# Basic validation
|
|
||||||
if not name or not price:
|
|
||||||
flash('Name and Price are required fields.')
|
|
||||||
return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id)
|
|
||||||
|
|
||||||
# Update name and price
|
|
||||||
for field in products[product_id]:
|
|
||||||
if field["fieldName"] == "_name":
|
|
||||||
field["value"] = name
|
|
||||||
elif field["fieldName"] == "_price":
|
|
||||||
field["value"] = f"${price}"
|
|
||||||
|
|
||||||
# Handle image upload
|
|
||||||
image_data = None
|
image_data = None
|
||||||
image_mime_type = None
|
image_mime_type = None
|
||||||
|
|
||||||
if 'image' in request.files and request.files['image'].filename:
|
# Process images after product is saved
|
||||||
file = request.files['image']
|
images_to_save = []
|
||||||
|
|
||||||
|
# Update fields based on form data
|
||||||
|
updated_product = []
|
||||||
|
|
||||||
|
# Always include _id field
|
||||||
|
for field in products[product_id]:
|
||||||
|
if field["fieldName"] == "_id":
|
||||||
|
updated_product.append(field)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Process each custom field
|
||||||
|
for custom_field in custom_fields:
|
||||||
|
field_name = custom_field['fieldName']
|
||||||
|
if field_name == '_id':
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find existing field data
|
||||||
|
existing_field = None
|
||||||
|
for field in products[product_id]:
|
||||||
|
if field["fieldName"] == field_name:
|
||||||
|
existing_field = field.copy()
|
||||||
|
break
|
||||||
|
|
||||||
|
if not existing_field:
|
||||||
|
existing_field = {
|
||||||
|
"fieldName": field_name,
|
||||||
|
"label": custom_field['label'],
|
||||||
|
"value": "",
|
||||||
|
"editable": custom_field['editable'],
|
||||||
|
"fieldType": custom_field['fieldType']
|
||||||
|
}
|
||||||
|
|
||||||
|
if custom_field['fieldType'] == 'IMAGE_URI':
|
||||||
|
# Check if new image was uploaded
|
||||||
|
image_field_name = f'image_{field_name}'
|
||||||
|
if image_field_name in request.files and request.files[image_field_name].filename:
|
||||||
|
file = request.files[image_field_name]
|
||||||
if allowed_file(file.filename):
|
if allowed_file(file.filename):
|
||||||
# Read new image data
|
# Read image data
|
||||||
image_data = file.read()
|
file.seek(0)
|
||||||
|
img_data = file.read()
|
||||||
|
|
||||||
# Determine mime type
|
# Determine mime type
|
||||||
extension = file.filename.rsplit('.', 1)[1].lower()
|
extension = file.filename.rsplit('.', 1)[1].lower()
|
||||||
@@ -139,41 +227,87 @@ def edit_product(tenant_id, product_id):
|
|||||||
'png': 'image/png',
|
'png': 'image/png',
|
||||||
'gif': 'image/gif'
|
'gif': 'image/gif'
|
||||||
}
|
}
|
||||||
image_mime_type = mime_types.get(extension, 'image/jpeg')
|
mime_type = mime_types.get(extension, 'image/jpeg')
|
||||||
|
|
||||||
# Update image path in product data
|
# Store for later saving
|
||||||
image_path = f"/images/{product_id}.{extension}"
|
images_to_save.append({
|
||||||
|
'field_name': field_name,
|
||||||
|
'data': img_data,
|
||||||
|
'mime_type': mime_type
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update image path (strip leading underscore from field_name to avoid double underscores)
|
||||||
|
field_suffix = field_name[1:] if field_name.startswith('_') else field_name
|
||||||
|
existing_field["value"] = f"/images/{product_id}_{field_suffix}.{extension}"
|
||||||
|
|
||||||
|
# For backward compatibility with _image field
|
||||||
|
if field_name == '_image':
|
||||||
|
image_data = img_data
|
||||||
|
image_mime_type = mime_type
|
||||||
|
# If no new image uploaded, keep the existing value (already in existing_field)
|
||||||
|
else:
|
||||||
|
# Get value from form
|
||||||
|
value = request.form.get(f'field_{field_name}', '')
|
||||||
|
if field_name == '_price' and value and not value.startswith('$'):
|
||||||
|
value = f"${value}"
|
||||||
|
existing_field["value"] = value
|
||||||
|
|
||||||
|
updated_product.append(existing_field)
|
||||||
|
|
||||||
|
# Handle backward compatibility fields
|
||||||
|
if '_name' not in [f['fieldName'] for f in custom_fields]:
|
||||||
|
name = request.form.get('name', '')
|
||||||
|
if name:
|
||||||
for field in products[product_id]:
|
for field in products[product_id]:
|
||||||
if field["fieldName"] == "_image":
|
if field["fieldName"] == "_name":
|
||||||
field["value"] = image_path
|
field["value"] = name
|
||||||
|
updated_product.append(field)
|
||||||
|
break
|
||||||
|
|
||||||
|
if '_price' not in [f['fieldName'] for f in custom_fields]:
|
||||||
|
price = request.form.get('price', '')
|
||||||
|
if price:
|
||||||
|
for field in products[product_id]:
|
||||||
|
if field["fieldName"] == "_price":
|
||||||
|
field["value"] = f"${price}"
|
||||||
|
updated_product.append(field)
|
||||||
|
break
|
||||||
|
|
||||||
# Save to database
|
# Save to database
|
||||||
database.save_product(product_id, tenant_id, products[product_id], image_data, image_mime_type)
|
database.save_product(product_id, tenant_id, updated_product, image_data, image_mime_type)
|
||||||
|
|
||||||
|
# Save additional images
|
||||||
|
for img in images_to_save:
|
||||||
|
database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type'])
|
||||||
|
|
||||||
flash('Product updated successfully!')
|
flash('Product updated successfully!')
|
||||||
return redirect(url_for('admin.index', tenant_id=tenant_id))
|
return redirect(f'/{tenant_id}/')
|
||||||
|
|
||||||
return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id)
|
return render_template('admin/edit_product.html',
|
||||||
|
product_id=product_id,
|
||||||
|
product=products[product_id],
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
custom_fields=custom_fields)
|
||||||
|
|
||||||
def delete_product(tenant_id, product_id):
|
def delete_product(tenant_id, product_id):
|
||||||
products = load_products(tenant_id)
|
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', tenant_id=tenant_id))
|
return redirect(f'/{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, tenant_id)
|
database.delete_product(product_id, tenant_id)
|
||||||
|
|
||||||
flash('Product deleted successfully!')
|
flash('Product deleted successfully!')
|
||||||
return redirect(url_for('admin.index', tenant_id=tenant_id))
|
return redirect(f'/{tenant_id}/')
|
||||||
|
|
||||||
def view_product(tenant_id, product_id):
|
def view_product(tenant_id, product_id):
|
||||||
products = load_products(tenant_id)
|
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', tenant_id=tenant_id))
|
return redirect(f'/{tenant_id}/')
|
||||||
|
|
||||||
return render_template('admin/view_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id)
|
return render_template('admin/view_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id)
|
||||||
|
|
||||||
@@ -195,39 +329,18 @@ def generate_barcode(tenant_id, product_id, code_type):
|
|||||||
# Redirect to the main barcode endpoint which generates dynamically
|
# Redirect to the main barcode endpoint which generates dynamically
|
||||||
return redirect(f'/{tenant_id}/barcodes/{product_id}_{barcode_type}.png')
|
return redirect(f'/{tenant_id}/barcodes/{product_id}_{barcode_type}.png')
|
||||||
|
|
||||||
def generate_barcode_page(tenant_id, product_id):
|
def view_all_barcodes(tenant_id):
|
||||||
"""Show a page with different barcode options for a product"""
|
"""Display all product barcodes for printing"""
|
||||||
products = load_products(tenant_id)
|
products = load_products(tenant_id)
|
||||||
|
tenant = database.get_or_create_tenant(tenant_id)
|
||||||
|
|
||||||
if product_id not in products:
|
# Get barcode type setting for this tenant
|
||||||
flash('Product not found.')
|
barcode_type = tenant.get('barcode_type', 'qr')
|
||||||
return redirect(url_for('admin.index', tenant_id=tenant_id))
|
|
||||||
|
|
||||||
# Extract product data
|
return render_template('admin/all_barcodes.html',
|
||||||
product_data = {}
|
products=products,
|
||||||
for field in products[product_id]:
|
tenant=tenant,
|
||||||
field_name = field["fieldName"][1:] if field["fieldName"].startswith('_') else field["fieldName"]
|
barcode_type=barcode_type)
|
||||||
product_data[field_name] = field["value"]
|
|
||||||
|
|
||||||
# Add product ID
|
|
||||||
product_data['id'] = product_id
|
|
||||||
|
|
||||||
# Check if barcode generation is available
|
|
||||||
dependency_status = {}
|
|
||||||
if BARCODE_GENERATOR_AVAILABLE:
|
|
||||||
dependency_status = BarcodeGenerator.check_dependencies()
|
|
||||||
else:
|
|
||||||
dependency_status = {
|
|
||||||
'qrcode': False,
|
|
||||||
'barcode': False,
|
|
||||||
'pillow': False
|
|
||||||
}
|
|
||||||
|
|
||||||
return render_template('admin/generate_barcode.html',
|
|
||||||
product_id=product_id,
|
|
||||||
product=product_data,
|
|
||||||
dependencies=dependency_status,
|
|
||||||
tenant_id=tenant_id)
|
|
||||||
|
|
||||||
def manage_credentials(tenant_id):
|
def manage_credentials(tenant_id):
|
||||||
"""Manage tenant login credentials"""
|
"""Manage tenant login credentials"""
|
||||||
@@ -240,8 +353,69 @@ def manage_credentials(tenant_id):
|
|||||||
if username and password:
|
if username and password:
|
||||||
database.update_tenant_credentials(tenant_id, username, password)
|
database.update_tenant_credentials(tenant_id, username, password)
|
||||||
flash('Credentials updated successfully!')
|
flash('Credentials updated successfully!')
|
||||||
return redirect(url_for('admin.index', tenant_id=tenant_id))
|
return redirect(f'/{tenant_id}/')
|
||||||
else:
|
else:
|
||||||
flash('Username and password are required.')
|
flash('Username and password are required.')
|
||||||
|
|
||||||
return render_template('admin/credentials.html', tenant=tenant, tenant_id=tenant_id)
|
return render_template('admin/credentials.html', tenant=tenant, tenant_id=tenant_id)
|
||||||
|
|
||||||
|
def manage_ar_fields(tenant_id):
|
||||||
|
"""Manage custom AR content fields"""
|
||||||
|
tenant = database.get_or_create_tenant(tenant_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
action = request.form.get('action')
|
||||||
|
|
||||||
|
if action == 'add':
|
||||||
|
field_data = {
|
||||||
|
'fieldName': request.form.get('fieldName'),
|
||||||
|
'label': request.form.get('label'),
|
||||||
|
'fieldType': request.form.get('fieldType'),
|
||||||
|
'editable': request.form.get('editable', 'true'),
|
||||||
|
'displayOrder': int(request.form.get('displayOrder', 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
if field_data['fieldName'] and field_data['label']:
|
||||||
|
database.save_custom_ar_field(tenant_id, field_data)
|
||||||
|
flash('Custom field added successfully!')
|
||||||
|
else:
|
||||||
|
flash('Field name and label are required.')
|
||||||
|
|
||||||
|
elif action == 'delete':
|
||||||
|
field_id = request.form.get('field_id')
|
||||||
|
if field_id:
|
||||||
|
database.delete_custom_ar_field(tenant_id, int(field_id))
|
||||||
|
flash('Custom field deleted successfully!')
|
||||||
|
|
||||||
|
elif action == 'update':
|
||||||
|
field_data = {
|
||||||
|
'id': int(request.form.get('field_id')),
|
||||||
|
'fieldName': request.form.get('fieldName'),
|
||||||
|
'label': request.form.get('label'),
|
||||||
|
'fieldType': request.form.get('fieldType'),
|
||||||
|
'editable': request.form.get('editable', 'true'),
|
||||||
|
'displayOrder': int(request.form.get('displayOrder', 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
if field_data['fieldName'] and field_data['label']:
|
||||||
|
database.save_custom_ar_field(tenant_id, field_data)
|
||||||
|
flash('Custom field updated successfully!')
|
||||||
|
else:
|
||||||
|
flash('Field name and label are required.')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.manage_ar_fields', tenant_id=tenant_id))
|
||||||
|
|
||||||
|
# Get existing custom fields
|
||||||
|
custom_fields = database.get_custom_ar_fields(tenant_id)
|
||||||
|
|
||||||
|
# Available field types
|
||||||
|
field_types = [
|
||||||
|
'TEXT',
|
||||||
|
'IMAGE_URI'
|
||||||
|
]
|
||||||
|
|
||||||
|
return render_template('admin/ar_fields.html',
|
||||||
|
tenant=tenant,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
custom_fields=custom_fields,
|
||||||
|
field_types=field_types)
|
||||||
|
|||||||
@@ -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', tenant_id=tenant_id) }}" method="POST" enctype="multipart/form-data">
|
<form action="/{{ tenant_id }}/add" 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">
|
||||||
@@ -22,32 +22,52 @@
|
|||||||
<div class="form-text">Must be unique. This will be used as the barcode for AR content.</div>
|
<div class="form-text">Must be unique. This will be used as the barcode for AR content.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% for field in custom_fields %}
|
||||||
|
{% if field.fieldName != '_id' %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="name" class="form-label">Product Name *</label>
|
<label for="field_{{ field.fieldName }}" class="form-label">{{ field.label }}</label>
|
||||||
<input type="text" class="form-control" id="name" name="name" required>
|
{% if field.fieldType == 'IMAGE_URI' %}
|
||||||
<div class="form-text">Enter a descriptive name for the product.</div>
|
<input type="file" class="form-control" id="image_{{ field.fieldName }}" name="image_{{ field.fieldName }}" accept="image/*" onchange="previewImage(this, 'preview_{{ field.fieldName }}')">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="price" class="form-label">Price *</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text">$</span>
|
|
||||||
<input type="text" class="form-control" id="price" name="price" required pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="49.99">
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Enter the price in decimal format (e.g., 49.99)</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="image" class="form-label">Product Image</label>
|
|
||||||
<input type="file" class="form-control" id="image" name="image" accept="image/*" onchange="previewImage(this)">
|
|
||||||
<div class="form-text">Supported formats: .png, .jpg, .jpeg, .gif</div>
|
<div class="form-text">Supported formats: .png, .jpg, .jpeg, .gif</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<img id="image-preview" class="image-preview" style="display: none;">
|
<img id="preview_{{ field.fieldName }}" class="image-preview" style="display: none; max-width: 200px;">
|
||||||
|
</div>
|
||||||
|
{% elif field.fieldType == 'TEXT' %}
|
||||||
|
{% if field.fieldName == '_price' %}
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">$</span>
|
||||||
|
<input type="text" class="form-control" id="field_{{ field.fieldName }}" name="field_{{ field.fieldName }}" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="49.99">
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<input type="text" class="form-control" id="field_{{ field.fieldName }}" name="field_{{ field.fieldName }}">
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<input type="text" class="form-control" id="field_{{ field.fieldName }}" name="field_{{ field.fieldName }}">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Fallback fields for backward compatibility -->
|
||||||
|
{% if not custom_fields|selectattr('fieldName', 'equalto', '_name')|list %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">Product Name</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not custom_fields|selectattr('fieldName', 'equalto', '_price')|list %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="price" class="form-label">Price</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">$</span>
|
||||||
|
<input type="text" class="form-control" id="price" name="price" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="49.99">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-secondary">Cancel</a>
|
<a href="/{{ 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>
|
||||||
@@ -59,8 +79,8 @@
|
|||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
function previewImage(input) {
|
function previewImage(input, previewId) {
|
||||||
var preview = document.getElementById('image-preview');
|
var preview = document.getElementById(previewId || 'image-preview');
|
||||||
|
|
||||||
if (input.files && input.files[0]) {
|
if (input.files && input.files[0]) {
|
||||||
var reader = new FileReader();
|
var reader = new FileReader();
|
||||||
|
|||||||
149
src/templates/admin/all_barcodes.html
Normal file
149
src/templates/admin/all_barcodes.html
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block title %}All Barcodes - {{ tenant.name }} - KCAP Demo Server{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
/* Screen styles */
|
||||||
|
@media screen {
|
||||||
|
.barcode-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-item {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-item img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-item .product-id {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-buttons {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print styles */
|
||||||
|
@media print {
|
||||||
|
/* Hide everything except barcodes */
|
||||||
|
nav, .navbar, .print-buttons, .alert, .btn {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid layout for print */
|
||||||
|
.barcode-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-item {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-item img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 5px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-item .product-id {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page breaks */
|
||||||
|
@page {
|
||||||
|
margin: 0.5cm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% 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('tenant_index', tenant_id=tenant.id) }}" class="btn btn-sm btn-secondary">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="print-buttons d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>All Product Barcodes</h1>
|
||||||
|
<button onclick="window.print()" class="btn btn-primary">
|
||||||
|
<i class="bi bi-printer"></i> Print Barcodes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if products %}
|
||||||
|
<div class="barcode-grid">
|
||||||
|
{% for product_id, product_data in products.items() %}
|
||||||
|
<div class="barcode-item">
|
||||||
|
<div class="product-id">{{ product_id }}</div>
|
||||||
|
<img src="/{{ tenant.id }}/barcodes/{{ product_id }}_{{ barcode_type }}.png"
|
||||||
|
alt="Barcode for {{ product_id }}"
|
||||||
|
onerror="this.src='data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'200\' height=\'100\'%3E%3Crect width=\'200\' height=\'100\' fill=\'%23f0f0f0\'/%3E%3Ctext x=\'50%25\' y=\'50%25\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'Arial\' font-size=\'12\' fill=\'%23999\'%3EBarcode unavailable%3C/text%3E%3C/svg%3E';">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No products available to generate barcodes.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Optional: Automatically print when page loads if requested via URL parameter
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
if (urlParams.get('autoprint') === 'true') {
|
||||||
|
window.onload = function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
window.print();
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
170
src/templates/admin/ar_fields.html
Normal file
170
src/templates/admin/ar_fields.html
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block title %}Manage AR Fields - KCAP Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1>Manage AR Content Fields</h1>
|
||||||
|
<p>Configure the fields that will be returned by the /arcontentfields and /arinfo endpoints.</p>
|
||||||
|
</div>
|
||||||
|
<a href="/{{ tenant_id }}/settings" class="btn btn-secondary">Back to Settings</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Current AR Fields</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if custom_fields %}
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Field Name</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Editable</th>
|
||||||
|
<th>Display Order</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for field in custom_fields %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ field.fieldName }}</td>
|
||||||
|
<td>{{ field.label }}</td>
|
||||||
|
<td>{{ field.fieldType }}</td>
|
||||||
|
<td>{{ field.editable }}</td>
|
||||||
|
<td>{{ field.displayOrder }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-primary"
|
||||||
|
data-id="{{ field.id }}"
|
||||||
|
data-fieldname="{{ field.fieldName }}"
|
||||||
|
data-label="{{ field.label }}"
|
||||||
|
data-fieldtype="{{ field.fieldType }}"
|
||||||
|
data-editable="{{ field.editable }}"
|
||||||
|
data-displayorder="{{ field.displayOrder }}"
|
||||||
|
onclick="editField(this)">Edit</button>
|
||||||
|
<form method="POST" style="display: inline-block;">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="field_id" value="{{ field.id }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure you want to delete this field?')">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No custom fields defined. Default fields will be used.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Add/Edit AR Field</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" id="fieldForm">
|
||||||
|
<input type="hidden" name="action" value="add" id="formAction">
|
||||||
|
<input type="hidden" name="field_id" value="" id="fieldId">
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="fieldName">Field Name</label>
|
||||||
|
<input type="text" class="form-control" id="fieldName" name="fieldName" required>
|
||||||
|
<small class="form-text text-muted">Use underscore prefix for system fields (e.g., _id, _price)</small>
|
||||||
|
<small class="form-text text-warning" id="fieldNameWarning" style="display: none;">Field name cannot be changed when editing</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="label">Label</label>
|
||||||
|
<input type="text" class="form-control" id="label" name="label" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="fieldType">Field Type</label>
|
||||||
|
<select class="form-control" id="fieldType" name="fieldType" required>
|
||||||
|
{% for type in field_types %}
|
||||||
|
<option value="{{ type }}">{{ type }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="editable">Editable</label>
|
||||||
|
<select class="form-control" id="editable" name="editable">
|
||||||
|
<option value="true">Yes</option>
|
||||||
|
<option value="false">No</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="displayOrder">Display Order</label>
|
||||||
|
<input type="number" class="form-control" id="displayOrder" name="displayOrder" value="0">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Save Field</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="resetForm()">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function editField(button) {
|
||||||
|
// Get data from button attributes
|
||||||
|
var fieldData = {
|
||||||
|
id: button.getAttribute('data-id'),
|
||||||
|
fieldName: button.getAttribute('data-fieldname'),
|
||||||
|
label: button.getAttribute('data-label'),
|
||||||
|
fieldType: button.getAttribute('data-fieldtype'),
|
||||||
|
editable: button.getAttribute('data-editable'),
|
||||||
|
displayOrder: button.getAttribute('data-displayorder')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Populate form
|
||||||
|
document.getElementById('formAction').value = 'update';
|
||||||
|
document.getElementById('fieldId').value = fieldData.id;
|
||||||
|
document.getElementById('fieldName').value = fieldData.fieldName;
|
||||||
|
document.getElementById('label').value = fieldData.label;
|
||||||
|
document.getElementById('fieldType').value = fieldData.fieldType;
|
||||||
|
document.getElementById('editable').value = fieldData.editable;
|
||||||
|
document.getElementById('displayOrder').value = fieldData.displayOrder;
|
||||||
|
|
||||||
|
// Disable field name when editing and show warning
|
||||||
|
document.getElementById('fieldName').disabled = true;
|
||||||
|
document.getElementById('fieldNameWarning').style.display = 'block';
|
||||||
|
|
||||||
|
// Update form heading
|
||||||
|
var formCard = document.querySelector('.card:last-child .card-header h5');
|
||||||
|
if (formCard) {
|
||||||
|
formCard.textContent = 'Edit AR Field';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change button text
|
||||||
|
document.querySelector('button[type="submit"]').textContent = 'Update Field';
|
||||||
|
|
||||||
|
// Scroll to form
|
||||||
|
document.getElementById('fieldForm').scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
document.getElementById('formAction').value = 'add';
|
||||||
|
document.getElementById('fieldId').value = '';
|
||||||
|
document.getElementById('fieldForm').reset();
|
||||||
|
|
||||||
|
// Enable field name and hide warning
|
||||||
|
document.getElementById('fieldName').disabled = false;
|
||||||
|
document.getElementById('fieldNameWarning').style.display = 'none';
|
||||||
|
|
||||||
|
// Reset form heading
|
||||||
|
var formCard = document.querySelector('.card:last-child .card-header h5');
|
||||||
|
if (formCard) {
|
||||||
|
formCard.textContent = 'Add/Edit AR Field';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset button text
|
||||||
|
document.querySelector('button[type="submit"]').textContent = 'Save Field';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -10,62 +10,87 @@
|
|||||||
<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', tenant_id=tenant_id, product_id=product_id) }}" method="POST" enctype="multipart/form-data">
|
<form action="/{{ tenant_id }}/edit/{{ 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>
|
||||||
<div class="form-text">Product ID cannot be changed.</div>
|
<div class="form-text">Product ID cannot be changed.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% for field in custom_fields %}
|
||||||
|
{% if field.fieldName != '_id' %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="name" class="form-label">Product Name *</label>
|
<label for="field_{{ field.fieldName }}" class="form-label">{{ field.label }}</label>
|
||||||
{% for field in product %}
|
|
||||||
{% if field.fieldName == '_name' %}
|
|
||||||
<input type="text" class="form-control" id="name" name="name" required value="{{ field.value }}">
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<div class="form-text">Enter a descriptive name for the product.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
{# Find current value using selectattr filter #}
|
||||||
<label for="price" class="form-label">Price *</label>
|
{% set matching_fields = product|selectattr('fieldName', 'equalto', field.fieldName)|list %}
|
||||||
<div class="input-group">
|
{% set current_value = matching_fields[0].value if matching_fields else '' %}
|
||||||
<span class="input-group-text">$</span>
|
|
||||||
{% for field in product %}
|
|
||||||
{% if field.fieldName == '_price' %}
|
|
||||||
{% set price_value = field.value|replace('$', '') %}
|
|
||||||
<input type="text" class="form-control" id="price" name="price" required
|
|
||||||
pattern="[0-9]+(\.[0-9]{1,2})?" value="{{ price_value }}">
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Enter the price in decimal format (e.g., 49.99)</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
{% if field.fieldType == 'IMAGE_URI' %}
|
||||||
<label for="image" class="form-label">Product Image</label>
|
<input type="file" class="form-control" id="image_{{ field.fieldName }}" name="image_{{ field.fieldName }}" accept="image/*" onchange="previewImage(this, 'preview_new_{{ field.fieldName }}')">
|
||||||
<input type="file" class="form-control" id="image" name="image" accept="image/*" onchange="previewImage(this)">
|
|
||||||
<div class="form-text">Leave empty to keep the current image.</div>
|
<div class="form-text">Leave empty to keep the current image.</div>
|
||||||
|
|
||||||
|
{% if current_value %}
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<label class="form-label">Current Image:</label>
|
<label class="form-label">Current Image:</label>
|
||||||
{% for field in product %}
|
|
||||||
{% if field.fieldName == '_image' %}
|
|
||||||
<div>
|
<div>
|
||||||
<img src="{{ field.value }}" alt="Current product image" class="image-preview">
|
<img src="/{{ tenant_id }}{{ current_value }}" alt="Current image" class="image-preview" style="max-width: 200px;">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<label class="form-label">New Image Preview:</label>
|
<label class="form-label">New Image Preview:</label>
|
||||||
<img id="image-preview" class="image-preview" style="display: none;">
|
<img id="preview_new_{{ field.fieldName }}" class="image-preview" style="display: none; max-width: 200px;">
|
||||||
|
</div>
|
||||||
|
{% elif field.fieldType == 'TEXT' %}
|
||||||
|
{% if field.fieldName == '_price' %}
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">$</span>
|
||||||
|
{% set price_value = current_value|replace('$', '') %}
|
||||||
|
<input type="text" class="form-control" id="field_{{ field.fieldName }}" name="field_{{ field.fieldName }}"
|
||||||
|
pattern="[0-9]+(\.[0-9]{1,2})?" value="{{ price_value }}" placeholder="49.99">
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<input type="text" class="form-control" id="field_{{ field.fieldName }}" name="field_{{ field.fieldName }}" value="{{ current_value }}">
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<input type="text" class="form-control" id="field_{{ field.fieldName }}" name="field_{{ field.fieldName }}" value="{{ current_value }}">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Fallback fields for backward compatibility -->
|
||||||
|
{% if not custom_fields|selectattr('fieldName', 'equalto', '_name')|list %}
|
||||||
|
{% for field in product %}
|
||||||
|
{% if field.fieldName == '_name' %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">Product Name</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" value="{{ field.value }}">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not custom_fields|selectattr('fieldName', 'equalto', '_price')|list %}
|
||||||
|
{% for field in product %}
|
||||||
|
{% if field.fieldName == '_price' %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="price" class="form-label">Price</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">$</span>
|
||||||
|
{% set price_value = field.value|replace('$', '') %}
|
||||||
|
<input type="text" class="form-control" id="price" name="price"
|
||||||
|
pattern="[0-9]+(\.[0-9]{1,2})?" value="{{ price_value }}" placeholder="49.99">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-secondary">Cancel</a>
|
<a href="/{{ 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>
|
||||||
@@ -77,8 +102,8 @@
|
|||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
function previewImage(input) {
|
function previewImage(input, previewId) {
|
||||||
var preview = document.getElementById('image-preview');
|
var preview = document.getElementById(previewId || 'image-preview');
|
||||||
|
|
||||||
if (input.files && input.files[0]) {
|
if (input.files && input.files[0]) {
|
||||||
var reader = new FileReader();
|
var reader = new FileReader();
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
{% extends "layout.html" %}
|
|
||||||
|
|
||||||
{% block title %}Generate Barcodes - KCAP Demo Server{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<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', 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 %}
|
|
||||||
<div class="alert alert-warning mb-4">
|
|
||||||
<h4 class="alert-heading">Missing Dependencies</h4>
|
|
||||||
<p>Some barcode generation features are unavailable because required packages are not installed:</p>
|
|
||||||
<ul>
|
|
||||||
{% if not dependencies.qrcode %}
|
|
||||||
<li><strong>QR Code:</strong> The 'qrcode' package is required for QR code generation</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if not dependencies.barcode %}
|
|
||||||
<li><strong>Barcodes:</strong> The 'python-barcode' package is required for EAN-13 and Code 128 generation</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if not dependencies.pillow %}
|
|
||||||
<li><strong>Image Processing:</strong> The 'Pillow' package is required for image processing</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
<hr>
|
|
||||||
<p class="mb-0">To install the required dependencies, run: <code>pip install -r requirements.txt</code></p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3>QR Code</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<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', 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 class="col-md-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3>EAN-13 Barcode</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<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', 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 class="col-md-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3>Code 128 Barcode</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<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', 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 class="row mt-4">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3>Product Information</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<p><strong>Product ID:</strong> {{ product_id }}</p>
|
|
||||||
<p><strong>Price:</strong> {{ product.price }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<p><strong>AR Info URL:</strong>
|
|
||||||
<a href="{{ url_for('get_ar_info', barcode=product_id) }}" target="_blank">
|
|
||||||
{{ url_for('get_ar_info', barcode=product_id, _external=True) }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
{% if product.image %}
|
|
||||||
<img src="{{ product.image }}" alt="Product Image" class="img-thumbnail" style="max-height: 100px;">
|
|
||||||
{% else %}
|
|
||||||
<p>No product image available</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3>Print All Codes</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>Use the button below to open a printable version of all barcodes for this product.</p>
|
|
||||||
<button class="btn btn-success" onclick="window.print()">Print Barcodes</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
@media print {
|
|
||||||
.navbar, .card-header, .btn, .text-muted, .alert {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
margin-bottom: 1cm;
|
|
||||||
}
|
|
||||||
.card-body {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.row {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.col-md-4 {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
flex: 0 0 100%;
|
|
||||||
page-break-after: always;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -9,6 +9,10 @@
|
|||||||
<strong>Current Tenant:</strong> {{ tenant.name }} (ID: {{ tenant.id }})
|
<strong>Current Tenant:</strong> {{ tenant.name }} (ID: {{ tenant.id }})
|
||||||
<div class="mt-2">
|
<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="{{ url_for('admin.manage_credentials', tenant_id=tenant.id) }}" class="btn btn-sm btn-secondary">Manage Credentials</a>
|
||||||
|
<a href="{{ url_for('admin.manage_ar_fields', tenant_id=tenant.id) }}" class="btn btn-sm btn-secondary">Manage AR Fields</a>
|
||||||
|
<a href="{{ url_for('admin.view_all_barcodes', tenant_id=tenant.id) }}" class="btn btn-sm btn-success">
|
||||||
|
<i class="bi bi-upc-scan"></i> View All Barcodes
|
||||||
|
</a>
|
||||||
<a href="/" class="btn btn-sm btn-outline-secondary">Switch Tenant</a>
|
<a href="/" class="btn btn-sm btn-outline-secondary">Switch Tenant</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,40 +25,38 @@
|
|||||||
<table class="table table-striped table-bordered align-middle">
|
<table class="table table-striped table-bordered align-middle">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 15%">Product ID</th>
|
<th>Product ID</th>
|
||||||
<th style="width: 25%">Name</th>
|
{% for custom_field in custom_fields %}
|
||||||
<th style="width: 10%">Price</th>
|
{% if custom_field.fieldName != '_id' %}
|
||||||
<th style="width: 20%">Image</th>
|
<th>{{ custom_field.label }}</th>
|
||||||
<th style="width: 30%">Actions</th>
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for product_id, product_data in products.items() %}
|
{% for product_id, product_data in products.items() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ product_id }}</td>
|
<td>{{ product_id }}</td>
|
||||||
|
{% for custom_field in custom_fields %}
|
||||||
|
{% if custom_field.fieldName != '_id' %}
|
||||||
<td>
|
<td>
|
||||||
{% for field in product_data %}
|
{% for field in product_data %}
|
||||||
{% if field.fieldName == '_name' %}
|
{% if field.fieldName == custom_field.fieldName %}
|
||||||
{{ field.value }}
|
{% if custom_field.fieldType == 'IMAGE_URI' %}
|
||||||
{% endif %}
|
{% if field.value %}
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% for field in product_data %}
|
|
||||||
{% if field.fieldName == '_price' %}
|
|
||||||
{{ field.value }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% for field in product_data %}
|
|
||||||
{% if field.fieldName == '_image' %}
|
|
||||||
<div class="product-image-container">
|
<div class="product-image-container">
|
||||||
<img src="{{ field.value }}" alt="Product image" class="product-image">
|
<img src="/{{ tenant.id }}{{ field.value }}" alt="{{ custom_field.label }}" class="product-image">
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ field.value }}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
<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', tenant_id=tenant.id, 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>
|
||||||
|
|||||||
@@ -5,40 +5,198 @@
|
|||||||
{% 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="jumbotron">
|
<div class="jumbotron position-relative">
|
||||||
<h1 class="display-4">KCAP Demo Server{% if tenant %} - {{ tenant.name }}{% endif %}</h1>
|
<div class="position-absolute top-0 end-0 p-3">
|
||||||
<p class="lead">A simple Flask API for demonstrating AR content retrieval for barcode scanning applications.</p>
|
<a href="/{{ tenant.id }}/settings" class="btn btn-secondary" title="Settings">
|
||||||
<hr class="my-4">
|
<i class="bi bi-gear-fill"></i> Settings
|
||||||
<p>This server simulates the Knox Capture API for AR overlays and includes endpoints for managing product attributes.</p>
|
</a>
|
||||||
<div class="mt-4">
|
|
||||||
<h2>API Endpoints</h2>
|
|
||||||
<ul class="list-group">
|
|
||||||
<li class="list-group-item">
|
|
||||||
<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 {% 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 {% 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 {% if tenant %}/{{ tenant.id }}{% endif %}/images/123456.png</code>
|
|
||||||
<p>Serves product images stored in the database.</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 class="display-4">KCAP Demo Server{% if tenant %} - {{ tenant.name }}{% endif %}</h1>
|
||||||
|
<p class="lead">Manage your product catalog and AR content for barcode scanning applications.</p>
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
{% if tenant %}
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<a href="/{{ tenant.id }}/admin/" class="btn btn-primary btn-lg">Go to Admin Interface</a>
|
<h2>Products</h2>
|
||||||
{% else %}
|
<div>
|
||||||
<a href="/admin" class="btn btn-primary btn-lg">Go to Admin Interface</a>
|
<a href="/{{ tenant.id }}/barcodes" class="btn btn-success me-2">
|
||||||
|
<i class="bi bi-upc-scan"></i> View All Barcodes
|
||||||
|
</a>
|
||||||
|
<a href="/{{ tenant.id }}/add" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add New Product
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if products %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Product ID</th>
|
||||||
|
{% for custom_field in custom_fields %}
|
||||||
|
{% if custom_field.fieldName != '_id' %}
|
||||||
|
<th>{{ custom_field.label }}</th>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for product_id, product_data in products.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ product_id }}</td>
|
||||||
|
{% for custom_field in custom_fields %}
|
||||||
|
{% if custom_field.fieldName != '_id' %}
|
||||||
|
<td>
|
||||||
|
{% set matching_fields = product_data|selectattr('fieldName', 'equalto', custom_field.fieldName)|list %}
|
||||||
|
{% if custom_field.fieldType == 'IMAGE_URI' %}
|
||||||
|
{% if matching_fields and matching_fields[0].value %}
|
||||||
|
<div class="product-image-container">
|
||||||
|
<img src="/{{ tenant.id }}{{ matching_fields[0].value }}" alt="{{ custom_field.label }}" class="product-image">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ matching_fields[0].value if matching_fields else '' }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<td>
|
||||||
|
<a href="/{{ tenant.id }}/edit/{{ product_id }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-pencil"></i> Edit
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-success"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#barcodeModal"
|
||||||
|
data-product-id="{{ product_id }}"
|
||||||
|
data-product-name="{% set matching_fields = product_data|selectattr('fieldName', 'equalto', '_name')|list %}{{ matching_fields[0].value if matching_fields else product_id }}">
|
||||||
|
<i class="bi bi-upc-scan"></i> Barcode
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||||
|
data-product-id="{{ product_id }}"
|
||||||
|
data-product-name="{% set matching_fields = product_data|selectattr('fieldName', 'equalto', '_name')|list %}{{ matching_fields[0].value if matching_fields else product_id }}">
|
||||||
|
<i class="bi bi-trash"></i> Delete
|
||||||
|
</button>
|
||||||
|
<form id="delete-form-{{ product_id }}" action="/{{ tenant.id }}/delete/{{ product_id }}" method="POST" style="display: none;">
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i> No products available yet. Click "Add New Product" to get started.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h2>Getting Started</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Configure your AR fields to define what information is returned for products</li>
|
||||||
|
<li>Add products to your catalog with the configured fields</li>
|
||||||
|
<li>Generate barcodes for your products</li>
|
||||||
|
<li>Use the AR endpoints in your scanning application</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barcode Modal -->
|
||||||
|
<div class="modal fade" id="barcodeModal" tabindex="-1" aria-labelledby="barcodeModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="barcodeModalLabel">Product Barcode</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center">
|
||||||
|
<h6 id="barcode-product-name" class="mb-3"></h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<img id="barcode-image" src="" alt="Product Barcode" class="img-fluid" style="max-width: 400px;">
|
||||||
|
</div>
|
||||||
|
<p class="text-muted"><small>Product ID: <code id="barcode-product-id"></code></small></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-danger text-white">
|
||||||
|
<h5 class="modal-title" id="deleteModalLabel">Confirm Deletion</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Are you sure you want to delete the product <strong id="delete-product-name"></strong>?</p>
|
||||||
|
<p class="text-danger"><i class="bi bi-exclamation-triangle-fill"></i> This action cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="confirm-delete-btn">Delete Product</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Barcode modal functionality
|
||||||
|
const barcodeModal = document.getElementById('barcodeModal');
|
||||||
|
if (barcodeModal) {
|
||||||
|
barcodeModal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
// Button that triggered the modal
|
||||||
|
const button = event.relatedTarget;
|
||||||
|
|
||||||
|
// Extract product info from data attributes
|
||||||
|
const productId = button.getAttribute('data-product-id');
|
||||||
|
const productName = button.getAttribute('data-product-name');
|
||||||
|
|
||||||
|
// Update modal content
|
||||||
|
const modalProductName = document.getElementById('barcode-product-name');
|
||||||
|
const modalProductId = document.getElementById('barcode-product-id');
|
||||||
|
const barcodeImage = document.getElementById('barcode-image');
|
||||||
|
|
||||||
|
modalProductName.textContent = productName;
|
||||||
|
modalProductId.textContent = productId;
|
||||||
|
|
||||||
|
// Set barcode image source using tenant's barcode type setting
|
||||||
|
const barcodeType = '{{ tenant.barcode_type or "code128" }}';
|
||||||
|
barcodeImage.src = '/{{ tenant.id }}/barcodes/' + productId + '_' + barcodeType + '.png';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation modal functionality
|
||||||
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
|
if (deleteModal) {
|
||||||
|
deleteModal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
// Button that triggered the modal
|
||||||
|
const button = event.relatedTarget;
|
||||||
|
|
||||||
|
// Extract product info from data attributes
|
||||||
|
const productId = button.getAttribute('data-product-id');
|
||||||
|
const productName = button.getAttribute('data-product-name');
|
||||||
|
|
||||||
|
// Update modal content
|
||||||
|
const modalProductName = document.getElementById('delete-product-name');
|
||||||
|
modalProductName.textContent = productName + " (ID: " + productId + ")";
|
||||||
|
|
||||||
|
// Setup the confirm button action
|
||||||
|
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
|
||||||
|
confirmDeleteBtn.onclick = function() {
|
||||||
|
document.getElementById('delete-form-' + productId).submit();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
52
src/templates/settings.html
Normal file
52
src/templates/settings.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Server Settings - 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">Server Settings</h1>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ 'success' if category == 'success' else 'danger' }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Server Configuration</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="server_url" class="form-label">Server URL</label>
|
||||||
|
<input type="url" class="form-control" id="server_url" name="server_url"
|
||||||
|
value="{{ server_url }}" placeholder="http://localhost:5000" required>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
This URL will be used for generating Knox Capture AR Template URLs.
|
||||||
|
Include the protocol (http:// or https://) and port if needed.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||||
|
<a href="/" class="btn btn-secondary">Back to Home</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Select or Create Tenant - KCAP Demo Server</title>
|
<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">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container mt-5">
|
<div class="container mt-5">
|
||||||
@@ -38,8 +39,15 @@
|
|||||||
<small>{{ tenant.created_at }}</small>
|
<small>{{ tenant.created_at }}</small>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-1">Username: {{ tenant.username }}</p>
|
<p class="mb-1">Username: {{ tenant.username }}</p>
|
||||||
|
<p class="mb-1">
|
||||||
|
<strong>Knox Capture AR Template URL:</strong>
|
||||||
|
<code>{{ server_url }}/{{ tenant.id }}/</code>
|
||||||
|
</p>
|
||||||
<small>ID: {{ tenant.id }}</small>
|
<small>ID: {{ tenant.id }}</small>
|
||||||
</a>
|
</a>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary me-2" data-bs-toggle="modal" data-bs-target="#qrModal-{{ tenant.id }}" onclick="event.stopPropagation();">
|
||||||
|
<i class="bi bi-qr-code"></i>
|
||||||
|
</button>
|
||||||
<form method="POST" action="/tenant/{{ tenant.id }}/delete" class="ms-3" onsubmit="return confirmDelete('{{ tenant.name }}')">
|
<form method="POST" action="/tenant/{{ tenant.id }}/delete" class="ms-3" onsubmit="return confirmDelete('{{ tenant.name }}')">
|
||||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -73,11 +81,36 @@
|
|||||||
<strong>Note:</strong> When you access a tenant URL directly (e.g., /my-tenant/),
|
<strong>Note:</strong> When you access a tenant URL directly (e.g., /my-tenant/),
|
||||||
it will be automatically created with default credentials (admin/admin).
|
it will be automatically created with default credentials (admin/admin).
|
||||||
</p>
|
</p>
|
||||||
|
<a href="/settings" class="btn btn-secondary mt-3">Server Settings</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- QR Code Modals for each tenant -->
|
||||||
|
{% for tenant in tenants %}
|
||||||
|
<div class="modal fade" id="qrModal-{{ tenant.id }}" tabindex="-1" aria-labelledby="qrModalLabel-{{ tenant.id }}" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="qrModalLabel-{{ tenant.id }}">AR Template URL QR Code - {{ tenant.name }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center">
|
||||||
|
<p class="text-muted mb-3">Scan this QR code for the Knox Capture AR Template URL:</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<img src="/{{ tenant.id }}/qrcode/template" alt="AR Template QR Code" class="img-fluid" style="max-width: 300px;">
|
||||||
|
</div>
|
||||||
|
<code class="d-block mt-2">{{ server_url }}/{{ tenant.id }}/</code>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const reservedIds = ['admin', 'api', 'login', 'logout', 'arcontentfields', 'arinfo',
|
const reservedIds = ['admin', 'api', 'login', 'logout', 'arcontentfields', 'arinfo',
|
||||||
@@ -88,11 +121,14 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const tenantId = document.getElementById('tenantId').value;
|
const tenantId = document.getElementById('tenantId').value;
|
||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
if (reservedIds.includes(tenantId.toLowerCase())) {
|
// Convert to lowercase for consistency
|
||||||
|
const normalizedId = tenantId.toLowerCase();
|
||||||
|
if (reservedIds.includes(normalizedId)) {
|
||||||
alert(`"${tenantId}" is a reserved name and cannot be used as a tenant ID.`);
|
alert(`"${tenantId}" is a reserved name and cannot be used as a tenant ID.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = '/' + tenantId + '/';
|
// Use the normalized (lowercase) ID in the URL
|
||||||
|
window.location.href = '/' + normalizedId + '/';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
127
src/templates/tenant_settings.html
Normal file
127
src/templates/tenant_settings.html
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block title %}Settings - {{ tenant.name }} - KCAP Demo Server{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>Settings - {{ tenant.name }}</h1>
|
||||||
|
<a href="/{{ tenant.id }}/" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Device Password Section -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-shield-lock"></i> Device Authentication</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Configure the username and password for device authentication.</p>
|
||||||
|
<form method="POST" action="/{{ tenant.id }}/settings/credentials">
|
||||||
|
<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 to change" autocomplete="new-password">
|
||||||
|
<div class="form-text">Leave blank to keep current password</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-save"></i> Update Credentials
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-upc-scan"></i> Barcode Settings</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Configure the default barcode type for products.</p>
|
||||||
|
<form method="POST" action="/{{ tenant.id }}/settings/barcode">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="barcode_type" class="form-label">Barcode Type</label>
|
||||||
|
<select class="form-select" id="barcode_type" name="barcode_type" required>
|
||||||
|
<option value="code128" {% if tenant.barcode_type == 'code128' %}selected{% endif %}>Code 128</option>
|
||||||
|
<option value="ean13" {% if tenant.barcode_type == 'ean13' %}selected{% endif %}>EAN-13</option>
|
||||||
|
<option value="qr" {% if tenant.barcode_type == 'qr' %}selected{% endif %}>QR Code</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">This will be used when displaying product barcodes</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-save"></i> Update Barcode Type
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom AR Fields Section -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-list-ul"></i> Custom AR Content Fields</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Manage the fields that are returned in the AR content API.</p>
|
||||||
|
<a href="/{{ tenant.id }}/ar_fields" class="btn btn-primary">
|
||||||
|
<i class="bi bi-gear"></i> Manage AR Fields
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if custom_fields %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<h6>Current Fields:</h6>
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for field in custom_fields %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{{ field.label }}</strong>
|
||||||
|
<small class="text-muted">({{ field.fieldName }})</small>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-secondary">{{ field.fieldType }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Settings -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-info-circle"></i> Tenant Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-sm-3">Tenant ID:</dt>
|
||||||
|
<dd class="col-sm-9"><code>{{ tenant.id }}</code></dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Tenant Name:</dt>
|
||||||
|
<dd class="col-sm-9">{{ tenant.name }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">API Endpoint:</dt>
|
||||||
|
<dd class="col-sm-9"><code>{{ server_url }}/{{ tenant.id }}/arinfo</code></dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Created:</dt>
|
||||||
|
<dd class="col-sm-9">{{ tenant.created_at }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user