Merge pull request #2 from mattintech/refactor

Refactor to MVC architecture and modernize UI
This commit is contained in:
2025-10-20 18:21:51 -04:00
committed by GitHub
44 changed files with 2781 additions and 1116 deletions

3
.gitignore vendored
View File

@@ -1,7 +1,8 @@
venv/ venv/
desktop.ini desktop.ini
static/ data/
__pycache__/ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
*.pyd *.pyd
.claude/

View File

@@ -1,55 +0,0 @@
# Project Planning and Code Guidelines
## General Code Guidelines
* **Modular Design:** Use Object-Oriented Programming (OOP) where possible to separate concerns and promote code reuse.
* **File Size Limit:** No single Python file should exceed **600 lines** to improve readability and maintainability.
* **Commenting:**
* Use clear, concise comments to explain complex logic.
* Include function and class docstrings where appropriate.
* Use in-line comments sparingly and only when the logic is not immediately obvious.
* **Code Structure:**
* Follow the Model-View-Controller (MVC) pattern where applicable.
* Group related classes and functions into separate modules for clarity.
* **Error Handling:** Implement robust error handling with meaningful messages and fallback behavior where possible.
* **Readability:**
* Use meaningful variable and function names.
* Avoid deeply nested loops and conditionals where possible.
## API Design Guidelines
* **RESTful Endpoints:** Use RESTful conventions for all API endpoints.
* **Status Codes:** Return appropriate HTTP status codes (e.g., 200 for success, 404 for not found, 500 for server error).
* **Error Responses:** Provide consistent and descriptive error messages.
* **Data Validation:** Validate all incoming data to prevent unexpected behavior and security issues.
* **Unified API and UI:** For demo purposes, both the API endpoints and admin UI should be served by the same Flask application.
## File Organization
* **Static Files:** Store images in the `static/images/` directory.
* **Product Data:** Store product metadata in `products.json`.
* **Configuration Files:** Use a dedicated `config/` directory for environment-specific settings.
* **Templates:** Store HTML templates in the `templates/` directory, with admin-specific templates in `templates/admin/`.
## Documentation Guidelines
* **Keep Documentation Updated:** Update TASK.md as features are implemented to track progress.
* **Readme First:** README.md should always reflect the current state of the project and provide clear setup instructions.
* **Code Comments:** Keep code comments in sync with implementation changes.
* **API Documentation:** Document all API endpoints, including request/response formats.
## Security Guidelines
* **No Hardcoded Credentials:** Avoid hardcoding sensitive information like passwords or API keys.
* **Input Sanitization:** Validate and sanitize all user input to prevent injection attacks.
* **Minimal Permissions:** Run the application with the least required privileges.
## Future Considerations
* **Scalability:** Plan for the potential migration to a database if the product catalog grows.
* **API Rate Limiting:** Consider adding rate limiting for public APIs.
* **Monitoring and Logging:** Implement basic logging for audit trails and error diagnosis.
* **Deployment Automation:** Use Docker or other containerization for easy deployment and scaling.

50
TASK.md
View File

@@ -1,50 +0,0 @@
# Task List - Flask AR API Demo
## Phase 1: Basic Admin UI ✅
* [x] Create a simple admin UI (no authentication required for demo purposes).
* [x] Allow adding, updating, and deleting products.
* [x] Store all product data in a `products.json` file for persistence.
* [x] Store images in the `static/images/` directory.
* [x] Implement basic form validation for product fields (ID, price, image).
* [x] Add a file upload feature for product images.
## Phase 2: API Improvements
* [x] Update the `/arinfo` endpoint to read from `products.json` instead of the in-memory dictionary.
* [ ] Add support for updating product attributes via the API.
* [x] Implement error handling and input validation.
* [ ] Optimize image loading for better performance.
## Phase 3: UI Enhancements
* [x] Add a responsive design for mobile and tablet support.
* [x] Include image previews in the admin UI.
* [ ] Add sorting and search capabilities for the product list.
* [x] Add barcode and QR code generation for products.
## Implementation Notes
* The APIs and admin UI have been implemented as a single server for demonstration purposes.
* The API endpoints and admin interface are now served by the same Flask application.
* Bootstrap 5 has been used for responsive UI design.
* Basic validation has been implemented for product forms.
* Flash messages provide user feedback for actions.
* Barcode generation features support three formats:
* QR Code: Links directly to the product's AR information endpoint
* EAN-13: Standard barcode format for retail products
* Code 128: High-density alphanumeric barcode
* Generated barcodes can be previewed, downloaded individually, or printed all at once.
* Barcodes are dynamically generated and stored in the `static/barcodes/` directory.
## Future Ideas
* [ ] Write basic unit tests for the API.
* [ ] Dockerize the application for easier deployment.
* [ ] Add a basic CI/CD pipeline (e.g., GitHub Actions or GitLab CI).
* [ ] Add user authentication for the admin UI.
* [ ] Implement role-based access control (RBAC).
* [ ] Add analytics for product views and updates.
* [ ] Integrate with a database for larger product catalogs.
* [ ] Add bulk import/export functionality for products.
* [ ] Implement a mobile-friendly scanner interface for testing the AR functionality.

284
admin.py
View File

