Add system settings, audit logging, user management, and dark mode fixes

System Settings:
- Add SystemSettings.vue with Zabbix integration, SMTP/email config, SAML SSO settings
- Add Setting model with key-value storage and typed values
- Add settings API with caching

Audit Logging:
- Add AuditLog model tracking user, IP, action, entity changes
- Add comprehensive audit logging to all CRUD operations:
  - Machines, Computers, Equipment, Network devices, VLANs, Subnets
  - Printers, USB devices (including checkout/checkin)
  - Applications, Settings, Users/Roles
- Track old/new values for all field changes
- Mask sensitive values (passwords, tokens) in logs

User Management:
- Add UsersList.vue with full user CRUD
- Add Role management with granular permissions
- Add 41 predefined permissions across 10 categories
- Add users API with roles and permissions endpoints

Reports:
- Add TonerReport.vue for printer supply monitoring

Dark Mode Fixes:
- Fix map position section in PCForm, PrinterForm
- Fix alert-warning in KnowledgeBaseDetail
- All components now use CSS variables for theming

CLI Commands:
- Add flask seed permissions
- Add flask seed settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-02-04 22:16:56 -05:00
parent 9efdb5f52d
commit e18c7c2d87
40 changed files with 4221 additions and 39 deletions

View File

@@ -13,3 +13,8 @@ JWT_REFRESH_TOKEN_EXPIRES=2592000
# Logging # Logging
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Zabbix Integration (optional - for printer supply monitoring)
ZABBIX_ENABLED=false
ZABBIX_URL=http://zabbix.example.com:8080
ZABBIX_TOKEN=your-zabbix-api-token

View File

