Add dashboard defaults (visitor-IP -> business-unit) for kiosk displays

Classic feature gap: a shopfloor/lobby kiosk auto-selects which business unit to
show based on the display PC's IP (classic dashboarddefaults table +
apivisitorlocation.asp). For the main admin dashboard this does nothing - it is
kiosk/visitor-display infra.

- Model: DashboardDefault (dashboarddefaults: ipaddress unique, businessunitid
  FK, description). Migration 7d01_dashboarddefaults (head).
- API (core, /api/dashboarddefaults): CRUD + GET /visitor-location that resolves
  the calling display's business unit from its IP (X-Forwarded-For/remote_addr,
  or explicit ?ipaddress=); unmapped IP returns a null businessunitid, not an
  error. Unauthenticated resolve (kiosks); writes are admin.
- Frontend: ShopfloorDashboard auto-selects its business unit via visitor-location
  on load when none is chosen; Settings > Dashboard Defaults CRUD page +
  dashboardDefaultsApi client.

Tests: create + resolve by IP -> BU; unmapped IP -> null; duplicate IP 409.
191 tests pass, naming green, app boots, endpoint + admin page verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 22:09:38 -04:00
parent 85f98e87cb
commit a4b98e10db
12 changed files with 475 additions and 2 deletions

View File