@@ -1,284 +0,0 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file
import os
import json
import uuid
import io
from werkzeug.utils import secure_filename
# Import barcode generator class, but handle the case if it fails
try:
from barcode_generator import BarcodeGenerator
BARCODE_GENERATOR_AVAILABLE = True
except ImportError:
BARCODE_GENERATOR_AVAILABLE = False
# Create a stub class for graceful degradation
class BarcodeGenerator:
@staticmethod
def check_dependencies():
return {'qrcode': False, 'barcode': False, 'pillow': False}
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
PRODUCTS_FILE = 'static/products.json'
UPLOAD_FOLDER = 'static/images'
BARCODE_FOLDER = 'static/barcodes'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def load_products():
try:
with open(PRODUCTS_FILE, 'r') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def save_products(products):
with open(PRODUCTS_FILE, 'w') as f:
json.dump(products, f, indent=2)
@admin_bp.route('/')
def index():
products = load_products()
return render_template('admin/index.html', products=products)
@admin_bp.route('/add', methods=['GET', 'POST'])
def add_product():
if request.method == 'POST':
# Get form data
product_id = request.form.get('product_id')
name = request.form.get('name')
price = request.form.get('price')
# Basic validation
if not product_id or not name or not price:
flash('Product ID, Name, and Price are required fields.')
return render_template('admin/add_product.html')
# Check if product ID already exists
products = load_products()
if product_id in products:
flash('Product ID already exists.')
return render_template('admin/add_product.html')
# Handle image upload
image_filename = f"{product_id}.png" # Default name
image_path = f"/images/{image_filename}"
if 'image' in request.files:
file = request.files['image']
if file and file.filename and allowed_file(file.filename):
extension = file.filename.rsplit('.', 1)[1].lower()
image_filename = f"{product_id}.{extension}"
image_path = f"/images/{image_filename}"
file.save(os.path.join(UPLOAD_FOLDER, image_filename))
# Create product data structure
product_data = [
{"fieldName": "_id", "label": "Item ID", "value": product_id, "editable": "false", "fieldType": "TEXT"},
{"fieldName": "_name", "label": "Product Name", "value": name, "editable": "true", "fieldType": "TEXT"},
{"fieldName": "_price", "label": "Sale Price", "value": f"${price}", "editable": "true", "fieldType": "TEXT"},
{"fieldName": "_image", "label": "Image", "value": image_path, "editable": "false", "fieldType": "IMAGE_URI"}
]
# Save to products.json
products[product_id] = product_data
save_products(products)
flash('Product added successfully!')
return redirect(url_for('admin.index'))
return render_template('admin/add_product.html')
@admin_bp.route('/edit/<product_id>', methods=['GET', 'POST'])
def edit_product(product_id):
products = load_products()
if product_id not in products:
flash('Product not found.')
return redirect(url_for('admin.index'))
if request.method == 'POST':
# Get form data
name = request.form.get('name')
price = request.form.get('price')
# Basic validation
if not name or not price:
flash('Name and Price are required fields.')
return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id])
# Update name and price
for field in products[product_id]:
if field["fieldName"] == "_name":
field["value"] = name
elif field["fieldName"] == "_price":
field["value"] = f"${price}"
# Handle image upload
if 'image' in request.files and request.files['image'].filename:
file = request.files['image']
if allowed_file(file.filename):
# Find current image path
current_image = None
for field in products[product_id]:
if field["fieldName"] == "_image":
current_image = field["value"]
if current_image:
# Get current filename
current_filename = os.path.basename(current_image)
# Delete old file if it exists and is different
old_path = os.path.join(UPLOAD_FOLDER, current_filename)
if os.path.exists(old_path):
os.remove(old_path)
# Save new image
extension = file.filename.rsplit('.', 1)[1].lower()
image_filename = f"{product_id}.{extension}"
image_path = f"/images/{image_filename}"
file.save(os.path.join(UPLOAD_FOLDER, image_filename))
# Update image path
for field in products[product_id]:
if field["fieldName"] == "_image":
field["value"] = image_path
# Save to products.json
save_products(products)
flash('Product updated successfully!')
return redirect(url_for('admin.index'))
return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id])
@admin_bp.route('/delete/<product_id>', methods=['POST'])
def delete_product(product_id):
products = load_products()
if product_id not in products:
flash('Product not found.')
return redirect(url_for('admin.index'))
# Find image path
image_path = None
for field in products[product_id]:
if field["fieldName"] == "_image":
image_path = field["value"]
# Delete image file
if image_path:
image_filename = os.path.basename(image_path)
image_file_path = os.path.join(UPLOAD_FOLDER, image_filename)
if os.path.exists(image_file_path):
os.remove(image_file_path)
# Delete product from dict
del products[product_id]
# Save updated products
save_products(products)
flash('Product deleted successfully!')
return redirect(url_for('admin.index'))
@admin_bp.route('/view/<product_id>')
def view_product(product_id):
products = load_products()
if product_id not in products:
flash('Product not found.')
return redirect(url_for('admin.index'))
return render_template('admin/view_product.html', product_id=product_id, product=products[product_id])
@admin_bp.route('/generate_barcode/<product_id>/<code_type>')
def generate_barcode(product_id, code_type):
"""Generate and serve a barcode or QR code for a product"""
# Make sure the barcodes directory exists
os.makedirs(BARCODE_FOLDER, exist_ok=True)
products = load_products()
if product_id not in products:
return jsonify({"error": "Product not found"}), 404
# Check if barcode generator is available
if not BARCODE_GENERATOR_AVAILABLE:
# Create a simple error image
from PIL import Image, ImageDraw, ImageFont
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)
d.text((25, 65), "Barcode generation unavailable", fill=(0, 0, 0))
# Save and serve the error image
image_path = os.path.join(BARCODE_FOLDER, f"{product_id}_{code_type}_error.png")
img.save(image_path)
return send_file(image_path, mimetype='image/png')
generator = BarcodeGenerator()
# Content for the code - use the product ID
data = product_id
# Base filename for the saved code
base_filename = f"{product_id}_{code_type}"
image_path = os.path.join(BARCODE_FOLDER, f"{base_filename}.png")
# Generate the requested code type
if code_type == 'qrcode':
# Generate QR code with product URL
product_url = request.host_url.rstrip('/') + f"/arinfo?barcode={product_id}"
img = generator.generate_qr_code(product_url)
generator.save_image(img, image_path)
elif code_type == 'ean13':
# For EAN-13, make sure the product ID is numeric
numeric_id = ''.join(filter(str.isdigit, product_id))
img = generator.generate_ean13_barcode(numeric_id)
generator.save_image(img, image_path)
elif code_type == 'code128':
img = generator.generate_code128_barcode(data)
generator.save_image(img, image_path)
else:
return jsonify({"error": "Invalid code type"}), 400
# Return the image directly
return send_file(image_path, mimetype='image/png')
@admin_bp.route('/generate_barcode_page/<product_id>')
def generate_barcode_page(product_id):
"""Show a page with different barcode options for a product"""
products = load_products()
if product_id not in products:
flash('Product not found.')
return redirect(url_for('admin.index'))
# Extract product data
product_data = {}
for field in products[product_id]:
field_name = field["fieldName"][1:] if field["fieldName"].startswith('_') else field["fieldName"]
product_data[field_name] = field["value"]
# Add product ID
product_data['id'] = product_id
# Check if barcode generation is available
dependency_status = {}
if BARCODE_GENERATOR_AVAILABLE:
dependency_status = BarcodeGenerator.check_dependencies()
else:
dependency_status = {
'qrcode': False,
'barcode': False,
'pillow': False
}
return render_template('admin/generate_barcode.html',
product_id=product_id,
product=product_data,
dependencies=dependency_status)