@@ -668,6 +668,75 @@ export const employeesApi = {
// Alias for different casing // Alias for different casing
export const businessUnitsApi = businessunitsApi export const businessUnitsApi = businessunitsApi
// System Settings API
export const settingsApi = {
list(params = {}) {
return api.get('/settings', { params })
},
get(key) {
return api.get(`/settings/${key}`)
},
update(key, value) {
return api.put(`/settings/${key}`, { value })
},
create(data) {
return api.post('/settings', data)
}
}
// Audit Logs API
export const auditLogsApi = {
list(params = {}) {
return api.get('/auditlogs', { params })
},
getEntityHistory(entitytype, entityid) {
return api.get(`/auditlogs/entity/${entitytype}/${entityid}`)
},
getStats() {
return api.get('/auditlogs/stats')
}
}
// Users API
export const usersApi = {
list() {
return api.get('/users')
},
get(id) {
return api.get(`/users/${id}`)
},
create(data) {
return api.post('/users', data)
},
update(id, data) {
return api.put(`/users/${id}`, data)
},
delete(id) {
return api.delete(`/users/${id}`)
},
// Permissions
permissions: {
list() {
return api.get('/users/permissions')
}
},
// Roles
roles: {
list() {
return api.get('/users/roles')
},
create(data) {
return api.post('/users/roles', data)
},
update(id, data) {
return api.put(`/users/roles/${id}`, data)
},
delete(id) {
return api.delete(`/users/roles/${id}`)
}
}
}
// Network API (devices, subnets, and VLANs) // Network API (devices, subnets, and VLANs)
export const networkApi = { export const networkApi = {
// Network devices // Network devices

View File

@@ -1506,4 +1506,73 @@ td.actions {
border-radius: 0.25rem; border-radius: 0.25rem;
} }
/* Notification Banner */
.notification-banner {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.notification-item {
flex: 1;
min-width: 250px;
padding: 0.625rem 0.875rem;
border-radius: 6px;
border-left: 4px solid var(--border);
background: var(--bg-card);
}
.notification-item.type-incident {
border-left-color: var(--danger);
background: rgba(245, 54, 92, 0.06);
}
.notification-item.type-change {
border-left-color: var(--warning);
background: rgba(255, 136, 0, 0.06);
}
.notification-item.type-awareness {
border-left-color: var(--success);
background: rgba(4, 185, 98, 0.06);
}
.notification-text {
display: block;
font-size: 0.85rem;
font-weight: 500;
color: var(--text);
}
.notification-meta {
display: flex;
gap: 0.75rem;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--text-light);
}
.notification-ticket {
color: var(--link);
text-decoration: none;
font-family: monospace;
}
.notification-ticket:hover {
text-decoration: underline;
}
@media (prefers-color-scheme: dark) {
.notification-item.type-incident {
background: rgba(245, 54, 92, 0.1);
}
.notification-item.type-change {
background: rgba(255, 136, 0, 0.1);
}
.notification-item.type-awareness {
background: rgba(4, 185, 98, 0.1);
}
}
/* Light mode is now default, dark mode via prefers-color-scheme */ /* Light mode is now default, dark mode via prefers-color-scheme */

View File

@@ -33,6 +33,11 @@ export default [
name: 'report-pc-relationships', name: 'report-pc-relationships',
component: () => import('../../views/reports/PCRelationshipsReport.vue') component: () => import('../../views/reports/PCRelationshipsReport.vue')
}, },
{
path: 'reports/toner',
name: 'toner-report',
component: () => import('../../views/reports/TonerReport.vue')
},
{ {
path: 'employees/:sso', path: 'employees/:sso',
name: 'employee-detail', name: 'employee-detail',
@@ -80,5 +85,23 @@ export default [
name: 'businessunits', name: 'businessunits',
component: () => import('../../views/settings/BusinessUnitsList.vue'), component: () => import('../../views/settings/BusinessUnitsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true } meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/system',
name: 'system-settings',
component: () => import('../../views/settings/SystemSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/auditlogs',
name: 'audit-logs',
component: () => import('../../views/settings/AuditLogs.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/users',
name: 'users',
component: () => import('../../views/settings/UsersList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
} }
] ]

View File

@@ -48,6 +48,25 @@
</aside> </aside>
<main class="main-content"> <main class="main-content">
<div v-if="activeNotifications.length" class="notification-banner">
<div
v-for="n in activeNotifications"
:key="n.notificationid"
class="notification-item"
:class="getNotificationType(n)"
>
<span class="notification-text">{{ n.notification }}</span>
<span class="notification-meta">
<span v-if="n.starttime" class="notification-date">{{ formatDate(n.starttime) }}</span>
<a
v-if="n.ticketnumber"
:href="`https://geit.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/${n.ticketnumber}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/back-button-label/IT4IT%20Homepage/search-context/now%2Fnav%2Fui`"
target="_blank"
class="notification-ticket"
>{{ n.ticketnumber }}</a>
</span>
</div>
</div>
<router-view /> <router-view />
</main> </main>
</div> </div>
@@ -62,12 +81,13 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme' import { currentTheme, toggleTheme } from '../stores/theme'
import { dashboardApi } from '../api' import { dashboardApi, notificationsApi } from '../api'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const searchQuery = ref('') const searchQuery = ref('')
const navItems = ref([]) const navItems = ref([])
const activeNotifications = ref([])
// Map backend icon names to Lucide components // Map backend icon names to Lucide components
const iconMap = { const iconMap = {
@@ -136,11 +156,31 @@ onMounted(async () => {
const response = await dashboardApi.navigation() const response = await dashboardApi.navigation()
navItems.value = buildNavItems(response.data.data || []) navItems.value = buildNavItems(response.data.data || [])
} catch (err) { } catch (err) {
// Fall back to default navigation if API unavailable
navItems.value = buildNavItems(defaultNav) navItems.value = buildNavItems(defaultNav)
} }
try {
const resp = await notificationsApi.active()
activeNotifications.value = resp.data.data?.notifications || []
} catch (err) {
// Notifications banner is non-critical
}
}) })
function getNotificationType(n) {
const type = (n.typename || '').toLowerCase()
if (type.includes('incident')) return 'type-incident'
if (type.includes('change')) return 'type-change'
if (type.includes('awareness')) return 'type-awareness'
return 'type-default'
}
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
function performSearch() { function performSearch() {
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
router.push({ path: '/search', query: { q: searchQuery.value.trim() } }) router.push({ path: '/search', query: { q: searchQuery.value.trim() } })

View File

@@ -202,4 +202,11 @@ hr {
background: #fff3cd; background: #fff3cd;
color: #856404; color: #856404;
} }
@media (prefers-color-scheme: dark) {
.alert-warning {
background: #3d3200;
color: #ffc107;
}
}
</style> </style>

View File

@@ -83,7 +83,7 @@
</div> </div>
</div> </div>
<div class="form-group"> <div v-if="!isRecognition" class="form-group">
<label for="businessunitid">Business Unit</label> <label for="businessunitid">Business Unit</label>
<select <select
id="businessunitid" id="businessunitid"
@@ -103,7 +103,7 @@
<small class="form-hint">Leave blank to apply to all</small> <small class="form-hint">Leave blank to apply to all</small>
</div> </div>
<div class="form-group"> <div v-if="!isRecognition" class="form-group">
<label for="appid">Related Application</label> <label for="appid">Related Application</label>
<select <select
id="appid" id="appid"
@@ -327,6 +327,12 @@ onMounted(async () => {
} else { } else {
// Set default start date to now // Set default start date to now
form.value.starttime = formatDateForInput(new Date().toISOString()) form.value.starttime = formatDateForInput(new Date().toISOString())
// Default to Recognition type
const recognitionType = types.value.find(t => t.typename?.toLowerCase() === 'recognition')
if (recognitionType) {
form.value.notificationtypeid = recognitionType.notificationtypeid
onTypeChange()
}
} }
} catch (err) { } catch (err) {
console.error('Error loading data:', err) console.error('Error loading data:', err)

View File

@@ -460,9 +460,11 @@ async function savePC() {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: #e3f2fd; background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
font-family: monospace; font-family: monospace;
color: var(--text);
} }
.map-modal-content { .map-modal-content {

View File

@@ -120,17 +120,44 @@ function generateQRCodes() {
QRCode.toCanvas(canvas, qrUrl, { QRCode.toCanvas(canvas, qrUrl, {
width: 144, width: 144,
margin: 0, margin: 0,
errorCorrectionLevel: 'M' errorCorrectionLevel: 'H'
}).then(() => {
drawLogoOverlay(canvas)
}).catch(err => console.error('QR error:', err)) }).catch(err => console.error('QR error:', err))
}) })
}) })
} }
function drawLogoOverlay(canvas) {
const ctx = canvas.getContext('2d')
const size = canvas.width
const logoSize = Math.round(size * 0.22)
const x = (size - logoSize) / 2
const y = (size - logoSize) / 2
// White circle background
ctx.beginPath()
ctx.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2)
ctx.fillStyle = '#fff'
ctx.fill()
// Load and draw the GE monogram
const img = new Image()
img.onload = () => {
ctx.drawImage(img, 0, 0, 32.5, 32, x, y, logoSize, logoSize)
}
const svgStr = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr)
}
function displayName(printer) { function displayName(printer) {
return printer.assetnumber || printer.name || `Printer-${printer.assetid}` return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
} }
function getIp(printer) { function getIp(printer) {
// Check direct ipaddress field first (from list API)
if (printer.ipaddress && printer.ipaddress !== 'USB') return printer.ipaddress
// Fall back to communications array (from detail API)
if (!printer.communications?.length) return null if (!printer.communications?.length) return null
const primary = printer.communications.find(c => c.isprimary) || printer.communications[0] const primary = printer.communications.find(c => c.isprimary) || printer.communications[0]
return primary?.ipaddress || primary?.address || null return primary?.ipaddress || primary?.address || null
@@ -276,7 +303,7 @@ function print() {
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; } .info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; } .info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; } .info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; } .csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
.empty-label { color: #999; font-size: 14px; } .empty-label { color: #999; font-size: 14px; }
@media print { @media print {

View File

@@ -56,6 +56,9 @@ const position = ref('1')
const qrCanvas = ref(null) const qrCanvas = ref(null)
const ipAddress = computed(() => { const ipAddress = computed(() => {
// Check direct ipaddress field first (from list API)
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
// Fall back to communications array (from detail API)
if (!printer.value?.communications?.length) return null if (!printer.value?.communications?.length) return null
const primary = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0] const primary = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
return primary?.ipaddress || primary?.address || null return primary?.ipaddress || primary?.address || null
@@ -87,10 +90,36 @@ function generateQR() {
QRCode.toCanvas(canvas, qrUrl, { QRCode.toCanvas(canvas, qrUrl, {
width: 144, width: 144,
margin: 0, margin: 0,
errorCorrectionLevel: 'M' errorCorrectionLevel: 'H'
}).then(() => {
drawLogoOverlay(canvas)
}).catch(err => console.error('QR error:', err)) }).catch(err => console.error('QR error:', err))
} }
function drawLogoOverlay(canvas) {
const ctx = canvas.getContext('2d')
const size = canvas.width
const logoSize = Math.round(size * 0.22)
const x = (size - logoSize) / 2
const y = (size - logoSize) / 2
// White circle background
ctx.beginPath()
ctx.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2)
ctx.fillStyle = '#fff'
ctx.fill()
// Load and draw the GE monogram (the circular part of the SVG)
const img = new Image()
img.onload = () => {
// Draw only the GE monogram portion (left 32x32 of the 138x32 SVG)
ctx.drawImage(img, 0, 0, 32.5, 32, x, y, logoSize, logoSize)
}
// Use a data URI with black fill for the monogram
const svgStr = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr)
}
function print() { function print() {
window.print() window.print()
} }
@@ -157,7 +186,7 @@ function print() {
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; } .info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; } .info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; } .info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; } .csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
@media print { @media print {
body { padding: 0; margin: 0; background: white; } body { padding: 0; margin: 0; background: white; }

View File

@@ -566,9 +566,11 @@ async function savePrinter() {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: #e3f2fd; background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
font-family: monospace; font-family: monospace;
color: var(--text);
} }
.map-modal-content { .map-modal-content {

View File

@@ -4,6 +4,11 @@
</div> </div>
<div class="reports-grid"> <div class="reports-grid">
<div class="report-card card" @click="router.push('/reports/toner')">
<h3>Toner Report</h3>
<p>View printers with low or critical toner/supply levels</p>
<span class="badge">Printers</span>
</div>
<div v-for="report in reports" :key="report.id" class="report-card card" @click="runReport(report)"> <div v-for="report in reports" :key="report.id" class="report-card card" @click="runReport(report)">
<h3>{{ report.name }}</h3> <h3>{{ report.name }}</h3>
<p>{{ report.description }}</p> <p>{{ report.description }}</p>

View File

@@ -0,0 +1,251 @@
<template>
<div>
<div class="page-header">
<h1>Toner Report</h1>
<div class="header-actions">
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading supply data...</div>
<div v-else-if="error" class="card">
<p class="text-danger">{{ error }}</p>
</div>
<template v-else>
<!-- Summary Bar -->
<div class="summary-bar">
<div class="summary-stat card">
<div class="stat-value">{{ summary.total_checked }}</div>
<div class="stat-label">Printers Checked</div>
</div>
<div class="summary-stat card stat-low">
<div class="stat-value">{{ summary.low }}</div>
<div class="stat-label">Low Supply</div>
</div>
<div class="summary-stat card stat-critical">
<div class="stat-value">{{ summary.critical }}</div>
<div class="stat-label">Critical Supply</div>
</div>
</div>
<!-- Filter Buttons -->
<div class="filters">
<button
v-for="f in filterOptions"
:key="f.value"
class="btn"
:class="filter === f.value ? 'btn-primary' : 'btn-secondary'"
@click="filter = f.value"
>
{{ f.label }}
</button>
</div>
<!-- Printers Table -->
<div class="card">
<div class="table-container">
<table v-if="filteredPrinters.length">
<thead>
<tr>
<th>Printer Name</th>
<th>Asset #</th>
<th>Location</th>
<th>IP Address</th>
<th>Supplies</th>
</tr>
</thead>
<tbody>
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
<td>
<router-link :to="`/printers/${printer.printerid}`">
{{ printer.printername || 'Unknown' }}
</router-link>
</td>
<td>{{ printer.assetnumber }}</td>
<td>{{ printer.location || '-' }}</td>
<td>{{ printer.ipaddress }}</td>
<td class="supplies-cell">
<div
v-for="(supply, idx) in printer.supplies"
:key="idx"
class="supply-row"
>
<span class="supply-name">{{ supply.name }}</span>
<div class="supply-bar-track">
<div
class="supply-bar-fill"
:class="'supply-' + supply.status"
:style="{ width: Math.max(supply.level, 2) + '%' }"
></div>
</div>
<span class="supply-level" :class="'supply-text-' + supply.status">
{{ supply.level }}%
</span>
</div>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-state">No printers match the selected filter.</p>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '@/api'
const loading = ref(true)
const error = ref(null)
const printers = ref([])
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
const filter = ref('all')
const filterOptions = [
{ label: 'All', value: 'all' },
{ label: 'Critical', value: 'critical' },
{ label: 'Low', value: 'low' }
]
const filteredPrinters = computed(() => {
if (filter.value === 'all') return printers.value
return printers.value.filter(p =>
p.supplies.some(s => s.status === filter.value)
)
})
onMounted(async () => {
try {
const response = await printersApi.lowSupplies()
const data = response.data.data
printers.value = data.printers || []
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
} catch (err) {
console.error('Error loading toner report:', err)
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
} finally {
loading.value = false
}
})
</script>
<style scoped>
.summary-bar {
display: flex;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.summary-stat {
flex: 1;
text-align: center;
padding: 1.25rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--text);
}
.stat-label {
color: var(--text-light);
margin-top: 0.25rem;
}
.stat-low .stat-value {
color: var(--warning);
}
.stat-critical .stat-value {
color: var(--danger);
}
.supplies-cell {
min-width: 280px;
}
.supply-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.supply-row:last-child {
margin-bottom: 0;
}
.supply-name {
flex: 0 0 120px;
font-size: 0.85rem;
color: var(--text-light);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.supply-bar-track {
flex: 1;
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
}
.supply-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.supply-ok {
background: var(--success);
}
.supply-low {
background: var(--warning);
}
.supply-critical {
background: var(--danger);
}
.supply-level {
flex: 0 0 40px;
text-align: right;
font-size: 0.85rem;
font-weight: 600;
}
.supply-text-ok {
color: var(--success);
}
.supply-text-low {
color: var(--warning);
}
.supply-text-critical {
color: var(--danger);
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-light);
}
.text-danger {
color: var(--danger);
}
.header-actions {
display: flex;
gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,367 @@
<template>
<div class="page-header">
<h1>Audit Logs</h1>
</div>
<div class="filters">
<input
type="text"
v-model="search"
placeholder="Search by name or user..."
@input="debouncedSearch"
>
<select v-model="filterAction" @change="loadLogs">
<option value="">All Actions</option>
<option value="created">Created</option>
<option value="updated">Updated</option>
<option value="deleted">Deleted</option>
</select>
<select v-model="filterEntity" @change="loadLogs">
<option value="">All Entities</option>
<option v-for="e in entityTypes" :key="e" :value="e">{{ e }}</option>
</select>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th style="width: 160px">Timestamp</th>
<th style="width: 100px">Action</th>
<th style="width: 100px">Entity</th>
<th>Name/ID</th>
<th style="width: 120px">User</th>
<th style="width: 130px">IP Address</th>
<th>Changes</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="loading">Loading...</td>
</tr>
<tr v-else-if="!logs.length">
<td colspan="7" class="empty">No audit logs found</td>
</tr>
<tr v-for="log in logs" :key="log.auditlogid">
<td class="timestamp">{{ formatDate(log.timestamp) }}</td>
<td>
<span class="badge" :class="actionClass(log.action)">
{{ log.action }}
</span>
</td>
<td>{{ log.entitytype }}</td>
<td>
<router-link
v-if="getEntityLink(log)"
:to="getEntityLink(log)"
class="entity-link"
>
{{ log.entityname || `#${log.entityid}` }}
</router-link>
<span v-else>{{ log.entityname || `#${log.entityid}` }}</span>
</td>
<td>{{ log.username || '-' }}</td>
<td class="ip">{{ log.ipaddress || '-' }}</td>
<td>
<button
v-if="log.changes && Object.keys(log.changes).length"
class="changes-btn"
@click="showChanges(log)"
>
{{ Object.keys(log.changes).length }} field(s)
</button>
<span v-else class="no-changes">-</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" v-if="totalPages > 1">
<button :disabled="page <= 1" @click="page--; loadLogs()">Prev</button>
<span>Page {{ page }} of {{ totalPages }}</span>
<button :disabled="page >= totalPages" @click="page++; loadLogs()">Next</button>
</div>
</div>
<!-- Changes Modal -->
<div v-if="selectedLog" class="modal-overlay" @click.self="selectedLog = null">
<div class="modal">
<div class="modal-header">
<h3>Changes - {{ selectedLog.entitytype }} {{ selectedLog.entityname }}</h3>
<button class="close-btn" @click="selectedLog = null">&times;</button>
</div>
<div class="modal-body">
<table class="changes-table">
<thead>
<tr>
<th>Field</th>
<th>Old Value</th>
<th>New Value</th>
</tr>
</thead>
<tbody>
<tr v-for="(change, field) in selectedLog.changes" :key="field">
<td>{{ field }}</td>
<td class="old-value">{{ formatValue(change.old) }}</td>
<td class="new-value">{{ formatValue(change.new) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { auditLogsApi } from '../../api'
const logs = ref([])
const loading = ref(true)
const page = ref(1)
const perpage = 50
const total = ref(0)
const search = ref('')
const filterAction = ref('')
const filterEntity = ref('')
const selectedLog = ref(null)
const entityTypes = ['Asset', 'Printer', 'Computer', 'Equipment', 'Network', 'Setting', 'User', 'Application', 'KnowledgeBase']
const totalPages = computed(() => Math.ceil(total.value / perpage))
let debounceTimer = null
function debouncedSearch() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
page.value = 1
loadLogs()
}, 300)
}
async function loadLogs() {
loading.value = true
try {
const params = {
page: page.value,
perpage
}
if (search.value) params.search = search.value
if (filterAction.value) params.action = filterAction.value
if (filterEntity.value) params.entitytype = filterEntity.value
const { data } = await auditLogsApi.list(params)
logs.value = data.data
total.value = data.meta.total
} catch (e) {
console.error('Failed to load audit logs:', e)
} finally {
loading.value = false
}
}
function formatDate(isoString) {
if (!isoString) return '-'
const d = new Date(isoString)
return d.toLocaleString()
}
function actionClass(action) {
switch (action) {
case 'created': return 'badge-success'
case 'updated': return 'badge-warning'
case 'deleted': return 'badge-danger'
default: return ''
}
}
function getEntityLink(log) {
if (!log.entityid) return null
const type = log.entitytype?.toLowerCase()
switch (type) {
case 'printer': return `/printers/${log.entityid}`
case 'computer': return `/pcs/${log.entityid}`
case 'equipment': return `/equipment/${log.entityid}`
case 'asset': return `/assets/${log.entityid}`
case 'application': return `/applications/${log.entityid}`
case 'knowledgebase': return `/kb/${log.entityid}`
default: return null
}
}
function showChanges(log) {
selectedLog.value = log
}
function formatValue(val) {
if (val === null || val === undefined) return '(empty)'
if (typeof val === 'boolean') return val ? 'Yes' : 'No'
if (typeof val === 'object') return JSON.stringify(val)
return String(val)
}
onMounted(loadLogs)
</script>
<style scoped>
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters input,
.filters select {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-card);
color: var(--text);
}
.filters input {
min-width: 250px;
}
.timestamp {
font-size: 0.85rem;
color: var(--text-light);
white-space: nowrap;
}
.ip {
font-family: monospace;
font-size: 0.85rem;
}
.badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
text-transform: capitalize;
}
.badge-success { background: var(--success); color: white; }
.badge-warning { background: var(--warning); color: #000; }
.badge-danger { background: var(--danger); color: white; }
.entity-link {
color: var(--link);
text-decoration: none;
}
.entity-link:hover {
text-decoration: underline;
}
.changes-btn {
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.changes-btn:hover {
background: var(--primary-dark);
}
.no-changes {
color: var(--text-light);
}
.loading, .empty {
text-align: center;
color: var(--text-light);
padding: 2rem;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
min-width: 500px;
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
font-size: 1rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-light);
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: var(--text);
}
.modal-body {
padding: 1rem;
overflow-y: auto;
}
.changes-table {
width: 100%;
border-collapse: collapse;
}
.changes-table th,
.changes-table td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.changes-table th {
background: var(--bg);
font-weight: 600;
}
.old-value {
color: var(--danger);
text-decoration: line-through;
}
.new-value {
color: var(--success);
}
</style>

View File

@@ -62,12 +62,30 @@
<h3>Subnets</h3> <h3>Subnets</h3>
<p>Manage IP subnets and DHCP</p> <p>Manage IP subnets and DHCP</p>
</router-link> </router-link>
<router-link to="/settings/system" class="settings-card">
<div class="card-icon"><Settings :size="28" /></div>
<h3>System Settings</h3>
<p>Configure integrations and system options</p>
</router-link>
<router-link to="/settings/auditlogs" class="settings-card">
<div class="card-icon"><FileText :size="28" /></div>
<h3>Audit Logs</h3>
<p>View system activity and change history</p>
</router-link>
<router-link to="/settings/users" class="settings-card">
<div class="card-icon"><Users :size="28" /></div>
<h3>Users & Roles</h3>
<p>Manage user accounts and permissions</p>
</router-link>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { Factory, MapPin, Tag, Package, Monitor, Laptop, Cog, Building, Globe, Link } from 'lucide-vue-next' import { Factory, MapPin, Tag, Package, Monitor, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users } from 'lucide-vue-next'
</script> </script>
<style scoped> <style scoped>

View File

@@ -0,0 +1,742 @@
<template>
<div class="page-header">
<h1>System Settings</h1>
</div>
<div class="settings-sections">
<!-- Integrations Section -->
<div class="section-card">
<h2 class="section-title">Integrations</h2>
<div class="setting-group">
<h3>Zabbix Integration</h3>
<p class="setting-description">
Connect to Zabbix for real-time printer supply monitoring. When enabled, supply levels
are fetched from Zabbix API and displayed on printer detail pages.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Enable Zabbix Integration</span>
<button
class="toggle-btn"
:class="{ active: settings.zabbix_enabled }"
@click="toggleSetting('zabbix_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<div class="setting-row" v-if="settings.zabbix_enabled">
<label>
<span>Zabbix API URL</span>
<input
type="url"
v-model="settings.zabbix_url"
placeholder="http://zabbix.example.com:8080"
@blur="saveSetting('zabbix_url', settings.zabbix_url)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row" v-if="settings.zabbix_enabled">
<label>
<span>Zabbix API Token</span>
<input
type="password"
v-model="settings.zabbix_token"
placeholder="Enter API token"
@blur="saveSetting('zabbix_token', settings.zabbix_token)"
:disabled="saving"
>
</label>
</div>
<div class="status-indicator" v-if="settings.zabbix_enabled">
<span class="status-dot" :class="zabbixStatus"></span>
<span>{{ zabbixMessage }}</span>
</div>
</div>
</div>
<!-- Email Section -->
<div class="section-card">
<h2 class="section-title">Email / SMTP</h2>
<div class="setting-group">
<h3>Email Notifications</h3>
<p class="setting-description">
Configure SMTP settings to enable email notifications for alerts, toner reports,
and other system notifications.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Enable Email Notifications</span>
<button
class="toggle-btn"
:class="{ active: settings.smtp_enabled }"
@click="toggleSetting('smtp_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<template v-if="settings.smtp_enabled">
<div class="settings-grid">
<div class="setting-row">
<label>
<span>SMTP Host</span>
<input
type="text"
v-model="settings.smtp_host"
placeholder="smtp.example.com"
@blur="saveSetting('smtp_host', settings.smtp_host)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row">
<label>
<span>SMTP Port</span>
<input
type="number"
v-model="settings.smtp_port"
placeholder="587"
@blur="saveSetting('smtp_port', settings.smtp_port)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row">
<label>
<span>Username</span>
<input
type="text"
v-model="settings.smtp_username"
placeholder="username"
@blur="saveSetting('smtp_username', settings.smtp_username)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row">
<label>
<span>Password</span>
<input
type="password"
v-model="settings.smtp_password"
placeholder="password"
@blur="saveSetting('smtp_password', settings.smtp_password)"
:disabled="saving"
>
</label>
</div>
</div>
<div class="setting-row">
<label class="toggle-label">
<span>Use TLS Encryption</span>
<button
class="toggle-btn"
:class="{ active: settings.smtp_use_tls }"
@click="toggleSetting('smtp_use_tls')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<div class="settings-grid">
<div class="setting-row">
<label>
<span>From Address</span>
<input
type="email"
v-model="settings.smtp_from_address"
placeholder="noreply@example.com"
@blur="saveSetting('smtp_from_address', settings.smtp_from_address)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row">
<label>
<span>From Name</span>
<input
type="text"
v-model="settings.smtp_from_name"
placeholder="ShopDB"
@blur="saveSetting('smtp_from_name', settings.smtp_from_name)"
:disabled="saving"
>
</label>
</div>
</div>
<div class="setting-row full-width">
<label>
<span>Default Alert Recipients</span>
<input
type="text"
v-model="settings.alert_recipients"
placeholder="user1@example.com, user2@example.com"
@blur="saveSetting('alert_recipients', settings.alert_recipients)"
:disabled="saving"
>
<small class="input-hint">Comma-separated list of email addresses</small>
</label>
</div>
<div class="status-indicator">
<span class="status-dot" :class="smtpStatus"></span>
<span>{{ smtpMessage }}</span>
</div>
<button
class="test-btn"
@click="testEmail"
:disabled="saving || testingEmail || !canTestEmail"
>
{{ testingEmail ? 'Sending...' : 'Send Test Email' }}
</button>
</template>
</div>
</div>
<!-- Audit Section -->
<div class="section-card">
<h2 class="section-title">Audit & Logging</h2>
<div class="setting-group">
<h3>Audit Log Retention</h3>
<p class="setting-description">
Configure how long audit logs are retained. Older logs will be automatically purged.
Set to 0 to keep logs indefinitely.
</p>
<div class="setting-row">
<label>
<span>Retention Period (days)</span>
<input
type="number"
v-model="settings.audit_retention_days"
min="0"
placeholder="90"
@blur="saveSetting('audit_retention_days', settings.audit_retention_days)"
:disabled="saving"
>
<small class="input-hint">0 = keep forever, recommended: 90 days</small>
</label>
</div>
<router-link to="/settings/auditlogs" class="view-logs-link">
View Audit Logs &rarr;
</router-link>
</div>
</div>
<!-- Authentication Section -->
<div class="section-card">
<h2 class="section-title">Authentication</h2>
<div class="setting-group">
<h3>SAML Single Sign-On</h3>
<p class="setting-description">
Enable SAML SSO for enterprise authentication. Users can sign in using your organization's
identity provider (Azure AD, Okta, etc.). Local login can remain enabled as a fallback.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Enable SAML SSO</span>
<button
class="toggle-btn"
:class="{ active: settings.saml_enabled }"
@click="toggleSetting('saml_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<template v-if="settings.saml_enabled">
<div class="setting-row">
<label>
<span>Identity Provider Metadata URL</span>
<input
type="url"
v-model="settings.saml_idp_metadata_url"
placeholder="https://login.microsoftonline.com/.../federationmetadata.xml"
@blur="saveSetting('saml_idp_metadata_url', settings.saml_idp_metadata_url)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row">
<label>
<span>Service Provider Entity ID</span>
<input
type="text"
v-model="settings.saml_entity_id"
placeholder="https://shopdb.example.com"
@blur="saveSetting('saml_entity_id', settings.saml_entity_id)"
:disabled="saving"
>
<small class="input-hint">Unique identifier for this application</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Assertion Consumer Service URL</span>
<input
type="url"
v-model="settings.saml_acs_url"
placeholder="https://shopdb.example.com/api/auth/saml/acs"
@blur="saveSetting('saml_acs_url', settings.saml_acs_url)"
:disabled="saving"
>
<small class="input-hint">URL where SAML responses are sent</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Admin Group Name</span>
<input
type="text"
v-model="settings.saml_admin_group"
placeholder="ShopDB-Admins"
@blur="saveSetting('saml_admin_group', settings.saml_admin_group)"
:disabled="saving"
>
<small class="input-hint">SAML group that grants admin role (leave empty to manage manually)</small>
</label>
</div>
<div class="setting-row">
<label class="toggle-label">
<span>Allow Local Login</span>
<button
class="toggle-btn"
:class="{ active: settings.saml_allow_local_login }"
@click="toggleSetting('saml_allow_local_login')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
<small class="input-hint toggle-hint">Keep enabled for admin fallback access</small>
</div>
<div class="setting-row">
<label class="toggle-label">
<span>Auto-Create Users</span>
<button
class="toggle-btn"
:class="{ active: settings.saml_auto_create_users }"
@click="toggleSetting('saml_auto_create_users')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
<small class="input-hint toggle-hint">Automatically create user accounts on first SAML login</small>
</div>
<div class="status-indicator">
<span class="status-dot" :class="samlStatus"></span>
<span>{{ samlMessage }}</span>
</div>
</template>
</div>
<div class="setting-group">
<h3>User Management</h3>
<p class="setting-description">
Manage local user accounts and roles. Local accounts work alongside SAML when both are enabled.
</p>
<router-link to="/settings/users" class="view-logs-link">
Manage Users &rarr;
</router-link>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="success-message">{{ success }}</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { settingsApi } from '../../api'
const settings = reactive({
// Zabbix
zabbix_enabled: false,
zabbix_url: '',
zabbix_token: '',
// Email
smtp_enabled: false,
smtp_host: '',
smtp_port: 587,
smtp_username: '',
smtp_password: '',
smtp_use_tls: true,
smtp_from_address: '',
smtp_from_name: 'ShopDB',
alert_recipients: '',
// Audit
audit_retention_days: 90,
// SAML
saml_enabled: false,
saml_idp_metadata_url: '',
saml_entity_id: '',
saml_acs_url: '',
saml_allow_local_login: true,
saml_auto_create_users: true,
saml_admin_group: ''
})
const loading = ref(true)
const saving = ref(false)
const testingEmail = ref(false)
const error = ref('')
const success = ref('')
// Zabbix status
const zabbixStatus = computed(() => {
if (!settings.zabbix_enabled) return 'inactive'
if (!settings.zabbix_url || !settings.zabbix_token) return 'warning'
return 'pending'
})
const zabbixMessage = computed(() => {
if (!settings.zabbix_enabled) return 'Disabled'
if (!settings.zabbix_url) return 'URL not configured'
if (!settings.zabbix_token) return 'Token not configured'
return 'Configured (connectivity checked on first use)'
})
// SMTP status
const smtpStatus = computed(() => {
if (!settings.smtp_enabled) return 'inactive'
if (!settings.smtp_host || !settings.smtp_from_address) return 'warning'
return 'pending'
})
const smtpMessage = computed(() => {
if (!settings.smtp_enabled) return 'Disabled'
if (!settings.smtp_host) return 'SMTP host not configured'
if (!settings.smtp_from_address) return 'From address not configured'
return 'Configured - use test button to verify'
})
const canTestEmail = computed(() => {
return settings.smtp_host && settings.smtp_from_address && settings.alert_recipients
})
// SAML status
const samlStatus = computed(() => {
if (!settings.saml_enabled) return 'inactive'
if (!settings.saml_idp_metadata_url || !settings.saml_entity_id) return 'warning'
return 'pending'
})
const samlMessage = computed(() => {
if (!settings.saml_enabled) return 'Disabled - using local authentication only'
if (!settings.saml_idp_metadata_url) return 'IdP metadata URL not configured'
if (!settings.saml_entity_id) return 'Entity ID not configured'
return 'Configured - SAML login available'
})
async function loadSettings() {
try {
loading.value = true
const { data } = await settingsApi.list()
for (const setting of data.data) {
if (setting.key in settings) {
settings[setting.key] = setting.value
}
}
} catch (e) {
error.value = 'Failed to load settings'
console.error(e)
} finally {
loading.value = false
}
}
async function toggleSetting(key) {
const newValue = !settings[key]
await saveSetting(key, newValue)
}
async function saveSetting(key, value) {
try {
saving.value = true
error.value = ''
success.value = ''
await settingsApi.update(key, value)
settings[key] = value
success.value = 'Setting saved'
setTimeout(() => { success.value = '' }, 2000)
} catch (e) {
error.value = e.response?.data?.message || 'Failed to save setting'
console.error(e)
} finally {
saving.value = false
}
}
async function testEmail() {
// TODO: Implement test email endpoint
testingEmail.value = true
error.value = ''
success.value = ''
try {
// await settingsApi.testEmail()
success.value = 'Test email feature coming soon'
} catch (e) {
error.value = e.response?.data?.message || 'Failed to send test email'
} finally {
testingEmail.value = false
setTimeout(() => { success.value = '' }, 3000)
}
}
onMounted(loadSettings)
</script>
<style scoped>
.settings-sections {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
}
.section-title {
margin: 0 0 1rem 0;
font-size: 1.1rem;
color: var(--text);
}
.setting-group {
border-top: 1px solid var(--border);
padding-top: 1rem;
}
.setting-group h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
color: var(--text);
}
.setting-description {
color: var(--text-light);
font-size: 0.9rem;
margin: 0 0 1rem 0;
line-height: 1.5;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.setting-row {
margin-bottom: 1rem;
}
.setting-row.full-width {
grid-column: 1 / -1;
}
.setting-row label {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.setting-row label span {
color: var(--text);
font-size: 0.9rem;
}
.setting-row input {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 1rem;
max-width: 400px;
}
.setting-row input[type="number"] {
max-width: 120px;
}
.setting-row input:focus {
outline: none;
border-color: var(--primary);
}
.input-hint {
color: var(--text-light);
font-size: 0.8rem;
}
.toggle-label {
flex-direction: row !important;
align-items: center;
justify-content: space-between;
max-width: 400px;
}
.toggle-btn {
position: relative;
width: 50px;
height: 26px;
border-radius: 13px;
border: none;
background: var(--secondary);
cursor: pointer;
transition: background 0.2s;
}
.toggle-btn.active {
background: var(--success);
}
.toggle-slider {
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
transition: transform 0.2s;
}
.toggle-btn.active .toggle-slider {
transform: translateX(24px);
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg);
border-radius: 4px;
font-size: 0.9rem;
color: var(--text-light);
max-width: 400px;
margin-bottom: 1rem;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.inactive {
background: var(--secondary);
}
.status-dot.warning {
background: var(--warning);
}
.status-dot.pending {
background: var(--primary);
}
.status-dot.success {
background: var(--success);
}
.test-btn {
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.test-btn:hover:not(:disabled) {
background: var(--primary-dark);
}
.test-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
margin-top: 1rem;
padding: 0.75rem;
background: var(--danger);
color: white;
border-radius: 4px;
}
.success-message {
margin-top: 1rem;
padding: 0.75rem;
background: var(--success);
color: white;
border-radius: 4px;
}
.view-logs-link {
display: inline-block;
margin-top: 0.5rem;
color: var(--link);
text-decoration: none;
font-size: 0.9rem;
}
.view-logs-link:hover {
text-decoration: underline;
}
.toggle-hint {
display: block;
margin-top: -0.5rem;
margin-left: 0;
}
</style>

View File

@@ -0,0 +1,745 @@
<template>
<div class="page-header">
<h1>User Management</h1>
<button class="btn-primary" @click="showCreateModal = true">Add User</button>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Name</th>
<th>Roles</th>
<th>Status</th>
<th>Last Login</th>
<th class="actions">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="loading">Loading...</td>
</tr>
<tr v-else-if="!users.length">
<td colspan="7" class="empty">No users found</td>
</tr>
<tr v-for="user in users" :key="user.userid">
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>{{ [user.firstname, user.lastname].filter(Boolean).join(' ') || '-' }}</td>
<td>
<span
v-for="role in user.roles"
:key="role.roleid"
class="badge"
:class="role.rolename === 'admin' ? 'badge-primary' : 'badge-secondary'"
>
{{ role.rolename }}
</span>
<span v-if="!user.roles.length" class="text-muted">None</span>
</td>
<td>
<span v-if="user.islocked" class="badge badge-danger">Locked</span>
<span v-else-if="!user.isactive" class="badge badge-warning">Inactive</span>
<span v-else class="badge badge-success">Active</span>
</td>
<td>{{ formatDate(user.lastlogindate) }}</td>
<td class="actions">
<button class="btn-sm" @click="editUser(user)">Edit</button>
<button
class="btn-sm btn-danger"
@click="confirmDelete(user)"
:disabled="user.userid === currentUserId"
>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Roles Section -->
<div class="page-header" style="margin-top: 2rem">
<h2>Roles</h2>
<button class="btn-secondary" @click="showRoleModal = true">Add Role</button>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Role Name</th>
<th>Description</th>
<th>Permissions</th>
<th>Users</th>
<th class="actions">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="role in roles" :key="role.roleid">
<td>
<span class="badge" :class="role.rolename === 'admin' ? 'badge-primary' : 'badge-secondary'">
{{ role.rolename }}
</span>
</td>
<td>{{ role.description || '-' }}</td>
<td>
<span v-if="role.isadmin" class="text-muted">All permissions</span>
<span v-else-if="role.permissions?.length">{{ role.permissions.length }} permissions</span>
<span v-else class="text-muted">None</span>
</td>
<td>{{ role.usercount }}</td>
<td class="actions">
<button class="btn-sm" @click="editRole(role)">Edit</button>
<button
class="btn-sm btn-danger"
@click="confirmDeleteRole(role)"
:disabled="role.rolename === 'admin' || role.usercount > 0"
>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- User Modal -->
<div v-if="showCreateModal || editingUser" class="modal-overlay" @click.self="closeUserModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editingUser ? 'Edit User' : 'Create User' }}</h3>
<button class="close-btn" @click="closeUserModal">&times;</button>
</div>
<div class="modal-body">
<form @submit.prevent="saveUser">
<div class="form-group">
<label>Username</label>
<input
type="text"
v-model="userForm.username"
required
:disabled="!!editingUser"
placeholder="username"
>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" v-model="userForm.email" required placeholder="user@example.com">
</div>
<div class="form-row">
<div class="form-group">
<label>First Name</label>
<input type="text" v-model="userForm.firstname" placeholder="John">
</div>
<div class="form-group">
<label>Last Name</label>
<input type="text" v-model="userForm.lastname" placeholder="Doe">
</div>
</div>
<div class="form-group">
<label>{{ editingUser ? 'New Password (leave blank to keep current)' : 'Password' }}</label>
<input
type="password"
v-model="userForm.password"
:required="!editingUser"
placeholder="********"
>
</div>
<div class="form-group">
<label>Roles</label>
<div class="checkbox-group">
<label v-for="role in roles" :key="role.roleid" class="checkbox-label">
<input
type="checkbox"
:value="role.roleid"
v-model="userForm.roles"
>
{{ role.rolename }}
</label>
</div>
</div>
<div class="form-group" v-if="editingUser">
<label class="checkbox-label">
<input type="checkbox" v-model="userForm.isactive">
Account Active
</label>
</div>
<div class="form-group" v-if="editingUser && editingUser.islocked">
<label class="checkbox-label">
<input type="checkbox" v-model="userForm.unlock">
Unlock Account
</label>
</div>
<div class="form-actions">
<button type="button" class="btn-secondary" @click="closeUserModal">Cancel</button>
<button type="submit" class="btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (editingUser ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Role Modal -->
<div v-if="showRoleModal || editingRole" class="modal-overlay" @click.self="closeRoleModal">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editingRole ? 'Edit Role' : 'Create Role' }}</h3>
<button class="close-btn" @click="closeRoleModal">&times;</button>
</div>
<div class="modal-body">
<form @submit.prevent="saveRole">
<div class="form-row">
<div class="form-group">
<label>Role Name</label>
<input
type="text"
v-model="roleForm.rolename"
required
:disabled="!!editingRole"
placeholder="rolename"
>
</div>
<div class="form-group">
<label>Description</label>
<input type="text" v-model="roleForm.description" placeholder="Role description">
</div>
</div>
<div class="form-group" v-if="!editingRole?.isadmin">
<label>Permissions</label>
<p class="text-muted" style="font-size: 0.85rem; margin: 0 0 0.5rem 0">
Select which actions this role can perform
</p>
<div class="permissions-grid">
<div v-for="(perms, category) in permissionsGrouped" :key="category" class="permission-category">
<div class="category-header">
<label class="checkbox-label">
<input
type="checkbox"
:checked="isCategoryFullySelected(category)"
:indeterminate="isCategoryPartiallySelected(category)"
@change="toggleCategory(category, $event.target.checked)"
>
<strong>{{ formatCategory(category) }}</strong>
</label>
</div>
<div class="category-perms">
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
<input
type="checkbox"
:value="p.name"
v-model="roleForm.permissions"
>
{{ p.description }}
</label>
</div>
</div>
</div>
</div>
<div v-else class="form-group">
<label>Permissions</label>
<p class="text-muted">Admin role has all permissions by default</p>
</div>
<div class="form-actions">
<button type="button" class="btn-secondary" @click="closeRoleModal">Cancel</button>
<button type="submit" class="btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (editingRole ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation -->
<div v-if="deletingUser" class="modal-overlay" @click.self="deletingUser = null">
<div class="modal modal-sm">
<div class="modal-header">
<h3>Delete User</h3>
<button class="close-btn" @click="deletingUser = null">&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete user <strong>{{ deletingUser.username }}</strong>?</p>
<p class="text-muted">This action cannot be undone.</p>
<div class="form-actions">
<button class="btn-secondary" @click="deletingUser = null">Cancel</button>
<button class="btn-danger" @click="deleteUser" :disabled="saving">
{{ saving ? 'Deleting...' : 'Delete' }}
</button>
</div>
</div>
</div>
</div>
<div v-if="error" class="error-toast">{{ error }}</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { usersApi } from '../../api'
import { useAuthStore } from '../../stores/auth'
const authStore = useAuthStore()
const currentUserId = authStore.user?.userid
const users = ref([])
const roles = ref([])
const permissions = ref([])
const permissionsGrouped = ref({})
const loading = ref(true)
const saving = ref(false)
const error = ref('')
// Modals
const showCreateModal = ref(false)
const showRoleModal = ref(false)
const editingUser = ref(null)
const editingRole = ref(null)
const deletingUser = ref(null)
// Forms
const userForm = reactive({
username: '',
email: '',
firstname: '',
lastname: '',
password: '',
roles: [],
isactive: true,
unlock: false
})
const roleForm = reactive({
rolename: '',
description: '',
permissions: []
})
async function loadData() {
loading.value = true
try {
const [usersRes, rolesRes, permsRes] = await Promise.all([
usersApi.list(),
usersApi.roles.list(),
usersApi.permissions.list()
])
users.value = usersRes.data.data
roles.value = rolesRes.data.data
permissions.value = permsRes.data.data.permissions
permissionsGrouped.value = permsRes.data.data.grouped
} catch (e) {
error.value = 'Failed to load users'
setTimeout(() => error.value = '', 3000)
} finally {
loading.value = false
}
}
function formatCategory(cat) {
return cat.charAt(0).toUpperCase() + cat.slice(1)
}
function isCategoryFullySelected(category) {
const catPerms = permissionsGrouped.value[category] || []
return catPerms.length > 0 && catPerms.every(p => roleForm.permissions.includes(p.name))
}
function isCategoryPartiallySelected(category) {
const catPerms = permissionsGrouped.value[category] || []
const selected = catPerms.filter(p => roleForm.permissions.includes(p.name))
return selected.length > 0 && selected.length < catPerms.length
}
function toggleCategory(category, checked) {
const catPerms = permissionsGrouped.value[category] || []
const permNames = catPerms.map(p => p.name)
if (checked) {
// Add all perms from this category
for (const name of permNames) {
if (!roleForm.permissions.includes(name)) {
roleForm.permissions.push(name)
}
}
} else {
// Remove all perms from this category
roleForm.permissions = roleForm.permissions.filter(p => !permNames.includes(p))
}
}
function formatDate(iso) {
if (!iso) return 'Never'
return new Date(iso).toLocaleDateString()
}
function editUser(user) {
editingUser.value = user
userForm.username = user.username
userForm.email = user.email
userForm.firstname = user.firstname || ''
userForm.lastname = user.lastname || ''
userForm.password = ''
userForm.roles = user.roles.map(r => r.roleid)
userForm.isactive = user.isactive
userForm.unlock = false
}
function closeUserModal() {
showCreateModal.value = false
editingUser.value = null
Object.assign(userForm, {
username: '', email: '', firstname: '', lastname: '',
password: '', roles: [], isactive: true, unlock: false
})
}
async function saveUser() {
saving.value = true
error.value = ''
try {
const data = {
email: userForm.email,
firstname: userForm.firstname || null,
lastname: userForm.lastname || null,
roles: userForm.roles
}
if (editingUser.value) {
data.isactive = userForm.isactive
if (userForm.password) data.password = userForm.password
if (userForm.unlock) data.unlock = true
await usersApi.update(editingUser.value.userid, data)
} else {
data.username = userForm.username
data.password = userForm.password
await usersApi.create(data)
}
closeUserModal()
await loadData()
} catch (e) {
error.value = e.response?.data?.message || 'Failed to save user'
setTimeout(() => error.value = '', 3000)
} finally {
saving.value = false
}
}
function confirmDelete(user) {
deletingUser.value = user
}
async function deleteUser() {
saving.value = true
try {
await usersApi.delete(deletingUser.value.userid)
deletingUser.value = null
await loadData()
} catch (e) {
error.value = e.response?.data?.message || 'Failed to delete user'
setTimeout(() => error.value = '', 3000)
} finally {
saving.value = false
}
}
// Roles
function editRole(role) {
editingRole.value = role
roleForm.rolename = role.rolename
roleForm.description = role.description || ''
roleForm.permissions = role.permissions ? [...role.permissions] : []
}
function closeRoleModal() {
showRoleModal.value = false
editingRole.value = null
roleForm.rolename = ''
roleForm.description = ''
roleForm.permissions = []
}
async function saveRole() {
saving.value = true
error.value = ''
try {
const data = {
description: roleForm.description,
permissions: roleForm.permissions
}
if (editingRole.value) {
await usersApi.roles.update(editingRole.value.roleid, data)
} else {
data.rolename = roleForm.rolename
await usersApi.roles.create(data)
}
closeRoleModal()
await loadData()
} catch (e) {
error.value = e.response?.data?.message || 'Failed to save role'
setTimeout(() => error.value = '', 3000)
} finally {
saving.value = false
}
}
function confirmDeleteRole(role) {
if (confirm(`Delete role "${role.rolename}"?`)) {
deleteRole(role)
}
}
async function deleteRole(role) {
try {
await usersApi.roles.delete(role.roleid)
await loadData()
} catch (e) {
error.value = e.response?.data?.message || 'Failed to delete role'
setTimeout(() => error.value = '', 3000)
}
}
onMounted(loadData)
</script>
<style scoped>
.badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
margin-right: 0.25rem;
}
.badge-primary { background: var(--primary); color: white; }
.badge-secondary { background: var(--secondary); color: white; }
.badge-success { background: var(--success); color: white; }
.badge-warning { background: var(--warning); color: #000; }
.badge-danger { background: var(--danger); color: white; }
.text-muted { color: var(--text-light); }
.btn-primary {
background: var(--primary);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.btn-primary:hover { background: var(--primary-dark); }
.btn-secondary {
background: var(--secondary);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.btn-danger {
background: var(--danger);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.85rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
color: var(--text);
}
.btn-sm:hover { background: var(--border); }
.btn-sm.btn-danger { background: var(--danger); color: white; border-color: var(--danger); }
.btn-sm:disabled { opacity: 0.5; cursor: not-allowed; }
.actions {
white-space: nowrap;
}
.actions button { margin-right: 0.25rem; }
.loading, .empty {
text-align: center;
color: var(--text-light);
padding: 2rem;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
width: 500px;
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-sm { width: 400px; }
.modal-lg { width: 700px; }
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.modal-header h3 { margin: 0; }
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-light);
line-height: 1;
}
.modal-body {
padding: 1rem;
overflow-y: auto;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.9rem;
color: var(--text);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.error-toast {
position: fixed;
bottom: 1rem;
right: 1rem;
background: var(--danger);
color: white;
padding: 0.75rem 1rem;
border-radius: 4px;
z-index: 1001;
}
/* Permissions grid */
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
max-height: 300px;
overflow-y: auto;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
}
.permission-category {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.5rem;
}
.category-header {
border-bottom: 1px solid var(--border);
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
.category-perms {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.perm-item {
font-size: 0.85rem;
padding: 0.25rem 0;
}
</style>

View File

@@ -4,7 +4,7 @@ from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, OperatingSystem, Application, AppVersion from shopdb.core.models import Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog
from shopdb.utils.responses import ( from shopdb.utils.responses import (
success_response, success_response,
error_response, error_response,
@@ -348,6 +348,12 @@ def create_computer():
) )
db.session.add(comp) db.session.add(comp)
db.session.flush()
# Audit log
AuditLog.log('created', 'Computer', entityid=comp.computerid,
entityname=data.get('hostname') or data['assetnumber'])
db.session.commit() db.session.commit()
result = asset.to_dict() result = asset.to_dict()
@@ -394,11 +400,18 @@ def update_computer(computer_id: int):
http_code=409 http_code=409
) )
# Track changes for audit log
changes = {}
# Update asset fields # Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive'] 'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
for key in asset_fields: for key in asset_fields:
if key in data: if key in data:
old_val = getattr(asset, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(asset, key, data[key]) setattr(asset, key, data[key])
# Update computer fields # Update computer fields
@@ -406,8 +419,17 @@ def update_computer(computer_id: int):
'lastreporteddate', 'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor'] 'lastreporteddate', 'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor']
for key in computer_fields: for key in computer_fields:
if key in data: if key in data:
old_val = getattr(comp, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(comp, key, data[key]) setattr(comp, key, data[key])
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'Computer', entityid=comp.computerid,
entityname=comp.hostname or asset.assetnumber, changes=changes)
db.session.commit() db.session.commit()
result = asset.to_dict() result = asset.to_dict()
@@ -431,6 +453,11 @@ def delete_computer(computer_id: int):
# Soft delete the asset # Soft delete the asset
comp.asset.isactive = False comp.asset.isactive = False
# Audit log
AuditLog.log('deleted', 'Computer', entityid=comp.computerid,
entityname=comp.hostname or comp.asset.assetnumber)
db.session.commit() db.session.commit()
return success_response(message='Computer deleted') return success_response(message='Computer deleted')

View File

@@ -4,7 +4,7 @@ from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, Vendor, Model from shopdb.core.models import Asset, AssetType, Vendor, Model, AuditLog
from shopdb.utils.responses import ( from shopdb.utils.responses import (
success_response, success_response,
error_response, error_response,
@@ -311,6 +311,12 @@ def create_equipment():
) )
db.session.add(equip) db.session.add(equip)
db.session.flush()
# Audit log
AuditLog.log('created', 'Equipment', entityid=equip.equipmentid,
entityname=data['assetnumber'])
db.session.commit() db.session.commit()
result = asset.to_dict() result = asset.to_dict()
@@ -347,11 +353,18 @@ def update_equipment(equipment_id: int):
http_code=409 http_code=409
) )
# Track changes for audit log
changes = {}
# Update asset fields # Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive'] 'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
for key in asset_fields: for key in asset_fields:
if key in data: if key in data:
old_val = getattr(asset, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(asset, key, data[key]) setattr(asset, key, data[key])
# Update equipment fields # Update equipment fields
@@ -361,8 +374,17 @@ def update_equipment(equipment_id: int):
'controllervendorid', 'controllermodelid'] 'controllervendorid', 'controllermodelid']
for key in equipment_fields: for key in equipment_fields:
if key in data: if key in data:
old_val = getattr(equip, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(equip, key, data[key]) setattr(equip, key, data[key])
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'Equipment', entityid=equip.equipmentid,
entityname=asset.assetnumber, changes=changes)
db.session.commit() db.session.commit()
result = asset.to_dict() result = asset.to_dict()
@@ -386,6 +408,11 @@ def delete_equipment(equipment_id: int):
# Soft delete the asset (equipment extension will stay linked) # Soft delete the asset (equipment extension will stay linked)
equip.asset.isactive = False equip.asset.isactive = False
# Audit log
AuditLog.log('deleted', 'Equipment', entityid=equip.equipmentid,
entityname=equip.asset.assetnumber)
db.session.commit() db.session.commit()
return success_response(message='Equipment deleted') return success_response(message='Equipment deleted')

