organizing the project
This commit is contained in:
285
src/admin.py
Normal file
285
src/admin.py
Normal file
@@ -0,0 +1,285 @@
|
||||
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')
|
||||
|
||||
DATA_FOLDER = os.path.join(os.path.dirname(__file__), 'data')
|
||||
PRODUCTS_FILE = os.path.join(DATA_FOLDER, 'products.json')
|
||||
UPLOAD_FOLDER = os.path.join(DATA_FOLDER, 'images')
|
||||
BARCODE_FOLDER = os.path.join(DATA_FOLDER, '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)
|
||||
87
src/app.py
Normal file
87
src/app.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from flask import Flask, jsonify, request, send_file, render_template, flash
|
||||
import os
|
||||
import json
|
||||
from admin import admin_bp
|
||||
|
||||
app = Flask(__name__)
|
||||
# Set DATA_FOLDER to the absolute path of the data directory inside src
|
||||
app.config['DATA_FOLDER'] = os.path.join(os.path.dirname(__file__), 'data')
|
||||
app.config['SECRET_KEY'] = 'dev-key-for-demo-only'
|
||||
|
||||
# Register blueprints
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# Load product data from products.json
|
||||
def load_products():
|
||||
try:
|
||||
products_file = os.path.join(app.config['DATA_FOLDER'], 'products.json')
|
||||
with open(products_file, '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['DATA_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['DATA_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['DATA_FOLDER'], 'images'), exist_ok=True)
|
||||
os.makedirs(os.path.join(app.config['DATA_FOLDER'], 'barcodes'), exist_ok=True)
|
||||
|
||||
# If products.json doesn't exist, create it with initial data
|
||||
products_file = os.path.join(app.config['DATA_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)
|
||||
185
src/barcode_generator.py
Normal file
185
src/barcode_generator.py
Normal file
@@ -0,0 +1,185 @@
|
||||
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()
|
||||
102
src/templates/admin/add_product.html
Normal file
102
src/templates/admin/add_product.html
Normal file
@@ -0,0 +1,102 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Add New Product - KCAP Demo Server{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Add New Product</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('admin.add_product') }}" method="POST" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label for="product_id" class="form-label">Product ID *</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="product_id" name="product_id" required>
|
||||
<button class="btn btn-outline-secondary" type="button" id="generate-id-btn" title="Generate Random ID">
|
||||
<i class="bi bi-magic"></i> Generate
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Must be unique. This will be used as the barcode for AR content.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Product Name *</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required>
|
||||
<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>
|
||||
<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="mt-2">
|
||||
<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">Add 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';
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a random product ID
|
||||
document.getElementById('generate-id-btn').addEventListener('click', function() {
|
||||
// Generate a random alphanumeric ID
|
||||
// Format: 3 letters followed by 4 numbers (e.g., ABC1234)
|
||||
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const numbers = '0123456789';
|
||||
|
||||
let id = '';
|
||||
|
||||
// Add 3 random uppercase letters
|
||||
for (let i = 0; i < 3; i++) {
|
||||
id += letters.charAt(Math.floor(Math.random() * letters.length));
|
||||
}
|
||||
|
||||
// Add 4 random numbers
|
||||
for (let i = 0; i < 4; i++) {
|
||||
id += numbers.charAt(Math.floor(Math.random() * numbers.length));
|
||||
}
|
||||
|
||||
// Set the generated ID in the input field
|
||||
document.getElementById('product_id').value = id;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
97
src/templates/admin/edit_product.html
Normal file
97
src/templates/admin/edit_product.html
Normal file
@@ -0,0 +1,97 @@
|
||||
{% 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 %}
|
||||
150
src/templates/admin/generate_barcode.html
Normal file
150
src/templates/admin/generate_barcode.html
Normal file
@@ -0,0 +1,150 @@
|
||||
{% 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 %}
|
||||
123
src/templates/admin/index.html
Normal file
123
src/templates/admin/index.html
Normal file
@@ -0,0 +1,123 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard - 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>Product Management</h1>
|
||||
<a href="{{ url_for('admin.add_product') }}" class="btn btn-primary">Add New Product</a>
|
||||
</div>
|
||||
|
||||
{% if products %}
|
||||
<table class="table table-striped table-bordered align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 15%">Product ID</th>
|
||||
<th style="width: 25%">Name</th>
|
||||
<th style="width: 10%">Price</th>
|
||||
<th style="width: 20%">Image</th>
|
||||
<th style="width: 30%">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for product_id, product_data in products.items() %}
|
||||
<tr>
|
||||
<td>{{ product_id }}</td>
|
||||
<td>
|
||||
{% for field in product_data %}
|
||||
{% if field.fieldName == '_name' %}
|
||||
{{ field.value }}
|
||||
{% endif %}
|
||||
{% 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">
|
||||
<img src="{{ field.value }}" alt="Product image" class="product-image">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
<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.generate_barcode_page', product_id=product_id) }}" class="btn btn-sm btn-outline-success">Barcodes</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-product-id="{{ product_id }}"
|
||||
data-product-name="{% for field in product_data %}{% if field.fieldName == '_name' %}{{ field.value }}{% endif %}{% endfor %}">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<form id="delete-form-{{ product_id }}" action="{{ url_for('admin.delete_product', product_id=product_id) }}" method="POST" style="display: none;">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
No products available. Click "Add New Product" to get started.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="deleteModalLabel">Confirm Deletion</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the product <strong id="delete-product-name"></strong>?</p>
|
||||
<p class="text-danger"><i class="bi bi-exclamation-triangle-fill"></i> This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirm-delete-btn">Delete Product</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Delete confirmation modal functionality
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
if (deleteModal) {
|
||||
deleteModal.addEventListener('show.bs.modal', function (event) {
|
||||
// Button that triggered the modal
|
||||
const button = event.relatedTarget;
|
||||
|
||||
// Extract product info from data attributes
|
||||
const productId = button.getAttribute('data-product-id');
|
||||
const productName = button.getAttribute('data-product-name');
|
||||
|
||||
// Update modal content
|
||||
const modalProductName = document.getElementById('delete-product-name');
|
||||
modalProductName.textContent = productName + " (ID: " + productId + ")";
|
||||
|
||||
// Setup the confirm button action
|
||||
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
|
||||
confirmDeleteBtn.onclick = function() {
|
||||
document.getElementById('delete-form-' + productId).submit();
|
||||
};
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
40
src/templates/index.html
Normal file
40
src/templates/index.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% 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 %}
|
||||
96
src/templates/layout.html
Normal file
96
src/templates/layout.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}KCAP Demo Server{% endblock %}</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
.navbar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
}
|
||||
.card {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.image-preview {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* Product image styles for consistent display */
|
||||
.product-image-container {
|
||||
width: 100px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
max-width: 100px;
|
||||
max-height: 120px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Ensure consistent height for table rows */
|
||||
.table tr {
|
||||
height: 150px;
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">KCAP Demo Server</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/">Admin</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="mt-3">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JavaScript -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user