@@ -673,6 +673,26 @@ export const employeesApi = {
// Alias for different casing
export const businessUnitsApi = businessunitsApi
// Dashboard defaults: visitor-IP -> business-unit mapping for kiosks
export const dashboardDefaultsApi = {
list() {
return api.get('/dashboarddefaults')
},
create(data) {
return api.post('/dashboarddefaults', data)
},
update(id, data) {
return api.put(`/dashboarddefaults/${id}`, data)
},
delete(id) {
return api.delete(`/dashboarddefaults/${id}`)
},
// Resolve the calling display's business unit by its IP
visitorLocation() {
return api.get('/dashboarddefaults/visitor-location')
}
}
// System Settings API
export const pluginsApi = {
list() {

View File

@@ -86,6 +86,12 @@ export default [
component: () => import('../../views/settings/BusinessUnitsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/dashboarddefaults',
name: 'dashboarddefaults',
component: () => import('../../views/settings/DashboardDefaultsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/system',
name: 'system-settings',

View File

@@ -129,7 +129,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { notificationsApi, businessUnitsApi } from '@/api'
import { notificationsApi, businessUnitsApi, dashboardDefaultsApi } from '@/api'
const loading = ref(true)
const businessUnit = ref('')
@@ -176,6 +176,19 @@ onMounted(async () => {
console.error('Error loading business units:', err)
}
// Auto-select this kiosk's business unit from its IP (visitor location),
// unless one was already chosen. Falls back to "all" when the IP is unmapped.
if (!businessUnit.value) {
try {
const response = await dashboardDefaultsApi.visitorLocation()
if (response.data.data?.businessunitid) {
businessUnit.value = response.data.data.businessunitid
}
} catch (err) {
console.error('Error resolving visitor location:', err)
}
}
await loadData()
// Auto-refresh every 30 seconds

View File

@@ -0,0 +1,187 @@
<template>
<div>
<div class="page-header">
<h2>Dashboard Defaults</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Mapping</button>
</div>
<p class="setting-description">
Map a kiosk/lobby display's IP address to the business unit it should show.
The shopfloor dashboard auto-selects this business unit by the display's IP
when none is chosen.
</p>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>IP Address</th>
<th>Business Unit</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="d in items" :key="d.dashboarddefaultid">
<td class="mono">{{ d.ipaddress }}</td>
<td>{{ d.businessunit || '-' }}</td>
<td class="cell-truncate" :title="d.description">{{ d.description || '-' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(d)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(d)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No mappings yet
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit Mapping' : 'Add Mapping' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="ipaddress">IP Address *</label>
<input id="ipaddress" v-model="form.ipaddress" type="text" class="form-control"
placeholder="e.g., 10.20.30.40" required />
</div>
<div class="form-group">
<label for="businessunitid">Business Unit *</label>
<select id="businessunitid" v-model="form.businessunitid" class="form-control" required>
<option value="">Select business unit...</option>
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
{{ bu.businessunit }}
</option>
</select>
</div>
<div class="form-group">
<label for="description">Description</label>
<input id="description" v-model="form.description" type="text" class="form-control"
placeholder="e.g., Materials lobby kiosk" />
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header"><h3>Delete Mapping</h3></div>
<div class="modal-body">
<p>Delete the mapping for <strong>{{ toDelete?.ipaddress }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteItem">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { dashboardDefaultsApi, businessUnitsApi } from '../../api'
const items = ref([])
const businessUnits = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
const form = ref({ ipaddress: '', businessunitid: '', description: '' })
onMounted(async () => {
try {
const response = await businessUnitsApi.list({ perpage: 100 })
businessUnits.value = response.data.data || []
} catch (err) {
console.error('Error loading business units:', err)
}
await loadData()
})
async function loadData() {
loading.value = true
try {
const response = await dashboardDefaultsApi.list()
items.value = response.data.data || []
} catch (err) {
console.error('Error loading dashboard defaults:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item ? {
ipaddress: item.ipaddress || '',
businessunitid: item.businessunitid || '',
description: item.description || ''
} : { ipaddress: '', businessunitid: '', description: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await dashboardDefaultsApi.update(editing.value.dashboarddefaultid, form.value)
} else {
await dashboardDefaultsApi.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
async function deleteItem() {
try {
await dashboardDefaultsApi.delete(toDelete.value.dashboarddefaultid)
showDeleteModal.value = false
toDelete.value = null
loadData()
} catch (err) {
alert('Failed to delete')
}
}
</script>

View File

@@ -57,6 +57,12 @@
<p>Manage organizational units</p>
</router-link>
<router-link to="/settings/dashboarddefaults" class="settings-card">
<div class="card-icon"><MonitorSmartphone :size="28" /></div>
<h3>Dashboard Defaults</h3>
<p>Map kiosk IPs to a default business unit</p>
</router-link>
<router-link to="/settings/vlans" class="settings-card">
<div class="card-icon"><Globe :size="28" /></div>
<h3>VLANs</h3>
@@ -97,7 +103,7 @@
</template>
<script setup>
import { Factory, MapPin, Tag, Package, Droplets, Monitor, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle } from 'lucide-vue-next'
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle } from 'lucide-vue-next'
</script>
<style scoped>

View File

@@ -0,0 +1,37 @@
"""Add dashboarddefaults (visitor-IP -> business-unit mapping)
A shopfloor / lobby kiosk resolves which business unit to display from the
display PC's IP. Powers the visitor-location lookup.
Revision ID: 7d01_dashboarddefaults
Revises: 7c04_fold_plugin_schema
Create Date: 2026-06-27
"""
from alembic import op
import sqlalchemy as sa
revision = '7d01_dashboarddefaults'
down_revision = '7c04_fold_plugin_schema'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'dashboarddefaults',
sa.Column('dashboarddefaultid', sa.Integer(), primary_key=True),
sa.Column('ipaddress', sa.String(length=50), nullable=False, unique=True),
sa.Column('businessunitid', sa.Integer(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=True),
sa.Column('modifieddate', sa.DateTime(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'],
name='fk_dashboarddefaults_businessunit'),
)
def downgrade():
op.drop_table('dashboarddefaults')

View File

@@ -101,6 +101,7 @@ CORE_BLUEPRINT_NAMES = (
'locations',
'operatingsystems',
'dashboard',
'dashboarddefaults',
'applications',
'search',
'reports',

View File

@@ -10,6 +10,7 @@ from .businessunits import businessunits_bp
from .locations import locations_bp
from .operatingsystems import operatingsystems_bp
from .dashboard import dashboard_bp
from .dashboarddefaults import dashboarddefaults_bp
from .applications import applications_bp
from .search import search_bp
from .reports import reports_bp
@@ -29,6 +30,7 @@ __all__ = [
'locations_bp',
'operatingsystems_bp',
'dashboard_bp',
'dashboarddefaults_bp',
'applications_bp',
'search_bp',
'reports_bp',

View File

@@ -0,0 +1,127 @@
"""Dashboard defaults API: visitor-IP -> business-unit mapping.
CRUD for the mappings plus a resolve endpoint a kiosk/lobby dashboard calls to
auto-select its business unit from the display PC's IP.
"""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import DashboardDefault, BusinessUnit, AuditLog
from shopdb.utils.responses import success_response, error_response, ErrorCodes
dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__)
def _request_ip():
"""Caller IP, honoring a single proxy hop via X-Forwarded-For."""
forwarded = request.headers.get('X-Forwarded-For')
if forwarded:
return forwarded.split(',')[0].strip()
return request.remote_addr
def _serialize(default):
data = default.to_dict()
data['businessunit'] = default.businessunit.businessunit \
if default.businessunit else None
return data
@dashboarddefaults_bp.route('/visitor-location', methods=['GET'])
def visitor_location():
"""Resolve the business unit for the calling display by its IP.
Unauthenticated: kiosks/lobby displays hit this. Returns the mapped
business unit, or a null businessunitid when the IP is not mapped.
"""
ipaddress = request.args.get('ipaddress') or _request_ip()
default = DashboardDefault.query.filter_by(
ipaddress=ipaddress, isactive=True).first()
if not default:
return success_response({
'ipaddress': ipaddress,
'businessunitid': None,
'businessunit': None,
})
return success_response({
'ipaddress': ipaddress,
'businessunitid': default.businessunitid,
'businessunit': default.businessunit.businessunit
if default.businessunit else None,
})
@dashboarddefaults_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_defaults():
"""List all visitor-IP -> business-unit mappings."""
defaults = DashboardDefault.query.filter_by(isactive=True).order_by(
DashboardDefault.ipaddress).all()
return success_response([_serialize(d) for d in defaults])
@dashboarddefaults_bp.route('', methods=['POST'])
@jwt_required()
def create_default():
"""Create a visitor-IP -> business-unit mapping."""
data = request.get_json() or {}
ipaddress = (data.get('ipaddress') or '').strip()
businessunitid = data.get('businessunitid')
if not ipaddress:
return error_response(ErrorCodes.VALIDATION_ERROR, 'ipaddress is required')
if not businessunitid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunitid is required')
if not BusinessUnit.query.get(businessunitid):
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
http_code=404)
if DashboardDefault.query.filter_by(ipaddress=ipaddress, isactive=True).first():
return error_response(ErrorCodes.CONFLICT,
f"IP {ipaddress} is already mapped", http_code=409)
default = DashboardDefault(ipaddress=ipaddress, businessunitid=businessunitid,
description=data.get('description'))
db.session.add(default)
db.session.flush()
AuditLog.log('created', 'DashboardDefault', entityid=default.dashboarddefaultid,
entityname=ipaddress)
db.session.commit()
return success_response(_serialize(default), message='Mapping created',
http_code=201)
@dashboarddefaults_bp.route('/<int:default_id>', methods=['PUT'])
@jwt_required()
def update_default(default_id):
"""Update a mapping."""
default = DashboardDefault.query.get(default_id)
if not default:
return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404)
data = request.get_json() or {}
if 'businessunitid' in data:
if not BusinessUnit.query.get(data['businessunitid']):
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
http_code=404)
default.businessunitid = data['businessunitid']
if 'ipaddress' in data and data['ipaddress']:
default.ipaddress = data['ipaddress'].strip()
if 'description' in data:
default.description = data['description']
db.session.commit()
return success_response(_serialize(default), message='Mapping updated')
@dashboarddefaults_bp.route('/<int:default_id>', methods=['DELETE'])
@jwt_required()
def delete_default(default_id):
"""Delete (deactivate) a mapping."""
default = DashboardDefault.query.get(default_id)
if not default:
return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404)
default.isactive = False
db.session.commit()
return success_response(message='Mapping deleted')

View File

@@ -6,6 +6,7 @@ from .machine import MachineType
from .vendor import Vendor
from .model import Model
from .businessunit import BusinessUnit
from .dashboarddefault import DashboardDefault
from .location import Location, LocationType
from .operatingsystem import OperatingSystem
from .relationship import AssetRelationship, RelationshipType
@@ -30,6 +31,7 @@ __all__ = [
'Vendor',
'Model',
'BusinessUnit',
'DashboardDefault',
'Location',
'LocationType',
'OperatingSystem',

View File

@@ -0,0 +1,28 @@
"""Dashboard default model: visitor-IP -> business-unit mapping.
A shopfloor / lobby kiosk display resolves which business unit to show by the
display PC's IP address. Powers the visitor-location lookup the shopfloor and
TV dashboards call when no explicit business unit is given.
"""
from shopdb.extensions import db
from .base import BaseModel
class DashboardDefault(BaseModel):
"""Maps a display PC IP address to the business unit it should show."""
__tablename__ = 'dashboarddefaults'
dashboarddefaultid = db.Column(db.Integer, primary_key=True)
ipaddress = db.Column(db.String(50), unique=True, nullable=False)
businessunitid = db.Column(
db.Integer,
db.ForeignKey('businessunits.businessunitid'),
nullable=False
)
description = db.Column(db.String(255))
businessunit = db.relationship('BusinessUnit')
def __repr__(self):
return f"<DashboardDefault {self.ipaddress} -> {self.businessunitid}>"

View File

@@ -0,0 +1,44 @@
"""Tests for dashboard defaults (visitor-IP -> business-unit resolution)."""
import pytest
@pytest.fixture
def businessunit(db):
from shopdb.core.models import BusinessUnit
bu = BusinessUnit(businessunit='Materials')
db.session.add(bu)
db.session.commit()
return bu
def test_create_and_resolve_visitor_location(client, db, auth_headers, businessunit):
"""A mapped IP resolves to its business unit; explicit ipaddress param works."""
created = client.post('/api/dashboarddefaults', json={
'ipaddress': '10.20.30.40',
'businessunitid': businessunit.businessunitid,
'description': 'Materials lobby kiosk',
}, headers=auth_headers)
assert created.status_code == 201, created.get_json()
resolved = client.get('/api/dashboarddefaults/visitor-location?ipaddress=10.20.30.40')
assert resolved.status_code == 200
data = resolved.get_json()['data']
assert data['businessunitid'] == businessunit.businessunitid
assert data['businessunit'] == 'Materials'
def test_unmapped_ip_resolves_to_null(client, db):
"""An unmapped IP returns a null business unit, not an error."""
resolved = client.get('/api/dashboarddefaults/visitor-location?ipaddress=1.2.3.4')
assert resolved.status_code == 200
assert resolved.get_json()['data']['businessunitid'] is None
def test_duplicate_ip_rejected(client, db, auth_headers, businessunit):
"""Mapping the same IP twice is a conflict."""
payload = {'ipaddress': '10.0.0.9', 'businessunitid': businessunit.businessunitid}
first = client.post('/api/dashboarddefaults', json=payload, headers=auth_headers)
assert first.status_code == 201
dup = client.post('/api/dashboarddefaults', json=payload, headers=auth_headers)
assert dup.status_code == 409