View File

@@ -4,7 +4,7 @@ from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, Vendor from shopdb.core.models import Asset, AssetType, Vendor, AuditLog
from shopdb.utils.responses import ( from shopdb.utils.responses import (
success_response, success_response,
error_response, error_response,
@@ -350,6 +350,12 @@ def create_network_device():
) )
db.session.add(netdev) db.session.add(netdev)
db.session.flush()
# Audit log
AuditLog.log('created', 'NetworkDevice', entityid=netdev.networkdeviceid,
entityname=data.get('hostname') or data['assetnumber'])
db.session.commit() db.session.commit()
result = asset.to_dict() result = asset.to_dict()
@@ -396,11 +402,18 @@ def update_network_device(device_id: int):
http_code=409 http_code=409
) )
# Track changes for audit log
changes = {}
# Update asset fields # Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive'] 'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
for key in asset_fields: for key in asset_fields:
if key in data: if key in data:
old_val = getattr(asset, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(asset, key, data[key]) setattr(asset, key, data[key])
# Update network device fields # Update network device fields
@@ -408,8 +421,17 @@ def update_network_device(device_id: int):
'firmwareversion', 'portcount', 'ispoe', 'ismanaged', 'rackunit'] 'firmwareversion', 'portcount', 'ispoe', 'ismanaged', 'rackunit']
for key in netdev_fields: for key in netdev_fields:
if key in data: if key in data:
old_val = getattr(netdev, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(netdev, key, data[key]) setattr(netdev, key, data[key])
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'NetworkDevice', entityid=netdev.networkdeviceid,
entityname=netdev.hostname or asset.assetnumber, changes=changes)
db.session.commit() db.session.commit()
result = asset.to_dict() result = asset.to_dict()
@@ -433,6 +455,11 @@ def delete_network_device(device_id: int):
# Soft delete the asset # Soft delete the asset
netdev.asset.isactive = False netdev.asset.isactive = False
# Audit log
AuditLog.log('deleted', 'NetworkDevice', entityid=netdev.networkdeviceid,
entityname=netdev.hostname or netdev.asset.assetnumber)
db.session.commit() db.session.commit()
return success_response(message='Network device deleted') return success_response(message='Network device deleted')
@@ -573,6 +600,12 @@ def create_vlan():
) )
db.session.add(vlan) db.session.add(vlan)
db.session.flush()
# Audit log
AuditLog.log('created', 'VLAN', entityid=vlan.vlanid,
entityname=f"VLAN {vlan.vlannumber} - {vlan.name}")
db.session.commit() db.session.commit()
return success_response(vlan.to_dict(), message='VLAN created', http_code=201) return success_response(vlan.to_dict(), message='VLAN created', http_code=201)
@@ -604,10 +637,21 @@ def update_vlan(vlan_id: int):
http_code=409 http_code=409
) )
# Track changes for audit log
changes = {}
for key in ['vlannumber', 'name', 'description', 'vlantype', 'isactive']: for key in ['vlannumber', 'name', 'description', 'vlantype', 'isactive']:
if key in data: if key in data:
old_val = getattr(vlan, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(vlan, key, data[key]) setattr(vlan, key, data[key])
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'VLAN', entityid=vlan.vlanid,
entityname=f"VLAN {vlan.vlannumber} - {vlan.name}", changes=changes)
db.session.commit() db.session.commit()
return success_response(vlan.to_dict(), message='VLAN updated') return success_response(vlan.to_dict(), message='VLAN updated')
@@ -634,6 +678,11 @@ def delete_vlan(vlan_id: int):
) )
vlan.isactive = False vlan.isactive = False
# Audit log
AuditLog.log('deleted', 'VLAN', entityid=vlan.vlanid,
entityname=f"VLAN {vlan.vlannumber} - {vlan.name}")
db.session.commit() db.session.commit()
return success_response(message='VLAN deleted') return success_response(message='VLAN deleted')
@@ -754,6 +803,12 @@ def create_subnet():
) )
db.session.add(subnet) db.session.add(subnet)
db.session.flush()
# Audit log
AuditLog.log('created', 'Subnet', entityid=subnet.subnetid,
entityname=f"{subnet.cidr} - {subnet.name}")
db.session.commit() db.session.commit()
return success_response(subnet.to_dict(), message='Subnet created', http_code=201) return success_response(subnet.to_dict(), message='Subnet created', http_code=201)
@@ -790,10 +845,21 @@ def update_subnet(subnet_id: int):
'locationid', 'dhcpenabled', 'dhcprangestart', 'dhcprangeend', 'locationid', 'dhcpenabled', 'dhcprangestart', 'dhcprangeend',
'dns1', 'dns2', 'isactive'] 'dns1', 'dns2', 'isactive']
# Track changes for audit log
changes = {}
for key in allowed_fields: for key in allowed_fields:
if key in data: if key in data:
old_val = getattr(subnet, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(subnet, key, data[key]) setattr(subnet, key, data[key])
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'Subnet', entityid=subnet.subnetid,
entityname=f"{subnet.cidr} - {subnet.name}", changes=changes)
db.session.commit() db.session.commit()
return success_response(subnet.to_dict(), message='Subnet updated') return success_response(subnet.to_dict(), message='Subnet updated')
@@ -812,6 +878,11 @@ def delete_subnet(subnet_id: int):
) )
subnet.isactive = False subnet.isactive = False
# Audit log
AuditLog.log('deleted', 'Subnet', entityid=subnet.subnetid,
entityname=f"{subnet.cidr} - {subnet.name}")
db.session.commit() db.session.commit()
return success_response(message='Subnet deleted') return success_response(message='Subnet deleted')

