Add dynamic custom field display and remove redundant admin routes

Major improvements to tenant management:
1. Dynamic field display - Product lists now automatically show all custom fields based on tenant configuration (e.g., inventory, custom attributes)
2. Fixed Jinja2 template scoping issue - Product field values now correctly display in edit forms using selectattr filter
3. Simplified URL structure - Removed redundant /tenant/admin/ routes, all product management now at /tenant/ root
4. Enhanced tenant landing page - Added delete functionality with confirmation modal, shows all custom fields except images
5. Updated all routes and redirects to use simplified paths (/tenant/add, /tenant/edit, etc.)

This consolidates the interface so /tenant/ serves as the main dashboard with full product management capabilities, while /tenant/settings handles configuration.
This commit is contained in:
2025-10-20 17:11:26 -04:00
parent 92eb45fc8c
commit f781cdf602
7 changed files with 142 additions and 103 deletions

View File

@@ -70,11 +70,12 @@ def tenant_index(tenant_id):
if tenant is None:
# Reserved tenant ID or invalid
return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404
# Load products for this tenant
products = load_products(tenant_id)
return render_template('index.html', tenant=tenant, products=products)
custom_fields = database.get_custom_ar_fields(tenant_id)
return render_template('index.html', tenant=tenant, products=products, custom_fields=custom_fields)
@app.route('/<tenant:tenant_id>/settings')
def tenant_settings(tenant_id):
@@ -292,21 +293,16 @@ def serve_barcode(tenant_id, filename):
app.logger.error(f"Barcode generation error: {str(e)}")
return jsonify({"error": "Failed to generate barcode"}), 500
# Import the admin routes directly and register them with tenant support
from routes.admin import (index as admin_index, add_product, edit_product,
delete_product, view_product, generate_barcode,
generate_barcode_page, manage_credentials, manage_ar_fields)
# Import product management routes
from routes.admin import (add_product, edit_product, delete_product,
generate_barcode, generate_barcode_page)
# Register admin routes with tenant prefix
app.add_url_rule('/<tenant:tenant_id>/admin/', 'admin.index', admin_index)
app.add_url_rule('/<tenant:tenant_id>/admin/add', 'admin.add_product', add_product, methods=['GET', 'POST'])
app.add_url_rule('/<tenant:tenant_id>/admin/edit/<product_id>', 'admin.edit_product', edit_product, methods=['GET', 'POST'])
app.add_url_rule('/<tenant:tenant_id>/admin/delete/<product_id>', 'admin.delete_product', delete_product, methods=['POST'])
app.add_url_rule('/<tenant:tenant_id>/admin/view/<product_id>', 'admin.view_product', view_product)
app.add_url_rule('/<tenant:tenant_id>/admin/generate_barcode/<product_id>/<code_type>', 'admin.generate_barcode', generate_barcode)
app.add_url_rule('/<tenant:tenant_id>/admin/generate_barcode_page/<product_id>', 'admin.generate_barcode_page', generate_barcode_page)
app.add_url_rule('/<tenant:tenant_id>/admin/credentials', 'admin.manage_credentials', manage_credentials, methods=['GET', 'POST'])
app.add_url_rule('/<tenant:tenant_id>/admin/ar_fields', 'admin.manage_ar_fields', manage_ar_fields, methods=['GET', 'POST'])
# Register product management routes (remove /admin/ from paths)
app.add_url_rule('/<tenant:tenant_id>/add', 'admin.add_product', add_product, methods=['GET', 'POST'])
app.add_url_rule('/<tenant:tenant_id>/edit/<product_id>', 'admin.edit_product', edit_product, methods=['GET', 'POST'])
app.add_url_rule('/<tenant:tenant_id>/delete/<product_id>', 'admin.delete_product', delete_product, methods=['POST'])
app.add_url_rule('/<tenant:tenant_id>/generate_barcode/<product_id>/<code_type>', 'admin.generate_barcode', generate_barcode)
app.add_url_rule('/<tenant:tenant_id>/generate_barcode_page/<product_id>', 'admin.generate_barcode_page', generate_barcode_page)
# Register API routes with tenant prefix
from routes.api import api_index

View File

