From 000c054734669ffcee603448af1f1c3d19d38dbe Mon Sep 17 00:00:00 2001
From: Matt Hills
Date: Thu, 19 Jun 2025 15:46:22 -0400
Subject: [PATCH 01/11] fixing field edits and adding custom fileds
---
src/app.py | 64 +++--
src/database.py | 156 +++++++++++-
src/routes/admin.py | 346 ++++++++++++++++++++------
src/templates/admin/add_product.html | 60 +++--
src/templates/admin/ar_fields.html | 170 +++++++++++++
src/templates/admin/edit_product.html | 114 +++++----
src/templates/admin/index.html | 1 +
src/templates/index.html | 61 +++--
8 files changed, 792 insertions(+), 180 deletions(-)
create mode 100644 src/templates/admin/ar_fields.html
diff --git a/src/app.py b/src/app.py
index b172660..1ab0ce9 100644
--- a/src/app.py
+++ b/src/app.py
@@ -76,12 +76,8 @@ def login(tenant_id):
@app.route('//arcontentfields', methods=['GET'])
def get_ar_content_fields(tenant_id):
- # Return a fixed set of attributes (tenant_id could be used for custom fields in the future)
- fields = [
- {"fieldName": "_id", "label": "Item ID", "editable": "false", "fieldType": "TEXT"},
- {"fieldName": "_price", "label": "Sale Price", "editable": "true", "fieldType": "TEXT"},
- {"fieldName": "_image", "label": "Image", "editable": "false", "fieldType": "IMAGE_URI"}
- ]
+ # Get custom fields defined for this tenant
+ fields = database.get_custom_ar_fields(tenant_id)
return jsonify(fields), 200
@app.route('//arinfo', methods=['GET'])
@@ -89,31 +85,39 @@ def get_ar_info(tenant_id):
barcode = request.args.get('barcode')
products = load_products(tenant_id)
- # Helper function to convert relative image paths to absolute URLs
- def make_absolute_urls(product_fields):
- # Create absolute URL for image fields
+ # Get custom AR fields for this tenant
+ custom_fields = database.get_custom_ar_fields(tenant_id)
+ custom_field_names = [f['fieldName'] for f in custom_fields]
+
+ # Helper function to convert relative image paths to absolute URLs and filter fields
+ def filter_and_process_fields(product_fields):
+ # Filter to only include fields defined in custom AR fields
+ filtered_fields = []
for field in product_fields:
- if field['fieldName'] == '_image' and field['value']:
- # If it's already an absolute URL, leave it as is
- if not field['value'].startswith('http'):
- # Build absolute URL using request host with tenant
- field['value'] = f"{request.url_root.rstrip('/')}/{tenant_id}{field['value']}"
- return product_fields
+ if field['fieldName'] in custom_field_names:
+ # Create absolute URL for image fields
+ if field['fieldName'] == '_image' and field['value']:
+ # If it's already an absolute URL, leave it as is
+ if not field['value'].startswith('http'):
+ # Build absolute URL using request host with tenant
+ field['value'] = f"{request.url_root.rstrip('/')}/{tenant_id}{field['value']}"
+ filtered_fields.append(field)
+ return filtered_fields
# If barcode is provided, return specific product
if barcode:
if barcode in products:
- product_data = make_absolute_urls(products[barcode])
+ product_data = filter_and_process_fields(products[barcode])
response = jsonify(product_data)
response.headers['Access-Control-Allow-Origin'] = '*'
return response, 200
return jsonify({"error": "Product not found"}), 404
# Return all products if no barcode specified
- # Convert all products to have absolute URLs
+ # Convert all products to have absolute URLs and filter fields
all_products = {}
for product_id, fields in products.items():
- all_products[product_id] = make_absolute_urls(fields)
+ all_products[product_id] = filter_and_process_fields(fields)
response = jsonify(all_products)
response.headers['Access-Control-Allow-Origin'] = '*'
return response, 200
@@ -123,7 +127,26 @@ def serve_image(tenant_id, filename):
# Log the request for debugging
app.logger.info(f"Image requested for tenant {tenant_id}: {filename}")
- # Extract product ID from filename (e.g., "123456.jpg" -> "123456")
+ # Check if filename contains field name (e.g., "123456_thumbnail.jpg")
+ base_name = os.path.splitext(filename)[0]
+
+ if '_' in base_name:
+ # Split to get product_id and field_name
+ parts = base_name.split('_', 1)
+ product_id = parts[0]
+ field_name = '_' + parts[1] if len(parts) > 1 else '_image'
+
+ # Try to get field-specific image
+ image_data = database.get_product_image_by_field(product_id, tenant_id, field_name)
+ if image_data:
+ image_bytes, mime_type = image_data
+ response = Response(image_bytes, mimetype=mime_type)
+ response.headers['Access-Control-Allow-Origin'] = '*'
+ response.headers['Cache-Control'] = 'public, max-age=3600'
+ app.logger.info(f"Serving field-specific image: {product_id}/{field_name} ({len(image_bytes)} bytes)")
+ return response
+
+ # Try standard image lookup (backward compatibility)
product_id = os.path.splitext(filename)[0]
# Get image from database for this tenant
@@ -203,7 +226,7 @@ def serve_barcode(tenant_id, filename):
# Import the admin routes directly and register them with tenant support
from routes.admin import (index as admin_index, add_product, edit_product,
delete_product, view_product, generate_barcode,
- generate_barcode_page, manage_credentials)
+ generate_barcode_page, manage_credentials, manage_ar_fields)
# Register admin routes with tenant prefix
app.add_url_rule('//admin/', 'admin.index', admin_index)
@@ -214,6 +237,7 @@ app.add_url_rule('//admin/view/', 'admin.view_prod
app.add_url_rule('//admin/generate_barcode//', 'admin.generate_barcode', generate_barcode)
app.add_url_rule('//admin/generate_barcode_page/', 'admin.generate_barcode_page', generate_barcode_page)
app.add_url_rule('//admin/credentials', 'admin.manage_credentials', manage_credentials, methods=['GET', 'POST'])
+app.add_url_rule('//admin/ar_fields', 'admin.manage_ar_fields', manage_ar_fields, methods=['GET', 'POST'])
# Register API routes with tenant prefix
from routes.api import api_index
diff --git a/src/database.py b/src/database.py
index 81f530f..d8692ec 100644
--- a/src/database.py
+++ b/src/database.py
@@ -75,6 +75,38 @@ def init_database():
)
''')
+ # Create custom_ar_fields table for tenant-specific AR field definitions
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS custom_ar_fields (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ tenant_id TEXT NOT NULL,
+ field_name TEXT NOT NULL,
+ label TEXT NOT NULL,
+ field_type TEXT NOT NULL,
+ editable TEXT DEFAULT 'true',
+ display_order INTEGER DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
+ UNIQUE(tenant_id, field_name)
+ )
+ ''')
+
+ # Create product_images table for storing multiple images per product
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS product_images (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ product_id TEXT NOT NULL,
+ tenant_id TEXT NOT NULL,
+ field_name TEXT NOT NULL,
+ image_data BLOB,
+ image_mime_type TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (product_id, tenant_id) REFERENCES products(id, tenant_id) ON DELETE CASCADE,
+ UNIQUE(product_id, tenant_id, field_name)
+ )
+ ''')
+
conn.commit()
def migrate_from_json():
@@ -340,6 +372,9 @@ def get_or_create_tenant(tenant_id: str, username: str = None, password: str = N
''', (tenant_id, tenant_id.title(), default_username, default_password))
conn.commit()
+ # Initialize default AR fields for the new tenant
+ init_default_ar_fields(tenant_id)
+
return {
'id': tenant_id,
'name': tenant_id.title(),
@@ -400,4 +435,123 @@ def cleanup_reserved_tenants():
print(f"Deleted reserved tenant: {tenant_id}")
conn.commit()
- return deleted_count
\ No newline at end of file
+ return deleted_count
+
+def get_custom_ar_fields(tenant_id: str) -> List[Dict[str, Any]]:
+ """Get custom AR fields for a tenant"""
+ with get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT id, field_name, label, field_type, editable, display_order
+ FROM custom_ar_fields
+ WHERE tenant_id = ?
+ ORDER BY display_order, field_name
+ ''', (tenant_id,))
+
+ fields = []
+ for row in cursor.fetchall():
+ fields.append({
+ 'id': row['id'],
+ 'fieldName': row['field_name'],
+ 'label': row['label'],
+ 'fieldType': row['field_type'],
+ 'editable': row['editable'],
+ 'displayOrder': row['display_order']
+ })
+
+ return fields
+
+def save_custom_ar_field(tenant_id: str, field_data: Dict[str, Any]) -> int:
+ """Save or update a custom AR field"""
+ with get_db() as conn:
+ cursor = conn.cursor()
+
+ if 'id' in field_data:
+ # Update existing field
+ cursor.execute('''
+ UPDATE custom_ar_fields
+ SET field_name = ?, label = ?, field_type = ?, editable = ?,
+ display_order = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND tenant_id = ?
+ ''', (
+ field_data['fieldName'],
+ field_data['label'],
+ field_data['fieldType'],
+ field_data.get('editable', 'true'),
+ field_data.get('displayOrder', 0),
+ field_data['id'],
+ tenant_id
+ ))
+ conn.commit()
+ return field_data['id']
+ else:
+ # Insert new field
+ cursor.execute('''
+ INSERT INTO custom_ar_fields
+ (tenant_id, field_name, label, field_type, editable, display_order)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ''', (
+ tenant_id,
+ field_data['fieldName'],
+ field_data['label'],
+ field_data['fieldType'],
+ field_data.get('editable', 'true'),
+ field_data.get('displayOrder', 0)
+ ))
+ conn.commit()
+ return cursor.lastrowid
+
+def delete_custom_ar_field(tenant_id: str, field_id: int):
+ """Delete a custom AR field"""
+ with get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ DELETE FROM custom_ar_fields
+ WHERE id = ? AND tenant_id = ?
+ ''', (field_id, tenant_id))
+ conn.commit()
+
+def init_default_ar_fields(tenant_id: str):
+ """Initialize default AR fields for a new tenant"""
+ default_fields = [
+ {"fieldName": "_id", "label": "Item ID", "fieldType": "TEXT", "editable": "false", "displayOrder": 1},
+ {"fieldName": "_price", "label": "Sale Price", "fieldType": "TEXT", "editable": "true", "displayOrder": 2},
+ {"fieldName": "_image", "label": "Image", "fieldType": "IMAGE_URI", "editable": "false", "displayOrder": 3}
+ ]
+
+ for field in default_fields:
+ save_custom_ar_field(tenant_id, field)
+
+def save_product_image(product_id: str, tenant_id: str, field_name: str, image_data: bytes, mime_type: str):
+ """Save an image for a specific field of a product"""
+ with get_db() as conn:
+ cursor = conn.cursor()
+
+ # Delete existing image for this field if any
+ cursor.execute('''
+ DELETE FROM product_images
+ WHERE product_id = ? AND tenant_id = ? AND field_name = ?
+ ''', (product_id, tenant_id, field_name))
+
+ # Insert new image
+ cursor.execute('''
+ INSERT INTO product_images (product_id, tenant_id, field_name, image_data, image_mime_type)
+ VALUES (?, ?, ?, ?, ?)
+ ''', (product_id, tenant_id, field_name, image_data, mime_type))
+
+ conn.commit()
+
+def get_product_image_by_field(product_id: str, tenant_id: str, field_name: str) -> Optional[tuple[bytes, str]]:
+ """Get image data for a specific field of a product"""
+ with get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT image_data, image_mime_type
+ FROM product_images
+ WHERE product_id = ? AND tenant_id = ? AND field_name = ?
+ ''', (product_id, tenant_id, field_name))
+
+ row = cursor.fetchone()
+ if row and row['image_data']:
+ return row['image_data'], row['image_mime_type']
+ return None
\ No newline at end of file
diff --git a/src/routes/admin.py b/src/routes/admin.py
index cf49501..7bb087e 100644
--- a/src/routes/admin.py
+++ b/src/routes/admin.py
@@ -41,119 +41,264 @@ def index(tenant_id):
return render_template('admin/index.html', products=products, tenant=tenant)
def add_product(tenant_id):
+ # Get custom AR fields for this tenant
+ custom_fields = database.get_custom_ar_fields(tenant_id)
+
if request.method == 'POST':
# Get form data
product_id = request.form.get('product_id')
- name = request.form.get('name')
- price = request.form.get('price')
# Basic validation
- if not product_id or not name or not price:
- flash('Product ID, Name, and Price are required fields.')
- return render_template('admin/add_product.html', tenant_id=tenant_id)
+ if not product_id:
+ flash('Product ID is required.')
+ return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields)
# Check if product ID already exists
products = load_products(tenant_id)
if product_id in products:
flash('Product ID already exists.')
- return render_template('admin/add_product.html', tenant_id=tenant_id)
+ return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields)
- # Handle image upload
+ # Handle backward compatibility image upload
image_data = None
image_mime_type = None
- image_path = f"/images/{product_id}.png" # Default path for display
- if 'image' in request.files:
- file = request.files['image']
- if file and file.filename and allowed_file(file.filename):
- extension = file.filename.rsplit('.', 1)[1].lower()
- image_path = f"/images/{product_id}.{extension}"
-
- # Read image data
- image_data = file.read()
-
- # Determine mime type
- mime_types = {
- 'jpg': 'image/jpeg',
- 'jpeg': 'image/jpeg',
- 'png': 'image/png',
- 'gif': 'image/gif'
- }
- image_mime_type = mime_types.get(extension, 'image/jpeg')
+ # Create product data structure based on custom fields
+ product_data = []
- # Create product data structure
- product_data = [
- {"fieldName": "_id", "label": "Item ID", "value": product_id, "editable": "false", "fieldType": "TEXT"},
- {"fieldName": "_name", "label": "Product Name", "value": name, "editable": "true", "fieldType": "TEXT"},
- {"fieldName": "_price", "label": "Sale Price", "value": f"${price}", "editable": "true", "fieldType": "TEXT"},
- {"fieldName": "_image", "label": "Image", "value": image_path, "editable": "false", "fieldType": "IMAGE_URI"}
- ]
+ # Always include _id field
+ product_data.append({
+ "fieldName": "_id",
+ "label": "Item ID",
+ "value": product_id,
+ "editable": "false",
+ "fieldType": "TEXT"
+ })
+
+ # Process images after product is saved
+ images_to_save = []
+
+ # Add other fields based on custom configuration
+ for field in custom_fields:
+ field_name = field['fieldName']
+ if field_name == '_id':
+ continue # Already added
+ elif field['fieldType'] == 'IMAGE_URI':
+ # Check if image was uploaded for this field
+ image_field_name = f'image_{field_name}'
+ if image_field_name in request.files:
+ file = request.files[image_field_name]
+ if file and file.filename and allowed_file(file.filename):
+ # Read image data
+ file.seek(0)
+ img_data = file.read()
+
+ # Determine mime type
+ extension = file.filename.rsplit('.', 1)[1].lower()
+ mime_types = {
+ 'jpg': 'image/jpeg',
+ 'jpeg': 'image/jpeg',
+ 'png': 'image/png',
+ 'gif': 'image/gif'
+ }
+ mime_type = mime_types.get(extension, 'image/jpeg')
+
+ # Store for later saving
+ images_to_save.append({
+ 'field_name': field_name,
+ 'data': img_data,
+ 'mime_type': mime_type
+ })
+
+ # Set the image URL path
+ image_path = f"/images/{product_id}_{field_name}.{extension}"
+
+ # For backward compatibility with _image field
+ if field_name == '_image':
+ image_data = img_data
+ image_mime_type = mime_type
+ else:
+ image_path = ""
+ else:
+ image_path = ""
+
+ product_data.append({
+ "fieldName": field_name,
+ "label": field['label'],
+ "value": image_path,
+ "editable": field['editable'],
+ "fieldType": field['fieldType']
+ })
+ else:
+ # Get value from form
+ value = request.form.get(f'field_{field_name}', '')
+ product_data.append({
+ "fieldName": field_name,
+ "label": field['label'],
+ "value": value,
+ "editable": field['editable'],
+ "fieldType": field['fieldType']
+ })
+
+ # Also save fields that aren't in custom fields (for backward compatibility)
+ if '_name' not in [f['fieldName'] for f in custom_fields]:
+ name = request.form.get('name', '')
+ if name:
+ product_data.append({
+ "fieldName": "_name",
+ "label": "Product Name",
+ "value": name,
+ "editable": "true",
+ "fieldType": "TEXT"
+ })
+
+ if '_price' not in [f['fieldName'] for f in custom_fields]:
+ price = request.form.get('price', '')
+ if price:
+ product_data.append({
+ "fieldName": "_price",
+ "label": "Sale Price",
+ "value": f"${price}",
+ "editable": "true",
+ "fieldType": "TEXT"
+ })
# Save to database
database.save_product(product_id, tenant_id, product_data, image_data, image_mime_type)
+ # Save additional images
+ for img in images_to_save:
+ database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type'])
+
flash('Product added successfully!')
return redirect(url_for('admin.index', tenant_id=tenant_id))
- return render_template('admin/add_product.html', tenant_id=tenant_id)
+ return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields)
def edit_product(tenant_id, product_id):
products = load_products(tenant_id)
+ custom_fields = database.get_custom_ar_fields(tenant_id)
if product_id not in products:
flash('Product not found.')
return redirect(url_for('admin.index', tenant_id=tenant_id))
if request.method == 'POST':
- # Get form data
- name = request.form.get('name')
- price = request.form.get('price')
-
- # Basic validation
- if not name or not price:
- flash('Name and Price are required fields.')
- return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id)
-
- # Update name and price
- for field in products[product_id]:
- if field["fieldName"] == "_name":
- field["value"] = name
- elif field["fieldName"] == "_price":
- field["value"] = f"${price}"
-
- # Handle image upload
+ # Handle backward compatibility image upload
image_data = None
image_mime_type = None
- if 'image' in request.files and request.files['image'].filename:
- file = request.files['image']
- if allowed_file(file.filename):
- # Read new image data
- image_data = file.read()
+ # Process images after product is saved
+ images_to_save = []
+
+ # Update fields based on form data
+ updated_product = []
+
+ # Always include _id field
+ for field in products[product_id]:
+ if field["fieldName"] == "_id":
+ updated_product.append(field)
+ break
+
+ # Process each custom field
+ for custom_field in custom_fields:
+ field_name = custom_field['fieldName']
+ if field_name == '_id':
+ continue
- # Determine mime type
- extension = file.filename.rsplit('.', 1)[1].lower()
- mime_types = {
- 'jpg': 'image/jpeg',
- 'jpeg': 'image/jpeg',
- 'png': 'image/png',
- 'gif': 'image/gif'
+ # Find existing field data
+ existing_field = None
+ for field in products[product_id]:
+ if field["fieldName"] == field_name:
+ existing_field = field.copy()
+ break
+
+ if not existing_field:
+ existing_field = {
+ "fieldName": field_name,
+ "label": custom_field['label'],
+ "value": "",
+ "editable": custom_field['editable'],
+ "fieldType": custom_field['fieldType']
}
- image_mime_type = mime_types.get(extension, 'image/jpeg')
-
- # Update image path in product data
- image_path = f"/images/{product_id}.{extension}"
+
+ if custom_field['fieldType'] == 'IMAGE_URI':
+ # Check if new image was uploaded
+ image_field_name = f'image_{field_name}'
+ if image_field_name in request.files and request.files[image_field_name].filename:
+ file = request.files[image_field_name]
+ if allowed_file(file.filename):
+ # Read image data
+ file.seek(0)
+ img_data = file.read()
+
+ # Determine mime type
+ extension = file.filename.rsplit('.', 1)[1].lower()
+ mime_types = {
+ 'jpg': 'image/jpeg',
+ 'jpeg': 'image/jpeg',
+ 'png': 'image/png',
+ 'gif': 'image/gif'
+ }
+ mime_type = mime_types.get(extension, 'image/jpeg')
+
+ # Store for later saving
+ images_to_save.append({
+ 'field_name': field_name,
+ 'data': img_data,
+ 'mime_type': mime_type
+ })
+
+ # Update image path
+ existing_field["value"] = f"/images/{product_id}_{field_name}.{extension}"
+
+ # For backward compatibility with _image field
+ if field_name == '_image':
+ image_data = img_data
+ image_mime_type = mime_type
+ else:
+ # Get value from form
+ value = request.form.get(f'field_{field_name}', '')
+ if field_name == '_price' and value and not value.startswith('$'):
+ value = f"${value}"
+ existing_field["value"] = value
+
+ updated_product.append(existing_field)
+
+ # Handle backward compatibility fields
+ if '_name' not in [f['fieldName'] for f in custom_fields]:
+ name = request.form.get('name', '')
+ if name:
for field in products[product_id]:
- if field["fieldName"] == "_image":
- field["value"] = image_path
+ if field["fieldName"] == "_name":
+ field["value"] = name
+ updated_product.append(field)
+ break
+
+ if '_price' not in [f['fieldName'] for f in custom_fields]:
+ price = request.form.get('price', '')
+ if price:
+ for field in products[product_id]:
+ if field["fieldName"] == "_price":
+ field["value"] = f"${price}"
+ updated_product.append(field)
+ break
# Save to database
- database.save_product(product_id, tenant_id, products[product_id], image_data, image_mime_type)
+ database.save_product(product_id, tenant_id, updated_product, image_data, image_mime_type)
+
+ # Save additional images
+ for img in images_to_save:
+ database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type'])
flash('Product updated successfully!')
return redirect(url_for('admin.index', tenant_id=tenant_id))
- return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id)
+ return render_template('admin/edit_product.html',
+ product_id=product_id,
+ product=products[product_id],
+ tenant_id=tenant_id,
+ custom_fields=custom_fields)
def delete_product(tenant_id, product_id):
products = load_products(tenant_id)
@@ -245,3 +390,64 @@ def manage_credentials(tenant_id):
flash('Username and password are required.')
return render_template('admin/credentials.html', tenant=tenant, tenant_id=tenant_id)
+
+def manage_ar_fields(tenant_id):
+ """Manage custom AR content fields"""
+ tenant = database.get_or_create_tenant(tenant_id)
+
+ if request.method == 'POST':
+ action = request.form.get('action')
+
+ if action == 'add':
+ field_data = {
+ 'fieldName': request.form.get('fieldName'),
+ 'label': request.form.get('label'),
+ 'fieldType': request.form.get('fieldType'),
+ 'editable': request.form.get('editable', 'true'),
+ 'displayOrder': int(request.form.get('displayOrder', 0))
+ }
+
+ if field_data['fieldName'] and field_data['label']:
+ database.save_custom_ar_field(tenant_id, field_data)
+ flash('Custom field added successfully!')
+ else:
+ flash('Field name and label are required.')
+
+ elif action == 'delete':
+ field_id = request.form.get('field_id')
+ if field_id:
+ database.delete_custom_ar_field(tenant_id, int(field_id))
+ flash('Custom field deleted successfully!')
+
+ elif action == 'update':
+ field_data = {
+ 'id': int(request.form.get('field_id')),
+ 'fieldName': request.form.get('fieldName'),
+ 'label': request.form.get('label'),
+ 'fieldType': request.form.get('fieldType'),
+ 'editable': request.form.get('editable', 'true'),
+ 'displayOrder': int(request.form.get('displayOrder', 0))
+ }
+
+ if field_data['fieldName'] and field_data['label']:
+ database.save_custom_ar_field(tenant_id, field_data)
+ flash('Custom field updated successfully!')
+ else:
+ flash('Field name and label are required.')
+
+ return redirect(url_for('admin.manage_ar_fields', tenant_id=tenant_id))
+
+ # Get existing custom fields
+ custom_fields = database.get_custom_ar_fields(tenant_id)
+
+ # Available field types
+ field_types = [
+ 'TEXT',
+ 'IMAGE_URI'
+ ]
+
+ return render_template('admin/ar_fields.html',
+ tenant=tenant,
+ tenant_id=tenant_id,
+ custom_fields=custom_fields,
+ field_types=field_types)
diff --git a/src/templates/admin/add_product.html b/src/templates/admin/add_product.html
index 2efc1fa..099495a 100644
--- a/src/templates/admin/add_product.html
+++ b/src/templates/admin/add_product.html
@@ -22,29 +22,49 @@
Must be unique. This will be used as the barcode for AR content.
-
-
Product Name *
-
-
Enter a descriptive name for the product.
-
+ {% for field in custom_fields %}
+ {% if field.fieldName != '_id' %}
+
+
{{ field.label }}
+ {% if field.fieldType == 'IMAGE_URI' %}
+
+
Supported formats: .png, .jpg, .jpeg, .gif
+
+
+
+ {% elif field.fieldType == 'TEXT' %}
+ {% if field.fieldName == '_price' %}
+
+ $
+
+
+ {% else %}
+
+ {% endif %}
+ {% else %}
+
+ {% endif %}
+
+ {% endif %}
+ {% endfor %}
+
+ {% if not custom_fields|selectattr('fieldName', 'equalto', '_name')|list %}
- Price *
+ Product Name
+
+
+ {% endif %}
+
+ {% if not custom_fields|selectattr('fieldName', 'equalto', '_price')|list %}
+
+
Price
$
-
-
-
Enter the price in decimal format (e.g., 49.99)
-
-
-
-
Product Image
-
-
Supported formats: .png, .jpg, .jpeg, .gif
-
+ {% endif %}
Cancel
@@ -59,8 +79,8 @@
{% block extra_js %}
-{% endblock %}
+{% endblock %}
\ No newline at end of file
diff --git a/src/templates/admin/ar_fields.html b/src/templates/admin/ar_fields.html
new file mode 100644
index 0000000..b49f93b
--- /dev/null
+++ b/src/templates/admin/ar_fields.html
@@ -0,0 +1,170 @@
+{% extends "layout.html" %}
+
+{% block title %}Manage AR Fields - KCAP Admin{% endblock %}
+
+{% block content %}
+
+
+
Manage AR Content Fields
+
Configure the fields that will be returned by the /arcontentfields and /arinfo endpoints.
+
+
Back to Admin
+
+
+
+
+
+ {% if custom_fields %}
+
+
+
+ Field Name
+ Label
+ Type
+ Editable
+ Display Order
+ Actions
+
+
+
+ {% for field in custom_fields %}
+
+ {{ field.fieldName }}
+ {{ field.label }}
+ {{ field.fieldType }}
+ {{ field.editable }}
+ {{ field.displayOrder }}
+
+ Edit
+
+
+
+ {% endfor %}
+
+
+ {% else %}
+
No custom fields defined. Default fields will be used.
+ {% endif %}
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/templates/admin/edit_product.html b/src/templates/admin/edit_product.html
index f085b0e..e7f85ad 100644
--- a/src/templates/admin/edit_product.html
+++ b/src/templates/admin/edit_product.html
@@ -17,52 +17,80 @@
Product ID cannot be changed.
-
-
Product Name *
+ {% for field in custom_fields %}
+ {% if field.fieldName != '_id' %}
+
+
{{ field.label }}
+
+ {% set current_value = '' %}
+ {% for prod_field in product %}
+ {% if prod_field.fieldName == field.fieldName %}
+ {% set current_value = prod_field.value %}
+ {% endif %}
+ {% endfor %}
+
+ {% if field.fieldType == 'IMAGE_URI' %}
+
+
Leave empty to keep the current image.
+
+ {% if current_value %}
+
+
Current Image:
+
+
+
+
+ {% endif %}
+
+
+
New Image Preview:
+
+
+ {% elif field.fieldType == 'TEXT' %}
+ {% if field.fieldName == '_price' %}
+
+ $
+ {% set price_value = current_value|replace('$', '') %}
+
+
+ {% else %}
+
+ {% endif %}
+ {% else %}
+
+ {% endif %}
+
+ {% endif %}
+ {% endfor %}
+
+
+ {% if not custom_fields|selectattr('fieldName', 'equalto', '_name')|list %}
{% for field in product %}
{% if field.fieldName == '_name' %}
-
+
+ Product Name
+
+
{% endif %}
{% endfor %}
-
Enter a descriptive name for the product.
-
+ {% endif %}
-
+ {% endif %}
+ {% endfor %}
+ {% endif %}
Cancel
@@ -77,8 +105,8 @@
{% block extra_js %}
-{% endblock %}
+{% endblock %}
\ No newline at end of file
diff --git a/src/templates/admin/index.html b/src/templates/admin/index.html
index 52079f8..4371b28 100644
--- a/src/templates/admin/index.html
+++ b/src/templates/admin/index.html
@@ -9,6 +9,7 @@
Current Tenant: {{ tenant.name }} (ID: {{ tenant.id }})
diff --git a/src/templates/index.html b/src/templates/index.html
index 86cc56a..b20730a 100644
--- a/src/templates/index.html
+++ b/src/templates/index.html
@@ -7,36 +7,45 @@
KCAP Demo Server{% if tenant %} - {{ tenant.name }}{% endif %}
-
A simple Flask API for demonstrating AR content retrieval for barcode scanning applications.
+
Manage your product catalog and AR content for barcode scanning applications.
-
This server simulates the Knox Capture API for AR overlays and includes endpoints for managing product attributes.
+
-
API Endpoints
-
-
- Login: GET {% if tenant %}/{{ tenant.id }}{% endif %}/login
- Authenticates using Basic Auth with tenant-specific credentials.
-
-
- Content Fields: GET {% if tenant %}/{{ tenant.id }}{% endif %}/arcontentfields
- Returns a list of available attributes (e.g., item ID, price, image URI).
-
-
- AR Info: GET {% if tenant %}/{{ tenant.id }}{% endif %}/arinfo?barcode=123456
- Returns product details for a given barcode, including image URLs.
-
-
- Static Image Server: GET {% if tenant %}/{{ tenant.id }}{% endif %}/images/123456.png
- Serves product images stored in the database.
-
-
+
Quick Actions
+
+
+
+
+
Product Management
+
Add, edit, and manage your product catalog with custom fields.
+ {% if tenant %}
+
Manage Products
+ {% endif %}
+
+
+
+
+
+
+
AR Field Configuration
+
Customize the fields returned by AR content endpoints.
+ {% if tenant %}
+
Configure AR Fields
+ {% endif %}
+
+
+
+
+
- {% if tenant %}
-
Go to Admin Interface
- {% else %}
-
Go to Admin Interface
- {% endif %}
+
Getting Started
+
+ Configure your AR fields to define what information is returned for products
+ Add products to your catalog with the configured fields
+ Generate barcodes for your products
+ Use the AR endpoints in your scanning application
+
From e744afbe26c66f5878ee2e630db6789e0f55a556 Mon Sep 17 00:00:00 2001
From: Matt Hills
Date: Thu, 19 Jun 2025 16:03:50 -0400
Subject: [PATCH 02/11] adding server settings
---
src/app.py | 23 ++++++++--
src/database.py | 70 +++++++++++++++++++++++++++--
src/templates/settings.html | 52 +++++++++++++++++++++
src/templates/tenant_selection.html | 2 +
4 files changed, 140 insertions(+), 7 deletions(-)
create mode 100644 src/templates/settings.html
diff --git a/src/app.py b/src/app.py
index 1ab0ce9..f94377d 100644
--- a/src/app.py
+++ b/src/app.py
@@ -28,7 +28,8 @@ def load_products(tenant_id=None):
def index():
# Show a tenant selection page or redirect to default
tenants = database.get_all_tenants()
- return render_template('tenant_selection.html', tenants=tenants)
+ server_url = database.get_server_url()
+ return render_template('tenant_selection.html', tenants=tenants, server_url=server_url)
@app.route('/tenant//delete', methods=['POST'])
def delete_tenant(tenant_id):
@@ -37,6 +38,22 @@ def delete_tenant(tenant_id):
flash(f'Tenant "{tenant_id}" has been deleted successfully.', 'success')
return redirect('/')
+@app.route('/settings', methods=['GET', 'POST'])
+def settings():
+ if request.method == 'POST':
+ server_url = request.form.get('server_url', '').strip()
+ if server_url:
+ # Remove trailing slash for consistency
+ server_url = server_url.rstrip('/')
+ database.set_setting('server_url', server_url)
+ flash('Server settings updated successfully.', 'success')
+ else:
+ flash('Please provide a valid server URL.', 'error')
+ return redirect('/settings')
+
+ server_url = database.get_server_url()
+ return render_template('settings.html', server_url=server_url)
+
@app.route('//')
def tenant_index(tenant_id):
# Auto-create tenant if it doesn't exist
@@ -56,8 +73,8 @@ def check_basic_auth(auth_header, tenant_id):
credentials = base64.b64decode(auth_header[6:]).decode('utf-8')
username, password = credentials.split(':', 1)
- # Get tenant credentials from database
- tenant = database.get_or_create_tenant(tenant_id)
+ # Get tenant credentials from database (without creating)
+ tenant = database.get_tenant(tenant_id)
if tenant and username == tenant['username'] and password == tenant['password']:
return True
except Exception:
diff --git a/src/database.py b/src/database.py
index d8692ec..836f9a6 100644
--- a/src/database.py
+++ b/src/database.py
@@ -107,6 +107,16 @@ def init_database():
)
''')
+ # Create settings table for server configuration
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ ''')
+
conn.commit()
def migrate_from_json():
@@ -342,10 +352,34 @@ def get_product_image(product_id: str, tenant_id: str) -> Optional[tuple[bytes,
return row['image_data'], row['image_mime_type']
return None
+def get_tenant(tenant_id: str) -> Optional[Dict[str, Any]]:
+ """Get a tenant without creating it"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
+ with get_db() as conn:
+ cursor = conn.cursor()
+
+ # Check if tenant exists
+ cursor.execute('SELECT id, name, username, password FROM tenants WHERE id = ?', (tenant_id,))
+ row = cursor.fetchone()
+
+ if row:
+ return {
+ 'id': row['id'],
+ 'name': row['name'],
+ 'username': row['username'],
+ 'password': row['password']
+ }
+ return None
+
def get_or_create_tenant(tenant_id: str, username: str = None, password: str = None) -> Optional[Dict[str, Any]]:
"""Get or create a tenant"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
# Check if tenant_id is reserved
- if tenant_id.lower() in RESERVED_TENANT_IDS:
+ if tenant_id in RESERVED_TENANT_IDS:
return None
with get_db() as conn:
@@ -366,10 +400,12 @@ def get_or_create_tenant(tenant_id: str, username: str = None, password: str = N
# Create new tenant with default credentials
default_username = username or 'admin'
default_password = password or 'admin'
+ # Preserve original casing for display name
+ display_name = tenant_id.replace('-', ' ').replace('_', ' ').title()
cursor.execute('''
INSERT INTO tenants (id, name, username, password)
VALUES (?, ?, ?, ?)
- ''', (tenant_id, tenant_id.title(), default_username, default_password))
+ ''', (tenant_id, display_name, default_username, default_password))
conn.commit()
# Initialize default AR fields for the new tenant
@@ -377,7 +413,7 @@ def get_or_create_tenant(tenant_id: str, username: str = None, password: str = N
return {
'id': tenant_id,
- 'name': tenant_id.title(),
+ 'name': display_name,
'username': default_username,
'password': default_password
}
@@ -554,4 +590,30 @@ def get_product_image_by_field(product_id: str, tenant_id: str, field_name: str)
row = cursor.fetchone()
if row and row['image_data']:
return row['image_data'], row['image_mime_type']
- return None
\ No newline at end of file
+ return None
+
+def get_setting(key: str, default_value: str = None) -> Optional[str]:
+ """Get a setting value by key"""
+ with get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('SELECT value FROM settings WHERE key = ?', (key,))
+ row = cursor.fetchone()
+
+ if row:
+ return row['value']
+ return default_value
+
+def set_setting(key: str, value: str):
+ """Set a setting value"""
+ with get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ INSERT INTO settings (key, value)
+ VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP
+ ''', (key, value, value))
+ conn.commit()
+
+def get_server_url() -> str:
+ """Get the configured server URL or return a default"""
+ return get_setting('server_url', 'http://localhost:5000')
\ No newline at end of file
diff --git a/src/templates/settings.html b/src/templates/settings.html
new file mode 100644
index 0000000..0cc6697
--- /dev/null
+++ b/src/templates/settings.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+ Server Settings - KCAP Demo Server
+
+
+
+
+
Server Settings
+
+ {% with messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+ {% for category, message in messages %}
+
+ {{ message }}
+
+
+ {% endfor %}
+ {% endif %}
+ {% endwith %}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/tenant_selection.html b/src/templates/tenant_selection.html
index 427672a..4dd29ed 100644
--- a/src/templates/tenant_selection.html
+++ b/src/templates/tenant_selection.html
@@ -38,6 +38,7 @@
{{ tenant.created_at }}
Username: {{ tenant.username }}
+ Knox Capture AR Template URL: {{ server_url }}/{{ tenant.id }}/
ID: {{ tenant.id }}
+ Server Settings
From 9f33e1aeb68dc4556eda1575158f65b566209f6c Mon Sep 17 00:00:00 2001
From: Matt Hills
Date: Thu, 19 Jun 2025 16:08:18 -0400
Subject: [PATCH 03/11] case insensitivity
---
src/app.py | 9 +++++
src/database.py | 59 ++++++++++++++++++++++++++++-
src/templates/tenant_selection.html | 7 +++-
3 files changed, 71 insertions(+), 4 deletions(-)
diff --git a/src/app.py b/src/app.py
index f94377d..af741a4 100644
--- a/src/app.py
+++ b/src/app.py
@@ -8,6 +8,7 @@ from barcode.writer import ImageWriter
from io import BytesIO
import shutil
from werkzeug.routing import BaseConverter
+from functools import wraps
app = Flask(__name__)
# Set DATA_FOLDER to the absolute path of the data directory inside src
@@ -17,6 +18,14 @@ app.config['SECRET_KEY'] = 'dev-key-for-demo-only'
# Custom converter for tenant IDs
class TenantConverter(BaseConverter):
regex = '[a-zA-Z0-9_-]+'
+
+ def to_python(self, value):
+ # Convert to lowercase when parsing from URL
+ return value.lower()
+
+ def to_url(self, value):
+ # Convert to lowercase when generating URLs
+ return value.lower()
app.url_map.converters['tenant'] = TenantConverter
diff --git a/src/database.py b/src/database.py
index 836f9a6..01c71d4 100644
--- a/src/database.py
+++ b/src/database.py
@@ -204,6 +204,10 @@ def migrate_from_json():
def get_all_products(tenant_id: str = None) -> Dict[str, List[Dict[str, Any]]]:
"""Get all products for a tenant in the legacy format"""
+ # Normalize tenant_id to lowercase if provided
+ if tenant_id:
+ tenant_id = tenant_id.lower()
+
with get_db() as conn:
cursor = conn.cursor()
@@ -248,6 +252,9 @@ def get_all_products(tenant_id: str = None) -> Dict[str, List[Dict[str, Any]]]:
def get_product(product_id: str, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
"""Get a single product by ID and tenant"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
with get_db() as conn:
cursor = conn.cursor()
@@ -272,6 +279,9 @@ def get_product(product_id: str, tenant_id: str) -> Optional[List[Dict[str, Any]
def save_product(product_id: str, tenant_id: str, fields: List[Dict[str, Any]], image_data: Optional[bytes] = None, image_mime_type: Optional[str] = None):
"""Save or update a product for a tenant"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
with get_db() as conn:
cursor = conn.cursor()
@@ -336,6 +346,9 @@ def save_product(product_id: str, tenant_id: str, fields: List[Dict[str, Any]],
def delete_product(product_id: str, tenant_id: str):
"""Delete a product for a tenant"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id))
@@ -343,6 +356,9 @@ def delete_product(product_id: str, tenant_id: str):
def get_product_image(product_id: str, tenant_id: str) -> Optional[tuple[bytes, str]]:
"""Get product image data and mime type for a tenant"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT image_data, image_mime_type FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id))
@@ -420,6 +436,9 @@ def get_or_create_tenant(tenant_id: str, username: str = None, password: str = N
def update_tenant_credentials(tenant_id: str, username: str, password: str):
"""Update tenant credentials"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('''
@@ -448,6 +467,9 @@ def get_all_tenants() -> List[Dict[str, Any]]:
def delete_tenant(tenant_id: str):
"""Delete a tenant and all associated data"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
with get_db() as conn:
cursor = conn.cursor()
# Due to ON DELETE CASCADE, this will also delete all products and product_fields
@@ -475,6 +497,9 @@ def cleanup_reserved_tenants():
def get_custom_ar_fields(tenant_id: str) -> List[Dict[str, Any]]:
"""Get custom AR fields for a tenant"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('''
@@ -499,6 +524,9 @@ def get_custom_ar_fields(tenant_id: str) -> List[Dict[str, Any]]:
def save_custom_ar_field(tenant_id: str, field_data: Dict[str, Any]) -> int:
"""Save or update a custom AR field"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
with get_db() as conn:
cursor = conn.cursor()
@@ -539,6 +567,9 @@ def save_custom_ar_field(tenant_id: str, field_data: Dict[str, Any]) -> int:
def delete_custom_ar_field(tenant_id: str, field_id: int):
"""Delete a custom AR field"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('''
@@ -549,17 +580,38 @@ def delete_custom_ar_field(tenant_id: str, field_id: int):
def init_default_ar_fields(tenant_id: str):
"""Initialize default AR fields for a new tenant"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
default_fields = [
{"fieldName": "_id", "label": "Item ID", "fieldType": "TEXT", "editable": "false", "displayOrder": 1},
{"fieldName": "_price", "label": "Sale Price", "fieldType": "TEXT", "editable": "true", "displayOrder": 2},
{"fieldName": "_image", "label": "Image", "fieldType": "IMAGE_URI", "editable": "false", "displayOrder": 3}
]
- for field in default_fields:
- save_custom_ar_field(tenant_id, field)
+ with get_db() as conn:
+ cursor = conn.cursor()
+ for field in default_fields:
+ # Use INSERT OR IGNORE to avoid duplicate key errors
+ cursor.execute('''
+ INSERT OR IGNORE INTO custom_ar_fields
+ (tenant_id, field_name, label, field_type, editable, display_order)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ''', (
+ tenant_id,
+ field['fieldName'],
+ field['label'],
+ field['fieldType'],
+ field.get('editable', 'true'),
+ field.get('displayOrder', 0)
+ ))
+ conn.commit()
def save_product_image(product_id: str, tenant_id: str, field_name: str, image_data: bytes, mime_type: str):
"""Save an image for a specific field of a product"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
with get_db() as conn:
cursor = conn.cursor()
@@ -579,6 +631,9 @@ def save_product_image(product_id: str, tenant_id: str, field_name: str, image_d
def get_product_image_by_field(product_id: str, tenant_id: str, field_name: str) -> Optional[tuple[bytes, str]]:
"""Get image data for a specific field of a product"""
+ # Normalize tenant_id to lowercase
+ tenant_id = tenant_id.lower()
+
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('''
diff --git a/src/templates/tenant_selection.html b/src/templates/tenant_selection.html
index 4dd29ed..5e2012f 100644
--- a/src/templates/tenant_selection.html
+++ b/src/templates/tenant_selection.html
@@ -90,11 +90,14 @@
e.preventDefault();
const tenantId = document.getElementById('tenantId').value;
if (tenantId) {
- if (reservedIds.includes(tenantId.toLowerCase())) {
+ // Convert to lowercase for consistency
+ const normalizedId = tenantId.toLowerCase();
+ if (reservedIds.includes(normalizedId)) {
alert(`"${tenantId}" is a reserved name and cannot be used as a tenant ID.`);
return;
}
- window.location.href = '/' + tenantId + '/';
+ // Use the normalized (lowercase) ID in the URL
+ window.location.href = '/' + normalizedId + '/';
}
});
From 6dc276a2925d45fa0bb7be8201605632e9af8115 Mon Sep 17 00:00:00 2001
From: Matt Hills
Date: Wed, 25 Jun 2025 18:11:12 -0400
Subject: [PATCH 04/11] fixing missing barcodes
---
src/app.py | 49 +++++++++-
src/templates/admin/generate_barcode.html | 4 +-
src/templates/index.html | 89 ++++++++++++------
src/templates/tenant_settings.html | 104 ++++++++++++++++++++++
4 files changed, 216 insertions(+), 30 deletions(-)
create mode 100644 src/templates/tenant_settings.html
diff --git a/src/app.py b/src/app.py
index af741a4..2842682 100644
--- a/src/app.py
+++ b/src/app.py
@@ -70,7 +70,46 @@ def tenant_index(tenant_id):
if tenant is None:
# Reserved tenant ID or invalid
return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404
- return render_template('index.html', tenant=tenant)
+
+ # Load products for this tenant
+ products = load_products(tenant_id)
+
+ return render_template('index.html', tenant=tenant, products=products)
+
+@app.route('//settings')
+def tenant_settings(tenant_id):
+ # Get tenant
+ tenant = database.get_or_create_tenant(tenant_id)
+ if tenant is None:
+ return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404
+
+ # Get custom AR fields for this tenant
+ custom_fields = database.get_custom_ar_fields(tenant_id)
+
+ # Get server URL for API endpoint display
+ server_url = database.get_server_url()
+
+ return render_template('tenant_settings.html', tenant=tenant, custom_fields=custom_fields, server_url=server_url)
+
+@app.route('//settings/credentials', methods=['POST'])
+def update_tenant_credentials(tenant_id):
+ # Get tenant
+ tenant = database.get_tenant(tenant_id)
+ if tenant is None:
+ return jsonify({"error": "Tenant not found"}), 404
+
+ username = request.form.get('username', '').strip()
+ password = request.form.get('password', '').strip()
+
+ if not username:
+ flash('Username is required.', 'error')
+ return redirect(f'/{tenant_id}/settings')
+
+ # Update credentials
+ database.update_tenant_credentials(tenant_id, username, password if password else None)
+
+ flash('Credentials updated successfully.', 'success')
+ return redirect(f'/{tenant_id}/settings')
def check_basic_auth(auth_header, tenant_id):
"""Validate Basic authentication credentials for a tenant"""
@@ -119,10 +158,14 @@ def get_ar_info(tenant_id):
def filter_and_process_fields(product_fields):
# Filter to only include fields defined in custom AR fields
filtered_fields = []
+
+ # Get field types for all custom fields
+ field_types = {f['fieldName']: f['fieldType'] for f in custom_fields}
+
for field in product_fields:
if field['fieldName'] in custom_field_names:
- # Create absolute URL for image fields
- if field['fieldName'] == '_image' and field['value']:
+ # Create absolute URL for IMAGE_URI fields
+ if field_types.get(field['fieldName']) == 'IMAGE_URI' and field['value']:
# If it's already an absolute URL, leave it as is
if not field['value'].startswith('http'):
# Build absolute URL using request host with tenant
diff --git a/src/templates/admin/generate_barcode.html b/src/templates/admin/generate_barcode.html
index 0615bf5..19a0d8c 100644
--- a/src/templates/admin/generate_barcode.html
+++ b/src/templates/admin/generate_barcode.html
@@ -86,8 +86,8 @@
diff --git a/src/templates/index.html b/src/templates/index.html
index b20730a..43ccb8f 100644
--- a/src/templates/index.html
+++ b/src/templates/index.html
@@ -5,37 +5,76 @@
{% block content %}
-
+
+
KCAP Demo Server{% if tenant %} - {{ tenant.name }}{% endif %}
Manage your product catalog and AR content for barcode scanning applications.
-
Quick Actions
-
-
-
-
-
Product Management
-
Add, edit, and manage your product catalog with custom fields.
- {% if tenant %}
-
Manage Products
- {% endif %}
-
-
-
-
-
-
-
AR Field Configuration
-
Customize the fields returned by AR content endpoints.
- {% if tenant %}
-
Configure AR Fields
- {% endif %}
-
-
-
+
+
+ {% if products %}
+
+
+
+
+ Product ID
+ Name
+ Price
+ Actions
+
+
+
+ {% for product_id, product_data in products.items() %}
+
+ {{ product_id }}
+
+ {% for field in product_data %}
+ {% if field.fieldName == '_name' %}
+ {{ field.value }}
+ {% endif %}
+ {% endfor %}
+
+
+ {% for field in product_data %}
+ {% if field.fieldName == '_price' %}
+ {{ field.value }}
+ {% endif %}
+ {% endfor %}
+
+
+
+ Edit
+
+
+ Barcodes
+
+
+
+ {% endfor %}
+
+
+
+
+ {% else %}
+
+ No products available yet. Click "Add New Product" to get started.
+
+ {% endif %}
diff --git a/src/templates/tenant_settings.html b/src/templates/tenant_settings.html
new file mode 100644
index 0000000..d6270ef
--- /dev/null
+++ b/src/templates/tenant_settings.html
@@ -0,0 +1,104 @@
+{% extends "layout.html" %}
+
+{% block title %}Settings - {{ tenant.name }} - KCAP Demo Server{% endblock %}
+
+{% block content %}
+
+
+
+
Settings - {{ tenant.name }}
+
+ Back to Dashboard
+
+
+
+
+
+
+
+
+
+
Configure the username and password for device authentication.
+
+
+
+
+
+
+
+
+
+
+
Manage the fields that are returned in the AR content API.
+
+ Manage AR Fields
+
+
+ {% if custom_fields %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+ Tenant ID:
+ {{ tenant.id }}
+
+ Tenant Name:
+ {{ tenant.name }}
+
+ API Endpoint:
+ {{ server_url }}/{{ tenant.id }}/arinfo
+
+ Created:
+ {{ tenant.created_at }}
+
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
From 92eb45fc8ca2cf00638453b14306ba6b7da820f1 Mon Sep 17 00:00:00 2001
From: Matt Hills
Date: Mon, 20 Oct 2025 16:45:58 -0400
Subject: [PATCH 05/11] Fix product image not saving/displaying in tenant admin
Fixed two issues with product images:
1. Images weren't being preserved when editing other product fields - added logic to keep existing image value when no new image is uploaded
2. Image URLs had double underscores (e.g., /images/123__image.jpg) due to field names starting with underscore - now strips leading underscore from field name when constructing image paths
---
src/routes/admin.py | 19 +++++++++++--------
1 file changed, 11 insertions(+), 8 deletions(-)
diff --git a/src/routes/admin.py b/src/routes/admin.py
index 7bb087e..82c692c 100644
--- a/src/routes/admin.py
+++ b/src/routes/admin.py
@@ -110,8 +110,9 @@ def add_product(tenant_id):
'mime_type': mime_type
})
- # Set the image URL path
- image_path = f"/images/{product_id}_{field_name}.{extension}"
+ # Set the image URL path (strip leading underscore from field_name to avoid double underscores)
+ field_suffix = field_name[1:] if field_name.startswith('_') else field_name
+ image_path = f"/images/{product_id}_{field_suffix}.{extension}"
# For backward compatibility with _image field
if field_name == '_image':
@@ -231,7 +232,7 @@ def edit_product(tenant_id, product_id):
# Read image data
file.seek(0)
img_data = file.read()
-
+
# Determine mime type
extension = file.filename.rsplit('.', 1)[1].lower()
mime_types = {
@@ -241,21 +242,23 @@ def edit_product(tenant_id, product_id):
'gif': 'image/gif'
}
mime_type = mime_types.get(extension, 'image/jpeg')
-
+
# Store for later saving
images_to_save.append({
'field_name': field_name,
'data': img_data,
'mime_type': mime_type
})
-
- # Update image path
- existing_field["value"] = f"/images/{product_id}_{field_name}.{extension}"
-
+
+ # Update image path (strip leading underscore from field_name to avoid double underscores)
+ field_suffix = field_name[1:] if field_name.startswith('_') else field_name
+ existing_field["value"] = f"/images/{product_id}_{field_suffix}.{extension}"
+
# For backward compatibility with _image field
if field_name == '_image':
image_data = img_data
image_mime_type = mime_type
+ # If no new image uploaded, keep the existing value (already in existing_field)
else:
# Get value from form
value = request.form.get(f'field_{field_name}', '')
From f781cdf602c6ee8e593ee9f779c6ace20d5e752f Mon Sep 17 00:00:00 2001
From: Matt Hills
Date: Mon, 20 Oct 2025 17:11:26 -0400
Subject: [PATCH 06/11] Add dynamic custom field display and remove redundant
admin routes
Major improvements to tenant management:
1. Dynamic field display - Product lists now automatically show all custom fields based on tenant configuration (e.g., inventory, custom attributes)
2. Fixed Jinja2 template scoping issue - Product field values now correctly display in edit forms using selectattr filter
3. Simplified URL structure - Removed redundant /tenant/admin/ routes, all product management now at /tenant/ root
4. Enhanced tenant landing page - Added delete functionality with confirmation modal, shows all custom fields except images
5. Updated all routes and redirects to use simplified paths (/tenant/add, /tenant/edit, etc.)
This consolidates the interface so /tenant/ serves as the main dashboard with full product management capabilities, while /tenant/settings handles configuration.
---
src/app.py | 30 +++----
src/routes/admin.py | 37 ++++-----
src/templates/admin/add_product.html | 4 +-
src/templates/admin/edit_product.html | 17 ++--
src/templates/admin/generate_barcode.html | 2 +-
src/templates/admin/index.html | 58 +++++++-------
src/templates/index.html | 97 +++++++++++++++++------
7 files changed, 142 insertions(+), 103 deletions(-)
diff --git a/src/app.py b/src/app.py
index 2842682..456a73a 100644
--- a/src/app.py
+++ b/src/app.py
@@ -70,11 +70,12 @@ def tenant_index(tenant_id):
if tenant is None:
# Reserved tenant ID or invalid
return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404
-
+
# Load products for this tenant
products = load_products(tenant_id)
-
- return render_template('index.html', tenant=tenant, products=products)
+ custom_fields = database.get_custom_ar_fields(tenant_id)
+
+ return render_template('index.html', tenant=tenant, products=products, custom_fields=custom_fields)
@app.route('//settings')
def tenant_settings(tenant_id):
@@ -292,21 +293,16 @@ def serve_barcode(tenant_id, filename):
app.logger.error(f"Barcode generation error: {str(e)}")
return jsonify({"error": "Failed to generate barcode"}), 500
-# Import the admin routes directly and register them with tenant support
-from routes.admin import (index as admin_index, add_product, edit_product,
- delete_product, view_product, generate_barcode,
- generate_barcode_page, manage_credentials, manage_ar_fields)
+# Import product management routes
+from routes.admin import (add_product, edit_product, delete_product,
+ generate_barcode, generate_barcode_page)
-# Register admin routes with tenant prefix
-app.add_url_rule('//admin/', 'admin.index', admin_index)
-app.add_url_rule('//admin/add', 'admin.add_product', add_product, methods=['GET', 'POST'])
-app.add_url_rule('//admin/edit/', 'admin.edit_product', edit_product, methods=['GET', 'POST'])
-app.add_url_rule('//admin/delete/', 'admin.delete_product', delete_product, methods=['POST'])
-app.add_url_rule('//admin/view/', 'admin.view_product', view_product)
-app.add_url_rule('//admin/generate_barcode//', 'admin.generate_barcode', generate_barcode)
-app.add_url_rule('//admin/generate_barcode_page/', 'admin.generate_barcode_page', generate_barcode_page)
-app.add_url_rule('//admin/credentials', 'admin.manage_credentials', manage_credentials, methods=['GET', 'POST'])
-app.add_url_rule('//admin/ar_fields', 'admin.manage_ar_fields', manage_ar_fields, methods=['GET', 'POST'])
+# Register product management routes (remove /admin/ from paths)
+app.add_url_rule('//add', 'admin.add_product', add_product, methods=['GET', 'POST'])
+app.add_url_rule('//edit/', 'admin.edit_product', edit_product, methods=['GET', 'POST'])
+app.add_url_rule('//delete/', 'admin.delete_product', delete_product, methods=['POST'])
+app.add_url_rule('//generate_barcode//', 'admin.generate_barcode', generate_barcode)
+app.add_url_rule('//generate_barcode_page/', 'admin.generate_barcode_page', generate_barcode_page)
# Register API routes with tenant prefix
from routes.api import api_index
diff --git a/src/routes/admin.py b/src/routes/admin.py
index 82c692c..1b24498 100644
--- a/src/routes/admin.py
+++ b/src/routes/admin.py
@@ -38,7 +38,8 @@ def save_products(products):
def index(tenant_id):
products = load_products(tenant_id)
tenant = database.get_or_create_tenant(tenant_id)
- return render_template('admin/index.html', products=products, tenant=tenant)
+ custom_fields = database.get_custom_ar_fields(tenant_id)
+ return render_template('admin/index.html', products=products, tenant=tenant, custom_fields=custom_fields)
def add_product(tenant_id):
# Get custom AR fields for this tenant
@@ -172,26 +173,26 @@ def add_product(tenant_id):
database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type'])
flash('Product added successfully!')
- return redirect(url_for('admin.index', tenant_id=tenant_id))
+ return redirect(f'/{tenant_id}/')
return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields)
def edit_product(tenant_id, product_id):
products = load_products(tenant_id)
custom_fields = database.get_custom_ar_fields(tenant_id)
-
+
if product_id not in products:
flash('Product not found.')
- return redirect(url_for('admin.index', tenant_id=tenant_id))
+ return redirect(f'/{tenant_id}/')
if request.method == 'POST':
# Handle backward compatibility image upload
image_data = None
image_mime_type = None
-
+
# Process images after product is saved
images_to_save = []
-
+
# Update fields based on form data
updated_product = []
@@ -295,7 +296,7 @@ def edit_product(tenant_id, product_id):
database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type'])
flash('Product updated successfully!')
- return redirect(url_for('admin.index', tenant_id=tenant_id))
+ return redirect(f'/{tenant_id}/')
return render_template('admin/edit_product.html',
product_id=product_id,
@@ -305,24 +306,24 @@ def edit_product(tenant_id, product_id):
def delete_product(tenant_id, product_id):
products = load_products(tenant_id)
-
+
if product_id not in products:
flash('Product not found.')
- return redirect(url_for('admin.index', tenant_id=tenant_id))
-
+ return redirect(f'/{tenant_id}/')
+
# Delete product from database (image is stored in DB)
database.delete_product(product_id, tenant_id)
-
+
flash('Product deleted successfully!')
- return redirect(url_for('admin.index', tenant_id=tenant_id))
+ return redirect(f'/{tenant_id}/')
def view_product(tenant_id, product_id):
products = load_products(tenant_id)
-
+
if product_id not in products:
flash('Product not found.')
- return redirect(url_for('admin.index', tenant_id=tenant_id))
-
+ return redirect(f'/{tenant_id}/')
+
return render_template('admin/view_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id)
def generate_barcode(tenant_id, product_id, code_type):
@@ -346,10 +347,10 @@ def generate_barcode(tenant_id, product_id, code_type):
def generate_barcode_page(tenant_id, product_id):
"""Show a page with different barcode options for a product"""
products = load_products(tenant_id)
-
+
if product_id not in products:
flash('Product not found.')
- return redirect(url_for('admin.index', tenant_id=tenant_id))
+ return redirect(f'/{tenant_id}/')
# Extract product data
product_data = {}
@@ -388,7 +389,7 @@ def manage_credentials(tenant_id):
if username and password:
database.update_tenant_credentials(tenant_id, username, password)
flash('Credentials updated successfully!')
- return redirect(url_for('admin.index', tenant_id=tenant_id))
+ return redirect(f'/{tenant_id}/')
else:
flash('Username and password are required.')
diff --git a/src/templates/admin/add_product.html b/src/templates/admin/add_product.html
index 099495a..d38843b 100644
--- a/src/templates/admin/add_product.html
+++ b/src/templates/admin/add_product.html
@@ -10,7 +10,7 @@
Add New Product