View File

@@ -285,17 +285,20 @@ def get_active_notifications():
""" """
now = datetime.utcnow() now = datetime.utcnow()
from datetime import timedelta
lookahead = now + timedelta(days=10)
notifications = Notification.query.filter( notifications = Notification.query.filter(
Notification.isactive == True, Notification.isactive == True,
db.or_( db.or_(
Notification.starttime.is_(None), Notification.starttime.is_(None),
Notification.starttime <= now Notification.starttime <= lookahead
), ),
db.or_( db.or_(
Notification.endtime.is_(None), Notification.endtime.is_(None),
Notification.endtime >= now Notification.endtime >= now
) )
).order_by(Notification.starttime.desc()).all() ).order_by(Notification.starttime.asc()).all()
data = [n.to_dict() for n in notifications] data = [n.to_dict() for n in notifications]

View File

@@ -1,9 +1,11 @@
"""Printers API routes - new Asset-based architecture.""" """Printers API routes - new Asset-based architecture."""
import logging
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.extensions import db, cache
from shopdb.core.models import Asset, AssetType, Vendor, Model, Communication, CommunicationType from shopdb.core.models import Asset, AssetType, Vendor, Model, Communication, CommunicationType
from shopdb.utils.responses import ( from shopdb.utils.responses import (
success_response, success_response,
@@ -16,6 +18,8 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import Printer, PrinterType from ..models import Printer, PrinterType
from ..services import ZabbixService from ..services import ZabbixService
logger = logging.getLogger(__name__)
printers_asset_bp = Blueprint('printers_asset', __name__) printers_asset_bp = Blueprint('printers_asset', __name__)
@@ -421,8 +425,12 @@ def get_printer_supplies(printer_id: int):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address') return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address')
service = ZabbixService() service = ZabbixService()
if not service.isconfigured: if not service.isconfigured or not service.isreachable:
return error_response(ErrorCodes.BAD_REQUEST, 'Zabbix not configured') # Return empty supplies if Zabbix not available (fail gracefully)
return success_response({
'ipaddress': comm.ipaddress,
'supplies': []
})
supplies = service.getsuppliesbyip(comm.ipaddress) supplies = service.getsuppliesbyip(comm.ipaddress)
@@ -432,6 +440,117 @@ def get_printer_supplies(printer_id: int):
}) })
# =============================================================================
# Low Supplies
# =============================================================================
def _get_low_supplies_data():
"""Build low supplies data (cached for 10 minutes)."""
cached = cache.get('printers_low_supplies')
if cached is not None:
return cached
service = ZabbixService()
if not service.isconfigured or not service.isreachable:
return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
# All active printers with an IP address
printers = (
db.session.query(Printer, Asset, Communication)
.join(Asset, Asset.assetid == Printer.assetid)
.join(Communication, Communication.assetid == Asset.assetid)
.filter(Asset.isactive == True)
.filter(Communication.ipaddress.isnot(None))
.filter(Communication.ipaddress != '')
.all()
)
# Dedupe by printer id (may have multiple comms)
seen = set()
unique_printers = []
for printer, asset, comm in printers:
if printer.printerid not in seen:
seen.add(printer.printerid)
unique_printers.append((printer, asset, comm))
results = []
total_checked = 0
for printer, asset, comm in unique_printers:
supplies = service.getsuppliesbyip_cached(comm.ipaddress)
if supplies is None:
continue
total_checked += 1
# Annotate each supply with status
annotated = []
has_low = False
for s in supplies:
level = s.get('level', 0)
if level <= 5:
status = 'critical'
has_low = True
elif level <= 10:
status = 'low'
has_low = True
else:
status = 'ok'
annotated.append({
'name': s.get('name', 'Unknown'),
'level': level,
'status': status
})
if has_low:
# Get location name
location_name = None
if asset.locationid:
from shopdb.core.models import Location
loc = Location.query.get(asset.locationid)
if loc:
location_name = loc.location
results.append({
'printerid': printer.printerid,
'printername': asset.name or printer.hostname or '',
'assetnumber': asset.assetnumber or '',
'ipaddress': comm.ipaddress,
'location': location_name,
'supplies': annotated
})
low_count = 0
critical_count = 0
for p in results:
has_critical = any(s['status'] == 'critical' for s in p['supplies'])
has_low = any(s['status'] == 'low' for s in p['supplies'])
if has_critical:
critical_count += 1
elif has_low:
low_count += 1
data = {
'printers': results,
'summary': {
'total_checked': total_checked,
'low': low_count,
'critical': critical_count
}
}
cache.set('printers_low_supplies', data, timeout=600)
return data
@printers_asset_bp.route('/lowsupplies', methods=['GET'])
@jwt_required(optional=True)
def low_supplies():
"""Get printers with low or critical supply levels."""
data = _get_low_supplies_data()
return success_response(data)
# ============================================================================= # =============================================================================
# Dashboard # Dashboard
# ============================================================================= # =============================================================================
@@ -465,12 +584,24 @@ def dashboard_summary():
).group_by(Vendor.vendor ).group_by(Vendor.vendor
).all() ).all()
# Get real low/critical supply counts (skip if Zabbix not reachable)
low_count = 0
critical_count = 0
service = ZabbixService()
if service.isconfigured and service.isreachable:
try:
supply_data = _get_low_supplies_data()
low_count = supply_data['summary']['low']
critical_count = supply_data['summary']['critical']
except Exception as e:
logger.warning(f"Could not fetch supply data for dashboard: {e}")
return success_response({ return success_response({
'total': total, 'total': total,
'totalprinters': total, # For dashboard compatibility 'totalprinters': total,
'online': total, # Placeholder - would need monitoring integration 'online': total,
'lowsupplies': 0, # Placeholder - would need Zabbix integration 'lowsupplies': low_count,
'criticalsupplies': 0, # Placeholder - would need Zabbix integration 'criticalsupplies': critical_count,
'bytype': [{'type': t, 'count': c} for t, c in by_type], 'bytype': [{'type': t, 'count': c} for t, c in by_type],
'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor], 'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
}) })