@@ -38,7 +38,8 @@ def save_products(products):
def index(tenant_id):
products = load_products(tenant_id)
tenant = database.get_or_create_tenant(tenant_id)
return render_template('admin/index.html', products=products, tenant=tenant)
custom_fields = database.get_custom_ar_fields(tenant_id)
return render_template('admin/index.html', products=products, tenant=tenant, custom_fields=custom_fields)
def add_product(tenant_id):
# Get custom AR fields for this tenant
@@ -172,26 +173,26 @@ def add_product(tenant_id):
database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type'])
flash('Product added successfully!')
return redirect(url_for('admin.index', tenant_id=tenant_id))
return redirect(f'/{tenant_id}/')
return render_template('admin/add_product.html', tenant_id=tenant_id, custom_fields=custom_fields)
def edit_product(tenant_id, product_id):
products = load_products(tenant_id)
custom_fields = database.get_custom_ar_fields(tenant_id)
if product_id not in products:
flash('Product not found.')
return redirect(url_for('admin.index', tenant_id=tenant_id))
return redirect(f'/{tenant_id}/')
if request.method == 'POST':
# Handle backward compatibility image upload
image_data = None
image_mime_type = None
# Process images after product is saved
images_to_save = []
# Update fields based on form data
updated_product = []
@@ -295,7 +296,7 @@ def edit_product(tenant_id, product_id):
database.save_product_image(product_id, tenant_id, img['field_name'], img['data'], img['mime_type'])
flash('Product updated successfully!')
return redirect(url_for('admin.index', tenant_id=tenant_id))
return redirect(f'/{tenant_id}/')
return render_template('admin/edit_product.html',
product_id=product_id,
@@ -305,24 +306,24 @@ def edit_product(tenant_id, product_id):
def delete_product(tenant_id, product_id):
products = load_products(tenant_id)
if product_id not in products:
flash('Product not found.')
return redirect(url_for('admin.index', tenant_id=tenant_id))
return redirect(f'/{tenant_id}/')
# Delete product from database (image is stored in DB)
database.delete_product(product_id, tenant_id)
flash('Product deleted successfully!')
return redirect(url_for('admin.index', tenant_id=tenant_id))
return redirect(f'/{tenant_id}/')
def view_product(tenant_id, product_id):
products = load_products(tenant_id)
if product_id not in products:
flash('Product not found.')
return redirect(url_for('admin.index', tenant_id=tenant_id))
return redirect(f'/{tenant_id}/')
return render_template('admin/view_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id)
def generate_barcode(tenant_id, product_id, code_type):
@@ -346,10 +347,10 @@ def generate_barcode(tenant_id, product_id, code_type):
def generate_barcode_page(tenant_id, product_id):
"""Show a page with different barcode options for a product"""
products = load_products(tenant_id)
if product_id not in products:
flash('Product not found.')
return redirect(url_for('admin.index', tenant_id=tenant_id))
return redirect(f'/{tenant_id}/')
# Extract product data
product_data = {}
@@ -388,7 +389,7 @@ def manage_credentials(tenant_id):
if username and password:
database.update_tenant_credentials(tenant_id, username, password)
flash('Credentials updated successfully!')
return redirect(url_for('admin.index', tenant_id=tenant_id))
return redirect(f'/{tenant_id}/')
else:
flash('Username and password are required.')

View File

@@ -10,7 +10,7 @@
<h2>Add New Product</h2>
</div>
<div class="card-body">
<form action="{{ url_for('admin.add_product', tenant_id=tenant_id) }}" method="POST" enctype="multipart/form-data">
<form action="/{{ tenant_id }}/add" method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="product_id" class="form-label">Product ID *</label>
<div class="input-group">
@@ -67,7 +67,7 @@
{% endif %}
<div class="d-flex justify-content-between">
<a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-secondary">Cancel</a>
<a href="/{{ tenant_id }}/" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Add Product</button>
</div>
</form>

View File

@@ -10,7 +10,7 @@
<h2>Edit Product</h2>
</div>
<div class="card-body">
<form action="{{ url_for('admin.edit_product', tenant_id=tenant_id, product_id=product_id) }}" method="POST" enctype="multipart/form-data">
<form action="/{{ tenant_id }}/edit/{{ product_id }}" method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="product_id" class="form-label">Product ID</label>
<input type="text" class="form-control" id="product_id" value="{{ product_id }}" disabled>
@@ -21,14 +21,11 @@
{% if field.fieldName != '_id' %}
<div class="mb-3">
<label for="field_{{ field.fieldName }}" class="form-label">{{ field.label }}</label>
{% set current_value = '' %}
{% for prod_field in product %}
{% if prod_field.fieldName == field.fieldName %}
{% set current_value = prod_field.value %}
{% endif %}
{% endfor %}
{# 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>
@@ -93,7 +90,7 @@
{% endif %}
<div class="d-flex justify-content-between">
<a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-secondary">Cancel</a>
<a href="/{{ tenant_id }}/" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Update Product</button>
</div>
</form>

View File

@@ -8,7 +8,7 @@
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h2>Generate Barcodes for Product: {{ product_id }}</h2>
<a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-outline-secondary">Back to Products</a>
<a href="/{{ tenant_id }}/" class="btn btn-outline-secondary">Back to Products</a>
</div>
<div class="card-body">
{% if not dependencies.qrcode or not dependencies.barcode or not dependencies.pillow %}

View File

@@ -22,46 +22,44 @@
<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>
<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>
<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>
{% for custom_field in custom_fields %}
{% if custom_field.fieldName != '_id' %}
<td>
{% for field in product_data %}
{% if field.fieldName == custom_field.fieldName %}
{% if custom_field.fieldType == 'IMAGE_URI' %}
{% if field.value %}
<div class="product-image-container">
<img src="{{ field.value }}" alt="{{ custom_field.label }}" class="product-image">
</div>
{% endif %}
{% else %}
{{ field.value }}
{% endif %}
{% endif %}
{% endfor %}
</td>
{% endif %}
{% endfor %}
<td>
<div class="d-flex justify-content-center gap-2">
<a href="{{ url_for('admin.edit_product', tenant_id=tenant.id, product_id=product_id) }}" class="btn btn-sm btn-outline-primary">Edit</a>
<a href="{{ url_for('admin.generate_barcode_page', tenant_id=tenant.id, product_id=product_id) }}" class="btn btn-sm btn-outline-success">Barcodes</a>
<button type="button" class="btn btn-sm btn-outline-danger"
data-bs-toggle="modal" data-bs-target="#deleteModal"
<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

View File

@@ -18,7 +18,7 @@
<div class="mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Products</h2>
<a href="/{{ tenant.id }}/admin/add" class="btn btn-primary">
<a href="/{{ tenant.id }}/add" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add New Product
</a>
</div>
@@ -29,8 +29,11 @@
<thead>
<tr>
<th>Product ID</th>
<th>Name</th>
<th>Price</th>
{% for custom_field in custom_fields %}
{% if custom_field.fieldName != '_id' and custom_field.fieldType != 'IMAGE_URI' %}
<th>{{ custom_field.label }}</th>
{% endif %}
{% endfor %}
<th>Actions</th>
</tr>
</thead>
@@ -38,45 +41,42 @@
{% for product_id, product_data in products.items() %}
<tr>
<td>{{ product_id }}</td>
{% for custom_field in custom_fields %}
{% if custom_field.fieldName != '_id' and custom_field.fieldType != 'IMAGE_URI' %}
<td>
{% set matching_fields = product_data|selectattr('fieldName', 'equalto', custom_field.fieldName)|list %}
{{ matching_fields[0].value if matching_fields else '' }}
</td>
{% endif %}
{% endfor %}
<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>
<a href="/{{ tenant.id }}/admin/edit/{{ product_id }}" class="btn btn-sm btn-outline-primary">
<a href="/{{ tenant.id }}/edit/{{ product_id }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i> Edit
</a>
<a href="/{{ tenant.id }}/admin/generate_barcode_page/{{ product_id }}" class="btn btn-sm btn-outline-success">
<a href="/{{ tenant.id }}/generate_barcode_page/{{ product_id }}" class="btn btn-sm btn-outline-success">
<i class="bi bi-upc-scan"></i> 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="{% 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>
<div class="mt-3">
<a href="/{{ tenant.id }}/admin/" class="btn btn-secondary">
<i class="bi bi-list-task"></i> Manage All Products
</a>
</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>
@@ -89,4 +89,51 @@
</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>
// 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 %}