85
app.py
View File

@@ -1,85 +0,0 @@
from flask import Flask, jsonify, request, send_file, render_template, flash
import os
import json
from admin import admin_bp
app = Flask(__name__)
app.config['STATIC_FOLDER'] = './static'
app.config['SECRET_KEY'] = 'dev-key-for-demo-only'
# Register blueprints
app.register_blueprint(admin_bp)
# Load product data from products.json
def load_products():
try:
with open('static/products.json', 'r') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
@app.route('/')
def index():
return render_template('index.html')
@app.route('/login', methods=['GET'])
def login():
# Just return 200 for this example, assuming no auth needed
return '', 200
@app.route('/arcontentfields', methods=['GET'])
def get_ar_content_fields():
# Return a fixed set of attributes
fields = [
{"fieldName": "_id", "label": "Item ID", "editable": "false", "fieldType": "TEXT"},
{"fieldName": "_price", "label": "Sale Price", "editable": "true", "fieldType": "TEXT"},
{"fieldName": "_image", "label": "Image", "editable": "false", "fieldType": "IMAGE_URI"}
]
return jsonify(fields), 200
@app.route('/arinfo', methods=['GET'])
def get_ar_info():
barcode = request.args.get('barcode')
products = load_products()
if not barcode or barcode not in products:
return jsonify({"error": "Item not found"}), 404
return jsonify(products[barcode]), 200
@app.route('/images/<path:filename>', methods=['GET'])
def serve_image(filename):
image_path = os.path.join(app.config['STATIC_FOLDER'], 'images', filename)
if os.path.exists(image_path):
return send_file(image_path)
return jsonify({"error": "Image not found"}), 404
@app.route('/barcodes/<path:filename>', methods=['GET'])
def serve_barcode(filename):
barcode_path = os.path.join(app.config['STATIC_FOLDER'], 'barcodes', filename)
if os.path.exists(barcode_path):
return send_file(barcode_path)
return jsonify({"error": "Barcode not found"}), 404
if __name__ == '__main__':
# Ensure the required directories exist
os.makedirs(os.path.join(app.config['STATIC_FOLDER'], 'images'), exist_ok=True)
os.makedirs(os.path.join(app.config['STATIC_FOLDER'], 'barcodes'), exist_ok=True)
# If products.json doesn't exist, create it with initial data
products_file = os.path.join(app.config['STATIC_FOLDER'], 'products.json')
if not os.path.exists(products_file):
initial_data = {
"123456": [
{"fieldName": "_id", "label": "Item ID", "value": "123456", "editable": "false", "fieldType": "TEXT"},
{"fieldName": "_price", "label": "Sale Price", "value": "$49.99", "editable": "true", "fieldType": "TEXT"},
{"fieldName": "_image", "label": "Image", "value": "/images/123456.png", "editable": "false", "fieldType": "IMAGE_URI"}
],
"789012": [
{"fieldName": "_id", "label": "Item ID", "value": "789012", "editable": "false", "fieldType": "TEXT"},
{"fieldName": "_price", "label": "Sale Price", "value": "$59.99", "editable": "true", "fieldType": "TEXT"},
{"fieldName": "_image", "label": "Image", "value": "/images/789012.png", "editable": "false", "fieldType": "IMAGE_URI"}
]
}
with open(products_file, 'w') as f:
json.dump(initial_data, f, indent=2)
app.run(port=5555, host="0.0.0.0", debug=True)

View File

