Refactor application to MVC architecture with Flask blueprints
Major architectural refactoring to improve code organization, maintainability, and scalability: - Implement MVC pattern with clear separation of concerns - Organize code into blueprints for different functional areas (main, tenant, admin, api) - Create models layer to encapsulate all database operations - Create services layer for business logic (auth, product, barcode) - Implement application factory pattern for better testability - Add environment-based configuration management - Remove old monolithic app.py and consolidate routes New structure: - app/models/ - Database operations (Tenant, Product, ARField, Settings) - app/services/ - Business logic (Auth, Product, Barcode services) - app/blueprints/ - Controllers organized by function - app/config.py - Environment configurations - run.py - Application entry point All routes remain backward compatible. No changes to API or templates functionality.
This commit is contained in:
49
src/app/__init__.py
Normal file
49
src/app/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
from flask import Flask, redirect
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
def create_app(config_name='development'):
|
||||
"""Application factory pattern"""
|
||||
app = Flask(__name__)
|
||||
|
||||
# Load configuration
|
||||
from app.config import config
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# Ensure data folder exists
|
||||
os.makedirs(app.config['DATA_FOLDER'], exist_ok=True)
|
||||
|
||||
# Custom converter for tenant IDs
|
||||
class TenantConverter(BaseConverter):
|
||||
regex = '[a-zA-Z0-9_-]+'
|
||||
|
||||
def to_python(self, value):
|
||||
# Convert to lowercase when parsing from URL
|
||||
return value.lower()
|
||||
|
||||
def to_url(self, value):
|
||||
# Convert to lowercase when generating URLs
|
||||
return value.lower()
|
||||
|
||||
app.url_map.converters['tenant'] = TenantConverter
|
||||
|
||||
# Initialize database
|
||||
with app.app_context():
|
||||
from app.models.base import init_database
|
||||
init_database()
|
||||
|
||||
# Register blueprints
|
||||
from app.blueprints import main_bp, tenant_bp, admin_bp, api_bp
|
||||
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(tenant_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
|
||||
# Catch-all route for admin without tenant - redirect to home
|
||||
@app.route('/admin/')
|
||||
@app.route('/admin/<path:path>')
|
||||
def redirect_admin_to_home(path=None):
|
||||
return redirect('/')
|
||||
|
||||
return app
|
||||
6
src/app/blueprints/__init__.py
Normal file
6
src/app/blueprints/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .main import main_bp
|
||||
from .tenant import tenant_bp
|
||||
from .admin import admin_bp
|
||||
from .api import api_bp
|
||||
|
||||
__all__ = ['main_bp', 'tenant_bp', 'admin_bp', 'api_bp']
|
||||
5
src/app/blueprints/admin/__init__.py
Normal file
5
src/app/blueprints/admin/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from flask import Blueprint
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/<tenant:tenant_id>')
|
||||
|
||||
from . import routes
|
||||
357
src/app/blueprints/admin/routes.py
Normal file
357
src/app/blueprints/admin/routes.py
Normal file
@@ -0,0 +1,357 @@
|
||||
from flask import render_template, request, redirect, url_for, flash, jsonify, current_app
|
||||
from . import admin_bp
|
||||
from app.models import TenantModel, ProductModel, ARFieldModel
|
||||
from app.services import ProductService
|
||||
|
||||
@admin_bp.route('/add', methods=['GET', 'POST'])
|
||||
def add_product(tenant_id):
|
||||
"""Add a new product"""
|
||||
custom_fields = ARFieldModel.get_all(tenant_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
product_id = request.form.get('product_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 = ProductModel.get_all(tenant_id)
|
||||
if product_id in products:
|
||||
flash('Product ID already exists.')
|
||||
return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields)
|
||||
|
||||
# Handle backward compatibility image upload
|
||||
image_data = None
|
||||
image_mime_type = None
|
||||
|
||||
# Create product data structure based on custom fields
|
||||
product_data = []
|
||||
|
||||
# Always include _id field
|
||||
product_data.append({
|
||||
"fieldName": "_id",
|
||||
"label": "Item ID",
|
||||
"value": product_id,
|
||||
"editable": "false",
|
||||
"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
|
||||
|
||||
if field['fieldType'] == 'IMAGE_URI':
|
||||
image_field_name = f'image_{field_name}'
|
||||
if image_field_name in request.files:
|
||||
file = request.files[image_field_name]
|
||||
allowed_extensions = current_app.config['ALLOWED_EXTENSIONS']
|
||||
if file and file.filename and ProductService.allowed_file(file.filename, allowed_extensions):
|
||||
file.seek(0)
|
||||
img_data = file.read()
|
||||
|
||||
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')
|
||||
|
||||
images_to_save.append({
|
||||
'field_name': field_name,
|
||||
'data': img_data,
|
||||
'mime_type': mime_type
|
||||
})
|
||||
|
||||
field_suffix = field_name[1:] if field_name.startswith('_') else field_name
|
||||
image_path = f"/images/{product_id}_{field_suffix}.{extension}"
|
||||
|
||||
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:
|
||||
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']
|
||||
})
|
||||
|
||||
# Handle backward compatibility fields
|
||||
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
|
||||
ProductModel.save(product_id, tenant_id, product_data, image_data, image_mime_type)
|
||||
|
||||
# Save additional images
|
||||
for img in images_to_save:
|
||||
ProductModel.save_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type'])
|
||||
|
||||
flash('Product added successfully!')
|
||||
return redirect(f'/{tenant_id}/')
|
||||
|
||||
return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields)
|
||||
|
||||
@admin_bp.route('/edit/<product_id>', methods=['GET', 'POST'])
|
||||
def edit_product(tenant_id, product_id):
|
||||
"""Edit an existing product"""
|
||||
products = ProductModel.get_all(tenant_id)
|
||||
custom_fields = ARFieldModel.get_all(tenant_id)
|
||||
|
||||
if product_id not in products:
|
||||
flash('Product not found.')
|
||||
return redirect(f'/{tenant_id}/')
|
||||
|
||||
if request.method == 'POST':
|
||||
image_data = None
|
||||
image_mime_type = None
|
||||
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':
|
||||
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]
|
||||
allowed_extensions = current_app.config['ALLOWED_EXTENSIONS']
|
||||
if ProductService.allowed_file(file.filename, allowed_extensions):
|
||||
file.seek(0)
|
||||
img_data = file.read()
|
||||
|
||||
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')
|
||||
|
||||
images_to_save.append({
|
||||
'field_name': field_name,
|
||||
'data': img_data,
|
||||
'mime_type': mime_type
|
||||
})
|
||||
|
||||
field_suffix = field_name[1:] if field_name.startswith('_') else field_name
|
||||
existing_field["value"] = f"/images/{product_id}_{field_suffix}.{extension}"
|
||||
|
||||
if field_name == '_image':
|
||||
image_data = img_data
|
||||
image_mime_type = mime_type
|
||||
else:
|
||||
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"] == "_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
|
||||
ProductModel.save(product_id, tenant_id, updated_product, image_data, image_mime_type)
|
||||
|
||||
# Save additional images
|
||||
for img in images_to_save:
|
||||
ProductModel.save_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type'])
|
||||
|
||||
flash('Product updated successfully!')
|
||||
return redirect(f'/{tenant_id}/')
|
||||
|
||||
return render_template('admin/edit_product.html',
|
||||
product_id=product_id,
|
||||
product=products[product_id],
|
||||
tenant_id=tenant_id,
|
||||
custom_fields=custom_fields)
|
||||
|
||||
@admin_bp.route('/delete/<product_id>', methods=['POST'])
|
||||
def delete_product(tenant_id, product_id):
|
||||
"""Delete a product"""
|
||||
products = ProductModel.get_all(tenant_id)
|
||||
|
||||
if product_id not in products:
|
||||
flash('Product not found.')
|
||||
return redirect(f'/{tenant_id}/')
|
||||
|
||||
ProductModel.delete(product_id, tenant_id)
|
||||
flash('Product deleted successfully!')
|
||||
return redirect(f'/{tenant_id}/')
|
||||
|
||||
@admin_bp.route('/generate_barcode/<product_id>/<code_type>')
|
||||
def generate_barcode(tenant_id, product_id, code_type):
|
||||
"""Redirect to the main barcode generation endpoint"""
|
||||
products = ProductModel.get_all(tenant_id)
|
||||
if product_id not in products:
|
||||
return jsonify({"error": "Product not found"}), 404
|
||||
|
||||
type_mapping = {
|
||||
'qrcode': 'qr',
|
||||
'ean13': 'ean13',
|
||||
'code128': 'code128'
|
||||
}
|
||||
|
||||
barcode_type = type_mapping.get(code_type, code_type)
|
||||
return redirect(f'/{tenant_id}/barcodes/{product_id}_{barcode_type}.png')
|
||||
|
||||
@admin_bp.route('/barcodes', methods=['GET'])
|
||||
def view_all_barcodes(tenant_id):
|
||||
"""Display all product barcodes for printing"""
|
||||
products = ProductModel.get_all(tenant_id)
|
||||
tenant = TenantModel.get_or_create(tenant_id)
|
||||
|
||||
barcode_type = tenant.get('barcode_type', 'qr')
|
||||
|
||||
return render_template('admin/all_barcodes.html',
|
||||
products=products,
|
||||
tenant=tenant,
|
||||
barcode_type=barcode_type)
|
||||
|
||||
@admin_bp.route('/ar_fields', methods=['GET', 'POST'])
|
||||
def manage_ar_fields(tenant_id):
|
||||
"""Manage custom AR content fields"""
|
||||
tenant = TenantModel.get_or_create(tenant_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'add':
|
||||
field_data = {
|
||||
'fieldName': request.form.get('fieldName'),
|
||||
'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']:
|
||||
ARFieldModel.save(tenant_id, field_data)
|
||||
flash('Custom field added successfully!')
|
||||
else:
|
||||
flash('Field name and label are required.')
|
||||
|
||||
elif action == 'delete':
|
||||
field_id = request.form.get('field_id')
|
||||
if field_id:
|
||||
ARFieldModel.delete(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']:
|
||||
ARFieldModel.save(tenant_id, field_data)
|
||||
flash('Custom field updated successfully!')
|
||||
else:
|
||||
flash('Field name and label are required.')
|
||||
|
||||
return redirect(url_for('admin.manage_ar_fields', tenant_id=tenant_id))
|
||||
|
||||
# Get existing custom fields
|
||||
custom_fields = ARFieldModel.get_all(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)
|
||||
5
src/app/blueprints/api/__init__.py
Normal file
5
src/app/blueprints/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from flask import Blueprint
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/<tenant:tenant_id>/api')
|
||||
|
||||
from . import routes
|
||||
7
src/app/blueprints/api/routes.py
Normal file
7
src/app/blueprints/api/routes.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from flask import jsonify
|
||||
from . import api_bp
|
||||
|
||||
@api_bp.route('/')
|
||||
def api_index(tenant_id):
|
||||
"""API home endpoint"""
|
||||
return jsonify({'message': 'API Home', 'tenant': tenant_id})
|
||||
5
src/app/blueprints/main/__init__.py
Normal file
5
src/app/blueprints/main/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from flask import Blueprint
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
from . import routes
|
||||
27
src/app/blueprints/main/routes.py
Normal file
27
src/app/blueprints/main/routes.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from flask import render_template, request, redirect, flash
|
||||
from . import main_bp
|
||||
from app.models import TenantModel, SettingsModel
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
"""Tenant selection page"""
|
||||
tenants = TenantModel.get_all()
|
||||
server_url = SettingsModel.get_server_url()
|
||||
return render_template('tenant_selection.html', tenants=tenants, server_url=server_url)
|
||||
|
||||
@main_bp.route('/settings', methods=['GET', 'POST'])
|
||||
def settings():
|
||||
"""Global settings page"""
|
||||
if request.method == 'POST':
|
||||
server_url = request.form.get('server_url', '').strip()
|
||||
if server_url:
|
||||
# Remove trailing slash for consistency
|
||||
server_url = server_url.rstrip('/')
|
||||
SettingsModel.set('server_url', server_url)
|
||||
flash('Server settings updated successfully.', 'success')
|
||||
else:
|
||||
flash('Please provide a valid server URL.', 'error')
|
||||
return redirect('/settings')
|
||||
|
||||
server_url = SettingsModel.get_server_url()
|
||||
return render_template('settings.html', server_url=server_url)
|
||||
5
src/app/blueprints/tenant/__init__.py
Normal file
5
src/app/blueprints/tenant/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from flask import Blueprint
|
||||
|
||||
tenant_bp = Blueprint('tenant', __name__, url_prefix='/<tenant:tenant_id>')
|
||||
|
||||
from . import routes
|
||||
224
src/app/blueprints/tenant/routes.py
Normal file
224
src/app/blueprints/tenant/routes.py
Normal file
@@ -0,0 +1,224 @@
|
||||
from flask import render_template, request, redirect, flash, jsonify, Response, current_app
|
||||
from . import tenant_bp
|
||||
from app.models import TenantModel, ProductModel, ARFieldModel, SettingsModel
|
||||
from app.services import AuthService, ProductService, BarcodeService
|
||||
import os
|
||||
|
||||
@tenant_bp.route('/')
|
||||
def index(tenant_id):
|
||||
"""Tenant home page - product listing"""
|
||||
tenant = TenantModel.get_or_create(tenant_id)
|
||||
if tenant is None:
|
||||
return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404
|
||||
|
||||
products = ProductModel.get_all(tenant_id)
|
||||
custom_fields = ARFieldModel.get_all(tenant_id)
|
||||
|
||||
return render_template('index.html', tenant=tenant, products=products, custom_fields=custom_fields)
|
||||
|
||||
@tenant_bp.route('/delete', methods=['POST'])
|
||||
def delete_tenant(tenant_id):
|
||||
"""Delete a tenant and all its data"""
|
||||
TenantModel.delete(tenant_id)
|
||||
flash(f'Tenant "{tenant_id}" has been deleted successfully.', 'success')
|
||||
return redirect('/')
|
||||
|
||||
@tenant_bp.route('/settings')
|
||||
def settings(tenant_id):
|
||||
"""Tenant settings page"""
|
||||
tenant = TenantModel.get_or_create(tenant_id)
|
||||
if tenant is None:
|
||||
return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404
|
||||
|
||||
custom_fields = ARFieldModel.get_all(tenant_id)
|
||||
server_url = SettingsModel.get_server_url()
|
||||
|
||||
return render_template('tenant_settings.html', tenant=tenant, custom_fields=custom_fields, server_url=server_url)
|
||||
|
||||
@tenant_bp.route('/settings/credentials', methods=['POST'])
|
||||
def update_credentials(tenant_id):
|
||||
"""Update tenant credentials"""
|
||||
tenant = TenantModel.get_by_id(tenant_id)
|
||||
if tenant is None:
|
||||
return jsonify({"error": "Tenant not found"}), 404
|
||||
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '').strip()
|
||||
|
||||
if not username:
|
||||
flash('Username is required.', 'error')
|
||||
return redirect(f'/{tenant_id}/settings')
|
||||
|
||||
TenantModel.update_credentials(tenant_id, username, password if password else None)
|
||||
flash('Credentials updated successfully.', 'success')
|
||||
return redirect(f'/{tenant_id}/settings')
|
||||
|
||||
@tenant_bp.route('/settings/barcode', methods=['POST'])
|
||||
def update_barcode_type(tenant_id):
|
||||
"""Update tenant barcode type"""
|
||||
tenant = TenantModel.get_by_id(tenant_id)
|
||||
if tenant is None:
|
||||
return jsonify({"error": "Tenant not found"}), 404
|
||||
|
||||
barcode_type = request.form.get('barcode_type', '').strip()
|
||||
|
||||
if not barcode_type:
|
||||
flash('Barcode type is required.', 'error')
|
||||
return redirect(f'/{tenant_id}/settings')
|
||||
|
||||
TenantModel.update_barcode_type(tenant_id, barcode_type)
|
||||
flash('Barcode type updated successfully.', 'success')
|
||||
return redirect(f'/{tenant_id}/settings')
|
||||
|
||||
@tenant_bp.route('/login', methods=['GET'])
|
||||
def login(tenant_id):
|
||||
"""Login endpoint for API authentication"""
|
||||
auth_header = request.headers.get('Authorization')
|
||||
|
||||
if AuthService.check_basic_auth(auth_header, tenant_id):
|
||||
return jsonify({"status": "success", "message": "Authentication successful"}), 200
|
||||
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
@tenant_bp.route('/arcontentfields', methods=['GET'])
|
||||
def get_ar_content_fields(tenant_id):
|
||||
"""Get custom AR fields for tenant"""
|
||||
fields = ARFieldModel.get_all(tenant_id)
|
||||
return jsonify(fields), 200
|
||||
|
||||
@tenant_bp.route('/arinfo', methods=['GET', 'POST'])
|
||||
def get_ar_info(tenant_id):
|
||||
"""Get or update AR product information"""
|
||||
barcode = request.args.get('barcode')
|
||||
|
||||
# Handle POST request - update product fields
|
||||
if request.method == 'POST':
|
||||
if not barcode:
|
||||
return jsonify({"error": "Barcode parameter required"}), 400
|
||||
|
||||
product = ProductModel.get_by_id(barcode, tenant_id)
|
||||
if not product:
|
||||
return jsonify({"error": "Product not found"}), 404
|
||||
|
||||
try:
|
||||
updated_fields = request.get_json()
|
||||
if not isinstance(updated_fields, list):
|
||||
return jsonify({"error": "Request body must be an array of fields"}), 400
|
||||
|
||||
# Update only the editable fields
|
||||
for updated_field in updated_fields:
|
||||
field_name = updated_field.get('fieldName')
|
||||
new_value = updated_field.get('value')
|
||||
|
||||
for field in product:
|
||||
if field['fieldName'] == field_name:
|
||||
if field.get('editable') == 'true':
|
||||
field['value'] = new_value
|
||||
break
|
||||
|
||||
# Save the updated product
|
||||
ProductModel.save(barcode, tenant_id, product)
|
||||
current_app.logger.info(f"Updated product {barcode} for tenant {tenant_id}")
|
||||
return jsonify({"success": True}), 200
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error updating product: {str(e)}")
|
||||
return jsonify({"error": "Failed to update product"}), 500
|
||||
|
||||
# Handle GET request - return product data
|
||||
if barcode:
|
||||
product_data = ProductService.get_product_filtered(barcode, tenant_id)
|
||||
if product_data:
|
||||
response = jsonify(product_data)
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response, 200
|
||||
return jsonify({"error": "Product not found"}), 404
|
||||
|
||||
# Return all products if no barcode specified
|
||||
all_products = ProductService.get_all_products_filtered(tenant_id)
|
||||
response = jsonify(all_products)
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response, 200
|
||||
|
||||
@tenant_bp.route('/images/<path:filename>', methods=['GET'])
|
||||
def serve_image(tenant_id, filename):
|
||||
"""Serve product images"""
|
||||
current_app.logger.info(f"Image requested for tenant {tenant_id}: {filename}")
|
||||
|
||||
# Check if filename contains field name (e.g., "123456_thumbnail.jpg")
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
|
||||
if '_' in base_name:
|
||||
parts = base_name.split('_', 1)
|
||||
product_id = parts[0]
|
||||
field_name = '_' + parts[1] if len(parts) > 1 else '_image'
|
||||
|
||||
# Try to get field-specific image
|
||||
image_data = ProductModel.get_image_by_field(product_id, tenant_id, field_name)
|
||||
if image_data:
|
||||
image_bytes, mime_type = image_data
|
||||
response = Response(image_bytes, mimetype=mime_type)
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
response.headers['Cache-Control'] = 'public, max-age=3600'
|
||||
current_app.logger.info(f"Serving field-specific image: {product_id}/{field_name} ({len(image_bytes)} bytes)")
|
||||
return response
|
||||
|
||||
# Try standard image lookup (backward compatibility)
|
||||
product_id = os.path.splitext(filename)[0]
|
||||
image_data = ProductModel.get_image(product_id, tenant_id)
|
||||
if image_data:
|
||||
image_bytes, mime_type = image_data
|
||||
response = Response(image_bytes, mimetype=mime_type)
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
response.headers['Cache-Control'] = 'public, max-age=3600'
|
||||
current_app.logger.info(f"Serving image from database: {product_id} ({len(image_bytes)} bytes)")
|
||||
return response
|
||||
|
||||
current_app.logger.warning(f"Image not found: {filename}")
|
||||
return jsonify({"error": "Image not found"}), 404
|
||||
|
||||
@tenant_bp.route('/qrcode/template', methods=['GET'])
|
||||
def generate_template_qr_code(tenant_id):
|
||||
"""Generate QR code for the AR Template URL"""
|
||||
try:
|
||||
server_url = SettingsModel.get_server_url()
|
||||
template_url = f"{server_url}/{tenant_id}/"
|
||||
buffer = BarcodeService.generate_qr_code(template_url)
|
||||
return Response(buffer.getvalue(), mimetype='image/png')
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"QR code generation error: {str(e)}")
|
||||
return jsonify({"error": "Failed to generate QR code"}), 500
|
||||
|
||||
@tenant_bp.route('/qrcode/arinfo', methods=['GET'])
|
||||
def generate_ar_qr_code(tenant_id):
|
||||
"""Generate QR code for the AR API endpoint"""
|
||||
try:
|
||||
server_url = SettingsModel.get_server_url()
|
||||
ar_url = f"{server_url}/{tenant_id}/arinfo"
|
||||
buffer = BarcodeService.generate_qr_code(ar_url)
|
||||
return Response(buffer.getvalue(), mimetype='image/png')
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"QR code generation error: {str(e)}")
|
||||
return jsonify({"error": "Failed to generate QR code"}), 500
|
||||
|
||||
@tenant_bp.route('/barcodes/<path:filename>', methods=['GET'])
|
||||
def serve_barcode(tenant_id, filename):
|
||||
"""Serve dynamically generated barcodes"""
|
||||
name_parts = os.path.splitext(filename)[0].split('_')
|
||||
if len(name_parts) < 2:
|
||||
return jsonify({"error": "Invalid barcode filename format"}), 400
|
||||
|
||||
product_id = '_'.join(name_parts[:-1])
|
||||
code_type = name_parts[-1].lower()
|
||||
|
||||
try:
|
||||
url = None
|
||||
if code_type == 'qr':
|
||||
url = f'http://{request.host}/{tenant_id}/arinfo?barcode={product_id}'
|
||||
|
||||
buffer = BarcodeService.generate_barcode(product_id, code_type, url)
|
||||
return Response(buffer.getvalue(), mimetype='image/png')
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Barcode generation error: {str(e)}")
|
||||
return jsonify({"error": "Failed to generate barcode"}), 500
|
||||
43
src/app/config.py
Normal file
43
src/app/config.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import os
|
||||
|
||||
class Config:
|
||||
"""Base configuration"""
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key-for-demo-only'
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA_FOLDER = os.path.join(BASE_DIR, 'data')
|
||||
DATABASE_PATH = os.path.join(DATA_FOLDER, 'products.db')
|
||||
|
||||
# Reserved tenant IDs that cannot be used
|
||||
RESERVED_TENANT_IDS = {
|
||||
'admin', 'api', 'login', 'logout', 'arcontentfields', 'arinfo',
|
||||
'images', 'barcodes', 'static', 'assets', 'js', 'css', 'tenant',
|
||||
'auth', 'oauth', 'callback', 'webhook', 'health', 'status'
|
||||
}
|
||||
|
||||
# File upload settings
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||||
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration"""
|
||||
DEBUG = True
|
||||
TESTING = False
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production configuration"""
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""Testing configuration"""
|
||||
DEBUG = True
|
||||
TESTING = True
|
||||
DATABASE_PATH = ':memory:'
|
||||
|
||||
# Configuration dictionary
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'testing': TestingConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
||||
6
src/app/models/__init__.py
Normal file
6
src/app/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .tenant import TenantModel
|
||||
from .product import ProductModel
|
||||
from .ar_field import ARFieldModel
|
||||
from .settings import SettingsModel
|
||||
|
||||
__all__ = ['TenantModel', 'ProductModel', 'ARFieldModel', 'SettingsModel']
|
||||
84
src/app/models/ar_field.py
Normal file
84
src/app/models/ar_field.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from typing import Dict, List, Any
|
||||
from .base import get_db
|
||||
|
||||
class ARFieldModel:
|
||||
"""Model for custom AR field operations"""
|
||||
|
||||
@staticmethod
|
||||
def get_all(tenant_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all custom AR fields for a tenant"""
|
||||
tenant_id = tenant_id.lower()
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT id, field_name, label, field_type, editable, display_order
|
||||
FROM custom_ar_fields
|
||||
WHERE tenant_id = ?
|
||||
ORDER BY display_order, id
|
||||
''', (tenant_id,))
|
||||
|
||||
fields = []
|
||||
for row in cursor.fetchall():
|
||||
fields.append({
|
||||
'id': row['id'],
|
||||
'fieldName': row['field_name'],
|
||||
'label': row['label'],
|
||||
'fieldType': row['field_type'],
|
||||
'editable': row['editable'],
|
||||
'displayOrder': row['display_order']
|
||||
})
|
||||
|
||||
return fields
|
||||
|
||||
@staticmethod
|
||||
def save(tenant_id: str, field_data: Dict[str, Any]):
|
||||
"""Save or update a custom AR field"""
|
||||
tenant_id = tenant_id.lower()
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if 'id' in field_data and field_data['id']:
|
||||
# Update existing field
|
||||
cursor.execute('''
|
||||
UPDATE custom_ar_fields
|
||||
SET field_name = ?, label = ?, field_type = ?, editable = ?,
|
||||
display_order = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND tenant_id = ?
|
||||
''', (
|
||||
field_data['fieldName'],
|
||||
field_data['label'],
|
||||
field_data['fieldType'],
|
||||
field_data['editable'],
|
||||
field_data['displayOrder'],
|
||||
field_data['id'],
|
||||
tenant_id
|
||||
))
|
||||
else:
|
||||
# Insert new field
|
||||
cursor.execute('''
|
||||
INSERT INTO custom_ar_fields
|
||||
(tenant_id, field_name, label, field_type, editable, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
tenant_id,
|
||||
field_data['fieldName'],
|
||||
field_data['label'],
|
||||
field_data['fieldType'],
|
||||
field_data['editable'],
|
||||
field_data['displayOrder']
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def delete(tenant_id: str, field_id: int):
|
||||
"""Delete a custom AR field"""
|
||||
tenant_id = tenant_id.lower()
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM custom_ar_fields WHERE id = ? AND tenant_id = ?',
|
||||
(field_id, tenant_id))
|
||||
conn.commit()
|
||||
111
src/app/models/base.py
Normal file
111
src/app/models/base.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from flask import current_app
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
"""Context manager for database connections"""
|
||||
db_path = current_app.config['DATABASE_PATH']
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def init_database():
|
||||
"""Initialize the database with required tables"""
|
||||
import os
|
||||
db_path = current_app.config['DATABASE_PATH']
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create tenants table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
barcode_type TEXT DEFAULT 'qr',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# Create products table with tenant_id
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id TEXT,
|
||||
tenant_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
price TEXT,
|
||||
inventory INTEGER,
|
||||
image_data BLOB,
|
||||
image_mime_type TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id, tenant_id),
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
# Create product_fields table with tenant_id
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS product_fields (
|
||||
product_id TEXT,
|
||||
tenant_id TEXT,
|
||||
field_name TEXT,
|
||||
label TEXT,
|
||||
value TEXT,
|
||||
editable TEXT,
|
||||
field_type TEXT,
|
||||
FOREIGN KEY (product_id, tenant_id) REFERENCES products(id, tenant_id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (product_id, tenant_id, field_name)
|
||||
)
|
||||
''')
|
||||
|
||||
# Create custom_ar_fields table for tenant-specific AR field definitions
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS custom_ar_fields (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tenant_id TEXT NOT NULL,
|
||||
field_name TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
field_type TEXT NOT NULL,
|
||||
editable TEXT DEFAULT 'true',
|
||||
display_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
UNIQUE(tenant_id, field_name)
|
||||
)
|
||||
''')
|
||||
|
||||
# Create product_images table for storing multiple images per product
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS product_images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
field_name TEXT NOT NULL,
|
||||
image_data BLOB NOT NULL,
|
||||
image_mime_type TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (product_id, tenant_id) REFERENCES products(id, tenant_id) ON DELETE CASCADE,
|
||||
UNIQUE(product_id, tenant_id, field_name)
|
||||
)
|
||||
''')
|
||||
|
||||
# Create settings table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
192
src/app/models/product.py
Normal file
192
src/app/models/product.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from .base import get_db
|
||||
|
||||
class ProductModel:
|
||||
"""Model for product operations"""
|
||||
|
||||
@staticmethod
|
||||
def get_all(tenant_id: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Get all products for a tenant in the legacy format"""
|
||||
tenant_id = tenant_id.lower()
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('SELECT id FROM products WHERE tenant_id = ?', (tenant_id,))
|
||||
product_ids = [row['id'] for row in cursor.fetchall()]
|
||||
|
||||
result = {}
|
||||
for product_id in product_ids:
|
||||
cursor.execute('''
|
||||
SELECT field_name, label, value, editable, field_type
|
||||
FROM product_fields
|
||||
WHERE product_id = ? AND tenant_id = ?
|
||||
ORDER BY field_name
|
||||
''', (product_id, tenant_id))
|
||||
|
||||
fields = []
|
||||
for row in cursor.fetchall():
|
||||
fields.append({
|
||||
'fieldName': row['field_name'],
|
||||
'label': row['label'],
|
||||
'value': row['value'],
|
||||
'editable': row['editable'],
|
||||
'fieldType': row['field_type']
|
||||
})
|
||||
|
||||
result[product_id] = fields
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(product_id: str, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Get a single product by ID and tenant"""
|
||||
tenant_id = tenant_id.lower()
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT field_name, label, value, editable, field_type
|
||||
FROM product_fields
|
||||
WHERE product_id = ? AND tenant_id = ?
|
||||
ORDER BY field_name
|
||||
''', (product_id, tenant_id))
|
||||
|
||||
fields = []
|
||||
for row in cursor.fetchall():
|
||||
fields.append({
|
||||
'fieldName': row['field_name'],
|
||||
'label': row['label'],
|
||||
'value': row['value'],
|
||||
'editable': row['editable'],
|
||||
'fieldType': row['field_type']
|
||||
})
|
||||
|
||||
return fields if fields else None
|
||||
|
||||
@staticmethod
|
||||
def save(product_id: str, tenant_id: str, fields: List[Dict[str, Any]],
|
||||
image_data: Optional[bytes] = None, image_mime_type: Optional[str] = None):
|
||||
"""Save or update a product for a tenant"""
|
||||
tenant_id = tenant_id.lower()
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Extract core fields
|
||||
name = ''
|
||||
price = ''
|
||||
inventory = None
|
||||
|
||||
for field in fields:
|
||||
if field['fieldName'] == '_name':
|
||||
name = field['value']
|
||||
elif field['fieldName'] == '_price':
|
||||
price = field['value']
|
||||
elif field['fieldName'] == '_inventory':
|
||||
inventory = int(field['value']) if field['value'] else None
|
||||
|
||||
# Check if product exists
|
||||
cursor.execute('SELECT id FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id))
|
||||
exists = cursor.fetchone() is not None
|
||||
|
||||
if exists:
|
||||
# Update existing product
|
||||
if image_data is not None:
|
||||
cursor.execute('''
|
||||
UPDATE products
|
||||
SET name = ?, price = ?, inventory = ?, image_data = ?, image_mime_type = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND tenant_id = ?
|
||||
''', (name, price, inventory, image_data, image_mime_type, product_id, tenant_id))
|
||||
else:
|
||||
cursor.execute('''
|
||||
UPDATE products
|
||||
SET name = ?, price = ?, inventory = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND tenant_id = ?
|
||||
''', (name, price, inventory, product_id, tenant_id))
|
||||
else:
|
||||
# Insert new product
|
||||
cursor.execute('''
|
||||
INSERT INTO products (id, tenant_id, name, price, inventory, image_data, image_mime_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (product_id, tenant_id, name, price, inventory, image_data, image_mime_type))
|
||||
|
||||
# Delete existing fields
|
||||
cursor.execute('DELETE FROM product_fields WHERE product_id = ? AND tenant_id = ?', (product_id, tenant_id))
|
||||
|
||||
# Insert all fields
|
||||
for field in fields:
|
||||
cursor.execute('''
|
||||
INSERT INTO product_fields
|
||||
(product_id, tenant_id, field_name, label, value, editable, field_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
product_id,
|
||||
tenant_id,
|
||||
field['fieldName'],
|
||||
field['label'],
|
||||
field['value'],
|
||||
field.get('editable', 'true'),
|
||||
field.get('fieldType', 'TEXT')
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def delete(product_id: str, tenant_id: str):
|
||||
"""Delete a product for a tenant"""
|
||||
tenant_id = tenant_id.lower()
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id))
|
||||
conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_image(product_id: str, tenant_id: str) -> Optional[Tuple[bytes, str]]:
|
||||
"""Get product image data and mime type for a tenant"""
|
||||
tenant_id = tenant_id.lower()
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT image_data, image_mime_type FROM products WHERE id = ? AND tenant_id = ?',
|
||||
(product_id, tenant_id))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row and row['image_data']:
|
||||
return row['image_data'], row['image_mime_type']
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def save_image(product_id: str, tenant_id: str, field_name: str,
|
||||
image_data: bytes, image_mime_type: str):
|
||||
"""Save an image for a specific product field"""
|
||||
tenant_id = tenant_id.lower()
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT OR REPLACE INTO product_images
|
||||
(product_id, tenant_id, field_name, image_data, image_mime_type)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (product_id, tenant_id, field_name, image_data, image_mime_type))
|
||||
conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_image_by_field(product_id: str, tenant_id: str, field_name: str) -> Optional[Tuple[bytes, str]]:
|
||||
"""Get image for a specific product field"""
|
||||
tenant_id = tenant_id.lower()
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT image_data, image_mime_type
|
||||
FROM product_images
|
||||
WHERE product_id = ? AND tenant_id = ? AND field_name = ?
|
||||
''', (product_id, tenant_id, field_name))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return row['image_data'], row['image_mime_type']
|
||||
return None
|
||||
30
src/app/models/settings.py
Normal file
30
src/app/models/settings.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from typing import Optional
|
||||
from .base import get_db
|
||||
|
||||
class SettingsModel:
|
||||
"""Model for application settings"""
|
||||
|
||||
@staticmethod
|
||||
def get(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||
"""Get a setting value by key"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT value FROM settings WHERE key = ?', (key,))
|
||||
row = cursor.fetchone()
|
||||
return row['value'] if row else default
|
||||
|
||||
@staticmethod
|
||||
def set(key: str, value: str):
|
||||
"""Set a setting value"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT OR REPLACE INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
''', (key, value))
|
||||
conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_server_url() -> str:
|
||||
"""Get server URL setting"""
|
||||
return SettingsModel.get('server_url', 'http://localhost:5555')
|
||||
98
src/app/models/tenant.py
Normal file
98
src/app/models/tenant.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from .base import get_db
|
||||
from flask import current_app
|
||||
|
||||
class TenantModel:
|
||||
"""Model for tenant operations"""
|
||||
|
||||
@staticmethod
|
||||
def get_all():
|
||||
"""Get all tenants"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM tenants ORDER BY created_at DESC')
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(tenant_id):
|
||||
"""Get tenant by ID"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM tenants WHERE id = ?', (tenant_id,))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
@staticmethod
|
||||
def create(tenant_id, name=None):
|
||||
"""Create a new tenant"""
|
||||
# Check if tenant_id is reserved
|
||||
if tenant_id.lower() in current_app.config['RESERVED_TENANT_IDS']:
|
||||
return None
|
||||
|
||||
if name is None:
|
||||
name = tenant_id
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(
|
||||
'INSERT INTO tenants (id, name, username, password) VALUES (?, ?, ?, ?)',
|
||||
(tenant_id, name, 'admin', 'password')
|
||||
)
|
||||
conn.commit()
|
||||
return TenantModel.get_by_id(tenant_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_or_create(tenant_id):
|
||||
"""Get existing tenant or create new one"""
|
||||
tenant = TenantModel.get_by_id(tenant_id)
|
||||
if tenant:
|
||||
return tenant
|
||||
return TenantModel.create(tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def update_credentials(tenant_id, username, password):
|
||||
"""Update tenant credentials"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'UPDATE tenants SET username = ?, password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
(username, password, tenant_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def update_barcode_type(tenant_id, barcode_type):
|
||||
"""Update tenant barcode type preference"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'UPDATE tenants SET barcode_type = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
(barcode_type, tenant_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def delete(tenant_id):
|
||||
"""Delete a tenant and all associated data"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM tenants WHERE id = ?', (tenant_id,))
|
||||
conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def cleanup_reserved():
|
||||
"""Clean up any accidentally created reserved tenants"""
|
||||
reserved = current_app.config['RESERVED_TENANT_IDS']
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
placeholders = ','.join('?' * len(reserved))
|
||||
cursor.execute(
|
||||
f'DELETE FROM tenants WHERE LOWER(id) IN ({placeholders})',
|
||||
tuple(reserved)
|
||||
)
|
||||
deleted_count = cursor.rowcount
|
||||
conn.commit()
|
||||
return deleted_count
|
||||
5
src/app/services/__init__.py
Normal file
5
src/app/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .auth_service import AuthService
|
||||
from .product_service import ProductService
|
||||
from .barcode_service import BarcodeService
|
||||
|
||||
__all__ = ['AuthService', 'ProductService', 'BarcodeService']
|
||||
25
src/app/services/auth_service.py
Normal file
25
src/app/services/auth_service.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import base64
|
||||
from app.models import TenantModel
|
||||
|
||||
class AuthService:
|
||||
"""Service for authentication logic"""
|
||||
|
||||
@staticmethod
|
||||
def check_basic_auth(auth_header: str, tenant_id: str) -> bool:
|
||||
"""Validate Basic authentication credentials for a tenant"""
|
||||
if not auth_header or not auth_header.startswith('Basic '):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Decode the base64 credentials
|
||||
credentials = base64.b64decode(auth_header[6:]).decode('utf-8')
|
||||
username, password = credentials.split(':', 1)
|
||||
|
||||
# Get tenant credentials from database (without creating)
|
||||
tenant = TenantModel.get_by_id(tenant_id)
|
||||
if tenant and username == tenant['username'] and password == tenant['password']:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
58
src/app/services/barcode_service.py
Normal file
58
src/app/services/barcode_service.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import qrcode
|
||||
import barcode
|
||||
from barcode.writer import ImageWriter
|
||||
from io import BytesIO
|
||||
|
||||
class BarcodeService:
|
||||
"""Service for barcode generation"""
|
||||
|
||||
@staticmethod
|
||||
def generate_qr_code(data: str) -> BytesIO:
|
||||
"""Generate QR code image"""
|
||||
buffer = BytesIO()
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
img.save(buffer, format='PNG')
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
@staticmethod
|
||||
def generate_ean13(product_id: str) -> BytesIO:
|
||||
"""Generate EAN-13 barcode"""
|
||||
buffer = BytesIO()
|
||||
# Convert product_id to numeric format if needed
|
||||
numeric_id = ''.join(filter(str.isdigit, product_id))
|
||||
if not numeric_id:
|
||||
numeric_id = str(abs(hash(product_id)) % 1000000000000)[:12]
|
||||
else:
|
||||
numeric_id = numeric_id[:12].zfill(12)
|
||||
|
||||
EAN = barcode.get_barcode_class('ean13')
|
||||
ean = EAN(numeric_id, writer=ImageWriter())
|
||||
ean.write(buffer)
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
@staticmethod
|
||||
def generate_code128(product_id: str) -> BytesIO:
|
||||
"""Generate Code 128 barcode"""
|
||||
buffer = BytesIO()
|
||||
CODE128 = barcode.get_barcode_class('code128')
|
||||
code = CODE128(product_id, writer=ImageWriter())
|
||||
code.write(buffer)
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
@staticmethod
|
||||
def generate_barcode(product_id: str, code_type: str, url: str = None) -> BytesIO:
|
||||
"""Generate barcode based on type"""
|
||||
if code_type == 'qr':
|
||||
return BarcodeService.generate_qr_code(url or product_id)
|
||||
elif code_type == 'ean13':
|
||||
return BarcodeService.generate_ean13(product_id)
|
||||
elif code_type == 'code128':
|
||||
return BarcodeService.generate_code128(product_id)
|
||||
else:
|
||||
raise ValueError(f"Unsupported barcode type: {code_type}")
|
||||
56
src/app/services/product_service.py
Normal file
56
src/app/services/product_service.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from typing import Dict, List, Any
|
||||
from app.models import ProductModel, ARFieldModel
|
||||
from flask import request
|
||||
|
||||
class ProductService:
|
||||
"""Service for product business logic"""
|
||||
|
||||
@staticmethod
|
||||
def filter_and_process_fields(product_fields: List[Dict[str, Any]],
|
||||
tenant_id: str,
|
||||
custom_fields: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Filter product fields to only include defined AR fields and process image URLs"""
|
||||
custom_field_names = [f['fieldName'] for f in custom_fields]
|
||||
field_types = {f['fieldName']: f['fieldType'] for f in custom_fields}
|
||||
filtered_fields = []
|
||||
|
||||
for field in product_fields:
|
||||
if field['fieldName'] in custom_field_names:
|
||||
# Create absolute URL for IMAGE_URI fields
|
||||
if field_types.get(field['fieldName']) == 'IMAGE_URI' and field['value']:
|
||||
# If it's already an absolute URL, leave it as is
|
||||
if not field['value'].startswith('http'):
|
||||
# Build absolute URL using request host with tenant
|
||||
field['value'] = f"{request.url_root.rstrip('/')}/{tenant_id}{field['value']}"
|
||||
filtered_fields.append(field)
|
||||
|
||||
return filtered_fields
|
||||
|
||||
@staticmethod
|
||||
def get_all_products_filtered(tenant_id: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Get all products with filtered fields"""
|
||||
products = ProductModel.get_all(tenant_id)
|
||||
custom_fields = ARFieldModel.get_all(tenant_id)
|
||||
|
||||
all_products = {}
|
||||
for product_id, fields in products.items():
|
||||
all_products[product_id] = ProductService.filter_and_process_fields(
|
||||
fields, tenant_id, custom_fields
|
||||
)
|
||||
|
||||
return all_products
|
||||
|
||||
@staticmethod
|
||||
def get_product_filtered(product_id: str, tenant_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get a single product with filtered fields"""
|
||||
product = ProductModel.get_by_id(product_id, tenant_id)
|
||||
if not product:
|
||||
return None
|
||||
|
||||
custom_fields = ARFieldModel.get_all(tenant_id)
|
||||
return ProductService.filter_and_process_fields(product, tenant_id, custom_fields)
|
||||
|
||||
@staticmethod
|
||||
def allowed_file(filename: str, allowed_extensions: set) -> bool:
|
||||
"""Check if file extension is allowed"""
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions
|
||||
122
src/app/templates/admin/add_product.html
Normal file
122
src/app/templates/admin/add_product.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Add New Product - KCAP Demo Server{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Add New Product</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/{{ tenant_id }}/add" method="POST" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label for="product_id" class="form-label">Product ID *</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="product_id" name="product_id" required>
|
||||
<button class="btn btn-outline-secondary" type="button" id="generate-id-btn" title="Generate Random ID">
|
||||
<i class="bi bi-magic"></i> Generate
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Must be unique. This will be used as the barcode for AR content.</div>
|
||||
</div>
|
||||
|
||||
{% for field in custom_fields %}
|
||||
{% if field.fieldName != '_id' %}
|
||||
<div class="mb-3">
|
||||
<label for="field_{{ field.fieldName }}" class="form-label">{{ field.label }}</label>
|
||||
{% if field.fieldType == 'IMAGE_URI' %}
|
||||
<input type="file" class="form-control" id="image_{{ field.fieldName }}" name="image_{{ field.fieldName }}" accept="image/*" onchange="previewImage(this, 'preview_{{ field.fieldName }}')">
|
||||
<div class="form-text">Supported formats: .png, .jpg, .jpeg, .gif</div>
|
||||
<div class="mt-2">
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/{{ tenant_id }}/" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Add Product</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function previewImage(input, previewId) {
|
||||
var preview = document.getElementById(previewId || 'image-preview');
|
||||
|
||||
if (input.files && input.files[0]) {
|
||||
var reader = new FileReader();
|
||||
|
||||
reader.onload = function(e) {
|
||||
preview.src = e.target.result;
|
||||
preview.style.display = 'block';
|
||||
}
|
||||
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
} else {
|
||||
preview.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a random product ID
|
||||
document.getElementById('generate-id-btn').addEventListener('click', function() {
|
||||
// Generate a random alphanumeric ID
|
||||
// Format: 3 letters followed by 4 numbers (e.g., ABC1234)
|
||||
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const numbers = '0123456789';
|
||||
|
||||
let id = '';
|
||||
|
||||
// Add 3 random uppercase letters
|
||||
for (let i = 0; i < 3; i++) {
|
||||
id += letters.charAt(Math.floor(Math.random() * letters.length));
|
||||
}
|
||||
|
||||
// Add 4 random numbers
|
||||
for (let i = 0; i < 4; i++) {
|
||||
id += numbers.charAt(Math.floor(Math.random() * numbers.length));
|
||||
}
|
||||
|
||||
// Set the generated ID in the input field
|
||||
document.getElementById('product_id').value = id;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
149
src/app/templates/admin/all_barcodes.html
Normal file
149
src/app/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/app/templates/admin/ar_fields.html
Normal file
170
src/app/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 %}
|
||||
92
src/app/templates/admin/credentials.html
Normal file
92
src/app/templates/admin/credentials.html
Normal file
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Manage Credentials - {{ tenant.name }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('admin.index', tenant_id=tenant_id) }}">{{ tenant.name }} Admin</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.index', tenant_id=tenant_id) }}">Products</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="{{ url_for('admin.manage_credentials', tenant_id=tenant_id) }}">Credentials</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/">Switch Tenant</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-5">
|
||||
<h1>Manage Login Credentials</h1>
|
||||
<p class="text-muted">These credentials will be used for KCAP authentication at <code>/{{ tenant_id }}/login</code></p>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-info alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Current Credentials</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>Username:</strong> {{ tenant.username }}</p>
|
||||
<p><strong>Password:</strong> {{ tenant.password }}</p>
|
||||
<p class="text-muted small">Note: In production, passwords should be encrypted and not displayed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Update Credentials</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" value="{{ tenant.username }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" placeholder="Enter new password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Update Credentials</button>
|
||||
<a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h3>Testing Instructions</h3>
|
||||
<p>To test the login endpoint with these credentials:</p>
|
||||
<pre class="bg-light p-3"><code>curl -u {{ tenant.username }}:{{ tenant.password }} http://{{ request.host }}/{{ tenant_id }}/login</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
122
src/app/templates/admin/edit_product.html
Normal file
122
src/app/templates/admin/edit_product.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Edit Product - KCAP Demo Server{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Edit Product</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/{{ tenant_id }}/edit/{{ product_id }}" method="POST" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label for="product_id" class="form-label">Product ID</label>
|
||||
<input type="text" class="form-control" id="product_id" value="{{ product_id }}" disabled>
|
||||
<div class="form-text">Product ID cannot be changed.</div>
|
||||
</div>
|
||||
|
||||
{% for field in custom_fields %}
|
||||
{% if field.fieldName != '_id' %}
|
||||
<div class="mb-3">
|
||||
<label for="field_{{ field.fieldName }}" class="form-label">{{ field.label }}</label>
|
||||
|
||||
{# Find current value using selectattr filter #}
|
||||
{% set matching_fields = product|selectattr('fieldName', 'equalto', field.fieldName)|list %}
|
||||
{% set current_value = matching_fields[0].value if matching_fields else '' %}
|
||||
|
||||
{% if field.fieldType == 'IMAGE_URI' %}
|
||||
<input type="file" class="form-control" id="image_{{ field.fieldName }}" name="image_{{ field.fieldName }}" accept="image/*" onchange="previewImage(this, 'preview_new_{{ field.fieldName }}')">
|
||||
<div class="form-text">Leave empty to keep the current image.</div>
|
||||
|
||||
{% if current_value %}
|
||||
<div class="mt-3">
|
||||
<label class="form-label">Current Image:</label>
|
||||
<div>
|
||||
<img src="/{{ tenant_id }}{{ current_value }}" alt="Current image" class="image-preview" style="max-width: 200px;">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="form-label">New Image Preview:</label>
|
||||
<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>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/{{ tenant_id }}/" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Update Product</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function previewImage(input, previewId) {
|
||||
var preview = document.getElementById(previewId || 'image-preview');
|
||||
|
||||
if (input.files && input.files[0]) {
|
||||
var reader = new FileReader();
|
||||
|
||||
reader.onload = function(e) {
|
||||
preview.src = e.target.result;
|
||||
preview.style.display = 'block';
|
||||
}
|
||||
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
} else {
|
||||
preview.style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
132
src/app/templates/admin/index.html
Normal file
132
src/app/templates/admin/index.html
Normal file
@@ -0,0 +1,132 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard - {{ tenant.name }} - KCAP Demo Server{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-12">
|
||||
<div class="alert alert-info">
|
||||
<strong>Current Tenant:</strong> {{ tenant.name }} (ID: {{ tenant.id }})
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('admin.manage_credentials', tenant_id=tenant.id) }}" class="btn btn-sm btn-secondary">Manage Credentials</a>
|
||||
<a href="{{ 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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Product Management</h1>
|
||||
<a href="{{ url_for('admin.add_product', tenant_id=tenant.id) }}" class="btn btn-primary">Add New Product</a>
|
||||
</div>
|
||||
|
||||
{% if products %}
|
||||
<table class="table table-striped table-bordered align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Product ID</th>
|
||||
{% for custom_field in custom_fields %}
|
||||
{% if custom_field.fieldName != '_id' %}
|
||||
<th>{{ custom_field.label }}</th>
|
||||
{% 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>
|
||||
{% for field in product_data %}
|
||||
{% if field.fieldName == custom_field.fieldName %}
|
||||
{% if custom_field.fieldType == 'IMAGE_URI' %}
|
||||
{% if field.value %}
|
||||
<div class="product-image-container">
|
||||
<img src="/{{ tenant.id }}{{ field.value }}" alt="{{ custom_field.label }}" class="product-image">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ field.value }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<td>
|
||||
<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.generate_barcode_page', tenant_id=tenant.id, product_id=product_id) }}" class="btn btn-sm btn-outline-success">Barcodes</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-product-id="{{ product_id }}"
|
||||
data-product-name="{% for field in product_data %}{% if field.fieldName == '_name' %}{{ field.value }}{% endif %}{% endfor %}">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<form id="delete-form-{{ product_id }}" action="{{ url_for('admin.delete_product', tenant_id=tenant.id, product_id=product_id) }}" method="POST" style="display: none;">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
No products available. Click "Add New Product" to get started.
|
||||
</div>
|
||||
{% endif %}
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// 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 %}
|
||||
202
src/app/templates/index.html
Normal file
202
src/app/templates/index.html
Normal file
@@ -0,0 +1,202 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Home - KCAP Demo Server{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-12">
|
||||
<div class="jumbotron position-relative">
|
||||
<div class="position-absolute top-0 end-0 p-3">
|
||||
<a href="/{{ tenant.id }}/settings" class="btn btn-secondary" title="Settings">
|
||||
<i class="bi bi-gear-fill"></i> Settings
|
||||
</a>
|
||||
</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="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>Products</h2>
|
||||
<div>
|
||||
<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 %}
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
96
src/app/templates/layout.html
Normal file
96
src/app/templates/layout.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}KCAP Demo Server{% endblock %}</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
.navbar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
}
|
||||
.card {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.image-preview {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* Product image styles for consistent display */
|
||||
.product-image-container {
|
||||
width: 100px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
max-width: 100px;
|
||||
max-height: 120px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Ensure consistent height for table rows */
|
||||
.table tr {
|
||||
height: 150px;
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">KCAP Demo Server</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/">Admin</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="mt-3">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JavaScript -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
52
src/app/templates/settings.html
Normal file
52
src/app/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>
|
||||
140
src/app/templates/tenant_selection.html
Normal file
140
src/app/templates/tenant_selection.html
Normal file
@@ -0,0 +1,140 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Select or Create Tenant - KCAP Demo Server</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<h1 class="text-center mb-4">KCAP Demo Server - Multi-Tenant</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-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Select an Existing Tenant</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if tenants %}
|
||||
<div class="list-group">
|
||||
{% for tenant in tenants %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<a href="/{{ tenant.id }}/" class="text-decoration-none flex-grow-1">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">{{ tenant.name }}</h5>
|
||||
<small>{{ tenant.created_at }}</small>
|
||||
</div>
|
||||
<p class="mb-1">Username: {{ tenant.username }}</p>
|
||||
<p class="mb-1">
|
||||
<strong>Knox Capture AR Template URL:</strong>
|
||||
<code>{{ server_url }}/{{ tenant.id }}/</code>
|
||||
</p>
|
||||
<small>ID: {{ tenant.id }}</small>
|
||||
</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 }}')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No tenants exist yet. Create one below!</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3>Create a New Tenant</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="newTenantForm">
|
||||
<div class="mb-3">
|
||||
<label for="tenantId" class="form-label">Tenant ID</label>
|
||||
<input type="text" class="form-control" id="tenantId" placeholder="e.g., demo-company" required pattern="[a-zA-Z0-9_-]+">
|
||||
<small class="form-text text-muted">Only letters, numbers, hyphens, and underscores allowed</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Create & Enter Tenant</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<p class="text-muted">
|
||||
<strong>Note:</strong> When you access a tenant URL directly (e.g., /my-tenant/),
|
||||
it will be automatically created with default credentials (admin/admin).
|
||||
</p>
|
||||
<a href="/settings" class="btn btn-secondary mt-3">Server Settings</a>
|
||||
</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>
|
||||
const reservedIds = ['admin', 'api', 'login', 'logout', 'arcontentfields', 'arinfo',
|
||||
'images', 'barcodes', 'static', 'assets', 'js', 'css', 'tenant',
|
||||
'auth', 'oauth', 'callback', 'webhook', 'health', 'status'];
|
||||
|
||||
document.getElementById('newTenantForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const tenantId = document.getElementById('tenantId').value;
|
||||
if (tenantId) {
|
||||
// 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;
|
||||
}
|
||||
// Use the normalized (lowercase) ID in the URL
|
||||
window.location.href = '/' + normalizedId + '/';
|
||||
}
|
||||
});
|
||||
|
||||
function confirmDelete(tenantName) {
|
||||
return confirm(`Are you sure you want to delete the tenant "${tenantName}"?\n\nThis will permanently delete all products and data associated with this tenant.`);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
127
src/app/templates/tenant_settings.html
Normal file
127
src/app/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 %}
|
||||
93
src/app/utils/barcode_generator.py
Normal file
93
src/app/utils/barcode_generator.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# Utility for barcode generation
|
||||
import os
|
||||
import io
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
try:
|
||||
import qrcode
|
||||
QRCODE_AVAILABLE = True
|
||||
except ImportError:
|
||||
QRCODE_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from barcode import EAN13, Code128
|
||||
from barcode.writer import ImageWriter
|
||||
BARCODE_AVAILABLE = True
|
||||
except ImportError:
|
||||
BARCODE_AVAILABLE = False
|
||||
|
||||
class BarcodeGenerator:
|
||||
@staticmethod
|
||||
def check_dependencies():
|
||||
return {
|
||||
'qrcode': QRCODE_AVAILABLE,
|
||||
'barcode': BARCODE_AVAILABLE,
|
||||
'pillow': True
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_qr_code(data, size=10, border=4):
|
||||
if not QRCODE_AVAILABLE:
|
||||
return BarcodeGenerator._generate_placeholder("QR Code Unavailable\nPlease install 'qrcode' package")
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=size,
|
||||
border=border,
|
||||
)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white").convert('RGB')
|
||||
return img
|
||||
|
||||
@staticmethod
|
||||
def generate_ean13_barcode(data):
|
||||
if not BARCODE_AVAILABLE:
|
||||
return BarcodeGenerator._generate_placeholder("EAN-13 Unavailable\nPlease install 'python-barcode' package")
|
||||
# EAN-13 requires 12 digits (13th is checksum, auto-calculated)
|
||||
numeric = ''.join(filter(str.isdigit, str(data)))
|
||||
if len(numeric) < 12:
|
||||
numeric = numeric.zfill(12)
|
||||
elif len(numeric) > 12:
|
||||
numeric = numeric[:12]
|
||||
ean = EAN13(numeric, writer=ImageWriter())
|
||||
output = io.BytesIO()
|
||||
ean.write(output)
|
||||
output.seek(0)
|
||||
img = Image.open(output)
|
||||
return img
|
||||
|
||||
@staticmethod
|
||||
def generate_code128_barcode(data):
|
||||
if not BARCODE_AVAILABLE:
|
||||
return BarcodeGenerator._generate_placeholder("Code 128 Unavailable\nPlease install 'python-barcode' package")
|
||||
code128 = Code128(str(data), writer=ImageWriter())
|
||||
output = io.BytesIO()
|
||||
code128.write(output)
|
||||
output.seek(0)
|
||||
img = Image.open(output)
|
||||
return img
|
||||
|
||||
@staticmethod
|
||||
def _generate_placeholder(message):
|
||||
img = Image.new('RGB', (300, 150), color=(255, 255, 255))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rectangle([0, 0, 299, 149], outline=(0, 0, 0), width=2)
|
||||
# Optionally, add multiline text
|
||||
lines = message.split('\n')
|
||||
y = 60
|
||||
for line in lines:
|
||||
d.text((20, y), line, fill=(0, 0, 0))
|
||||
y += 20
|
||||
return img
|
||||
|
||||
@staticmethod
|
||||
def save_image(image, path):
|
||||
image.save(path, format='PNG')
|
||||
|
||||
@staticmethod
|
||||
def get_image_as_bytes(image, format='PNG'):
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format=format)
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
Reference in New Issue
Block a user