Refactor application to MVC architecture with Flask blueprints

Major architectural refactoring to improve code organization, maintainability, and scalability:

- Implement MVC pattern with clear separation of concerns
- Organize code into blueprints for different functional areas (main, tenant, admin, api)
- Create models layer to encapsulate all database operations
- Create services layer for business logic (auth, product, barcode)
- Implement application factory pattern for better testability
- Add environment-based configuration management
- Remove old monolithic app.py and consolidate routes

New structure:
- app/models/ - Database operations (Tenant, Product, ARField, Settings)
- app/services/ - Business logic (Auth, Product, Barcode services)
- app/blueprints/ - Controllers organized by function
- app/config.py - Environment configurations
- run.py - Application entry point

All routes remain backward compatible. No changes to API or templates functionality.
This commit is contained in:
2025-10-20 18:11:16 -04:00
parent a775270790
commit f97808c7b3
41 changed files with 1147 additions and 1715 deletions

View File

@@ -0,0 +1,122 @@
{% extends "layout.html" %}
{% block title %}Add New Product - KCAP Demo Server{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header">
<h2>Add New Product</h2>
</div>
<div class="card-body">
<form action="/{{ tenant_id }}/add" method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="product_id" class="form-label">Product ID *</label>
<div class="input-group">
<input type="text" class="form-control" id="product_id" name="product_id" required>
<button class="btn btn-outline-secondary" type="button" id="generate-id-btn" title="Generate Random ID">
<i class="bi bi-magic"></i> Generate
</button>
</div>
<div class="form-text">Must be unique. This will be used as the barcode for AR content.</div>
</div>
{% for field in custom_fields %}
{% if field.fieldName != '_id' %}
<div class="mb-3">
<label for="field_{{ field.fieldName }}" class="form-label">{{ field.label }}</label>
{% if field.fieldType == 'IMAGE_URI' %}
<input type="file" class="form-control" id="image_{{ field.fieldName }}" name="image_{{ field.fieldName }}" accept="image/*" onchange="previewImage(this, 'preview_{{ field.fieldName }}')">
<div class="form-text">Supported formats: .png, .jpg, .jpeg, .gif</div>
<div class="mt-2">
<img id="preview_{{ field.fieldName }}" class="image-preview" style="display: none; max-width: 200px;">
</div>
{% elif field.fieldType == 'TEXT' %}
{% if field.fieldName == '_price' %}
<div class="input-group">
<span class="input-group-text">$</span>
<input type="text" class="form-control" id="field_{{ field.fieldName }}" name="field_{{ field.fieldName }}" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="49.99">
</div>
{% else %}
<input type="text" class="form-control" id="field_{{ field.fieldName }}" name="field_{{ field.fieldName }}">
{% endif %}
{% else %}
<input type="text" class="form-control" id="field_{{ field.fieldName }}" name="field_{{ field.fieldName }}">
{% endif %}
</div>
{% endif %}
{% endfor %}
<!-- Fallback fields for backward compatibility -->
{% if not custom_fields|selectattr('fieldName', 'equalto', '_name')|list %}
<div class="mb-3">
<label for="name" class="form-label">Product Name</label>
<input type="text" class="form-control" id="name" name="name">
</div>
{% endif %}
{% if not custom_fields|selectattr('fieldName', 'equalto', '_price')|list %}
<div class="mb-3">
<label for="price" class="form-label">Price</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="text" class="form-control" id="price" name="price" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="49.99">
</div>
</div>
{% endif %}
<div class="d-flex justify-content-between">
<a href="/{{ tenant_id }}/" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Add Product</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function previewImage(input, previewId) {
var preview = document.getElementById(previewId || 'image-preview');
if (input.files && input.files[0]) {
var reader = new FileReader();
reader.onload = function(e) {
preview.src = e.target.result;
preview.style.display = 'block';
}
reader.readAsDataURL(input.files[0]);
} else {
preview.style.display = 'none';
}
}
// Generate a random product ID
document.getElementById('generate-id-btn').addEventListener('click', function() {
// Generate a random alphanumeric ID
// Format: 3 letters followed by 4 numbers (e.g., ABC1234)
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const numbers = '0123456789';
let id = '';
// Add 3 random uppercase letters
for (let i = 0; i < 3; i++) {
id += letters.charAt(Math.floor(Math.random() * letters.length));
}
// Add 4 random numbers
for (let i = 0; i < 4; i++) {
id += numbers.charAt(Math.floor(Math.random() * numbers.length));
}
// Set the generated ID in the input field
document.getElementById('product_id').value = id;
});
</script>
{% endblock %}

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

@@ -0,0 +1,132 @@
{% extends "layout.html" %}
{% block title %}Admin Dashboard - {{ tenant.name }} - KCAP Demo Server{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-md-12">
<div class="alert alert-info">
<strong>Current Tenant:</strong> {{ tenant.name }} (ID: {{ tenant.id }})
<div class="mt-2">
<a href="{{ url_for('admin.manage_credentials', tenant_id=tenant.id) }}" class="btn btn-sm btn-secondary">Manage Credentials</a>
<a href="{{ url_for('admin.manage_ar_fields', tenant_id=tenant.id) }}" class="btn btn-sm btn-secondary">Manage AR Fields</a>
<a href="{{ url_for('admin.view_all_barcodes', tenant_id=tenant.id) }}" class="btn btn-sm btn-success">
<i class="bi bi-upc-scan"></i> View All Barcodes
</a>
<a href="/" class="btn btn-sm btn-outline-secondary">Switch Tenant</a>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Product Management</h1>
<a href="{{ url_for('admin.add_product', tenant_id=tenant.id) }}" class="btn btn-primary">Add New Product</a>
</div>
{% if products %}
<table class="table table-striped table-bordered align-middle">
<thead class="table-light">
<tr>
<th>Product ID</th>
{% for custom_field in custom_fields %}
{% if custom_field.fieldName != '_id' %}
<th>{{ custom_field.label }}</th>
{% endif %}
{% endfor %}
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for product_id, product_data in products.items() %}
<tr>
<td>{{ product_id }}</td>
{% for custom_field in custom_fields %}
{% if custom_field.fieldName != '_id' %}
<td>
{% for field in product_data %}
{% if field.fieldName == custom_field.fieldName %}
{% if custom_field.fieldType == 'IMAGE_URI' %}
{% if field.value %}
<div class="product-image-container">
<img src="/{{ tenant.id }}{{ field.value }}" alt="{{ custom_field.label }}" class="product-image">
</div>
{% endif %}
{% else %}
{{ field.value }}
{% endif %}
{% endif %}
{% endfor %}
</td>
{% endif %}
{% endfor %}
<td>
<div class="d-flex justify-content-center gap-2">
<a href="{{ url_for('admin.edit_product', tenant_id=tenant.id, product_id=product_id) }}" class="btn btn-sm btn-outline-primary">Edit</a>
<a href="{{ url_for('admin.generate_barcode_page', tenant_id=tenant.id, product_id=product_id) }}" class="btn btn-sm btn-outline-success">Barcodes</a>
<button type="button" class="btn btn-sm btn-outline-danger"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-product-id="{{ product_id }}"
data-product-name="{% for field in product_data %}{% if field.fieldName == '_name' %}{{ field.value }}{% endif %}{% endfor %}">
Delete
</button>
</div>
<form id="delete-form-{{ product_id }}" action="{{ url_for('admin.delete_product', tenant_id=tenant.id, product_id=product_id) }}" method="POST" style="display: none;">
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-info">
No products available. Click "Add New Product" to get started.
</div>
{% endif %}
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteModalLabel">Confirm Deletion</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete the product <strong id="delete-product-name"></strong>?</p>
<p class="text-danger"><i class="bi bi-exclamation-triangle-fill"></i> This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-delete-btn">Delete Product</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Delete confirmation modal functionality
const deleteModal = document.getElementById('deleteModal');
if (deleteModal) {
deleteModal.addEventListener('show.bs.modal', function (event) {
// Button that triggered the modal
const button = event.relatedTarget;
// Extract product info from data attributes
const productId = button.getAttribute('data-product-id');
const productName = button.getAttribute('data-product-name');
// Update modal content
const modalProductName = document.getElementById('delete-product-name');
modalProductName.textContent = productName + " (ID: " + productId + ")";
// Setup the confirm button action
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
confirmDeleteBtn.onclick = function() {
document.getElementById('delete-form-' + productId).submit();
};
});
}
</script>
{% endblock %}

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

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,140 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Select or Create Tenant - KCAP Demo Server</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
</head>
<body>
<div class="container mt-5">
<h1 class="text-center mb-4">KCAP Demo Server - Multi-Tenant</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'success' if category == 'success' else 'danger' }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3>Select an Existing Tenant</h3>
</div>
<div class="card-body">
{% if tenants %}
<div class="list-group">
{% for tenant in tenants %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<a href="/{{ tenant.id }}/" class="text-decoration-none flex-grow-1">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ tenant.name }}</h5>
<small>{{ tenant.created_at }}</small>
</div>
<p class="mb-1">Username: {{ tenant.username }}</p>
<p class="mb-1">
<strong>Knox Capture AR Template URL:</strong>
<code>{{ server_url }}/{{ tenant.id }}/</code>
</p>
<small>ID: {{ tenant.id }}</small>
</a>
<button type="button" class="btn btn-sm btn-outline-primary me-2" data-bs-toggle="modal" data-bs-target="#qrModal-{{ tenant.id }}" onclick="event.stopPropagation();">
<i class="bi bi-qr-code"></i>
</button>
<form method="POST" action="/tenant/{{ tenant.id }}/delete" class="ms-3" onsubmit="return confirmDelete('{{ tenant.name }}')">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted">No tenants exist yet. Create one below!</p>
{% endif %}
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h3>Create a New Tenant</h3>
</div>
<div class="card-body">
<form id="newTenantForm">
<div class="mb-3">
<label for="tenantId" class="form-label">Tenant ID</label>
<input type="text" class="form-control" id="tenantId" placeholder="e.g., demo-company" required pattern="[a-zA-Z0-9_-]+">
<small class="form-text text-muted">Only letters, numbers, hyphens, and underscores allowed</small>
</div>
<button type="submit" class="btn btn-primary">Create & Enter Tenant</button>
</form>
</div>
</div>
<div class="mt-4 text-center">
<p class="text-muted">
<strong>Note:</strong> When you access a tenant URL directly (e.g., /my-tenant/),
it will be automatically created with default credentials (admin/admin).
</p>
<a href="/settings" class="btn btn-secondary mt-3">Server Settings</a>
</div>
</div>
</div>
</div>
<!-- QR Code Modals for each tenant -->
{% for tenant in tenants %}
<div class="modal fade" id="qrModal-{{ tenant.id }}" tabindex="-1" aria-labelledby="qrModalLabel-{{ tenant.id }}" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="qrModalLabel-{{ tenant.id }}">AR Template URL QR Code - {{ tenant.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<p class="text-muted mb-3">Scan this QR code for the Knox Capture AR Template URL:</p>
<div class="mb-3">
<img src="/{{ tenant.id }}/qrcode/template" alt="AR Template QR Code" class="img-fluid" style="max-width: 300px;">
</div>
<code class="d-block mt-2">{{ server_url }}/{{ tenant.id }}/</code>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endfor %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const reservedIds = ['admin', 'api', 'login', 'logout', 'arcontentfields', 'arinfo',
'images', 'barcodes', 'static', 'assets', 'js', 'css', 'tenant',
'auth', 'oauth', 'callback', 'webhook', 'health', 'status'];
document.getElementById('newTenantForm').addEventListener('submit', function(e) {
e.preventDefault();
const tenantId = document.getElementById('tenantId').value;
if (tenantId) {
// Convert to lowercase for consistency
const normalizedId = tenantId.toLowerCase();
if (reservedIds.includes(normalizedId)) {
alert(`"${tenantId}" is a reserved name and cannot be used as a tenant ID.`);
return;
}
// Use the normalized (lowercase) ID in the URL
window.location.href = '/' + normalizedId + '/';
}
});
function confirmDelete(tenantName) {
return confirm(`Are you sure you want to delete the tenant "${tenantName}"?\n\nThis will permanently delete all products and data associated with this tenant.`);
}
</script>
</body>
</html>

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