@@ -1,112 +0,0 @@
"""
This file is a compatibility wrapper for the main app.py
It addresses the Werkzeug import issue by patching the necessary functions
"""
import sys
# Add compatibility for url_quote before importing Flask
try:
# First try normal import
from werkzeug.urls import url_quote
except ImportError:
# If it fails, create a shim for the missing function
import werkzeug
from werkzeug.urls import quote as url_quote
# Patch the werkzeug.urls module
werkzeug.urls.url_quote = url_quote
sys.modules['werkzeug.urls'].url_quote = url_quote
# Now import Flask normally
from flask import Flask, jsonify, request, send_file, render_template, flash
import os
import json
from admin import admin_bp
app = Flask(__name__)
app.config['STATIC_FOLDER'] = './static'
app.config['SECRET_KEY'] = 'dev-key-for-demo-only'
# Register blueprints
app.register_blueprint(admin_bp)
# Load product data from products.json
def load_products():
try:
with open('static/products.json', 'r') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
@app.route('/')
def index():
return render_template('index.html')
@app.route('/login', methods=['GET'])
def login():
# Just return 200 for this example, assuming no auth needed
return '', 200
@app.route('/arcontentfields', methods=['GET'])
def get_ar_content_fields():
# Return a fixed set of attributes
fields = [
{"fieldName": "_id", "label": "Item ID", "editable": "false", "fieldType": "TEXT"},
{"fieldName": "_price", "label": "Sale Price", "editable": "true", "fieldType": "TEXT"},
{"fieldName": "_image", "label": "Image", "editable": "false", "fieldType": "IMAGE_URI"}
]
return jsonify(fields), 200
@app.route('/arinfo', methods=['GET'])
def get_ar_info():
barcode = request.args.get('barcode')
products = load_products()
if not barcode or barcode not in products:
return jsonify({"error": "Item not found"}), 404
return jsonify(products[barcode]), 200
@app.route('/images/<path:filename>', methods=['GET'])
def serve_image(filename):
image_path = os.path.join(app.config['STATIC_FOLDER'], 'images', filename)
if os.path.exists(image_path):
return send_file(image_path)
return jsonify({"error": "Image not found"}), 404
@app.route('/barcodes/<path:filename>', methods=['GET'])
def serve_barcode(filename):
barcode_path = os.path.join(app.config['STATIC_FOLDER'], 'barcodes', filename)
if os.path.exists(barcode_path):
return send_file(barcode_path)
return jsonify({"error": "Barcode not found"}), 404
if __name__ == '__main__':
# Print helpful information
print("=" * 80)
print("KCAP Demo Server")
print("=" * 80)
print("This version includes compatibility fixes for Werkzeug/Flask version mismatches.")
print("If you encounter dependency errors, please run: pip install -r requirements.txt")
print("=" * 80)
# Ensure the required directories exist
os.makedirs(os.path.join(app.config['STATIC_FOLDER'], 'images'), exist_ok=True)
os.makedirs(os.path.join(app.config['STATIC_FOLDER'], 'barcodes'), exist_ok=True)
# If products.json doesn't exist, create it with initial data
products_file = os.path.join(app.config['STATIC_FOLDER'], 'products.json')
if not os.path.exists(products_file):
initial_data = {
"123456": [
{"fieldName": "_id", "label": "Item ID", "value": "123456", "editable": "false", "fieldType": "TEXT"},
{"fieldName": "_price", "label": "Sale Price", "value": "$49.99", "editable": "true", "fieldType": "TEXT"},
{"fieldName": "_image", "label": "Image", "value": "/images/123456.png", "editable": "false", "fieldType": "IMAGE_URI"}
],
"789012": [
{"fieldName": "_id", "label": "Item ID", "value": "789012", "editable": "false", "fieldType": "TEXT"},
{"fieldName": "_price", "label": "Sale Price", "value": "$59.99", "editable": "true", "fieldType": "TEXT"},
{"fieldName": "_image", "label": "Image", "value": "/images/789012.png", "editable": "false", "fieldType": "IMAGE_URI"}
]
}
with open(products_file, 'w') as f:
json.dump(initial_data, f, indent=2)
app.run(port=5555, host="0.0.0.0", debug=True)

View File

@@ -1,185 +0,0 @@
import os
import io
from PIL import Image, ImageDraw, ImageFont
# Try to import barcode libraries, provide fallback if not available
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:
"""
A utility class for generating QR codes and barcodes.
"""
@staticmethod
def check_dependencies():
"""
Check if all required dependencies are installed.
Returns:
dict: Status of each dependency
"""
return {
'qrcode': QRCODE_AVAILABLE,
'barcode': BARCODE_AVAILABLE,
'pillow': True # PIL/Pillow is imported directly at the top level
}
@staticmethod
def generate_qr_code(data, size=10, border=4):
"""
Generates a QR code as a PIL Image object.
Args:
data (str): The data to encode in the QR code
size (int): The size of the QR code (1-40)
border (int): The border size of the QR code
Returns:
PIL.Image: The generated QR code image or a placeholder image if qrcode is not available
"""
if not QRCODE_AVAILABLE:
return BarcodeGenerator._generate_placeholder("QR Code Unavailable\nPlease install 'qrcode' package")
qr = qrcode.QRCode(
version=size,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=border,
)
qr.add_data(data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
return img
@staticmethod
def generate_ean13_barcode(data):
"""
Generates an EAN-13 barcode as a PIL Image object.
Args:
data (str): The data to encode in the barcode (must be 12 digits)
Returns:
PIL.Image: The generated barcode image or a placeholder image if barcode is not available
"""
if not BARCODE_AVAILABLE:
return BarcodeGenerator._generate_placeholder("EAN-13 Unavailable\nPlease install 'python-barcode' package")
# Pad data to 12 digits if needed
if len(data) < 12:
data = data.zfill(12)
elif len(data) > 12:
data = data[:12]
# Create a BytesIO object to temporarily store the image
buffer = io.BytesIO()
EAN13(data, writer=ImageWriter()).write(buffer)
# Create a PIL Image from the BytesIO object
buffer.seek(0)
image = Image.open(buffer)
return image
@staticmethod
def generate_code128_barcode(data):
"""
Generates a Code 128 barcode as a PIL Image object.
Args:
data (str): The data to encode in the barcode
Returns:
PIL.Image: The generated barcode image or a placeholder image if barcode is not available
"""
if not BARCODE_AVAILABLE:
return BarcodeGenerator._generate_placeholder("Code 128 Unavailable\nPlease install 'python-barcode' package")
# Create a BytesIO object to temporarily store the image
buffer = io.BytesIO()
Code128(data, writer=ImageWriter()).write(buffer)
# Create a PIL Image from the BytesIO object
buffer.seek(0)
image = Image.open(buffer)
return image
@staticmethod
def _generate_placeholder(message):
"""
Generates a placeholder image with a message.
Args:
message (str): Message to display on the placeholder
Returns:
PIL.Image: The generated placeholder image
"""
# Create a placeholder image
img = Image.new('RGB', (300, 150), color=(255, 255, 255))
d = ImageDraw.Draw(img)
# Draw a border
d.rectangle([0, 0, 299, 149], outline=(0, 0, 0), width=2)
# Add text
try:
# Try to use a default font
font = ImageFont.truetype("arial.ttf", 20)
except IOError:
# Fallback to default font
font = ImageFont.load_default()
# Center the text
lines = message.split('\n')
y_offset = 40
for line in lines:
text_width = d.textlength(line, font=font)
d.text(((300 - text_width) // 2, y_offset), line, font=font, fill=(0, 0, 0))
y_offset += 30
return img
@staticmethod
def save_image(image, path):
"""
Saves a PIL Image to the specified path.
Args:
image (PIL.Image): The image to save
path (str): The path to save the image to
Returns:
str: The path where the image was saved
"""
image.save(path)
return path
@staticmethod
def get_image_as_bytes(image, format='PNG'):
"""
Converts a PIL Image to bytes.
Args:
image (PIL.Image): The image to convert
format (str): The format to save the image as
Returns:
bytes: The image as bytes
"""
buffer = io.BytesIO()
image.save(buffer, format=format)
return buffer.getvalue()

49
src/app/__init__.py Normal file
View 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

View 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']

View File

@@ -0,0 +1,5 @@
from flask import Blueprint
admin_bp = Blueprint('admin', __name__, url_prefix='/<tenant:tenant_id>')
from . import routes

View 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)

View File

@@ -0,0 +1,5 @@
from flask import Blueprint
api_bp = Blueprint('api', __name__, url_prefix='/<tenant:tenant_id>/api')
from . import routes

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

View File

@@ -0,0 +1,5 @@
from flask import Blueprint
main_bp = Blueprint('main', __name__)
from . import routes

View 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)

View File

@@ -0,0 +1,5 @@
from flask import Blueprint
tenant_bp = Blueprint('tenant', __name__, url_prefix='/<tenant:tenant_id>')
from . import routes

View 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
View 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
}