View File

@@ -8,6 +8,7 @@ from shopdb.utils.responses import success_response, error_response, paginated_r
from shopdb.utils.pagination import get_pagination_params, paginate_query from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.core.models.machine import Machine, MachineType from shopdb.core.models.machine import Machine, MachineType
from shopdb.core.models.communication import Communication, CommunicationType from shopdb.core.models.communication import Communication, CommunicationType
from shopdb.core.models import AuditLog
from ..models import PrinterData from ..models import PrinterData
from ..services import ZabbixService from ..services import ZabbixService
@@ -132,10 +133,21 @@ def update_printer_data(machine_id: int):
pd = PrinterData(machineid=machine_id) pd = PrinterData(machineid=machine_id)
db.session.add(pd) db.session.add(pd)
# Track changes for audit log
changes = {}
for key in ['windowsname', 'sharename', 'iscsf', 'installpath', 'pin']: for key in ['windowsname', 'sharename', 'iscsf', 'installpath', 'pin']:
if key in data: if key in data:
old_val = getattr(pd, key, None)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(pd, key, data[key]) setattr(pd, key, data[key])
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'Printer', entityid=machine_id,
entityname=machine.machinenumber or machine.hostname, changes=changes)
db.session.commit() db.session.commit()
return success_response({ return success_response({
@@ -176,14 +188,28 @@ def update_printer_communication(machine_id: int):
comm = Communication(machineid=machine_id, comtypeid=ip_comtype.comtypeid) comm = Communication(machineid=machine_id, comtypeid=ip_comtype.comtypeid)
db.session.add(comm) db.session.add(comm)
# Track changes for audit log
changes = {}
# Update fields # Update fields
if 'ipaddress' in data: if 'ipaddress' in data:
if comm.ipaddress != data['ipaddress']:
changes['ipaddress'] = {'old': comm.ipaddress, 'new': data['ipaddress']}
comm.ipaddress = data['ipaddress'] comm.ipaddress = data['ipaddress']
if 'isprimary' in data: if 'isprimary' in data:
if comm.isprimary != data['isprimary']:
changes['isprimary'] = {'old': comm.isprimary, 'new': data['isprimary']}
comm.isprimary = data['isprimary'] comm.isprimary = data['isprimary']
if 'macaddress' in data: if 'macaddress' in data:
if comm.macaddress != data['macaddress']:
changes['macaddress'] = {'old': comm.macaddress, 'new': data['macaddress']}
comm.macaddress = data['macaddress'] comm.macaddress = data['macaddress']
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'Printer', entityid=machine_id,
entityname=machine.machinenumber or machine.hostname, changes=changes)
db.session.commit() db.session.commit()
return success_response({ return success_response({
@@ -211,8 +237,12 @@ def get_printer_supplies(machine_id: int):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address') return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address')
service = ZabbixService() service = ZabbixService()
if not service.isconfigured: if not service.isconfigured or not service.isreachable:
return error_response(ErrorCodes.SERVICE_UNAVAILABLE, 'Zabbix not configured') # Return empty supplies if Zabbix not available (fail gracefully)
return success_response({
'ipaddress': primary_comm.ipaddress,
'supplies': []
})
supplies = service.getsuppliesbyip(primary_comm.ipaddress) supplies = service.getsuppliesbyip(primary_comm.ipaddress)

View File

@@ -6,6 +6,8 @@ from typing import Dict, List, Optional
import requests import requests
from flask import current_app from flask import current_app
from shopdb.extensions import cache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -14,20 +16,70 @@ class ZabbixService:
Zabbix API service for real-time printer supply lookups. Zabbix API service for real-time printer supply lookups.
Queries Zabbix by IP address to get current supply levels. Queries Zabbix by IP address to get current supply levels.
No caching - always returns live data. Use getsuppliesbyip_cached() for cached lookups or
getsuppliesbyip() for live data.
Configuration:
ZABBIX_ENABLED: Set to True to enable Zabbix integration (default: False)
ZABBIX_URL: Zabbix API URL (e.g., http://zabbix.example.com:8080)
ZABBIX_TOKEN: Zabbix API authentication token
""" """
CACHE_TTL = 600 # 10 minutes
REACHABLE_CHECK_TTL = 60 # Check reachability every 60 seconds
def __init__(self): def __init__(self):
self._url = None self._url = None
self._token = None self._token = None
self._enabled = None
@property
def isenabled(self) -> bool:
"""Check if Zabbix integration is enabled."""
# Check database setting first, fall back to env var
from shopdb.core.models import Setting
db_enabled = Setting.get('zabbix_enabled')
if db_enabled is not None:
return bool(db_enabled)
# Fall back to env var for backwards compatibility
return current_app.config.get('ZABBIX_ENABLED', False)
@property @property
def isconfigured(self) -> bool: def isconfigured(self) -> bool:
"""Check if Zabbix is configured.""" """Check if Zabbix is enabled and configured."""
self._url = current_app.config.get('ZABBIX_URL') if not self.isenabled:
self._token = current_app.config.get('ZABBIX_TOKEN') return False
# Check database settings first, fall back to env vars
from shopdb.core.models import Setting
self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL')
self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
return bool(self._url and self._token) return bool(self._url and self._token)
@property
def isreachable(self) -> bool:
"""Check if Zabbix is reachable (cached for 60 seconds)."""
if not self.isenabled or not self.isconfigured:
return False
cache_key = 'zabbix_reachable'
cached = cache.get(cache_key)
if cached is not None:
return cached
# Quick connectivity check with 500ms timeout
try:
response = requests.get(
f"{self._url}/api_jsonrpc.php",
timeout=0.5
)
reachable = response.status_code in (200, 401, 403, 405)
except requests.RequestException:
reachable = False
cache.set(cache_key, reachable, timeout=self.REACHABLE_CHECK_TTL)
logger.debug(f"Zabbix reachability check: {reachable}")
return reachable
def _apicall(self, method: str, params: Dict) -> Optional[Dict]: def _apicall(self, method: str, params: Dict) -> Optional[Dict]:
"""Make a Zabbix API call.""" """Make a Zabbix API call."""
if not self.isconfigured: if not self.isconfigured:
@@ -46,7 +98,7 @@ class ZabbixService:
f"{self._url}/api_jsonrpc.php", f"{self._url}/api_jsonrpc.php",
json=payload, json=payload,
headers={'Content-Type': 'application/json'}, headers={'Content-Type': 'application/json'},
timeout=10 timeout=0.5 # 500ms timeout - fail fast if Zabbix is slow/unreachable
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
@@ -131,3 +183,21 @@ class ZabbixService:
"""Get Zabbix host ID for an IP address.""" """Get Zabbix host ID for an IP address."""
host = self.gethostbyip(ip) host = self.gethostbyip(ip)
return host['hostid'] if host else None return host['hostid'] if host else None
def getsuppliesbyip_cached(self, ip: str) -> Optional[List[Dict]]:
"""Get printer supply levels with caching (10-minute TTL)."""
cache_key = f'zabbix_supplies_{ip}'
result = cache.get(cache_key)
if result is not None:
return result
result = self.getsuppliesbyip(ip)
if result is not None:
cache.set(cache_key, result, timeout=self.CACHE_TTL)
return result
def clearcache(self, ip: str = None):
"""Clear cached supply data for one IP or all."""
if ip:
cache.delete(f'zabbix_supplies_{ip}')
cache.delete('printers_low_supplies')

View File

@@ -5,6 +5,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime from datetime import datetime
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import AuditLog
from shopdb.utils.responses import ( from shopdb.utils.responses import (
success_response, success_response,
error_response, error_response,
@@ -140,6 +141,11 @@ def create_usb_device():
) )
db.session.add(device) db.session.add(device)
db.session.flush()
AuditLog.log('created', 'USBDevice', entityid=device.usbdeviceid,
entityname=device.label or device.serialnumber)
db.session.commit() db.session.commit()
return success_response(device.to_dict(), message='Device created', http_code=201) return success_response(device.to_dict(), message='Device created', http_code=201)
@@ -184,13 +190,22 @@ def update_usb_device(device_id: int):
data = request.get_json() or {} data = request.get_json() or {}
# Update allowed fields # Track changes for audit log
changes = {}
for field in ['label', 'assetnumber', 'usbdevicetypeid', 'capacitygb', for field in ['label', 'assetnumber', 'usbdevicetypeid', 'capacitygb',
'vendorid', 'productid', 'manufacturer', 'productname', 'vendorid', 'productid', 'manufacturer', 'productname',
'storagelocation', 'pin', 'notes']: 'storagelocation', 'pin', 'notes']:
if field in data: if field in data:
old_val = getattr(device, field)
new_val = data[field]
if old_val != new_val:
changes[field] = {'old': old_val, 'new': new_val}
setattr(device, field, data[field]) setattr(device, field, data[field])
if changes:
AuditLog.log('updated', 'USBDevice', entityid=device.usbdeviceid,
entityname=device.label or device.serialnumber, changes=changes)
device.modifieddate = datetime.utcnow() device.modifieddate = datetime.utcnow()
db.session.commit() db.session.commit()
@@ -219,6 +234,10 @@ def delete_usb_device(device_id: int):
device.isactive = False device.isactive = False
device.modifieddate = datetime.utcnow() device.modifieddate = datetime.utcnow()
AuditLog.log('deleted', 'USBDevice', entityid=device.usbdeviceid,
entityname=device.label or device.serialnumber)
db.session.commit() db.session.commit()
return success_response(None, message='Device deleted') return success_response(None, message='Device deleted')
@@ -272,6 +291,11 @@ def checkout_device(device_id: int):
device.modifieddate = datetime.utcnow() device.modifieddate = datetime.utcnow()
db.session.add(checkout) db.session.add(checkout)
AuditLog.log('checked_out', 'USBDevice', entityid=device.usbdeviceid,
entityname=device.label or device.serialnumber,
changes={'checked_out_to': data['sso'], 'reason': data.get('checkoutreason')})
db.session.commit() db.session.commit()
return success_response(checkout.to_dict(), message='Device checked out', http_code=201) return success_response(checkout.to_dict(), message='Device checked out', http_code=201)
@@ -311,12 +335,17 @@ def checkin_device(device_id: int):
active_checkout.waswiped = data.get('waswiped', False) active_checkout.waswiped = data.get('waswiped', False)
# Update device status # Update device status
previous_user = device.currentuserid
device.ischeckedout = False device.ischeckedout = False
device.currentuserid = None device.currentuserid = None
device.currentusername = None device.currentusername = None
device.currentcheckoutdate = None device.currentcheckoutdate = None
device.modifieddate = datetime.utcnow() device.modifieddate = datetime.utcnow()
AuditLog.log('checked_in', 'USBDevice', entityid=device.usbdeviceid,
entityname=device.label or device.serialnumber,
changes={'returned_by': previous_user, 'wiped': data.get('waswiped', False)})
db.session.commit() db.session.commit()
return success_response( return success_response(

View File

@@ -4,6 +4,7 @@ flask-sqlalchemy>=3.1
flask-migrate>=4.0 flask-migrate>=4.0
flask-jwt-extended>=4.6 flask-jwt-extended>=4.6
flask-cors>=4.0 flask-cors>=4.0
flask-caching>=2.0
flask-marshmallow>=1.2 flask-marshmallow>=1.2
marshmallow-sqlalchemy>=0.29 marshmallow-sqlalchemy>=0.29

View File

@@ -87,6 +87,9 @@ def register_blueprints(app: Flask):
collector_bp, collector_bp,
employees_bp, employees_bp,
slides_bp, slides_bp,
settings_bp,
auditlogs_bp,
users_bp,
) )
api_prefix = '/api' api_prefix = '/api'
@@ -110,6 +113,9 @@ def register_blueprints(app: Flask):
app.register_blueprint(collector_bp, url_prefix=f'{api_prefix}/collector') app.register_blueprint(collector_bp, url_prefix=f'{api_prefix}/collector')
app.register_blueprint(employees_bp, url_prefix=f'{api_prefix}/employees') app.register_blueprint(employees_bp, url_prefix=f'{api_prefix}/employees')
app.register_blueprint(slides_bp, url_prefix=f'{api_prefix}/slides') app.register_blueprint(slides_bp, url_prefix=f'{api_prefix}/slides')
app.register_blueprint(settings_bp, url_prefix=f'{api_prefix}/settings')
app.register_blueprint(auditlogs_bp, url_prefix=f'{api_prefix}/auditlogs')
app.register_blueprint(users_bp, url_prefix=f'{api_prefix}/users')
def register_cli_commands(app: Flask): def register_cli_commands(app: Flask):

View File

@@ -145,3 +145,180 @@ def seed_test_user():
click.echo(click.style("Test user created: admin / admin123", fg='green')) click.echo(click.style("Test user created: admin / admin123", fg='green'))
else: else:
click.echo(click.style("Test user already exists", fg='yellow')) click.echo(click.style("Test user already exists", fg='yellow'))
@seed_cli.command('permissions')
@with_appcontext
def seed_permissions():
"""Seed predefined permissions."""
from shopdb.extensions import db
from shopdb.core.models import Permission
created = Permission.seed()
db.session.commit()
click.echo(click.style(f"{created} permissions created.", fg='green'))
@seed_cli.command('settings')
@with_appcontext
def seed_settings():
"""Seed default system settings."""
from shopdb.extensions import db
from shopdb.core.models import Setting
defaults = [
# Zabbix integration
{
'key': 'zabbix_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'integrations',
'description': 'Enable Zabbix integration for printer supply monitoring'
},
{
'key': 'zabbix_url',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Zabbix API URL (e.g., http://zabbix.example.com:8080)'
},
{
'key': 'zabbix_token',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Zabbix API authentication token'
},
# Email/SMTP settings
{
'key': 'smtp_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'email',
'description': 'Enable email notifications and alerts'
},
{
'key': 'smtp_host',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'SMTP server hostname'
},
{
'key': 'smtp_port',
'value': '587',
'valuetype': 'integer',
'category': 'email',
'description': 'SMTP server port (usually 587 for TLS, 465 for SSL, 25 for unencrypted)'
},
{
'key': 'smtp_username',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'SMTP authentication username'
},
{
'key': 'smtp_password',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'SMTP authentication password'
},
{
'key': 'smtp_use_tls',
'value': 'true',
'valuetype': 'boolean',
'category': 'email',
'description': 'Use TLS encryption for SMTP connection'
},
{
'key': 'smtp_from_address',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'From address for outgoing emails'
},
{
'key': 'smtp_from_name',
'value': 'ShopDB',
'valuetype': 'string',
'category': 'email',
'description': 'From name for outgoing emails'
},
{
'key': 'alert_recipients',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'Default email recipients for alerts (comma-separated)'
},
# Audit log settings
{
'key': 'audit_retention_days',
'value': '90',
'valuetype': 'integer',
'category': 'audit',
'description': 'Number of days to retain audit logs (0 = keep forever)'
},
# Authentication settings
{
'key': 'saml_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'auth',
'description': 'Enable SAML SSO authentication'
},
{
'key': 'saml_idp_metadata_url',
'value': '',
'valuetype': 'string',
'category': 'auth',
'description': 'SAML Identity Provider metadata URL'
},
{
'key': 'saml_entity_id',
'value': '',
'valuetype': 'string',
'category': 'auth',
'description': 'SAML Service Provider entity ID (e.g., https://shopdb.example.com)'
},
{
'key': 'saml_acs_url',
'value': '',
'valuetype': 'string',
'category': 'auth',
'description': 'SAML Assertion Consumer Service URL'
},
{
'key': 'saml_allow_local_login',
'value': 'true',
'valuetype': 'boolean',
'category': 'auth',
'description': 'Allow local username/password login when SAML is enabled'
},
{
'key': 'saml_auto_create_users',
'value': 'true',
'valuetype': 'boolean',
'category': 'auth',
'description': 'Automatically create users on first SAML login'
},
{
'key': 'saml_admin_group',
'value': '',
'valuetype': 'string',
'category': 'auth',
'description': 'SAML group name that grants admin role'
},
]
created = 0
for d in defaults:
if not Setting.query.filter_by(key=d['key']).first():
setting = Setting(**d)
db.session.add(setting)
created += 1
db.session.commit()
click.echo(click.style(f"{created} default settings created.", fg='green'))

View File

@@ -36,6 +36,14 @@ class Config:
# Logging # Logging
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO') LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
# Zabbix
ZABBIX_URL = os.environ.get('ZABBIX_URL', '')
ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '')
# Cache
CACHE_TYPE = 'SimpleCache'
CACHE_DEFAULT_TIMEOUT = 600 # 10 minutes
# Pagination # Pagination
DEFAULT_PAGE_SIZE = 20 DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100 MAX_PAGE_SIZE = 100

View File

@@ -19,6 +19,9 @@ from .reports import reports_bp
from .collector import collector_bp from .collector import collector_bp
from .employees import employees_bp from .employees import employees_bp
from .slides import slides_bp from .slides import slides_bp
from .settings import settings_bp
from .auditlogs import auditlogs_bp
from .users import users_bp
__all__ = [ __all__ = [
'auth_bp', 'auth_bp',
@@ -40,4 +43,7 @@ __all__ = [
'collector_bp', 'collector_bp',
'employees_bp', 'employees_bp',
'slides_bp', 'slides_bp',
'settings_bp',
'auditlogs_bp',
'users_bp',
] ]

View File

@@ -5,7 +5,7 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import ( from shopdb.core.models import (
Application, AppVersion, AppOwner, SupportTeam, InstalledApp, Machine Application, AppVersion, AppOwner, SupportTeam, InstalledApp, Machine, AuditLog
) )
from shopdb.utils.responses import ( from shopdb.utils.responses import (
success_response, success_response,
@@ -132,6 +132,10 @@ def create_application():
) )
db.session.add(app) db.session.add(app)
db.session.flush()
AuditLog.log('created', 'Application', entityid=app.appid, entityname=app.appname)
db.session.commit() db.session.commit()
return success_response(app.to_dict(), message='Application created', http_code=201) return success_response(app.to_dict(), message='Application created', http_code=201)
@@ -163,10 +167,20 @@ def update_application(app_id: int):
'applicationnotes', 'installpath', 'applicationlink', 'documentationpath', 'applicationnotes', 'installpath', 'applicationlink', 'documentationpath',
'ishidden', 'isprinter', 'islicenced', 'image', 'isactive' 'ishidden', 'isprinter', 'islicenced', 'image', 'isactive'
] ]
changes = {}
for key in fields: for key in fields:
if key in data: if key in data:
old_val = getattr(app, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(app, key, data[key]) setattr(app, key, data[key])
if changes:
AuditLog.log('updated', 'Application', entityid=app.appid,
entityname=app.appname, changes=changes)
db.session.commit() db.session.commit()
return success_response(app.to_dict(), message='Application updated') return success_response(app.to_dict(), message='Application updated')
@@ -181,6 +195,9 @@ def delete_application(app_id: int):
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
app.isactive = False app.isactive = False
AuditLog.log('deleted', 'Application', entityid=app.appid, entityname=app.appname)
db.session.commit() db.session.commit()
return success_response(message='Application deleted') return success_response(message='Application deleted')

View File

@@ -0,0 +1,146 @@
"""Audit log API routes."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.core.models import AuditLog
from shopdb.utils.responses import success_response, error_response, ErrorCodes
auditlogs_bp = Blueprint('auditlogs', __name__)
@auditlogs_bp.route('', methods=['GET'])
@jwt_required()
def list_auditlogs():
"""
List audit logs with filtering and pagination.
Query params:
page: Page number (default 1)
perpage: Items per page (default 50, max 200)
action: Filter by action (created, updated, deleted)
entitytype: Filter by entity type
userid: Filter by user ID
search: Search in entityname or username
from_date: Filter from date (ISO format)
to_date: Filter to date (ISO format)
"""
page = request.args.get('page', 1, type=int)
perpage = min(request.args.get('perpage', 50, type=int), 200)
query = AuditLog.query
# Filters
action = request.args.get('action')
if action:
query = query.filter(AuditLog.action == action)
entitytype = request.args.get('entitytype')
if entitytype:
query = query.filter(AuditLog.entitytype == entitytype)
userid = request.args.get('userid', type=int)
if userid:
query = query.filter(AuditLog.userid == userid)
search = request.args.get('search')
if search:
search_term = f'%{search}%'
query = query.filter(
(AuditLog.entityname.ilike(search_term)) |
(AuditLog.username.ilike(search_term))
)
from_date = request.args.get('from_date')
if from_date:
from datetime import datetime
try:
dt = datetime.fromisoformat(from_date.replace('Z', '+00:00'))
query = query.filter(AuditLog.timestamp >= dt)
except ValueError:
pass
to_date = request.args.get('to_date')
if to_date:
from datetime import datetime
try:
dt = datetime.fromisoformat(to_date.replace('Z', '+00:00'))
query = query.filter(AuditLog.timestamp <= dt)
except ValueError:
pass
# Order by most recent first
query = query.order_by(AuditLog.timestamp.desc())
# Paginate
pagination = query.paginate(page=page, per_page=perpage, error_out=False)
return success_response(
[log.to_dict() for log in pagination.items],
meta={
'page': page,
'perpage': perpage,
'total': pagination.total,
'pages': pagination.pages
}
)
@auditlogs_bp.route('/entity/<entitytype>/<int:entityid>', methods=['GET'])
@jwt_required()
def get_entity_history(entitytype: str, entityid: int):
"""Get audit history for a specific entity."""
logs = AuditLog.query.filter_by(
entitytype=entitytype,
entityid=entityid
).order_by(AuditLog.timestamp.desc()).all()
return success_response([log.to_dict() for log in logs])
@auditlogs_bp.route('/stats', methods=['GET'])
@jwt_required()
def get_stats():
"""Get audit log statistics."""
from sqlalchemy import func
from datetime import datetime, timedelta
# Actions by type
actions = db_func_count_by(AuditLog.action)
# Entity types
entities = db_func_count_by(AuditLog.entitytype)
# Recent activity (last 7 days)
week_ago = datetime.utcnow() - timedelta(days=7)
recent_count = AuditLog.query.filter(AuditLog.timestamp >= week_ago).count()
# Most active users (last 7 days)
from shopdb.extensions import db
active_users = db.session.query(
AuditLog.username,
func.count(AuditLog.auditlogid).label('count')
).filter(
AuditLog.timestamp >= week_ago,
AuditLog.username.isnot(None)
).group_by(AuditLog.username).order_by(func.count(AuditLog.auditlogid).desc()).limit(5).all()
return success_response({
'actions': actions,
'entities': entities,
'recentCount': recent_count,
'activeUsers': [{'username': u[0], 'count': u[1]} for u in active_users]
})
def db_func_count_by(column):
"""Helper to count grouped by a column."""
from sqlalchemy import func
from shopdb.extensions import db
results = db.session.query(
column,
func.count().label('count')
).group_by(column).all()
return {r[0]: r[1] for r in results}

View File

@@ -16,7 +16,7 @@ from flask import Blueprint, request, g
from flask_jwt_extended import jwt_required, current_user from flask_jwt_extended import jwt_required, current_user
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import Machine, MachineType from shopdb.core.models import Machine, MachineType, AuditLog
from shopdb.core.models.relationship import MachineRelationship, RelationshipType from shopdb.core.models.relationship import MachineRelationship, RelationshipType
from shopdb.utils.responses import ( from shopdb.utils.responses import (
success_response, success_response,
@@ -259,6 +259,12 @@ def create_machine():
machine.createdby = current_user.username machine.createdby = current_user.username
db.session.add(machine) db.session.add(machine)
db.session.flush()
# Audit log
AuditLog.log('created', 'Machine', entityid=machine.machineid,
entityname=machine.machinenumber)
db.session.commit() db.session.commit()
return success_response( return success_response(
@@ -306,11 +312,22 @@ def update_machine(machine_id: int):
'requiresmanualconfig', 'notes', 'isactive' 'requiresmanualconfig', 'notes', 'isactive'
] ]
# Track changes for audit log
changes = {}
for key, value in data.items(): for key, value in data.items():
if key in allowed_fields: if key in allowed_fields:
old_val = getattr(machine, key)
if old_val != value:
changes[key] = {'old': old_val, 'new': value}
setattr(machine, key, value) setattr(machine, key, value)
machine.modifiedby = current_user.username machine.modifiedby = current_user.username
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'Machine', entityid=machine.machineid,
entityname=machine.machinenumber, changes=changes)
db.session.commit() db.session.commit()
return success_response(machine.to_dict(), message='Machine updated successfully') return success_response(machine.to_dict(), message='Machine updated successfully')
@@ -331,6 +348,11 @@ def delete_machine(machine_id: int):
) )
machine.soft_delete(deleted_by=current_user.username) machine.soft_delete(deleted_by=current_user.username)
# Audit log
AuditLog.log('deleted', 'Machine', entityid=machine.machineid,
entityname=machine.machinenumber)
db.session.commit() db.session.commit()
return success_response(message='Machine deleted successfully') return success_response(message='Machine deleted successfully')

293
shopdb/core/api/settings.py Normal file
View File

@@ -0,0 +1,293 @@
"""Settings API routes."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db, cache
from shopdb.core.models import Setting, AuditLog
from shopdb.utils.responses import success_response, error_response, ErrorCodes
settings_bp = Blueprint('settings', __name__)
# Cache key for settings
SETTINGS_CACHE_KEY = 'system_settings'
SETTINGS_CACHE_TTL = 300 # 5 minutes
def get_cached_settings():
"""Get all settings from cache or database."""
cached = cache.get(SETTINGS_CACHE_KEY)
if cached is not None:
return cached
settings = Setting.query.all()
result = {s.key: s.get_typed_value() for s in settings}
cache.set(SETTINGS_CACHE_KEY, result, timeout=SETTINGS_CACHE_TTL)
return result
def invalidate_settings_cache():
"""Clear the settings cache."""
cache.delete(SETTINGS_CACHE_KEY)
@settings_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_settings():
"""List all settings, optionally filtered by category."""
category = request.args.get('category')
query = Setting.query
if category:
query = query.filter_by(category=category)
settings = query.order_by(Setting.category, Setting.key).all()
return success_response([s.to_dict() for s in settings])
@settings_bp.route('/<key>', methods=['GET'])
@jwt_required(optional=True)
def get_setting(key: str):
"""Get a single setting by key."""
setting = Setting.query.filter_by(key=key).first()
if not setting:
return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404)
return success_response(setting.to_dict())
@settings_bp.route('/<key>', methods=['PUT'])
@jwt_required()
def update_setting(key: str):
"""Update a setting value."""
data = request.get_json()
if data is None or 'value' not in data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'value is required')
setting = Setting.query.filter_by(key=key).first()
if not setting:
return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404)
# Track old value for audit
old_value = setting.value
# Convert value to string for storage
value = data['value']
if isinstance(value, bool):
setting.value = 'true' if value else 'false'
else:
setting.value = str(value) if value is not None else None
# Audit log (mask sensitive values)
is_sensitive = 'password' in key or 'token' in key or 'secret' in key
AuditLog.log('updated', 'Setting', entityname=key, changes={
'value': {
'old': '***' if is_sensitive else old_value,
'new': '***' if is_sensitive else setting.value
}
})
db.session.commit()
invalidate_settings_cache()
return success_response(setting.to_dict(), message='Setting updated')
@settings_bp.route('', methods=['POST'])
@jwt_required()
def create_setting():
"""Create a new setting (admin only)."""
data = request.get_json()
if not data or not data.get('key'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'key is required')
if Setting.query.filter_by(key=data['key']).first():
return error_response(ErrorCodes.CONFLICT, f"Setting '{data['key']}' already exists", http_code=409)
value = data.get('value')
if isinstance(value, bool):
value_str = 'true' if value else 'false'
else:
value_str = str(value) if value is not None else None
setting = Setting(
key=data['key'],
value=value_str,
valuetype=data.get('valuetype', 'string'),
category=data.get('category', 'general'),
description=data.get('description')
)
db.session.add(setting)
db.session.commit()
invalidate_settings_cache()
return success_response(setting.to_dict(), message='Setting created', http_code=201)
@settings_bp.route('/seed', methods=['POST'])
@jwt_required()
def seed_default_settings():
"""Seed default settings if they don't exist."""
defaults = [
# Zabbix integration
{
'key': 'zabbix_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'integrations',
'description': 'Enable Zabbix integration for printer supply monitoring'
},
{
'key': 'zabbix_url',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Zabbix API URL (e.g., http://zabbix.example.com:8080)'
},
{
'key': 'zabbix_token',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Zabbix API authentication token'
},
# Email/SMTP settings
{
'key': 'smtp_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'email',
'description': 'Enable email notifications and alerts'
},
{
'key': 'smtp_host',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'SMTP server hostname'
},
{
'key': 'smtp_port',
'value': '587',
'valuetype': 'integer',
'category': 'email',
'description': 'SMTP server port (usually 587 for TLS, 465 for SSL, 25 for unencrypted)'
},
{
'key': 'smtp_username',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'SMTP authentication username'
},
{
'key': 'smtp_password',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'SMTP authentication password'
},
{
'key': 'smtp_use_tls',
'value': 'true',
'valuetype': 'boolean',
'category': 'email',
'description': 'Use TLS encryption for SMTP connection'
},
{
'key': 'smtp_from_address',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'From address for outgoing emails'
},
{
'key': 'smtp_from_name',
'value': 'ShopDB',
'valuetype': 'string',
'category': 'email',
'description': 'From name for outgoing emails'
},
{
'key': 'alert_recipients',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'Default email recipients for alerts (comma-separated)'
},
# Audit log settings
{
'key': 'audit_retention_days',
'value': '90',
'valuetype': 'integer',
'category': 'audit',
'description': 'Number of days to retain audit logs (0 = keep forever)'
},
# Authentication settings
{
'key': 'saml_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'auth',
'description': 'Enable SAML SSO authentication'
},
{
'key': 'saml_idp_metadata_url',
'value': '',
'valuetype': 'string',
'category': 'auth',
'description': 'SAML Identity Provider metadata URL'
},
{
'key': 'saml_entity_id',
'value': '',
'valuetype': 'string',
'category': 'auth',
'description': 'SAML Service Provider entity ID (e.g., https://shopdb.example.com)'
},
{
'key': 'saml_acs_url',
'value': '',
'valuetype': 'string',
'category': 'auth',
'description': 'SAML Assertion Consumer Service URL'
},
{
'key': 'saml_allow_local_login',
'value': 'true',
'valuetype': 'boolean',
'category': 'auth',
'description': 'Allow local username/password login when SAML is enabled'
},
{
'key': 'saml_auto_create_users',
'value': 'true',
'valuetype': 'boolean',
'category': 'auth',
'description': 'Automatically create users on first SAML login'
},
{
'key': 'saml_admin_group',
'value': '',
'valuetype': 'string',
'category': 'auth',
'description': 'SAML group name that grants admin role'
},
]
created = 0
for d in defaults:
if not Setting.query.filter_by(key=d['key']).first():
setting = Setting(**d)
db.session.add(setting)
created += 1
db.session.commit()
invalidate_settings_cache()
return success_response({'created': created}, message=f'{created} default settings created')

351
shopdb/core/api/users.py Normal file
View File

@@ -0,0 +1,351 @@
"""User management API routes."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user
from werkzeug.security import generate_password_hash
from shopdb.extensions import db
from shopdb.core.models import User, Role, Permission, AuditLog
from shopdb.utils.responses import success_response, error_response, ErrorCodes
users_bp = Blueprint('users', __name__)
@users_bp.route('', methods=['GET'])
@jwt_required()
def list_users():
"""List all users."""
if not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
users = User.query.order_by(User.username).all()
return success_response([user_to_dict(u) for u in users])
@users_bp.route('/<int:userid>', methods=['GET'])
@jwt_required()
def get_user(userid: int):
"""Get a single user."""
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = User.query.get(userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
return success_response(user_to_dict(user))
@users_bp.route('', methods=['POST'])
@jwt_required()
def create_user():
"""Create a new user."""
if not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
# Validate required fields
if not data.get('username'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Username is required')
if not data.get('email'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Email is required')
if not data.get('password'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Password is required')
# Check uniqueness
if User.query.filter_by(username=data['username']).first():
return error_response(ErrorCodes.CONFLICT, 'Username already exists', http_code=409)
if User.query.filter_by(email=data['email']).first():
return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409)
user = User(
username=data['username'],
email=data['email'],
passwordhash=generate_password_hash(data['password']),
firstname=data.get('firstname'),
lastname=data.get('lastname'),
isactive=data.get('isactive', True)
)
# Assign roles
role_ids = data.get('roles', [])
if role_ids:
roles = Role.query.filter(Role.roleid.in_(role_ids)).all()
user.roles = roles
db.session.add(user)
# Audit log
AuditLog.log('created', 'User', entityname=user.username)
db.session.commit()
return success_response(user_to_dict(user), message='User created', http_code=201)
@users_bp.route('/<int:userid>', methods=['PUT'])
@jwt_required()
def update_user(userid: int):
"""Update a user."""
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = User.query.get(userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
changes = {}
# Update fields
if 'email' in data and data['email'] != user.email:
if User.query.filter(User.email == data['email'], User.userid != userid).first():
return error_response(ErrorCodes.CONFLICT, 'Email already in use', http_code=409)
changes['email'] = {'old': user.email, 'new': data['email']}
user.email = data['email']
if 'firstname' in data:
if data['firstname'] != user.firstname:
changes['firstname'] = {'old': user.firstname, 'new': data['firstname']}
user.firstname = data['firstname']
if 'lastname' in data:
if data['lastname'] != user.lastname:
changes['lastname'] = {'old': user.lastname, 'new': data['lastname']}
user.lastname = data['lastname']
# Admin-only fields
if current_user.hasrole('admin'):
if 'isactive' in data:
if data['isactive'] != user.isactive:
changes['isactive'] = {'old': user.isactive, 'new': data['isactive']}
user.isactive = data['isactive']
if 'roles' in data:
old_roles = [r.rolename for r in user.roles]
roles = Role.query.filter(Role.roleid.in_(data['roles'])).all()
new_roles = [r.rolename for r in roles]
if set(old_roles) != set(new_roles):
changes['roles'] = {'old': old_roles, 'new': new_roles}
user.roles = roles
# Unlock user
if data.get('unlock'):
user.lockeduntil = None
user.failedlogins = 0
changes['unlocked'] = {'old': True, 'new': False}
# Password change
if 'password' in data and data['password']:
user.passwordhash = generate_password_hash(data['password'])
changes['password'] = {'old': '***', 'new': '***'}
if changes:
AuditLog.log('updated', 'User', entityid=user.userid, entityname=user.username, changes=changes)
db.session.commit()
return success_response(user_to_dict(user), message='User updated')
@users_bp.route('/<int:userid>', methods=['DELETE'])
@jwt_required()
def delete_user(userid: int):
"""Delete a user."""
if not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
if current_user.userid == userid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account')
user = User.query.get(userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
username = user.username
db.session.delete(user)
AuditLog.log('deleted', 'User', entityid=userid, entityname=username)
db.session.commit()
return success_response(None, message='User deleted')
# Permissions endpoints
@users_bp.route('/permissions', methods=['GET'])
@jwt_required()
def list_permissions():
"""List all permissions grouped by category."""
permissions = Permission.query.order_by(Permission.category, Permission.name).all()
# Group by category
grouped = {}
for p in permissions:
if p.category not in grouped:
grouped[p.category] = []
grouped[p.category].append({
'permissionid': p.permissionid,
'name': p.name,
'description': p.description
})
return success_response({
'permissions': [{
'permissionid': p.permissionid,
'name': p.name,
'description': p.description,
'category': p.category
} for p in permissions],
'grouped': grouped
})
# Roles endpoints
@users_bp.route('/roles', methods=['GET'])
@jwt_required()
def list_roles():
"""List all roles with their permissions."""
roles = Role.query.order_by(Role.rolename).all()
return success_response([{
'roleid': r.roleid,
'rolename': r.rolename,
'description': r.description,
'usercount': r.users.count(),
'permissions': [p.name for p in r.permissions],
'isadmin': r.rolename == 'admin'
} for r in roles])
@users_bp.route('/roles', methods=['POST'])
@jwt_required()
def create_role():
"""Create a new role."""
if not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
data = request.get_json()
if not data or not data.get('rolename'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required')
if Role.query.filter_by(rolename=data['rolename']).first():
return error_response(ErrorCodes.CONFLICT, 'Role already exists', http_code=409)
role = Role(
rolename=data['rolename'],
description=data.get('description')
)
# Assign permissions
if 'permissions' in data:
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
role.permissions = perms
db.session.add(role)
AuditLog.log('created', 'Role', entityname=role.rolename)
db.session.commit()
return success_response({
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'permissions': [p.name for p in role.permissions]
}, message='Role created', http_code=201)
@users_bp.route('/roles/<int:roleid>', methods=['PUT'])
@jwt_required()
def update_role(roleid: int):
"""Update a role."""
if not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
role = Role.query.get(roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
# Cannot modify admin role permissions
if role.rolename == 'admin' and 'permissions' in request.get_json():
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot modify admin role permissions')
data = request.get_json()
changes = {}
if 'description' in data:
if data['description'] != role.description:
changes['description'] = {'old': role.description, 'new': data['description']}
role.description = data['description']
# Update permissions
if 'permissions' in data and role.rolename != 'admin':
old_perms = [p.name for p in role.permissions]
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
new_perms = [p.name for p in perms]
if set(old_perms) != set(new_perms):
changes['permissions'] = {'old': old_perms, 'new': new_perms}
role.permissions = perms
if changes:
AuditLog.log('updated', 'Role', entityid=role.roleid, entityname=role.rolename, changes=changes)
db.session.commit()
return success_response({
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'permissions': [p.name for p in role.permissions]
}, message='Role updated')
@users_bp.route('/roles/<int:roleid>', methods=['DELETE'])
@jwt_required()
def delete_role(roleid: int):
"""Delete a role."""
if not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
role = Role.query.get(roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
if role.rolename == 'admin':
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role')
if role.users.count() > 0:
return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)')
rolename = role.rolename
db.session.delete(role)
AuditLog.log('deleted', 'Role', entityid=roleid, entityname=rolename)
db.session.commit()
return success_response(None, message='Role deleted')
def user_to_dict(user: User) -> dict:
"""Convert user to dict for API response."""
return {
'userid': user.userid,
'username': user.username,
'email': user.email,
'firstname': user.firstname,
'lastname': user.lastname,
'isactive': user.isactive,
'islocked': user.islocked,
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
'failedlogins': user.failedlogins,
'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles],
'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None,
'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate else None
}

View File

@@ -10,9 +10,11 @@ from .location import Location
from .operatingsystem import OperatingSystem from .operatingsystem import OperatingSystem
from .relationship import MachineRelationship, AssetRelationship, RelationshipType from .relationship import MachineRelationship, AssetRelationship, RelationshipType
from .communication import Communication, CommunicationType from .communication import Communication, CommunicationType
from .user import User, Role from .user import User, Role, Permission
from .application import Application, AppVersion, AppOwner, SupportTeam, InstalledApp from .application import Application, AppVersion, AppOwner, SupportTeam, InstalledApp
from .knowledgebase import KnowledgeBase from .knowledgebase import KnowledgeBase
from .setting import Setting
from .auditlog import AuditLog
__all__ = [ __all__ = [
# Base # Base
@@ -44,6 +46,7 @@ __all__ = [
# Auth # Auth
'User', 'User',
'Role', 'Role',
'Permission',
# Applications # Applications
'Application', 'Application',
'AppVersion', 'AppVersion',
@@ -52,4 +55,8 @@ __all__ = [
'InstalledApp', 'InstalledApp',
# Knowledge Base # Knowledge Base
'KnowledgeBase', 'KnowledgeBase',
# Settings
'Setting',
# Audit
'AuditLog',
] ]

View File

@@ -0,0 +1,156 @@
"""Audit log model for tracking changes."""
from datetime import datetime
from shopdb.extensions import db
class AuditLog(db.Model):
"""
Audit log for tracking user actions.
Records who did what, when, from where, and what changed.
"""
__tablename__ = 'auditlogs'
auditlogid = db.Column(db.Integer, primary_key=True)
# Who
userid = db.Column(db.Integer, db.ForeignKey('users.userid'), nullable=True)
username = db.Column(db.String(100), nullable=True) # Denormalized for history
# When
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
# Where (client info)
ipaddress = db.Column(db.String(45), nullable=True) # IPv6 max length
useragent = db.Column(db.String(255), nullable=True)
# What
action = db.Column(db.String(20), nullable=False, index=True) # created, updated, deleted
entitytype = db.Column(db.String(50), nullable=False, index=True) # Asset, Printer, Setting, etc.
entityid = db.Column(db.Integer, nullable=True) # ID of the affected record
entityname = db.Column(db.String(255), nullable=True) # Human-readable identifier
# Changes (stored as JSON text)
_changes = db.Column('changes', db.Text, nullable=True) # {"field": {"old": x, "new": y}, ...}
@property
def changes(self):
"""Get changes as dict."""
if self._changes:
import json
try:
return json.loads(self._changes)
except (json.JSONDecodeError, TypeError):
return None
return None
@changes.setter
def changes(self, value):
"""Set changes from dict."""
if value is not None:
import json
self._changes = json.dumps(value)
else:
self._changes = None
# Additional context
details = db.Column(db.Text, nullable=True) # Optional description
# Relationship
user = db.relationship('User', backref=db.backref('auditlogs', lazy='dynamic'))
def to_dict(self):
return {
'auditlogid': self.auditlogid,
'userid': self.userid,
'username': self.username,
'timestamp': self.timestamp.isoformat() + 'Z' if self.timestamp else None,
'ipaddress': self.ipaddress,
'useragent': self.useragent,
'action': self.action,
'entitytype': self.entitytype,
'entityid': self.entityid,
'entityname': self.entityname,
'changes': self.changes,
'details': self.details
}
@classmethod
def log(cls, action: str, entitytype: str, entityid: int = None,
entityname: str = None, changes: dict = None, details: str = None,
user=None, request=None):
"""
Create an audit log entry.
Args:
action: 'created', 'updated', 'deleted'
entitytype: Type of entity (e.g., 'Asset', 'Printer', 'Setting')
entityid: ID of the affected record
entityname: Human-readable name/identifier
changes: Dict of field changes {"field": {"old": x, "new": y}}
details: Optional description
user: Current user object (or will try to get from flask-jwt-extended)
request: Flask request object (or will try to get current request)
"""
from flask import request as flask_request
from flask_jwt_extended import current_user, verify_jwt_in_request
# Get user info
if user is None:
try:
verify_jwt_in_request(optional=True)
user = current_user
except:
pass
userid = user.userid if user else None
username = user.username if user else None
# Get request info
req = request or flask_request
ipaddress = None
useragent = None
if req:
# Handle proxy forwarding
ipaddress = req.headers.get('X-Forwarded-For', req.remote_addr)
if ipaddress and ',' in ipaddress:
ipaddress = ipaddress.split(',')[0].strip()
useragent = req.headers.get('User-Agent', '')[:255]
entry = cls(
userid=userid,
username=username,
ipaddress=ipaddress,
useragent=useragent,
action=action,
entitytype=entitytype,
entityid=entityid,
entityname=entityname,
changes=changes,
details=details
)
db.session.add(entry)
# Don't commit here - let the caller handle transaction
return entry
@classmethod
def log_create(cls, entitytype: str, entity, name_field: str = 'name'):
"""Log a create action."""
entityname = getattr(entity, name_field, None) or str(entity)
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
return cls.log('created', entitytype, entityid=entityid, entityname=entityname)
@classmethod
def log_update(cls, entitytype: str, entity, changes: dict, name_field: str = 'name'):
"""Log an update action with changes."""
entityname = getattr(entity, name_field, None) or str(entity)
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
return cls.log('updated', entitytype, entityid=entityid, entityname=entityname, changes=changes)
@classmethod
def log_delete(cls, entitytype: str, entityid: int, entityname: str = None):
"""Log a delete action."""
return cls.log('deleted', entitytype, entityid=entityid, entityname=entityname)

View File

@@ -0,0 +1,73 @@
"""System settings model for key-value configuration storage."""
from datetime import datetime
from shopdb.extensions import db
class Setting(db.Model):
"""
Key-value store for system settings.
Settings can be managed via the admin UI and are cached
for performance.
"""
__tablename__ = 'settings'
settingid = db.Column(db.Integer, primary_key=True, autoincrement=True)
key = db.Column(db.String(100), unique=True, nullable=False, index=True)
value = db.Column(db.Text, nullable=True)
valuetype = db.Column(db.String(20), default='string') # string, boolean, integer, json
category = db.Column(db.String(50), default='general') # For grouping in UI
description = db.Column(db.String(255), nullable=True)
createddate = db.Column(db.DateTime, default=datetime.utcnow)
modifieddate = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
'settingid': self.settingid,
'key': self.key,
'value': self.get_typed_value(),
'valuetype': self.valuetype,
'category': self.category,
'description': self.description,
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
'modifieddate': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None,
}
def get_typed_value(self):
"""Return value converted to its proper type."""
if self.value is None:
return None
if self.valuetype == 'boolean':
return self.value.lower() in ('true', '1', 'yes')
if self.valuetype == 'integer':
try:
return int(self.value)
except (ValueError, TypeError):
return 0
return self.value
@classmethod
def get(cls, key: str, default=None):
"""Get a setting value by key."""
setting = cls.query.filter_by(key=key).first()
if setting:
return setting.get_typed_value()
return default
@classmethod
def set(cls, key: str, value, valuetype: str = 'string', category: str = 'general', description: str = None):
"""Set a setting value, creating if it doesn't exist."""
setting = cls.query.filter_by(key=key).first()
if not setting:
setting = cls(key=key, valuetype=valuetype, category=category, description=description)
db.session.add(setting)
# Convert value to string for storage
if isinstance(value, bool):
setting.value = 'true' if value else 'false'
else:
setting.value = str(value) if value is not None else None
db.session.commit()
return setting

View File

@@ -12,6 +12,98 @@ userroles = db.Table(
db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True) db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True)
) )
# Association table for role permissions (many-to-many)
rolepermissions = db.Table(
'rolepermissions',
db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True),
db.Column('permissionid', db.Integer, db.ForeignKey('permissions.permissionid'), primary_key=True)
)
class Permission(db.Model):
"""
Permission model for granular access control.
Permissions are predefined and assigned to roles.
"""
__tablename__ = 'permissions'
permissionid = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.String(255))
category = db.Column(db.String(50), default='general') # For grouping in UI
# Predefined permissions
PERMISSIONS = [
# Assets
('assets.view', 'View assets', 'assets'),
('assets.create', 'Create assets', 'assets'),
('assets.edit', 'Edit assets', 'assets'),
('assets.delete', 'Delete assets', 'assets'),
# Equipment
('equipment.view', 'View equipment', 'equipment'),
('equipment.create', 'Create equipment', 'equipment'),
('equipment.edit', 'Edit equipment', 'equipment'),
('equipment.delete', 'Delete equipment', 'equipment'),
# Computers
('computers.view', 'View computers', 'computers'),
('computers.create', 'Create computers', 'computers'),
('computers.edit', 'Edit computers', 'computers'),
('computers.delete', 'Delete computers', 'computers'),
# Printers
('printers.view', 'View printers', 'printers'),
('printers.create', 'Create printers', 'printers'),
('printers.edit', 'Edit printers', 'printers'),
('printers.delete', 'Delete printers', 'printers'),
# Network
('network.view', 'View network devices', 'network'),
('network.create', 'Create network devices', 'network'),
('network.edit', 'Edit network devices', 'network'),
('network.delete', 'Delete network devices', 'network'),
# Applications
('applications.view', 'View applications', 'applications'),
('applications.create', 'Create applications', 'applications'),
('applications.edit', 'Edit applications', 'applications'),
('applications.delete', 'Delete applications', 'applications'),
# Knowledge Base
('kb.view', 'View knowledge base', 'knowledgebase'),
('kb.create', 'Create KB articles', 'knowledgebase'),
('kb.edit', 'Edit KB articles', 'knowledgebase'),
('kb.delete', 'Delete KB articles', 'knowledgebase'),
# Notifications
('notifications.view', 'View notifications', 'notifications'),
('notifications.create', 'Create notifications', 'notifications'),
('notifications.edit', 'Edit notifications', 'notifications'),
('notifications.delete', 'Delete notifications', 'notifications'),
# Reports
('reports.view', 'View reports', 'reports'),
('reports.export', 'Export reports', 'reports'),
# Settings
('settings.view', 'View settings', 'admin'),
('settings.edit', 'Edit settings', 'admin'),
# Users
('users.view', 'View users', 'admin'),
('users.create', 'Create users', 'admin'),
('users.edit', 'Edit users', 'admin'),
('users.delete', 'Delete users', 'admin'),
# Audit
('audit.view', 'View audit logs', 'admin'),
]
def __repr__(self):
return f"<Permission {self.name}>"
@classmethod
def seed(cls):
"""Seed predefined permissions."""
created = 0
for name, description, category in cls.PERMISSIONS:
if not cls.query.filter_by(name=name).first():
perm = cls(name=name, description=description, category=category)
db.session.add(perm)
created += 1
return created
class Role(BaseModel): class Role(BaseModel):
"""User role model.""" """User role model."""
@@ -21,9 +113,29 @@ class Role(BaseModel):
rolename = db.Column(db.String(50), unique=True, nullable=False) rolename = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
# Permissions relationship
permissions = db.relationship(
'Permission',
secondary=rolepermissions,
backref=db.backref('roles', lazy='dynamic')
)
def __repr__(self): def __repr__(self):
return f"<Role {self.rolename}>" return f"<Role {self.rolename}>"
def haspermission(self, permission_name: str) -> bool:
"""Check if role has a specific permission."""
# Admin role has all permissions
if self.rolename == 'admin':
return True
return any(p.name == permission_name for p in self.permissions)
def getpermissionnames(self) -> list:
"""Get list of permission names."""
if self.rolename == 'admin':
return [p[0] for p in Permission.PERMISSIONS]
return [p.name for p in self.permissions]
class User(BaseModel): class User(BaseModel):
"""User model for authentication.""" """User model for authentication."""
@@ -64,10 +176,19 @@ class User(BaseModel):
"""Check if user has a specific role.""" """Check if user has a specific role."""
return any(r.rolename == rolename for r in self.roles) return any(r.rolename == rolename for r in self.roles)
def haspermission(self, permission_name: str) -> bool:
"""Check if user has a specific permission through any role."""
# Admin role has all permissions
if self.hasrole('admin'):
return True
return any(r.haspermission(permission_name) for r in self.roles)
def getpermissions(self) -> list: def getpermissions(self) -> list:
"""Get list of permission names from roles.""" """Get list of all permission names from all roles."""
# Simple role-based permissions if self.hasrole('admin'):
perms = [] return [p[0] for p in Permission.PERMISSIONS]
perms = set()
for role in self.roles: for role in self.roles:
perms.append(role.rolename) perms.update(role.getpermissionnames())
return perms return list(perms)

View File

@@ -5,6 +5,7 @@ from flask_migrate import Migrate
from flask_jwt_extended import JWTManager from flask_jwt_extended import JWTManager
from flask_cors import CORS from flask_cors import CORS
from flask_marshmallow import Marshmallow from flask_marshmallow import Marshmallow
from flask_caching import Cache
# Initialize extensions without app # Initialize extensions without app
db = SQLAlchemy() db = SQLAlchemy()
@@ -12,6 +13,7 @@ migrate = Migrate()
jwt = JWTManager() jwt = JWTManager()
cors = CORS() cors = CORS()
ma = Marshmallow() ma = Marshmallow()
cache = Cache()
def init_extensions(app): def init_extensions(app):
@@ -23,3 +25,4 @@ def init_extensions(app):
r"/api/*": {"origins": app.config.get('CORS_ORIGINS', '*')} r"/api/*": {"origins": app.config.get('CORS_ORIGINS', '*')}
}) })
ma.init_app(app) ma.init_app(app)
cache.init_app(app)