View 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']

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

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

View File

@@ -0,0 +1,5 @@
from .auth_service import AuthService
from .product_service import ProductService
from .barcode_service import BarcodeService
__all__ = ['AuthService', 'ProductService', 'BarcodeService']

View 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

View 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}")

View 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

View File

@@ -10,7 +10,7 @@
<h2>Add New Product</h2> <h2>Add New Product</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<form action="{{ url_for('admin.add_product') }}" method="POST" enctype="multipart/form-data"> <form action="/{{ tenant_id }}/add" method="POST" enctype="multipart/form-data">
<div class="mb-3"> <div class="mb-3">
<label for="product_id" class="form-label">Product ID *</label> <label for="product_id" class="form-label">Product ID *</label>
<div class="input-group"> <div class="input-group">
@@ -22,32 +22,52 @@
<div class="form-text">Must be unique. This will be used as the barcode for AR content.</div> <div class="form-text">Must be unique. This will be used as the barcode for AR content.</div>
</div> </div>
{% for field in custom_fields %}
{% if field.fieldName != '_id' %}
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">Product Name *</label> <label for="field_{{ field.fieldName }}" class="form-label">{{ field.label }}</label>
<input type="text" class="form-control" id="name" name="name" required> {% if field.fieldType == 'IMAGE_URI' %}
<div class="form-text">Enter a descriptive name for the product.</div> <input type="file" class="form-control" id="image_{{ field.fieldName }}" name="image_{{ field.fieldName }}" accept="image/*" onchange="previewImage(this, 'preview_{{ field.fieldName }}')">
</div>
<div class="mb-3">
<label for="price" class="form-label">Price *</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="text" class="form-control" id="price" name="price" required pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="49.99">
</div>
<div class="form-text">Enter the price in decimal format (e.g., 49.99)</div>
</div>
<div class="mb-3">
<label for="image" class="form-label">Product Image</label>
<input type="file" class="form-control" id="image" name="image" accept="image/*" onchange="previewImage(this)">
<div class="form-text">Supported formats: .png, .jpg, .jpeg, .gif</div> <div class="form-text">Supported formats: .png, .jpg, .jpeg, .gif</div>
<div class="mt-2"> <div class="mt-2">
<img id="image-preview" class="image-preview" style="display: none;"> <img id="preview_{{ field.fieldName }}" class="image-preview" style="display: none; max-width: 200px;">
</div>
{% elif field.fieldType == 'TEXT' %}
{% if field.fieldName == '_price' %}
<div class="input-group">
<span class="input-group-text">$</span>
<input type="text" class="form-control" id="field_{{ field.fieldName }}" name="field_{{ field.fieldName }}" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="49.99">
</div>
{% else %}
<input type="text" class="form-control" id="field_{{ field.fieldName }}" name="field_{{ field.fieldName }}">
{% endif %}
{% else %}
<input type="text" class="form-control" id="field_{{ field.fieldName }}" name="field_{{ field.fieldName }}">
{% endif %}
</div>
{% endif %}
{% endfor %}
<!-- Fallback fields for backward compatibility -->
{% if not custom_fields|selectattr('fieldName', 'equalto', '_name')|list %}
<div class="mb-3">
<label for="name" class="form-label">Product Name</label>
<input type="text" class="form-control" id="name" name="name">
</div>
{% endif %}
{% if not custom_fields|selectattr('fieldName', 'equalto', '_price')|list %}
<div class="mb-3">
<label for="price" class="form-label">Price</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="text" class="form-control" id="price" name="price" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="49.99">
</div> </div>
</div> </div>
{% endif %}
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a href="{{ url_for('admin.index') }}" class="btn btn-secondary">Cancel</a> <a href="/{{ tenant_id }}/" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Add Product</button> <button type="submit" class="btn btn-primary">Add Product</button>
</div> </div>
</form> </form>
@@ -59,8 +79,8 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
function previewImage(input) { function previewImage(input, previewId) {
var preview = document.getElementById('image-preview'); var preview = document.getElementById(previewId || 'image-preview');
if (input.files && input.files[0]) { if (input.files && input.files[0]) {
var reader = new FileReader(); var reader = new FileReader();

View 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 %}

View 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 %}

View File

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

View File

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

View File

@@ -1,57 +1,66 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}Admin Dashboard - KCAP Demo Server{% endblock %} {% block title %}Admin Dashboard - {{ tenant.name }} - KCAP Demo Server{% endblock %}
{% block content %} {% block content %}
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-12"> <div class="col-md-12">
<div class="alert alert-info">
<strong>Current Tenant:</strong> {{ tenant.name }} (ID: {{ tenant.id }})
<div class="mt-2">
<a href="{{ url_for('admin.manage_credentials', tenant_id=tenant.id) }}" class="btn btn-sm btn-secondary">Manage Credentials</a>
<a href="{{ 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"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1>Product Management</h1> <h1>Product Management</h1>
<a href="{{ url_for('admin.add_product') }}" class="btn btn-primary">Add New Product</a> <a href="{{ url_for('admin.add_product', tenant_id=tenant.id) }}" class="btn btn-primary">Add New Product</a>
</div> </div>
{% if products %} {% if products %}
<table class="table table-striped table-bordered align-middle"> <table class="table table-striped table-bordered align-middle">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th style="width: 15%">Product ID</th> <th>Product ID</th>
<th style="width: 25%">Name</th> {% for custom_field in custom_fields %}
<th style="width: 10%">Price</th> {% if custom_field.fieldName != '_id' %}
<th style="width: 20%">Image</th> <th>{{ custom_field.label }}</th>
<th style="width: 30%">Actions</th> {% endif %}
{% endfor %}
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for product_id, product_data in products.items() %} {% for product_id, product_data in products.items() %}
<tr> <tr>
<td>{{ product_id }}</td> <td>{{ product_id }}</td>
{% for custom_field in custom_fields %}
{% if custom_field.fieldName != '_id' %}
<td> <td>
{% for field in product_data %} {% for field in product_data %}
{% if field.fieldName == '_name' %} {% if field.fieldName == custom_field.fieldName %}
{{ field.value }} {% if custom_field.fieldType == 'IMAGE_URI' %}
{% endif %} {% if field.value %}
{% endfor %}
</td>
<td>
{% for field in product_data %}
{% if field.fieldName == '_price' %}
{{ field.value }}
{% endif %}
{% endfor %}
</td>
<td>
{% for field in product_data %}
{% if field.fieldName == '_image' %}
<div class="product-image-container"> <div class="product-image-container">
<img src="{{ field.value }}" alt="Product image" class="product-image"> <img src="/{{ tenant.id }}{{ field.value }}" alt="{{ custom_field.label }}" class="product-image">
</div> </div>
{% endif %} {% endif %}
{% else %}
{{ field.value }}
{% endif %}
{% endif %}
{% endfor %} {% endfor %}
</td> </td>
{% endif %}
{% endfor %}
<td> <td>
<div class="d-flex justify-content-center gap-2"> <div class="d-flex justify-content-center gap-2">
<a href="{{ url_for('admin.edit_product', product_id=product_id) }}" class="btn btn-sm btn-outline-primary">Edit</a> <a href="{{ url_for('admin.edit_product', tenant_id=tenant.id, product_id=product_id) }}" class="btn btn-sm btn-outline-primary">Edit</a>
<a href="{{ url_for('admin.generate_barcode_page', product_id=product_id) }}" class="btn btn-sm btn-outline-success">Barcodes</a> <a href="{{ url_for('admin.generate_barcode_page', tenant_id=tenant.id, product_id=product_id) }}" class="btn btn-sm btn-outline-success">Barcodes</a>
<button type="button" class="btn btn-sm btn-outline-danger" <button type="button" class="btn btn-sm btn-outline-danger"
data-bs-toggle="modal" data-bs-target="#deleteModal" data-bs-toggle="modal" data-bs-target="#deleteModal"
data-product-id="{{ product_id }}" data-product-id="{{ product_id }}"
@@ -59,7 +68,7 @@
Delete Delete
</button> </button>
</div> </div>
<form id="delete-form-{{ product_id }}" action="{{ url_for('admin.delete_product', product_id=product_id) }}" method="POST" style="display: none;"> <form id="delete-form-{{ product_id }}" action="{{ url_for('admin.delete_product', tenant_id=tenant.id, product_id=product_id) }}" method="POST" style="display: none;">
</form> </form>
</td> </td>
</tr> </tr>

View 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 %}

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

View File

@@ -0,0 +1,268 @@
<!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">
<style>
.tenant-card {
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
height: 100%;
}
.tenant-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.tenant-card-clickable {
text-decoration: none;
color: inherit;
display: block;
}
.settings-btn {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
}
.search-container {
position: sticky;
top: 0;
background: white;
z-index: 100;
padding: 20px 0;
}
.tenant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.create-tenant-card {
border: 2px dashed #dee2e6;
background: #f8f9fa;
}
.create-tenant-card:hover {
border-color: #0d6efd;
background: #e7f1ff;
}
</style>
</head>
<body>
<!-- Top Right Action Buttons -->
<div class="settings-btn">
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#createTenantModal" title="New Tenant">
<i class="bi bi-plus-circle-fill"></i>
</button>
<a href="/settings" class="btn btn-secondary" title="Server Settings">
<i class="bi bi-gear-fill"></i>
</a>
</div>
<div class="container-fluid px-4 py-4">
<h1 class="text-center mb-4">KCAP Demo Server</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 %}
<!-- Search and Filter -->
<div class="search-container">
<div class="row mb-3">
<div class="col-md-8 offset-md-2">
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" id="searchTenants" class="form-control" placeholder="Search tenants...">
<button class="btn btn-outline-secondary" type="button" id="clearSearch">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Tenant Grid -->
<div class="tenant-grid" id="tenantGrid">
{% if tenants %}
{% for tenant in tenants %}
<div class="tenant-item" data-tenant-name="{{ tenant.name|lower }}" data-tenant-id="{{ tenant.id|lower }}">
<div class="card tenant-card">
<a href="/{{ tenant.id }}/" class="tenant-card-clickable">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<h5 class="card-title mb-0">{{ tenant.name }}</h5>
<span class="badge bg-secondary">{{ tenant.id }}</span>
</div>
<p class="card-text text-muted small mb-2">
<i class="bi bi-person"></i> {{ tenant.username }}
</p>
<p class="card-text text-muted small mb-2">
<i class="bi bi-calendar"></i> {{ tenant.created_at }}
</p>
<div class="mb-2">
<small class="text-muted">AR Template URL:</small>
<div class="input-group input-group-sm">
<input type="text" class="form-control form-control-sm" value="{{ server_url }}/{{ tenant.id }}/" readonly onclick="this.select()">
</div>
</div>
</div>
</a>
<div class="card-footer bg-white d-flex justify-content-between" onclick="event.stopPropagation();">
<button type="button" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#qrModal-{{ tenant.id }}">
<i class="bi bi-qr-code"></i> QR
</button>
<form method="POST" action="/tenant/{{ tenant.id }}/delete" class="d-inline" onsubmit="return confirmDelete('{{ tenant.name }}')">
<button type="submit" class="btn btn-danger btn-sm">
<i class="bi bi-trash"></i> Delete
</button>
</form>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12">
<div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #dee2e6;"></i>
<p class="text-muted mt-3">No tenants exist yet. Create your first tenant!</p>
</div>
</div>
{% endif %}
</div>
<!-- No Results Message -->
<div id="noResults" class="text-center py-5" style="display: none;">
<i class="bi bi-search" style="font-size: 4rem; color: #dee2e6;"></i>
<p class="text-muted mt-3">No tenants found matching your search.</p>
</div>
</div>
<!-- Create Tenant Modal -->
<div class="modal fade" id="createTenantModal" tabindex="-1" aria-labelledby="createTenantModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createTenantModalLabel">Create New Tenant</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-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>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
<small>Default credentials: <strong>admin</strong> / <strong>admin</strong></small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="newTenantForm" class="btn btn-primary">Create & Enter</button>
</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 - {{ 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 Knox Capture AR:</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'];
// Create tenant form submission
document.getElementById('newTenantForm').addEventListener('submit', function(e) {
e.preventDefault();
const tenantId = document.getElementById('tenantId').value;
if (tenantId) {
const normalizedId = tenantId.toLowerCase();
if (reservedIds.includes(normalizedId)) {
alert(`"${tenantId}" is a reserved name and cannot be used as a tenant ID.`);
return;
}
window.location.href = '/' + normalizedId + '/';
}
});
// Search functionality
const searchInput = document.getElementById('searchTenants');
const clearButton = document.getElementById('clearSearch');
const tenantItems = document.querySelectorAll('.tenant-item');
const noResults = document.getElementById('noResults');
const tenantGrid = document.getElementById('tenantGrid');
function filterTenants() {
const searchTerm = searchInput.value.toLowerCase().trim();
let visibleCount = 0;
tenantItems.forEach(item => {
const tenantName = item.getAttribute('data-tenant-name');
const tenantId = item.getAttribute('data-tenant-id');
if (tenantName.includes(searchTerm) || tenantId.includes(searchTerm)) {
item.style.display = '';
visibleCount++;
} else {
item.style.display = 'none';
}
});
// Show/hide no results message
if (visibleCount === 0 && searchTerm !== '') {
tenantGrid.style.display = 'none';
noResults.style.display = 'block';
} else {
tenantGrid.style.display = 'grid';
noResults.style.display = 'none';
}
}
searchInput.addEventListener('input', filterTenants);
clearButton.addEventListener('click', function() {
searchInput.value = '';
filterTenants();
searchInput.focus();
});
// Delete confirmation
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>

View 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 %}

View 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()

20
src/run.py Normal file
View File

@@ -0,0 +1,20 @@
import os
import shutil
from app import create_app
from app.models import TenantModel
# Create application
app = create_app(os.environ.get('FLASK_ENV', 'development'))
if __name__ == '__main__':
with app.app_context():
# Migrate existing data from JSON if needed (backward compatibility)
# Note: This is for the old database.py migration - keeping for reference
# but in the new structure, this would be handled differently
# Clean up any accidentally created reserved tenants
deleted_count = TenantModel.cleanup_reserved()
if deleted_count > 0:
print(f"Cleaned up {deleted_count} reserved tenant(s)")
app.run(port=5555, host="0.0.0.0", debug=True)

View File

@@ -1,97 +0,0 @@
{% 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="{{ url_for('admin.edit_product', product_id=product_id) }}" method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="product_id" class="form-label">Product ID</label>
<input type="text" class="form-control" id="product_id" value="{{ product_id }}" disabled>
<div class="form-text">Product ID cannot be changed.</div>
</div>
<div class="mb-3">
<label for="name" class="form-label">Product Name *</label>
{% for field in product %}
{% if field.fieldName == '_name' %}
<input type="text" class="form-control" id="name" name="name" required value="{{ field.value }}">
{% endif %}
{% endfor %}
<div class="form-text">Enter a descriptive name for the product.</div>
</div>
<div class="mb-3">
<label for="price" class="form-label">Price *</label>
<div class="input-group">
<span class="input-group-text">$</span>
{% for field in product %}
{% if field.fieldName == '_price' %}
{% set price_value = field.value|replace('$', '') %}
<input type="text" class="form-control" id="price" name="price" required
pattern="[0-9]+(\.[0-9]{1,2})?" value="{{ price_value }}">
{% endif %}
{% endfor %}
</div>
<div class="form-text">Enter the price in decimal format (e.g., 49.99)</div>
</div>
<div class="mb-3">
<label for="image" class="form-label">Product Image</label>
<input type="file" class="form-control" id="image" name="image" accept="image/*" onchange="previewImage(this)">
<div class="form-text">Leave empty to keep the current image.</div>
<div class="mt-3">
<label class="form-label">Current Image:</label>
{% for field in product %}
{% if field.fieldName == '_image' %}
<div>
<img src="{{ field.value }}" alt="Current product image" class="image-preview">
</div>
{% endif %}
{% endfor %}
</div>
<div class="mt-2">
<label class="form-label">New Image Preview:</label>
<img id="image-preview" class="image-preview" style="display: none;">
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('admin.index') }}" 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) {
var preview = document.getElementById('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 %}

View File

@@ -1,150 +0,0 @@
{% extends "layout.html" %}
{% block title %}Generate Barcodes - KCAP Demo Server{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h2>Generate Barcodes for Product: {{ product_id }}</h2>
<a href="{{ url_for('admin.index') }}" class="btn btn-outline-secondary">Back to Products</a>
</div>
<div class="card-body">
{% if not dependencies.qrcode or not dependencies.barcode or not dependencies.pillow %}
<div class="alert alert-warning mb-4">
<h4 class="alert-heading">Missing Dependencies</h4>
<p>Some barcode generation features are unavailable because required packages are not installed:</p>
<ul>
{% if not dependencies.qrcode %}
<li><strong>QR Code:</strong> The 'qrcode' package is required for QR code generation</li>
{% endif %}
{% if not dependencies.barcode %}
<li><strong>Barcodes:</strong> The 'python-barcode' package is required for EAN-13 and Code 128 generation</li>
{% endif %}
{% if not dependencies.pillow %}
<li><strong>Image Processing:</strong> The 'Pillow' package is required for image processing</li>
{% endif %}
</ul>
<hr>
<p class="mb-0">To install the required dependencies, run: <code>pip install -r requirements.txt</code></p>
</div>
{% endif %}
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3>QR Code</h3>
</div>
<div class="card-body text-center">
<img src="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='qrcode') }}" alt="QR Code" class="img-fluid mb-3" style="max-width: 250px;">
<p class="text-muted">QR Code contains a link to the product AR info.</p>
<a href="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='qrcode') }}" class="btn btn-primary" download="product_{{ product_id }}_qrcode.png">Download QR Code</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3>EAN-13 Barcode</h3>
</div>
<div class="card-body text-center">
<img src="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='ean13') }}" alt="EAN-13 Barcode" class="img-fluid mb-3" style="max-width: 250px;">
<p class="text-muted">Standard EAN-13 barcode format.</p>
<a href="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='ean13') }}" class="btn btn-primary" download="product_{{ product_id }}_ean13.png">Download EAN-13</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3>Code 128 Barcode</h3>
</div>
<div class="card-body text-center">
<img src="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='code128') }}" alt="Code 128 Barcode" class="img-fluid mb-3" style="max-width: 250px;">
<p class="text-muted">High-density alphanumeric barcode.</p>
<a href="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='code128') }}" class="btn btn-primary" download="product_{{ product_id }}_code128.png">Download Code 128</a>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h3>Product Information</h3>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<p><strong>Product ID:</strong> {{ product_id }}</p>
<p><strong>Price:</strong> {{ product.price }}</p>
</div>
<div class="col-md-4">
<p><strong>AR Info URL:</strong>
<a href="{{ url_for('get_ar_info', barcode=product_id) }}" target="_blank">
{{ url_for('get_ar_info', barcode=product_id, _external=True) }}
</a>
</p>
</div>
<div class="col-md-4">
{% if product.image %}
<img src="{{ product.image }}" alt="Product Image" class="img-thumbnail" style="max-height: 100px;">
{% else %}
<p>No product image available</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h3>Print All Codes</h3>
</div>
<div class="card-body">
<p>Use the button below to open a printable version of all barcodes for this product.</p>
<button class="btn btn-success" onclick="window.print()">Print Barcodes</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
@media print {
.navbar, .card-header, .btn, .text-muted, .alert {
display: none;
}
.card {
border: none;
margin-bottom: 1cm;
}
.card-body {
text-align: center;
}
.row {
display: block;
}
.col-md-4 {
width: 100%;
max-width: 100%;
flex: 0 0 100%;
page-break-after: always;
}
}
</style>
{% endblock %}

View File

@@ -1,40 +0,0 @@
{% 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">
<h1 class="display-4">KCAP Demo Server</h1>
<p class="lead">A simple Flask API for demonstrating AR content retrieval for barcode scanning applications.</p>
<hr class="my-4">
<p>This server simulates the Knox Capture API for AR overlays and includes endpoints for managing product attributes.</p>
<div class="mt-4">
<h2>API Endpoints</h2>
<ul class="list-group">
<li class="list-group-item">
<strong>Login:</strong> <code>GET /login</code>
<p>Simulates a simple login with a 200 response (no authentication required).</p>
</li>
<li class="list-group-item">
<strong>Content Fields:</strong> <code>GET /arcontentfields</code>
<p>Returns a list of available attributes (e.g., item ID, price, image URI).</p>
</li>
<li class="list-group-item">
<strong>AR Info:</strong> <code>GET /arinfo?barcode=123456</code>
<p>Returns product details for a given barcode, including image URLs.</p>
</li>
<li class="list-group-item">
<strong>Static Image Server:</strong> <code>GET /images/123456.png</code>
<p>Serves image files from the <code>static/images/</code> directory.</p>
</li>
</ul>
</div>
<div class="mt-4">
<a href="/admin" class="btn btn-primary btn-lg">Go to Admin Interface</a>
</div>
</div>
</div>
</div>
{% endblock %}