diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cad5d55..cc12af4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "shopdb-frontend", "version": "1.0.0", "dependencies": { + "@fullcalendar/core": "^6.1.20", + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/vue3": "^6.1.20", "axios": "^1.6.0", "leaflet": "^1.9.4", "pinia": "^2.1.0", @@ -456,6 +459,34 @@ "node": ">=12" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", + "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", + "license": "MIT", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz", + "integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/vue3": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz", + "integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20", + "vue": "^3.0.11" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1379,6 +1410,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1a95a57..6beb2b9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,9 @@ "preview": "vite preview" }, "dependencies": { + "@fullcalendar/core": "^6.1.20", + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/vue3": "^6.1.20", "axios": "^1.6.0", "leaflet": "^1.9.4", "pinia": "^2.1.0", diff --git a/frontend/public/static/images/sitemap2025-dark.png b/frontend/public/static/images/sitemap2025-dark.png new file mode 100644 index 0000000..b2c29b8 Binary files /dev/null and b/frontend/public/static/images/sitemap2025-dark.png differ diff --git a/frontend/public/static/images/sitemap2025-light.png b/frontend/public/static/images/sitemap2025-light.png new file mode 100644 index 0000000..43ace7e Binary files /dev/null and b/frontend/public/static/images/sitemap2025-light.png differ diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 480db2c..bc6670d 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -172,7 +172,7 @@ export const locationsApi = { // Printers API export const printersApi = { list(params = {}) { - return api.get('/printers/', { params }) + return api.get('/printers', { params }) }, get(id) { return api.get(`/printers/${id}`) @@ -390,3 +390,267 @@ export const knowledgebaseApi = { return api.get('/knowledgebase/stats') } } + +// Assets API (unified) +export const assetsApi = { + list(params = {}) { + return api.get('/assets', { params }) + }, + get(id) { + return api.get(`/assets/${id}`) + }, + create(data) { + return api.post('/assets', data) + }, + update(id, data) { + return api.put(`/assets/${id}`, data) + }, + delete(id) { + return api.delete(`/assets/${id}`) + }, + getMap(params = {}) { + return api.get('/assets/map', { params }) + }, + // Relationships + getRelationships(id) { + return api.get(`/assets/${id}/relationships`) + }, + createRelationship(data) { + return api.post('/assets/relationships', data) + }, + deleteRelationship(relationshipId) { + return api.delete(`/assets/relationships/${relationshipId}`) + }, + // Search assets (for relationship picker) + search(query, params = {}) { + return api.get('/assets', { params: { search: query, ...params } }) + }, + // Lookup asset by asset/machine number + lookup(assetnumber) { + return api.get(`/assets/lookup/${encodeURIComponent(assetnumber)}`) + }, + types: { + list() { + return api.get('/assets/types') + }, + get(id) { + return api.get(`/assets/types/${id}`) + } + }, + statuses: { + list() { + return api.get('/assets/statuses') + } + } +} + +// Notifications API +export const notificationsApi = { + list(params = {}) { + return api.get('/notifications', { params }) + }, + get(id) { + return api.get(`/notifications/${id}`) + }, + create(data) { + return api.post('/notifications', data) + }, + update(id, data) { + return api.put(`/notifications/${id}`, data) + }, + delete(id) { + return api.delete(`/notifications/${id}`) + }, + getActive() { + return api.get('/notifications/active') + }, + getCalendar(params = {}) { + return api.get('/notifications/calendar', { params }) + }, + dashboardSummary() { + return api.get('/notifications/dashboard/summary') + }, + getShopfloor(params = {}) { + return api.get('/notifications/shopfloor', { params }) + }, + getEmployeeRecognitions(sso) { + return api.get(`/notifications/employee/${sso}`) + }, + types: { + list() { + return api.get('/notifications/types') + }, + create(data) { + return api.post('/notifications/types', data) + } + } +} + +// USB Devices API +export const usbApi = { + list(params = {}) { + return api.get('/usb', { params }) + }, + get(id) { + return api.get(`/usb/${id}`) + }, + create(data) { + return api.post('/usb', data) + }, + update(id, data) { + return api.put(`/usb/${id}`, data) + }, + delete(id) { + return api.delete(`/usb/${id}`) + }, + checkout(id, data) { + return api.post(`/usb/${id}/checkout`, data) + }, + checkin(id, data = {}) { + return api.post(`/usb/${id}/checkin`, data) + }, + getHistory(id, params = {}) { + return api.get(`/usb/${id}/history`, { params }) + }, + getAvailable() { + return api.get('/usb/available') + }, + getCheckedOut() { + return api.get('/usb/checkedout') + }, + getUserCheckouts(userId) { + return api.get(`/usb/user/${userId}`) + }, + dashboardSummary() { + return api.get('/usb/dashboard/summary') + }, + types: { + list() { + return api.get('/usb/types') + }, + create(data) { + return api.post('/usb/types', data) + } + } +} + +// Reports API +export const reportsApi = { + list() { + return api.get('/reports') + }, + equipmentByType(params = {}) { + return api.get('/reports/equipment-by-type', { params }) + }, + assetsByStatus(params = {}) { + return api.get('/reports/assets-by-status', { params }) + }, + kbPopularity(params = {}) { + return api.get('/reports/kb-popularity', { params }) + }, + warrantyStatus(params = {}) { + return api.get('/reports/warranty-status', { params }) + }, + softwareCompliance(params = {}) { + return api.get('/reports/software-compliance', { params }) + }, + assetInventory(params = {}) { + return api.get('/reports/asset-inventory', { params }) + } +} + +// Employees API (wjf_employees database) +export const employeesApi = { + search(query, limit = 10) { + return api.get('/employees/search', { params: { q: query, limit } }) + }, + lookup(sso) { + return api.get(`/employees/lookup/${sso}`) + }, + lookupMultiple(ssoList) { + return api.get('/employees/lookup', { params: { sso: ssoList } }) + } +} + +// Alias for different casing +export const businessUnitsApi = businessunitsApi + +// Network API (devices, subnets, and VLANs) +export const networkApi = { + // Network devices + list(params = {}) { + return api.get('/network', { params }) + }, + get(id) { + return api.get(`/network/${id}`) + }, + getByAsset(assetId) { + return api.get(`/network/by-asset/${assetId}`) + }, + getByHostname(hostname) { + return api.get(`/network/by-hostname/${hostname}`) + }, + create(data) { + return api.post('/network', data) + }, + update(id, data) { + return api.put(`/network/${id}`, data) + }, + delete(id) { + return api.delete(`/network/${id}`) + }, + dashboardSummary() { + return api.get('/network/dashboard/summary') + }, + // Network device types + types: { + list(params = {}) { + return api.get('/network/types', { params }) + }, + get(id) { + return api.get(`/network/types/${id}`) + }, + create(data) { + return api.post('/network/types', data) + }, + update(id, data) { + return api.put(`/network/types/${id}`, data) + } + }, + // VLANs + vlans: { + list(params = {}) { + return api.get('/network/vlans', { params }) + }, + get(id) { + return api.get(`/network/vlans/${id}`) + }, + create(data) { + return api.post('/network/vlans', data) + }, + update(id, data) { + return api.put(`/network/vlans/${id}`, data) + }, + delete(id) { + return api.delete(`/network/vlans/${id}`) + } + }, + // Subnets + subnets: { + list(params = {}) { + return api.get('/network/subnets', { params }) + }, + get(id) { + return api.get(`/network/subnets/${id}`) + }, + create(data) { + return api.post('/network/subnets', data) + }, + update(id, data) { + return api.put(`/network/subnets/${id}`, data) + }, + delete(id) { + return api.delete(`/network/subnets/${id}`) + } + } +} diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index c31fd95..3ae39f7 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -1,42 +1,106 @@ /* Reset and base styles */ +@import url('https://fonts.googleapis.com/css?family=Roboto:400,500,700&display=swap'); + * { margin: 0; padding: 0; box-sizing: border-box; } +/* GE Aerospace Color Palette */ :root { - --primary: #2563eb; - --primary-dark: #1d4ed8; - --secondary: #64748b; - --success: #22c55e; - --warning: #f59e0b; - --danger: #ef4444; - --bg: #f8fafc; + /* Brand Colors */ + --ge-atmosphere-blue: #00003d; + --ge-sky-blue: #4181ff; + --ge-avionics-green: #0ad64f; + --ge-tungsten: #eaeaea; + + /* Light Mode (default) */ + --primary: #4181ff; + --primary-dark: #2d6ce0; + --secondary: #6c757d; + --success: #0ad64f; + --warning: #ff9500; + --danger: #dc3545; + --info: #4181ff; + --bg: #f5f5f5; --bg-card: #ffffff; - --text: #1e293b; - --text-light: #64748b; - --border: #e2e8f0; - --link: #2563eb; - --sidebar-width: 280px; + --bg-card-solid: #ffffff; + --text: #1a1a2e; + --text-light: #6c757d; + --border: #dee2e6; + --link: #4181ff; + --sidebar-bg: #00003d; + --sidebar-text: #ffffff; + --sidebar-width: 250px; +} + +/* Dark Mode - via data-theme attribute or system preference fallback */ +[data-theme="dark"], +:root:not([data-theme]) { + @media (prefers-color-scheme: dark) { + --primary: #4181ff; + --primary-dark: #2d6ce0; + --secondary: #6c757d; + --success: #0ad64f; + --warning: #ff9500; + --danger: #f5365c; + --info: #4181ff; + --bg: #0a0a1a; + --bg-card: rgba(0, 0, 61, 0.4); + --bg-card-solid: #0d0d2b; + --text: rgba(255, 255, 255, 0.9); + --text-light: rgba(255, 255, 255, 0.6); + --border: rgba(65, 129, 255, 0.2); + --link: #4181ff; + --sidebar-bg: #00003d; + --sidebar-text: #ffffff; + } +} + +/* Dark Mode - explicit selection */ +[data-theme="dark"] { + --primary: #4181ff; + --primary-dark: #2d6ce0; + --secondary: #6c757d; + --success: #0ad64f; + --warning: #ff9500; + --danger: #f5365c; + --info: #4181ff; + --bg: #0a0a1a; + --bg-card: rgba(0, 0, 61, 0.4); + --bg-card-solid: #0d0d2b; + --text: rgba(255, 255, 255, 0.9); + --text-light: rgba(255, 255, 255, 0.6); + --border: rgba(65, 129, 255, 0.2); + --link: #4181ff; + --sidebar-bg: #00003d; + --sidebar-text: #ffffff; +} + +/* Card border only in light mode */ +[data-theme="light"] .card { + border: 1px solid var(--border); } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: 'Roboto', sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; - font-size: 18px; + font-size: 15px; + letter-spacing: 0.5px; } /* Links */ a { color: var(--link); text-decoration: none; + outline: none; } -/* Only underline text links on hover, not card/button links */ a:hover { + color: var(--primary-dark); text-decoration: none; } @@ -48,6 +112,11 @@ td a:not(.btn):hover, text-decoration: underline; } +/* Headings */ +h1, h2, h3, h4, h5, h6 { + color: var(--text); +} + /* Layout */ .app-layout { display: flex; @@ -56,69 +125,75 @@ td a:not(.btn):hover, .sidebar { width: var(--sidebar-width); - background: var(--text); - color: white; - padding: 1.25rem 0; + background: var(--sidebar-bg); + color: var(--sidebar-text); + padding: 0; position: fixed; height: 100vh; overflow-y: auto; + overflow-x: hidden; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + z-index: 1000; } .sidebar-header { - padding: 1.25rem; - border-bottom: 1px solid rgba(255,255,255,0.1); - margin-bottom: 1.25rem; + padding: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.2); text-align: center; } .sidebar-header h1 { - font-size: 1.5rem; - font-weight: 600; + font-size: 14px; + font-weight: 400; + text-transform: uppercase; + color: #ffffff; + margin: 0.5rem 0 0 0; + letter-spacing: 1px; } .sidebar-logo { - width: 100%; - max-width: 200px; + width: 180px; height: auto; - margin-bottom: 0.75rem; - filter: brightness(0) invert(1); display: block; - margin-left: auto; - margin-right: auto; + margin: 0 auto; + filter: brightness(0) invert(1); } .sidebar-search { - padding: 0 1.25rem 1.25rem; + padding: 10px 15px; } .sidebar-search input { width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(255,255,255,0.2); - border-radius: 6px; - background: rgba(255,255,255,0.1); + padding: 0.375rem 0.75rem; + border: 0; + border-radius: 0.25rem; + background: rgba(255, 255, 255, 0.2); color: white; - font-size: 1.125rem; + font-size: 15px; + height: 34px; } .sidebar-search input::placeholder { - color: rgba(255,255,255,0.5); + color: #fff !important; + font-size: 13px; + opacity: 0.5 !important; } .sidebar-search input:focus { outline: none; - border-color: var(--primary); - background: rgba(255,255,255,0.15); + background: rgba(0,0,0,0.2); + box-shadow: 0 0 0 0.2rem rgba(255, 255, 255, 0.45); } .sidebar-nav a { display: flex; align-items: center; - padding: 1rem 1.25rem; - color: rgba(255,255,255,0.7); + padding: 0.7rem 1.25rem; + color: rgba(255,255,255,0.85); text-decoration: none; transition: all 0.2s; - font-size: 1.125rem; + font-size: 14px; } .sidebar-nav a:hover, @@ -128,13 +203,31 @@ td a:not(.btn):hover, } .sidebar-nav a.active { - border-left: 4px solid var(--primary); + border-left: 3px solid var(--primary); +} + +.sidebar-nav a.external-link::after { + content: '\2197'; + font-size: 10px; + margin-left: auto; + opacity: 0.6; +} + +.nav-section { + padding: 0.75rem 1.25rem 0.5rem; + color: rgba(255,255,255,0.5); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + margin-top: 0.5rem; } .main-content { flex: 1; margin-left: var(--sidebar-width); - padding: 2rem; + padding: 20px 10px 70px 10px; + overflow-x: hidden; } /* Header */ @@ -142,93 +235,136 @@ td a:not(.btn):hover, display: flex; justify-content: space-between; align-items: center; - margin-bottom: 2rem; + margin-bottom: 1.5rem; +} + +.page-header h1 { + font-size: 20px; + font-weight: 500; + line-height: 20px; + margin: 0; } .page-header h2 { - font-size: 2rem; - font-weight: 600; + font-size: 20px; + font-weight: 500; + line-height: 20px; } /* Cards */ .card { background: var(--bg-card); - border-radius: 10px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); - padding: 1.75rem; - margin-bottom: 1.25rem; + border-radius: 0.25rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); + padding: 1.25rem; + margin-bottom: 25px; + border: 1px solid var(--border); +} + +[data-theme="dark"] .card { + border: none; } .card-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.25rem; - padding-bottom: 1rem; + margin-bottom: 0; + padding: 0.75rem 1.25rem; + background: transparent; border-bottom: 1px solid var(--border); + font-weight: 600; + font-size: 14px; + color: var(--text); } .card-header h3 { - font-size: 1.25rem; + font-size: 16px; font-weight: 600; + margin: 0; +} + +.card-title { + margin-bottom: 0.75rem; + font-weight: 600; + font-size: 16px; + color: var(--text); +} + +.card-footer { + padding: 0.75rem 1.25rem; + background-color: transparent; + border-top: 1px solid var(--border); } /* Dashboard widgets */ .dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 1.25rem; - margin-bottom: 2rem; + gap: 25px; + margin-bottom: 25px; } .stat-card { background: var(--bg-card); - border-radius: 10px; - padding: 1.5rem; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + border-radius: 0.25rem; + padding: 1.25rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); } .stat-card .label { - font-size: 1rem; + font-size: 14px; color: var(--text-light); margin-bottom: 0.5rem; } .stat-card .value { - font-size: 2.25rem; + font-size: 2rem; font-weight: 600; + color: var(--text); } .stat-card.success .value { color: var(--success); } .stat-card.warning .value { color: var(--warning); } .stat-card.danger .value { color: var(--danger); } +.stat-card.info .value { color: var(--info); } /* Tables */ .table-container { overflow-x: auto; + white-space: nowrap; } table { width: 100%; border-collapse: collapse; + margin-bottom: 1rem; + color: var(--text); } tr { - border-bottom: 1px solid var(--border); + border-bottom: none; } th, td { - padding: 1rem 1.25rem; + padding: 0.75rem; text-align: left; - font-size: 1.125rem; + font-size: 13px; vertical-align: middle; + white-space: nowrap; + border-top: 1px solid var(--border); } th { font-weight: 600; - font-size: 1.0625rem; - color: var(--text-light); - background: var(--bg); + font-size: 11px; + color: var(--text); + text-transform: uppercase; + letter-spacing: 1px; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.3); + border-top: none; } th.sortable { @@ -237,16 +373,17 @@ th.sortable { } th.sortable:hover { - color: var(--text); + color: var(--primary); } .sort-arrow { margin-left: 0.25rem; - font-size: 0.75rem; + font-size: 10px; } tr:hover { - background: var(--bg); + background: var(--primary); + background: rgba(65, 129, 255, 0.1); } /* Buttons */ @@ -254,104 +391,259 @@ tr:hover { display: inline-flex; align-items: center; justify-content: center; - gap: 0.625rem; - padding: 0.75rem 1.5rem; + gap: 0.5rem; + padding: 9px 19px; border: none; - border-radius: 8px; - font-size: 1.125rem; + border-radius: 0.25rem; + font-size: 11px; font-weight: 500; + letter-spacing: 1px; + text-transform: uppercase; cursor: pointer; transition: all 0.2s; vertical-align: middle; line-height: 1; text-decoration: none; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); +} + +.btn:focus { + box-shadow: none; + outline: none; } .btn-primary { background: var(--primary); color: white; + border-color: var(--primary); } .btn-primary:hover { background: var(--primary-dark); + border-color: var(--primary-dark); + color: white; } .btn-secondary { - background: var(--bg); - color: var(--text); - border: 1px solid var(--border); + background: var(--secondary); + color: white; + border-color: var(--secondary); } .btn-secondary:hover { - background: var(--border); + background: #82503f; + border-color: #82503f; + color: white; +} + +.btn-success { + background: var(--success); + color: white; + border-color: var(--success); +} + +.btn-success:hover { + background: #019e4c; + border-color: #019e4c; +} + +.btn-info { + background: var(--info); + color: white; + border-color: var(--info); +} + +.btn-info:hover { + background: #039ce0; + border-color: #039ce0; +} + +.btn-warning { + background: var(--warning); + color: white; + border-color: var(--warning); +} + +.btn-warning:hover { + background: #e67c02; + border-color: #e67c02; } .btn-danger { background: var(--danger); color: white; + border-color: var(--danger); } .btn-danger:hover { - background: #dc2626; + background: #e62c51; + border-color: #e62c51; +} + +.btn-link { + color: var(--info); + background: transparent; + border: none; + box-shadow: none; } .btn-sm { - padding: 0.5rem 1rem; - font-size: 1rem; - line-height: 1; + font-size: 10px; + font-weight: 500; + padding: 6px 15px; +} + +.btn-lg { + padding: 12px 38px; + font-size: 14px; } /* Forms */ .form-group { - margin-bottom: 1.5rem; + margin-bottom: 1rem; } .form-group label { display: block; - font-size: 1.125rem; + font-size: 14px; font-weight: 500; margin-bottom: 0.5rem; + color: var(--text); } .form-control { width: 100%; - padding: 0.75rem 1rem; + padding: 0.625rem 0.875rem; border: 1px solid var(--border); - border-radius: 8px; - font-size: 1.125rem; - transition: border-color 0.2s; + border-radius: 6px; + font-size: 0.9375rem; + background: var(--bg-card); + color: var(--text); + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } .form-control:focus { outline: none; border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.15); +} + +.form-control::placeholder { + color: var(--text-light); + opacity: 0.7; } select.form-control { - background: white; + background: var(--bg-card); + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.25rem; +} + +select.form-control option { + background: var(--bg-card); + color: var(--text); +} + +/* Textarea styling */ +textarea.form-control { + min-height: 100px; + resize: vertical; + line-height: 1.5; +} + +/* Input group for search with icon */ +.input-group { + display: flex; + position: relative; +} + +.input-group .form-control { + flex: 1; +} + +.input-group-icon { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: var(--text-light); + pointer-events: none; +} + +.input-group .form-control.has-icon { + padding-left: 2.5rem; +} + +/* Form labels */ +.form-group label { + display: block; + margin-bottom: 0.375rem; + font-weight: 500; + color: var(--text); + font-size: 0.875rem; +} + +/* Checkbox and radio styling */ +input[type="checkbox"], +input[type="radio"] { + width: 1.125rem; + height: 1.125rem; + accent-color: var(--primary); + cursor: pointer; +} + +/* Dark mode form adjustments */ +@media (prefers-color-scheme: dark) { + .form-control { + background: var(--bg); + border-color: var(--border); + } + + .form-control:focus { + background: var(--bg); + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2); + } + + select.form-control { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-color: var(--bg); + } + + select.form-control option { + background: #1a1a2e; + } } /* Form grid */ .form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 1.25rem; + gap: 1rem; } /* Badges */ .badge { display: inline-block; - padding: 0.5rem 0.875rem; - border-radius: 6px; - font-size: 1rem; + padding: 0.25em 0.5em; + border-radius: 0.25rem; + font-size: 11px; font-weight: 500; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; } -.badge-success { background: #dcfce7; color: #166534; } -.badge-warning { background: #fef3c7; color: #92400e; } -.badge-danger { background: #fee2e2; color: #991b1b; } -.badge-info { background: #dbeafe; color: #1e40af; } +.badge-success { background: var(--success); color: #fff; } +.badge-warning { background: var(--warning); color: #fff; } +.badge-danger { background: var(--danger); color: #fff; } +.badge-info { background: var(--info); color: #fff; } +.badge-primary { background: var(--primary); color: #fff; } +.badge-secondary { background: var(--secondary); color: #fff; } /* Login page */ .login-container { @@ -363,117 +655,159 @@ select.form-control { } .login-box { - background: white; - padding: 2.5rem; - border-radius: 10px; - box-shadow: 0 4px 8px rgba(0,0,0,0.1); + background: var(--bg-card); + padding: 2rem; + border-radius: 0.25rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); width: 100%; - max-width: 450px; + max-width: 400px; +} + +.login-logo { + display: block; + width: 160px; + height: auto; + margin: 0 auto 1rem; +} + +/* Logo is white by default - invert for light mode */ +[data-theme="light"] .login-logo { + filter: invert(1); } .login-box h1 { text-align: center; - margin-bottom: 2rem; - font-size: 1.75rem; + margin-bottom: 1.5rem; + font-size: 24px; + color: var(--text); } .login-box .btn { width: 100%; justify-content: center; - padding: 1rem; + padding: 12px; } .error-message { - background: #fee2e2; - color: #991b1b; - padding: 1rem; - border-radius: 8px; - margin-bottom: 1.25rem; - font-size: 1rem; + background: rgba(245, 54, 92, 0.2); + color: #f5365c; + padding: 0.75rem 1rem; + border-radius: 0.25rem; + margin-bottom: 1rem; + font-size: 14px; + border: 1px solid rgba(245, 54, 92, 0.3); } /* Modal */ .modal-overlay { position: fixed; inset: 0; - background: rgba(0,0,0,0.5); + background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; - z-index: 1000; + z-index: 1050; } .modal { - background: white; - border-radius: 10px; + background: var(--bg-card-solid); + border-radius: 0.25rem; width: 100%; - max-width: 600px; + max-width: 500px; max-height: 90vh; overflow-y: auto; + box-shadow: 0 16px 38px -12px rgba(0,0,0,.56), 0 4px 25px 0 rgba(0,0,0,.12), 0 8px 10px -5px rgba(0,0,0,.2); + color: var(--text); } .modal-header { display: flex; justify-content: space-between; align-items: center; - padding: 1.25rem 1.75rem; + padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); + background: transparent; } .modal-header h3 { - font-size: 1.375rem; + font-size: 16px; font-weight: 600; + color: var(--text); + margin: 0; } .modal-body { - padding: 1.75rem; + padding: 1.25rem; } .modal-footer { display: flex; justify-content: flex-end; - gap: 0.75rem; - padding: 1.25rem 1.75rem; + gap: 0.5rem; + padding: 1rem 1.25rem; border-top: 1px solid var(--border); } /* Search and filters */ .filters { display: flex; - gap: 1rem; - margin-bottom: 1.25rem; + gap: 0.75rem; + margin-bottom: 1rem; + flex-wrap: wrap; + align-items: center; } .filters input[type="text"], .filters input.form-control { - flex: 1; + flex: 1 1 auto; min-width: 200px; + max-width: 350px; } .filters select, .filters select.form-control { - width: 220px; + min-width: 160px; + max-width: 220px; + flex: 0 1 auto; +} + +/* Filter bar with background */ +.filter-bar { + display: flex; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 1rem; + flex-wrap: wrap; + align-items: center; +} + +.filter-bar .form-control { + background: var(--bg); } /* Pagination */ .pagination { display: flex; justify-content: center; - gap: 0.375rem; - margin-top: 1.5rem; + gap: 0.25rem; + margin-top: 1rem; } .pagination button { - padding: 0.625rem 1rem; + padding: 0.5rem 0.75rem; border: 1px solid var(--border); - background: white; - border-radius: 6px; + background: transparent; + border-radius: 0.25rem; cursor: pointer; - font-size: 1rem; + font-size: 13px; + color: var(--text); } .pagination button:hover { - background: var(--bg); + background: rgba(255, 255, 255, 0.1); } .pagination button.active { @@ -485,21 +819,47 @@ select.form-control { /* Loading */ .loading { text-align: center; - padding: 2.5rem; + padding: 2rem; color: var(--text-light); - font-size: 1.125rem; + font-size: 14px; +} + +/* Sidebar footer */ +.sidebar-footer { + margin-top: auto; + border-top: 1px solid rgba(255,255,255,0.1); +} + +.theme-toggle { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + padding: 0.75rem 1.25rem; + background: transparent; + border: none; + color: rgba(255,255,255,0.7); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.theme-toggle:hover { + background: rgba(255,255,255,0.1); + color: white; } /* User menu */ .user-menu { - padding: 1.25rem; + padding: 1rem 1.25rem; border-top: 1px solid rgba(255,255,255,0.1); - margin-top: auto; } .user-menu .username { - font-size: 1rem; - margin-bottom: 0.625rem; + font-size: 14px; + margin-bottom: 0.5rem; + color: rgba(255,255,255,0.85); } .user-menu .btn { @@ -510,7 +870,7 @@ select.form-control { /* Action buttons in tables */ .actions { display: inline-flex; - gap: 0.375rem; + gap: 0.25rem; align-items: center; vertical-align: middle; white-space: nowrap; @@ -530,18 +890,18 @@ td.actions { .header-actions { display: flex; - gap: 0.75rem; + gap: 0.5rem; } /* Hero Card */ .hero-card { display: flex; - gap: 2.5rem; + gap: 2rem; background: var(--bg-card); - border-radius: 12px; - padding: 2rem; - margin-bottom: 2rem; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + border-radius: 0.25rem; + padding: 1.5rem; + margin-bottom: 25px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); } .hero-image { @@ -549,111 +909,112 @@ td.actions { } .hero-image img { - width: 220px; - height: 220px; + width: 180px; + height: 180px; object-fit: contain; - background: var(--bg); - border-radius: 10px; - padding: 1.25rem; + background: rgba(0, 0, 0, 0.3); + border-radius: 0.25rem; + padding: 1rem; } .hero-content { flex: 1; display: flex; flex-direction: column; - gap: 1.25rem; + gap: 1rem; } .hero-title { display: flex; align-items: baseline; - gap: 1rem; + gap: 0.75rem; } .hero-title h1 { - font-size: 2.75rem; - font-weight: 700; + font-size: 30px; + font-weight: 600; margin: 0; color: var(--text); + line-height: 34px; } .hero-alias { - font-size: 1.75rem; + font-size: 18px; color: var(--text-light); } .hero-meta { display: flex; - gap: 0.75rem; + gap: 0.5rem; } .badge-lg { - padding: 0.5rem 1rem; - font-size: 1.1rem; - font-weight: 600; - border-radius: 6px; + padding: 0.35rem 0.75rem; + font-size: 12px; + font-weight: 500; + border-radius: 0.25rem; } .hero-details { display: flex; flex-wrap: wrap; - gap: 2.5rem; + gap: 2rem; margin-top: auto; } .hero-detail { display: flex; flex-direction: column; - gap: 0.375rem; + gap: 0.25rem; } .hero-detail-label { - font-size: 0.875rem; + font-size: 10px; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 1px; color: var(--text-light); } .hero-detail-value { - font-size: 1.25rem; + font-size: 15px; font-weight: 500; color: var(--text); } .hero-detail-value.mono { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; - font-size: 1.1rem; + font-size: 14px; } /* Content Grid */ .content-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 2rem; - margin-bottom: 2rem; + gap: 25px; + margin-bottom: 25px; } .content-column { display: flex; flex-direction: column; - gap: 2rem; + gap: 25px; } /* Section Cards */ .section-card { background: var(--bg-card); - border-radius: 10px; - padding: 1.75rem; - box-shadow: 0 2px 4px rgba(0,0,0,0.08); + border-radius: 0.25rem; + padding: 1.25rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); } .section-title { - font-size: 1rem; + font-size: 14px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-light); - margin: 0 0 1.25rem 0; + letter-spacing: 1px; + color: var(--text); + margin: 0 0 1rem 0; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); } @@ -662,23 +1023,23 @@ td.actions { .info-list { display: flex; flex-direction: column; - gap: 1rem; + gap: 0.75rem; } .info-row { display: flex; justify-content: space-between; align-items: center; - gap: 1.5rem; + gap: 1rem; } .info-label { - font-size: 1.125rem; + font-size: 14px; color: var(--text-light); } .info-value { - font-size: 1.125rem; + font-size: 14px; font-weight: 500; color: var(--text); text-align: right; @@ -686,29 +1047,29 @@ td.actions { .info-value.mono, .mono { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; - font-size: 1rem; + font-size: 13px; } /* Feature Tags */ .feature-tag { display: inline-block; - padding: 0.3rem 0.625rem; - margin-left: 0.375rem; - font-size: 0.875rem; - border-radius: 5px; - background: var(--bg); + padding: 0.25rem 0.5rem; + margin-left: 0.25rem; + font-size: 11px; + border-radius: 0.25rem; + background: rgba(255, 255, 255, 0.1); color: var(--text-light); } .feature-tag.active { - background: #e3f2fd; - color: #1976d2; + background: rgba(20, 171, 239, 0.2); + color: var(--info); } /* Empty State */ .empty-message { color: var(--text-light); - font-size: 1.125rem; + font-size: 14px; font-style: italic; } @@ -717,35 +1078,35 @@ td.actions { display: flex; align-items: center; justify-content: space-between; - gap: 1.25rem; + gap: 1rem; } .device-link { display: flex; align-items: center; - gap: 1rem; + gap: 0.75rem; text-decoration: none; color: inherit; } .device-link:hover .device-name { - color: var(--link); + color: var(--info); } .device-icon { - width: 52px; - height: 52px; + width: 44px; + height: 44px; display: flex; align-items: center; justify-content: center; - background: #e3f2fd; - border-radius: 10px; - color: #1976d2; + background: rgba(20, 171, 239, 0.2); + border-radius: 0.25rem; + color: var(--info); } .device-icon svg { - width: 28px; - height: 28px; + width: 24px; + height: 24px; } .device-info { @@ -755,43 +1116,43 @@ td.actions { .device-name { font-weight: 600; - font-size: 1.25rem; + font-size: 15px; } .device-alias { - font-size: 1rem; + font-size: 13px; color: var(--text-light); } .connection-type { - padding: 0.375rem 0.75rem; - font-size: 0.95rem; - background: #e8f5e9; - color: #2e7d32; - border-radius: 5px; + padding: 0.25rem 0.5rem; + font-size: 11px; + background: rgba(4, 185, 98, 0.2); + color: var(--success); + border-radius: 0.25rem; } /* Equipment List */ .equipment-list { display: flex; flex-direction: column; - gap: 0.75rem; + gap: 0.5rem; } .equipment-item { display: flex; align-items: center; justify-content: space-between; - padding: 1rem; - background: var(--bg); - border-radius: 8px; + padding: 0.75rem; + background: rgba(0, 0, 0, 0.3); + border-radius: 0.25rem; text-decoration: none; color: inherit; transition: background 0.15s; } .equipment-item:hover { - background: var(--border); + background: rgba(255, 255, 255, 0.1); } .equipment-info { @@ -801,71 +1162,71 @@ td.actions { .equipment-name { font-weight: 500; - font-size: 1.125rem; + font-size: 14px; } .equipment-alias { - font-size: 1rem; + font-size: 13px; color: var(--text-light); } .connection-tag { - padding: 0.3rem 0.625rem; - font-size: 0.875rem; - background: #e8f5e9; - color: #2e7d32; - border-radius: 5px; + padding: 0.2rem 0.5rem; + font-size: 11px; + background: rgba(4, 185, 98, 0.2); + color: var(--success); + border-radius: 0.25rem; } /* Network */ .network-list { display: flex; flex-direction: column; - gap: 1rem; + gap: 0.75rem; } .network-item { - padding: 1rem; - background: var(--bg); - border-radius: 8px; + padding: 0.75rem; + background: rgba(0, 0, 0, 0.3); + border-radius: 0.25rem; } .network-primary { display: flex; align-items: center; - gap: 0.75rem; + gap: 0.5rem; } .ip-address { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; - font-size: 1.125rem; + font-size: 14px; font-weight: 500; } .primary-badge { - padding: 0.25rem 0.5rem; - font-size: 0.8rem; + padding: 0.2rem 0.4rem; + font-size: 10px; text-transform: uppercase; - background: #4caf50; + background: var(--success); color: white; - border-radius: 4px; + border-radius: 0.25rem; } .network-secondary { - margin-top: 0.375rem; + margin-top: 0.25rem; } .mac-address { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; - font-size: 1rem; + font-size: 13px; color: var(--text-light); } /* Notes */ .notes-text { margin: 0; - font-size: 1.125rem; - line-height: 1.7; + font-size: 14px; + line-height: 1.6; color: var(--text); } @@ -873,18 +1234,18 @@ td.actions { .audit-footer { display: flex; justify-content: space-between; - padding: 1.25rem; - margin-top: 2rem; - font-size: 1rem; + padding: 1rem; + margin-top: 25px; + font-size: 13px; color: var(--text-light); border-top: 1px solid var(--border); } /* Location link */ .location-link { - color: var(--link); + color: var(--info); cursor: pointer; - border-bottom: 1px dashed var(--link); + border-bottom: 1px dashed var(--info); } .location-link:hover { @@ -933,122 +1294,163 @@ td.actions { } /* ============================================ - DARK MODE + FULLCALENDAR DARK THEME OVERRIDES ============================================ */ -@media (prefers-color-scheme: dark) { - :root { - --primary: #3b82f6; - --primary-dark: #2563eb; - --secondary: #94a3b8; - --success: #22c55e; - --warning: #f59e0b; - --danger: #ef4444; - --bg: #0f172a; - --bg-card: #1e293b; - --text: #e2e8f0; - --text-light: #94a3b8; - --border: #334155; - --link: #60a5fa; - } - - body { - background: var(--bg); - color: var(--text); - } - - .sidebar { - background: #0f172a; - } - - .card, .stat-card, .section-card, .hero-card { - background: var(--bg-card); - } - - table th { - background: #0f172a; - color: var(--text-light); - } - - table tr:hover { - background: #0f172a; - } - - .btn-secondary { - background: var(--bg-card); - color: var(--text); - border-color: var(--border); - } - - .btn-secondary:hover { - background: var(--border); - } - - .form-control { - background: var(--bg-card); - border-color: var(--border); - color: var(--text); - } - - select.form-control { - background: var(--bg-card); - } - - .modal { - background: var(--bg-card); - } - - .pagination button { - background: var(--bg-card); - border-color: var(--border); - color: var(--text); - } - - .pagination button:hover { - background: var(--border); - } - - .login-box { - background: var(--bg-card); - } - - .hero-image img { - background: #0f172a; - } - - .equipment-item, .network-item { - background: #0f172a; - } - - .equipment-item:hover { - background: var(--border); - } - - .feature-tag { - background: #334155; - color: #94a3b8; - } - - .feature-tag.active { - background: #1e3a5f; - color: #60a5fa; - } - - .device-icon { - background: #1e3a5f; - color: #60a5fa; - } - - .connection-type, .connection-tag { - background: #14532d; - color: #4ade80; - } - - .badge-success { background: #166534; color: #4ade80; } - .badge-warning { background: #92400e; color: #fbbf24; } - .badge-danger { background: #991b1b; color: #f87171; } - .badge-info { background: #1e40af; color: #60a5fa; } - - .sidebar-logo { - filter: brightness(0) invert(1); - } +.fc { + --fc-border-color: var(--border); + --fc-button-bg-color: var(--primary); + --fc-button-border-color: var(--primary); + --fc-button-hover-bg-color: var(--primary-dark); + --fc-button-hover-border-color: var(--primary-dark); + --fc-button-active-bg-color: var(--primary-dark); + --fc-button-active-border-color: var(--primary-dark); + --fc-page-bg-color: transparent; + --fc-neutral-bg-color: rgba(0, 0, 0, 0.2); + --fc-list-event-hover-bg-color: rgba(255, 255, 255, 0.1); + --fc-today-bg-color: rgba(121, 52, 243, 0.15); } + +.fc .fc-button { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + font-weight: 500; +} + +.fc .fc-col-header-cell-cushion, +.fc .fc-daygrid-day-number { + color: var(--text); +} + +.fc .fc-daygrid-day.fc-day-today { + background: var(--fc-today-bg-color); +} + +.fc-theme-standard .fc-scrollgrid { + border-color: var(--border); +} + +.fc-theme-standard td, .fc-theme-standard th { + border-color: var(--border); +} + +.fc .fc-daygrid-day-top { + flex-direction: row; +} + +.fc-event { + font-size: 12px; + border-radius: 0.25rem; +} + +.fc .fc-toolbar-title { + font-size: 18px; + font-weight: 500; +} + +.fc .fc-more-link { + color: var(--info); + font-size: 11px; +} + +.fc .fc-popover { + background: #111111; + border: 1px solid var(--border); + border-radius: 0.25rem; + box-shadow: 0 16px 38px -12px rgba(0,0,0,.56), 0 4px 25px 0 rgba(0,0,0,.12), 0 8px 10px -5px rgba(0,0,0,.2); +} + +.fc .fc-popover-header { + background: rgba(0, 0, 0, 0.3); + color: var(--text); + padding: 0.5rem 0.75rem; + font-size: 13px; +} + +.fc .fc-popover-body { + padding: 0.5rem; +} + +.fc .fc-daygrid-more-link { + margin-top: 2px; +} + +.fc-more-tooltip { + position: fixed; + background: var(--bg-card-solid); + border: 1px solid var(--border); + border-radius: 0.25rem; + padding: 0.5rem; + z-index: 9999; + min-width: 180px; + max-width: 350px; + box-shadow: 0 16px 38px -12px rgba(0,0,0,.56), 0 4px 25px 0 rgba(0,0,0,.12), 0 8px 10px -5px rgba(0,0,0,.2); + pointer-events: auto; + color: var(--text); +} + +.fc-more-tooltip-event { + padding: 0.25rem 0.5rem; + font-size: 12px; + color: var(--text); + border-left: 3px solid; + margin-bottom: 0.25rem; + background: var(--bg); + border-radius: 0 0.25rem 0.25rem 0; + cursor: pointer; + transition: background 0.15s ease; +} + +.fc-more-tooltip-event:hover { + background: rgba(65, 129, 255, 0.15); +} + +.fc-more-tooltip-event:last-child { + margin-bottom: 0; +} + +/* ============================================ + LEAFLET DARK THEME OVERRIDES + ============================================ */ +.leaflet-container { + background: #111111; +} + +.leaflet-popup-content-wrapper { + background: #111111; + color: var(--text); + border-radius: 0.25rem; + box-shadow: 0 16px 38px -12px rgba(0,0,0,.56), 0 4px 25px 0 rgba(0,0,0,.12), 0 8px 10px -5px rgba(0,0,0,.2); +} + +.leaflet-popup-tip { + background: #111111; +} + +.leaflet-popup-content { + margin: 0.75rem 1rem; + font-size: 14px; +} + +.leaflet-popup-content a { + color: var(--info); +} + +.leaflet-control-zoom a { + background: rgba(0, 0, 0, 0.7) !important; + color: #fff !important; + border: none !important; +} + +.leaflet-control-zoom a:hover { + background: rgba(0, 0, 0, 0.9) !important; +} + +.leaflet-control-layers { + background: rgba(0, 0, 0, 0.7); + color: var(--text); + border: none; + border-radius: 0.25rem; +} + +/* Light mode is now default, dark mode via prefers-color-scheme */ diff --git a/frontend/src/components/AssetRelationships.vue b/frontend/src/components/AssetRelationships.vue new file mode 100644 index 0000000..8277188 --- /dev/null +++ b/frontend/src/components/AssetRelationships.vue @@ -0,0 +1,679 @@ + + + + + diff --git a/frontend/src/components/EmbeddedLocationMap.vue b/frontend/src/components/EmbeddedLocationMap.vue new file mode 100644 index 0000000..c9415f0 --- /dev/null +++ b/frontend/src/components/EmbeddedLocationMap.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/frontend/src/components/EmployeeSearch.vue b/frontend/src/components/EmployeeSearch.vue new file mode 100644 index 0000000..6aa1127 --- /dev/null +++ b/frontend/src/components/EmployeeSearch.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/frontend/src/components/LocationMapTooltip.vue b/frontend/src/components/LocationMapTooltip.vue index 6421b59..43e4395 100644 --- a/frontend/src/components/LocationMapTooltip.vue +++ b/frontend/src/components/LocationMapTooltip.vue @@ -41,13 +41,28 @@ + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index d653a48..ee903d3 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -1,6 +1,7 @@ @@ -73,6 +130,45 @@ function handleMarkerClick(machine) { flex-shrink: 0; } +.layer-toggles { + display: flex; + gap: 1rem; + padding: 0.75rem 1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 0.75rem; + flex-wrap: wrap; +} + +.layer-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + padding: 0.5rem 0.75rem; + border-radius: 6px; + transition: background 0.2s; +} + +.layer-toggle:hover { + background: var(--bg); +} + +.layer-toggle input[type="checkbox"] { + width: 1.125rem; + height: 1.125rem; +} + +.layer-icon { + font-size: 1.25rem; +} + +.layer-count { + color: var(--text-light); + font-size: 0.875rem; +} + .map-page :deep(.shopfloor-map) { flex: 1; } diff --git a/frontend/src/views/ShopfloorDashboard.vue b/frontend/src/views/ShopfloorDashboard.vue new file mode 100644 index 0000000..02f8246 --- /dev/null +++ b/frontend/src/views/ShopfloorDashboard.vue @@ -0,0 +1,531 @@ + + + + + diff --git a/frontend/src/views/TVDashboard.vue b/frontend/src/views/TVDashboard.vue new file mode 100644 index 0000000..7c72a30 --- /dev/null +++ b/frontend/src/views/TVDashboard.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/frontend/src/views/employees/EmployeeDetail.vue b/frontend/src/views/employees/EmployeeDetail.vue new file mode 100644 index 0000000..272dd9f --- /dev/null +++ b/frontend/src/views/employees/EmployeeDetail.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue b/frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue index ed87346..4a59848 100644 --- a/frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue +++ b/frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue @@ -157,12 +157,20 @@ async function openArticle() { .info-table td a { color: var(--primary, #1976d2); text-decoration: none; + word-break: break-all; + overflow-wrap: break-word; } .info-table td a:hover { text-decoration: underline; } +.info-table td { + word-break: break-word; + overflow-wrap: break-word; + max-width: 500px; +} + hr { margin: 1.5rem 0; border: none; diff --git a/frontend/src/views/machines/MachineForm.vue b/frontend/src/views/machines/MachineForm.vue index 5804470..2eee5c4 100644 --- a/frontend/src/views/machines/MachineForm.vue +++ b/frontend/src/views/machines/MachineForm.vue @@ -239,6 +239,7 @@ @@ -267,6 +268,7 @@ import { useRoute, useRouter } from 'vue-router' import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, relationshipTypesApi, modelsApi, businessunitsApi } from '../../api' import ShopFloorMap from '../../components/ShopFloorMap.vue' import Modal from '../../components/Modal.vue' +import { currentTheme } from '../../stores/theme' const route = useRoute() const router = useRouter() diff --git a/frontend/src/views/network/NetworkDeviceDetail.vue b/frontend/src/views/network/NetworkDeviceDetail.vue new file mode 100644 index 0000000..bb1faf8 --- /dev/null +++ b/frontend/src/views/network/NetworkDeviceDetail.vue @@ -0,0 +1,348 @@ + + + + + diff --git a/frontend/src/views/network/NetworkDeviceForm.vue b/frontend/src/views/network/NetworkDeviceForm.vue new file mode 100644 index 0000000..130b9a4 --- /dev/null +++ b/frontend/src/views/network/NetworkDeviceForm.vue @@ -0,0 +1,497 @@ + + + + + diff --git a/frontend/src/views/network/NetworkDevicesList.vue b/frontend/src/views/network/NetworkDevicesList.vue new file mode 100644 index 0000000..ef8e399 --- /dev/null +++ b/frontend/src/views/network/NetworkDevicesList.vue @@ -0,0 +1,360 @@ + + + + + diff --git a/frontend/src/views/notifications/NotificationForm.vue b/frontend/src/views/notifications/NotificationForm.vue new file mode 100644 index 0000000..1b8015d --- /dev/null +++ b/frontend/src/views/notifications/NotificationForm.vue @@ -0,0 +1,601 @@ + + + + + diff --git a/frontend/src/views/notifications/NotificationsList.vue b/frontend/src/views/notifications/NotificationsList.vue new file mode 100644 index 0000000..5e4745e --- /dev/null +++ b/frontend/src/views/notifications/NotificationsList.vue @@ -0,0 +1,168 @@ + + + diff --git a/frontend/src/views/pcs/PCForm.vue b/frontend/src/views/pcs/PCForm.vue index 90454f6..9ad5f4c 100644 --- a/frontend/src/views/pcs/PCForm.vue +++ b/frontend/src/views/pcs/PCForm.vue @@ -242,6 +242,7 @@ @@ -270,6 +271,7 @@ import { useRoute, useRouter } from 'vue-router' import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api' import ShopFloorMap from '../../components/ShopFloorMap.vue' import Modal from '../../components/Modal.vue' +import { currentTheme } from '../../stores/theme' const route = useRoute() const router = useRouter() diff --git a/frontend/src/views/printers/PrinterDetail.vue b/frontend/src/views/printers/PrinterDetail.vue index 12d015f..5fc250d 100644 --- a/frontend/src/views/printers/PrinterDetail.vue +++ b/frontend/src/views/printers/PrinterDetail.vue @@ -13,32 +13,28 @@ @@ -71,8 +83,8 @@ .settings-card { display: block; padding: 1.5rem; - background: white; - border: 1px solid #e0e0e0; + background: var(--bg-card); + border: 1px solid var(--border); border-radius: 8px; text-decoration: none; color: inherit; @@ -80,8 +92,8 @@ } .settings-card:hover { - border-color: #1976d2; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); + border-color: var(--primary); + box-shadow: 0 2px 8px rgba(0,0,0,0.15); } .card-icon { @@ -91,12 +103,12 @@ .settings-card h3 { margin: 0 0 0.5rem 0; - color: #333; + color: var(--text); } .settings-card p { margin: 0; - color: #666; + color: var(--text-light); font-size: 0.9rem; } diff --git a/frontend/src/views/settings/SubnetsList.vue b/frontend/src/views/settings/SubnetsList.vue new file mode 100644 index 0000000..f76f326 --- /dev/null +++ b/frontend/src/views/settings/SubnetsList.vue @@ -0,0 +1,559 @@ + + + + + diff --git a/frontend/src/views/settings/VLANsList.vue b/frontend/src/views/settings/VLANsList.vue new file mode 100644 index 0000000..88eaf66 --- /dev/null +++ b/frontend/src/views/settings/VLANsList.vue @@ -0,0 +1,369 @@ + + + + + diff --git a/frontend/src/views/usb/USBDetail.vue b/frontend/src/views/usb/USBDetail.vue new file mode 100644 index 0000000..529d93d --- /dev/null +++ b/frontend/src/views/usb/USBDetail.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/frontend/src/views/usb/USBForm.vue b/frontend/src/views/usb/USBForm.vue new file mode 100644 index 0000000..666f8e5 --- /dev/null +++ b/frontend/src/views/usb/USBForm.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/frontend/src/views/usb/USBList.vue b/frontend/src/views/usb/USBList.vue new file mode 100644 index 0000000..b5c2dce --- /dev/null +++ b/frontend/src/views/usb/USBList.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 5b40f18..08fed9d 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,13 +1,23 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +import path from 'path' export default defineConfig({ plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + }, server: { - port: 3000, + port: 5173, proxy: { '/api': { - target: 'http://localhost:5050', + target: 'http://localhost:5000', + changeOrigin: true + }, + '/static': { + target: 'http://localhost:5000', changeOrigin: true } } diff --git a/plugins/computers/__init__.py b/plugins/computers/__init__.py new file mode 100644 index 0000000..c96a537 --- /dev/null +++ b/plugins/computers/__init__.py @@ -0,0 +1,5 @@ +"""Computers plugin for ShopDB.""" + +from .plugin import ComputersPlugin + +__all__ = ['ComputersPlugin'] diff --git a/plugins/computers/api/__init__.py b/plugins/computers/api/__init__.py new file mode 100644 index 0000000..0bea25b --- /dev/null +++ b/plugins/computers/api/__init__.py @@ -0,0 +1,5 @@ +"""Computers plugin API.""" + +from .routes import computers_bp + +__all__ = ['computers_bp'] diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py new file mode 100644 index 0000000..ae79206 --- /dev/null +++ b/plugins/computers/api/routes.py @@ -0,0 +1,628 @@ +"""Computers plugin API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType, OperatingSystem, Application, AppVersion +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from ..models import Computer, ComputerType, ComputerInstalledApp + +computers_bp = Blueprint('computers', __name__) + + +# ============================================================================= +# Computer Types +# ============================================================================= + +@computers_bp.route('/types', methods=['GET']) +@jwt_required() +def list_computer_types(): + """List all computer types.""" + page, per_page = get_pagination_params(request) + + query = ComputerType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(ComputerType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(ComputerType.computertype.ilike(f'%{search}%')) + + query = query.order_by(ComputerType.computertype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@computers_bp.route('/types/', methods=['GET']) +@jwt_required() +def get_computer_type(type_id: int): + """Get a single computer type.""" + t = ComputerType.query.get(type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@computers_bp.route('/types', methods=['POST']) +@jwt_required() +def create_computer_type(): + """Create a new computer type.""" + data = request.get_json() + + if not data or not data.get('computertype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'computertype is required') + + if ComputerType.query.filter_by(computertype=data['computertype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Computer type '{data['computertype']}' already exists", + http_code=409 + ) + + t = ComputerType( + computertype=data['computertype'], + description=data.get('description'), + icon=data.get('icon') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Computer type created', http_code=201) + + +@computers_bp.route('/types/', methods=['PUT']) +@jwt_required() +def update_computer_type(type_id: int): + """Update a computer type.""" + t = ComputerType.query.get(type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer type with ID {type_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'computertype' in data and data['computertype'] != t.computertype: + if ComputerType.query.filter_by(computertype=data['computertype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Computer type '{data['computertype']}' already exists", + http_code=409 + ) + + for key in ['computertype', 'description', 'icon', 'isactive']: + if key in data: + setattr(t, key, data[key]) + + db.session.commit() + return success_response(t.to_dict(), message='Computer type updated') + + +# ============================================================================= +# Computers CRUD +# ============================================================================= + +@computers_bp.route('', methods=['GET']) +@jwt_required() +def list_computers(): + """ + List all computers with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status + - search: Search by asset number, name, or hostname + - type_id: Filter by computer type ID + - os_id: Filter by operating system ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + - shopfloor: Filter by shopfloor flag (true/false) + """ + page, per_page = get_pagination_params(request) + + # Join Computer with Asset + query = db.session.query(Computer).join(Asset) + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%'), + Computer.hostname.ilike(f'%{search}%') + ) + ) + + # Computer type filter + if type_id := request.args.get('type_id'): + query = query.filter(Computer.computertypeid == int(type_id)) + + # OS filter + if os_id := request.args.get('os_id'): + query = query.filter(Computer.osid == int(os_id)) + + # Location filter + if location_id := request.args.get('location_id'): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunit_id'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Shopfloor filter + if shopfloor := request.args.get('shopfloor'): + query = query.filter(Computer.isshopfloor == (shopfloor.lower() == 'true')) + + # Sorting + sort_by = request.args.get('sort', 'hostname') + sort_dir = request.args.get('dir', 'asc') + + if sort_by == 'hostname': + col = Computer.hostname + elif sort_by == 'assetnumber': + col = Asset.assetnumber + elif sort_by == 'name': + col = Asset.name + elif sort_by == 'lastreporteddate': + col = Computer.lastreporteddate + else: + col = Computer.hostname + + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + + items, total = paginate_query(query, page, per_page) + + # Build response with both asset and computer data + data = [] + for comp in items: + item = comp.asset.to_dict() if comp.asset else {} + item['computer'] = comp.to_dict() + data.append(item) + + return paginated_response(data, page, per_page, total) + + +@computers_bp.route('/', methods=['GET']) +@jwt_required() +def get_computer(computer_id: int): + """Get a single computer with full details.""" + comp = Computer.query.get(computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + result = comp.asset.to_dict() if comp.asset else {} + result['computer'] = comp.to_dict() + + return success_response(result) + + +@computers_bp.route('/by-asset/', methods=['GET']) +@jwt_required() +def get_computer_by_asset(asset_id: int): + """Get computer data by asset ID.""" + comp = Computer.query.filter_by(assetid=asset_id).first() + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer for asset {asset_id} not found', + http_code=404 + ) + + result = comp.asset.to_dict() if comp.asset else {} + result['computer'] = comp.to_dict() + + return success_response(result) + + +@computers_bp.route('/by-hostname/', methods=['GET']) +@jwt_required() +def get_computer_by_hostname(hostname: str): + """Get computer by hostname.""" + comp = Computer.query.filter_by(hostname=hostname).first() + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with hostname {hostname} not found', + http_code=404 + ) + + result = comp.asset.to_dict() if comp.asset else {} + result['computer'] = comp.to_dict() + + return success_response(result) + + +@computers_bp.route('', methods=['POST']) +@jwt_required() +def create_computer(): + """ + Create new computer (creates both Asset and Computer records). + + Required fields: + - assetnumber: Business identifier + + Optional fields: + - name, serialnumber, statusid, locationid, businessunitid + - computertypeid, hostname, osid + - isvnc, iswinrm, isshopfloor + - mapleft, maptop, notes + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Check for duplicate hostname + if data.get('hostname'): + if Computer.query.filter_by(hostname=data['hostname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Computer with hostname '{data['hostname']}' already exists", + http_code=409 + ) + + # Get computer asset type + computer_type = AssetType.query.filter_by(assettype='computer').first() + if not computer_type: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Computer asset type not found. Plugin may not be properly installed.', + http_code=500 + ) + + # Create the core asset + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + assettypeid=computer_type.assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapleft=data.get('mapleft'), + maptop=data.get('maptop'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.flush() # Get the assetid + + # Create the computer extension + comp = Computer( + assetid=asset.assetid, + computertypeid=data.get('computertypeid'), + hostname=data.get('hostname'), + osid=data.get('osid'), + loggedinuser=data.get('loggedinuser'), + lastreporteddate=data.get('lastreporteddate'), + lastboottime=data.get('lastboottime'), + isvnc=data.get('isvnc', False), + iswinrm=data.get('iswinrm', False), + isshopfloor=data.get('isshopfloor', False) + ) + + db.session.add(comp) + db.session.commit() + + result = asset.to_dict() + result['computer'] = comp.to_dict() + + return success_response(result, message='Computer created', http_code=201) + + +@computers_bp.route('/', methods=['PUT']) +@jwt_required() +def update_computer(computer_id: int): + """Update computer (both Asset and Computer records).""" + comp = Computer.query.get(computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + asset = comp.asset + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Check for conflicting hostname + if 'hostname' in data and data['hostname'] != comp.hostname: + existing = Computer.query.filter_by(hostname=data['hostname']).first() + if existing and existing.computerid != computer_id: + return error_response( + ErrorCodes.CONFLICT, + f"Computer with hostname '{data['hostname']}' already exists", + http_code=409 + ) + + # Update asset fields + asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', + 'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive'] + for key in asset_fields: + if key in data: + setattr(asset, key, data[key]) + + # Update computer fields + computer_fields = ['computertypeid', 'hostname', 'osid', 'loggedinuser', + 'lastreporteddate', 'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor'] + for key in computer_fields: + if key in data: + setattr(comp, key, data[key]) + + db.session.commit() + + result = asset.to_dict() + result['computer'] = comp.to_dict() + + return success_response(result, message='Computer updated') + + +@computers_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_computer(computer_id: int): + """Delete (soft delete) computer.""" + comp = Computer.query.get(computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + # Soft delete the asset + comp.asset.isactive = False + db.session.commit() + + return success_response(message='Computer deleted') + + +# ============================================================================= +# Installed Applications +# ============================================================================= + +@computers_bp.route('//apps', methods=['GET']) +@jwt_required() +def get_installed_apps(computer_id: int): + """Get all installed applications for a computer.""" + comp = Computer.query.get(computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + apps = ComputerInstalledApp.query.filter_by( + computerid=computer_id, + isactive=True + ).all() + + data = [app.to_dict() for app in apps] + + return success_response(data) + + +@computers_bp.route('//apps', methods=['POST']) +@jwt_required() +def add_installed_app(computer_id: int): + """Add an installed application to a computer.""" + comp = Computer.query.get(computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data or not data.get('appid'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'appid is required') + + appid = data['appid'] + + # Validate app exists + if not Application.query.get(appid): + return error_response(ErrorCodes.NOT_FOUND, f'Application {appid} not found', http_code=404) + + # Check for duplicate + existing = ComputerInstalledApp.query.filter_by( + computerid=computer_id, + appid=appid + ).first() + + if existing: + if existing.isactive: + return error_response( + ErrorCodes.CONFLICT, + 'This application is already installed on this computer', + http_code=409 + ) + else: + # Reactivate + existing.isactive = True + existing.appversionid = data.get('appversionid') + db.session.commit() + return success_response(existing.to_dict(), message='Application reinstalled') + + # Create new installation record + installed = ComputerInstalledApp( + computerid=computer_id, + appid=appid, + appversionid=data.get('appversionid') + ) + + db.session.add(installed) + db.session.commit() + + return success_response(installed.to_dict(), message='Application installed', http_code=201) + + +@computers_bp.route('//apps/', methods=['DELETE']) +@jwt_required() +def remove_installed_app(computer_id: int, app_id: int): + """Remove an installed application from a computer.""" + installed = ComputerInstalledApp.query.filter_by( + computerid=computer_id, + appid=app_id, + isactive=True + ).first() + + if not installed: + return error_response( + ErrorCodes.NOT_FOUND, + 'Installation record not found', + http_code=404 + ) + + installed.isactive = False + db.session.commit() + + return success_response(message='Application uninstalled') + + +# ============================================================================= +# Status Reporting +# ============================================================================= + +@computers_bp.route('//report', methods=['POST']) +@jwt_required(optional=True) +def report_status(computer_id: int): + """ + Report computer status (for agent-based reporting). + + This endpoint can be called periodically by a client agent + to update status information. + """ + comp = Computer.query.get(computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + data = request.get_json() or {} + + # Update status fields + from datetime import datetime + comp.lastreporteddate = datetime.utcnow() + + if 'loggedinuser' in data: + comp.loggedinuser = data['loggedinuser'] + if 'lastboottime' in data: + comp.lastboottime = data['lastboottime'] + + db.session.commit() + + return success_response(message='Status reported') + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@computers_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required() +def dashboard_summary(): + """Get computer dashboard summary data.""" + # Total active computers + total = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True + ).count() + + # Count by computer type + by_type = db.session.query( + ComputerType.computertype, + db.func.count(Computer.computerid) + ).join(Computer, Computer.computertypeid == ComputerType.computertypeid + ).join(Asset, Asset.assetid == Computer.assetid + ).filter(Asset.isactive == True + ).group_by(ComputerType.computertype + ).all() + + # Count by OS + by_os = db.session.query( + OperatingSystem.osname, + db.func.count(Computer.computerid) + ).join(Computer, Computer.osid == OperatingSystem.osid + ).join(Asset, Asset.assetid == Computer.assetid + ).filter(Asset.isactive == True + ).group_by(OperatingSystem.osname + ).all() + + # Count shopfloor vs non-shopfloor + shopfloor_count = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True, + Computer.isshopfloor == True + ).count() + + return success_response({ + 'total': total, + 'by_type': [{'type': t, 'count': c} for t, c in by_type], + 'by_os': [{'os': o, 'count': c} for o, c in by_os], + 'shopfloor': shopfloor_count, + 'non_shopfloor': total - shopfloor_count + }) diff --git a/plugins/computers/manifest.json b/plugins/computers/manifest.json new file mode 100644 index 0000000..6b07fd5 --- /dev/null +++ b/plugins/computers/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "computers", + "version": "1.0.0", + "description": "Computer management plugin for PCs, servers, and workstations with software tracking", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=1.0.0", + "api_prefix": "/api/computers", + "provides": { + "asset_type": "computer", + "features": [ + "computer_tracking", + "software_inventory", + "remote_access", + "os_management" + ] + }, + "settings": { + "enable_winrm": true, + "enable_vnc": true, + "auto_report_interval_hours": 24 + } +} diff --git a/plugins/computers/models/__init__.py b/plugins/computers/models/__init__.py new file mode 100644 index 0000000..db18ae7 --- /dev/null +++ b/plugins/computers/models/__init__.py @@ -0,0 +1,9 @@ +"""Computers plugin models.""" + +from .computer import Computer, ComputerType, ComputerInstalledApp + +__all__ = [ + 'Computer', + 'ComputerType', + 'ComputerInstalledApp', +] diff --git a/plugins/computers/models/computer.py b/plugins/computers/models/computer.py new file mode 100644 index 0000000..4e9af22 --- /dev/null +++ b/plugins/computers/models/computer.py @@ -0,0 +1,184 @@ +"""Computer plugin models.""" + +from shopdb.extensions import db +from shopdb.core.models.base import BaseModel + + +class ComputerType(BaseModel): + """ + Computer type classification. + + Examples: Shopfloor PC, Engineer Workstation, CMM PC, Server, etc. + """ + __tablename__ = 'computertypes' + + computertypeid = db.Column(db.Integer, primary_key=True) + computertype = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class Computer(BaseModel): + """ + Computer-specific extension data. + + Links to core Asset table via assetid. + Stores computer-specific fields like hostname, OS, logged in user, etc. + """ + __tablename__ = 'computers' + + computerid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Computer classification + computertypeid = db.Column( + db.Integer, + db.ForeignKey('computertypes.computertypeid'), + nullable=True + ) + + # Network identity + hostname = db.Column( + db.String(100), + index=True, + comment='Network hostname' + ) + + # Operating system + osid = db.Column( + db.Integer, + db.ForeignKey('operatingsystems.osid'), + nullable=True + ) + + # Status tracking + loggedinuser = db.Column(db.String(100), nullable=True) + lastreporteddate = db.Column(db.DateTime, nullable=True) + lastboottime = db.Column(db.DateTime, nullable=True) + + # Remote access features + isvnc = db.Column( + db.Boolean, + default=False, + comment='VNC remote access enabled' + ) + iswinrm = db.Column( + db.Boolean, + default=False, + comment='WinRM enabled' + ) + + # Classification flags + isshopfloor = db.Column( + db.Boolean, + default=False, + comment='Shopfloor PC (vs office PC)' + ) + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('computer', uselist=False, lazy='joined') + ) + computertype = db.relationship('ComputerType', backref='computers') + operatingsystem = db.relationship('OperatingSystem', backref='computers') + + # Installed applications (one-to-many) + installedapps = db.relationship( + 'ComputerInstalledApp', + back_populates='computer', + cascade='all, delete-orphan', + lazy='dynamic' + ) + + __table_args__ = ( + db.Index('idx_computer_type', 'computertypeid'), + db.Index('idx_computer_hostname', 'hostname'), + db.Index('idx_computer_os', 'osid'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.computertype: + result['computertype_name'] = self.computertype.computertype + if self.operatingsystem: + result['os_name'] = self.operatingsystem.osname + + return result + + +class ComputerInstalledApp(db.Model): + """ + Junction table for applications installed on computers. + + Tracks which applications are installed on which computers, + including version information. + """ + __tablename__ = 'computerinstalledapps' + + id = db.Column(db.Integer, primary_key=True) + computerid = db.Column( + db.Integer, + db.ForeignKey('computers.computerid', ondelete='CASCADE'), + nullable=False + ) + appid = db.Column( + db.Integer, + db.ForeignKey('applications.appid'), + nullable=False + ) + appversionid = db.Column( + db.Integer, + db.ForeignKey('appversions.appversionid'), + nullable=True + ) + isactive = db.Column(db.Boolean, default=True, nullable=False) + installeddate = db.Column(db.DateTime, default=db.func.now()) + + # Relationships + computer = db.relationship('Computer', back_populates='installedapps') + application = db.relationship('Application') + appversion = db.relationship('AppVersion') + + __table_args__ = ( + db.UniqueConstraint('computerid', 'appid', name='uq_computer_app'), + db.Index('idx_compapp_computer', 'computerid'), + db.Index('idx_compapp_app', 'appid'), + ) + + def to_dict(self): + """Convert to dictionary.""" + return { + 'id': self.id, + 'computerid': self.computerid, + 'appid': self.appid, + 'appversionid': self.appversionid, + 'isactive': self.isactive, + 'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None, + 'application': { + 'appid': self.application.appid, + 'appname': self.application.appname, + 'appdescription': self.application.appdescription, + } if self.application else None, + 'version': self.appversion.version if self.appversion else None + } + + def __repr__(self): + return f"" diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py new file mode 100644 index 0000000..ce6ea79 --- /dev/null +++ b/plugins/computers/plugin.py @@ -0,0 +1,209 @@ +"""Computers plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.extensions import db +from shopdb.core.models import AssetType, AssetStatus + +from .models import Computer, ComputerType, ComputerInstalledApp +from .api import computers_bp + +logger = logging.getLogger(__name__) + + +class ComputersPlugin(BasePlugin): + """ + Computers plugin - manages PC, server, and workstation assets. + + Computers include shopfloor PCs, engineer workstations, servers, etc. + Uses the new Asset architecture with Computer extension table. + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifestpath = Path(__file__).parent / 'manifest.json' + if manifestpath.exists(): + with open(manifestpath, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'computers'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Computer management for PCs, servers, and workstations' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/computers'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return computers_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [Computer, ComputerType, ComputerInstalledApp] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Computers plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_asset_type() + self._ensure_computer_types() + logger.info("Computers plugin installed") + + def _ensure_asset_type(self) -> None: + """Ensure computer asset type exists.""" + existing = AssetType.query.filter_by(assettype='computer').first() + if not existing: + at = AssetType( + assettype='computer', + plugin_name='computers', + table_name='computers', + description='PCs, servers, and workstations', + icon='desktop' + ) + db.session.add(at) + logger.debug("Created asset type: computer") + db.session.commit() + + def _ensure_computer_types(self) -> None: + """Ensure basic computer types exist.""" + computer_types = [ + ('Shopfloor PC', 'PC located on the shop floor for machine operation', 'desktop'), + ('Engineer Workstation', 'Engineering workstation for CAD/CAM work', 'laptop'), + ('CMM PC', 'PC dedicated to CMM operation', 'desktop'), + ('Server', 'Server system', 'server'), + ('Kiosk', 'Kiosk or info display PC', 'tv'), + ('Laptop', 'Laptop computer', 'laptop'), + ('Virtual Machine', 'Virtual machine', 'cloud'), + ('Other', 'Other computer type', 'desktop'), + ] + + for name, description, icon in computer_types: + existing = ComputerType.query.filter_by(computertype=name).first() + if not existing: + ct = ComputerType( + computertype=name, + description=description, + icon=icon + ) + db.session.add(ct) + logger.debug(f"Created computer type: {name}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Computers plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('computers') + def computerscli(): + """Computers plugin commands.""" + pass + + @computerscli.command('list-types') + def list_types(): + """List all computer types.""" + from flask import current_app + + with current_app.app_context(): + types = ComputerType.query.filter_by(isactive=True).all() + if not types: + click.echo('No computer types found.') + return + + click.echo('Computer Types:') + for t in types: + click.echo(f" [{t.computertypeid}] {t.computertype}") + + @computerscli.command('stats') + def stats(): + """Show computer statistics.""" + from flask import current_app + from shopdb.core.models import Asset + + with current_app.app_context(): + total = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True + ).count() + + click.echo(f"Total active computers: {total}") + + # Shopfloor count + shopfloor = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True, + Computer.isshopfloor == True + ).count() + + click.echo(f" Shopfloor PCs: {shopfloor}") + click.echo(f" Other: {total - shopfloor}") + + @computerscli.command('find') + @click.argument('hostname') + def find_by_hostname(hostname): + """Find a computer by hostname.""" + from flask import current_app + + with current_app.app_context(): + comp = Computer.query.filter( + Computer.hostname.ilike(f'%{hostname}%') + ).first() + + if not comp: + click.echo(f'No computer found matching hostname: {hostname}') + return + + click.echo(f'Found: {comp.hostname}') + click.echo(f' Asset: {comp.asset.assetnumber}') + click.echo(f' Type: {comp.computertype.computertype if comp.computertype else "N/A"}') + click.echo(f' OS: {comp.operatingsystem.osname if comp.operatingsystem else "N/A"}') + click.echo(f' Logged in: {comp.loggedinuser or "N/A"}') + + return [computerscli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Computer Status', + 'component': 'ComputerStatusWidget', + 'endpoint': '/api/computers/dashboard/summary', + 'size': 'medium', + 'position': 6, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Computers', + 'icon': 'desktop', + 'route': '/computers', + 'position': 15, + }, + ] diff --git a/plugins/equipment/__init__.py b/plugins/equipment/__init__.py new file mode 100644 index 0000000..5bb66ac --- /dev/null +++ b/plugins/equipment/__init__.py @@ -0,0 +1,5 @@ +"""Equipment plugin for ShopDB.""" + +from .plugin import EquipmentPlugin + +__all__ = ['EquipmentPlugin'] diff --git a/plugins/equipment/api/__init__.py b/plugins/equipment/api/__init__.py new file mode 100644 index 0000000..4d41e08 --- /dev/null +++ b/plugins/equipment/api/__init__.py @@ -0,0 +1,5 @@ +"""Equipment plugin API.""" + +from .routes import equipment_bp + +__all__ = ['equipment_bp'] diff --git a/plugins/equipment/api/routes.py b/plugins/equipment/api/routes.py new file mode 100644 index 0000000..772955c --- /dev/null +++ b/plugins/equipment/api/routes.py @@ -0,0 +1,429 @@ +"""Equipment plugin API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType, Vendor, Model +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from ..models import Equipment, EquipmentType + +equipment_bp = Blueprint('equipment', __name__) + + +# ============================================================================= +# Equipment Types +# ============================================================================= + +@equipment_bp.route('/types', methods=['GET']) +@jwt_required() +def list_equipment_types(): + """List all equipment types.""" + page, per_page = get_pagination_params(request) + + query = EquipmentType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(EquipmentType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(EquipmentType.equipmenttype.ilike(f'%{search}%')) + + query = query.order_by(EquipmentType.equipmenttype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@equipment_bp.route('/types/', methods=['GET']) +@jwt_required() +def get_equipment_type(type_id: int): + """Get a single equipment type.""" + t = EquipmentType.query.get(type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Equipment type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@equipment_bp.route('/types', methods=['POST']) +@jwt_required() +def create_equipment_type(): + """Create a new equipment type.""" + data = request.get_json() + + if not data or not data.get('equipmenttype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'equipmenttype is required') + + if EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Equipment type '{data['equipmenttype']}' already exists", + http_code=409 + ) + + t = EquipmentType( + equipmenttype=data['equipmenttype'], + description=data.get('description'), + icon=data.get('icon') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Equipment type created', http_code=201) + + +@equipment_bp.route('/types/', methods=['PUT']) +@jwt_required() +def update_equipment_type(type_id: int): + """Update an equipment type.""" + t = EquipmentType.query.get(type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Equipment type with ID {type_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'equipmenttype' in data and data['equipmenttype'] != t.equipmenttype: + if EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Equipment type '{data['equipmenttype']}' already exists", + http_code=409 + ) + + for key in ['equipmenttype', 'description', 'icon', 'isactive']: + if key in data: + setattr(t, key, data[key]) + + db.session.commit() + return success_response(t.to_dict(), message='Equipment type updated') + + +# ============================================================================= +# Equipment CRUD +# ============================================================================= + +@equipment_bp.route('', methods=['GET']) +@jwt_required() +def list_equipment(): + """ + List all equipment with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status + - search: Search by asset number or name + - type_id: Filter by equipment type ID + - vendor_id: Filter by vendor ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + """ + page, per_page = get_pagination_params(request) + + # Join Equipment with Asset + query = db.session.query(Equipment).join(Asset) + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%') + ) + ) + + # Equipment type filter + if type_id := request.args.get('type_id'): + query = query.filter(Equipment.equipmenttypeid == int(type_id)) + + # Vendor filter + if vendor_id := request.args.get('vendor_id'): + query = query.filter(Equipment.vendorid == int(vendor_id)) + + # Location filter + if location_id := request.args.get('location_id'): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunit_id'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Sorting + sort_by = request.args.get('sort', 'assetnumber') + sort_dir = request.args.get('dir', 'asc') + + if sort_by == 'assetnumber': + col = Asset.assetnumber + elif sort_by == 'name': + col = Asset.name + else: + col = Asset.assetnumber + + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + + items, total = paginate_query(query, page, per_page) + + # Build response with both asset and equipment data + data = [] + for equip in items: + item = equip.asset.to_dict() if equip.asset else {} + item['equipment'] = equip.to_dict() + data.append(item) + + return paginated_response(data, page, per_page, total) + + +@equipment_bp.route('/', methods=['GET']) +@jwt_required() +def get_equipment(equipment_id: int): + """Get a single equipment item with full details.""" + equip = Equipment.query.get(equipment_id) + + if not equip: + return error_response( + ErrorCodes.NOT_FOUND, + f'Equipment with ID {equipment_id} not found', + http_code=404 + ) + + result = equip.asset.to_dict() if equip.asset else {} + result['equipment'] = equip.to_dict() + + return success_response(result) + + +@equipment_bp.route('/by-asset/', methods=['GET']) +@jwt_required() +def get_equipment_by_asset(asset_id: int): + """Get equipment data by asset ID.""" + equip = Equipment.query.filter_by(assetid=asset_id).first() + + if not equip: + return error_response( + ErrorCodes.NOT_FOUND, + f'Equipment for asset {asset_id} not found', + http_code=404 + ) + + result = equip.asset.to_dict() if equip.asset else {} + result['equipment'] = equip.to_dict() + + return success_response(result) + + +@equipment_bp.route('', methods=['POST']) +@jwt_required() +def create_equipment(): + """ + Create new equipment (creates both Asset and Equipment records). + + Required fields: + - assetnumber: Business identifier + + Optional fields: + - name, serialnumber, statusid, locationid, businessunitid + - equipmenttypeid, vendorid, modelnumberid + - requiresmanualconfig, islocationonly + - mapleft, maptop, notes + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Get equipment asset type + equipment_type = AssetType.query.filter_by(assettype='equipment').first() + if not equipment_type: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Equipment asset type not found. Plugin may not be properly installed.', + http_code=500 + ) + + # Create the core asset + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + assettypeid=equipment_type.assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapleft=data.get('mapleft'), + maptop=data.get('maptop'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.flush() # Get the assetid + + # Create the equipment extension + equip = Equipment( + assetid=asset.assetid, + equipmenttypeid=data.get('equipmenttypeid'), + vendorid=data.get('vendorid'), + modelnumberid=data.get('modelnumberid'), + requiresmanualconfig=data.get('requiresmanualconfig', False), + islocationonly=data.get('islocationonly', False), + lastmaintenancedate=data.get('lastmaintenancedate'), + nextmaintenancedate=data.get('nextmaintenancedate'), + maintenanceintervaldays=data.get('maintenanceintervaldays') + ) + + db.session.add(equip) + db.session.commit() + + result = asset.to_dict() + result['equipment'] = equip.to_dict() + + return success_response(result, message='Equipment created', http_code=201) + + +@equipment_bp.route('/', methods=['PUT']) +@jwt_required() +def update_equipment(equipment_id: int): + """Update equipment (both Asset and Equipment records).""" + equip = Equipment.query.get(equipment_id) + + if not equip: + return error_response( + ErrorCodes.NOT_FOUND, + f'Equipment with ID {equipment_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + asset = equip.asset + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Update asset fields + asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', + 'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive'] + for key in asset_fields: + if key in data: + setattr(asset, key, data[key]) + + # Update equipment fields + equipment_fields = ['equipmenttypeid', 'vendorid', 'modelnumberid', + 'requiresmanualconfig', 'islocationonly', + 'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays'] + for key in equipment_fields: + if key in data: + setattr(equip, key, data[key]) + + db.session.commit() + + result = asset.to_dict() + result['equipment'] = equip.to_dict() + + return success_response(result, message='Equipment updated') + + +@equipment_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_equipment(equipment_id: int): + """Delete (soft delete) equipment.""" + equip = Equipment.query.get(equipment_id) + + if not equip: + return error_response( + ErrorCodes.NOT_FOUND, + f'Equipment with ID {equipment_id} not found', + http_code=404 + ) + + # Soft delete the asset (equipment extension will stay linked) + equip.asset.isactive = False + db.session.commit() + + return success_response(message='Equipment deleted') + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@equipment_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required() +def dashboard_summary(): + """Get equipment dashboard summary data.""" + # Total active equipment count + total = db.session.query(Equipment).join(Asset).filter( + Asset.isactive == True + ).count() + + # Count by equipment type + by_type = db.session.query( + EquipmentType.equipmenttype, + db.func.count(Equipment.equipmentid) + ).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid + ).join(Asset, Asset.assetid == Equipment.assetid + ).filter(Asset.isactive == True + ).group_by(EquipmentType.equipmenttype + ).all() + + # Count by status + from shopdb.core.models import AssetStatus + by_status = db.session.query( + AssetStatus.status, + db.func.count(Equipment.equipmentid) + ).join(Asset, Asset.assetid == Equipment.assetid + ).join(AssetStatus, AssetStatus.statusid == Asset.statusid + ).filter(Asset.isactive == True + ).group_by(AssetStatus.status + ).all() + + return success_response({ + 'total': total, + 'by_type': [{'type': t, 'count': c} for t, c in by_type], + 'by_status': [{'status': s, 'count': c} for s, c in by_status] + }) diff --git a/plugins/equipment/manifest.json b/plugins/equipment/manifest.json new file mode 100644 index 0000000..a1393a7 --- /dev/null +++ b/plugins/equipment/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "equipment", + "version": "1.0.0", + "description": "Equipment management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing equipment", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=1.0.0", + "api_prefix": "/api/equipment", + "provides": { + "asset_type": "equipment", + "features": [ + "equipment_tracking", + "maintenance_scheduling", + "vendor_management", + "model_catalog" + ] + }, + "settings": { + "enable_maintenance_alerts": true, + "maintenance_alert_days": 30 + } +} diff --git a/plugins/equipment/models/__init__.py b/plugins/equipment/models/__init__.py new file mode 100644 index 0000000..e0040e3 --- /dev/null +++ b/plugins/equipment/models/__init__.py @@ -0,0 +1,8 @@ +"""Equipment plugin models.""" + +from .equipment import Equipment, EquipmentType + +__all__ = [ + 'Equipment', + 'EquipmentType', +] diff --git a/plugins/equipment/models/equipment.py b/plugins/equipment/models/equipment.py new file mode 100644 index 0000000..8588b6a --- /dev/null +++ b/plugins/equipment/models/equipment.py @@ -0,0 +1,109 @@ +"""Equipment plugin models.""" + +from shopdb.extensions import db +from shopdb.core.models.base import BaseModel + + +class EquipmentType(BaseModel): + """ + Equipment type classification. + + Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc. + """ + __tablename__ = 'equipmenttypes' + + equipmenttypeid = db.Column(db.Integer, primary_key=True) + equipmenttype = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class Equipment(BaseModel): + """ + Equipment-specific extension data. + + Links to core Asset table via assetid. + Stores equipment-specific fields like type, model, vendor, etc. + """ + __tablename__ = 'equipment' + + equipmentid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Equipment classification + equipmenttypeid = db.Column( + db.Integer, + db.ForeignKey('equipmenttypes.equipmenttypeid'), + nullable=True + ) + + # Vendor and model + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True + ) + + # Equipment-specific fields + requiresmanualconfig = db.Column( + db.Boolean, + default=False, + comment='Multi-PC machine needs manual configuration' + ) + islocationonly = db.Column( + db.Boolean, + default=False, + comment='Virtual location marker (not actual equipment)' + ) + + # Maintenance tracking + lastmaintenancedate = db.Column(db.DateTime, nullable=True) + nextmaintenancedate = db.Column(db.DateTime, nullable=True) + maintenanceintervaldays = db.Column(db.Integer, nullable=True) + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('equipment', uselist=False, lazy='joined') + ) + equipmenttype = db.relationship('EquipmentType', backref='equipment') + vendor = db.relationship('Vendor', backref='equipment_items') + model = db.relationship('Model', backref='equipment_items') + + __table_args__ = ( + db.Index('idx_equipment_type', 'equipmenttypeid'), + db.Index('idx_equipment_vendor', 'vendorid'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.equipmenttype: + result['equipmenttype_name'] = self.equipmenttype.equipmenttype + if self.vendor: + result['vendor_name'] = self.vendor.vendor + if self.model: + result['model_name'] = self.model.modelnumber + + return result diff --git a/plugins/equipment/plugin.py b/plugins/equipment/plugin.py new file mode 100644 index 0000000..7efc966 --- /dev/null +++ b/plugins/equipment/plugin.py @@ -0,0 +1,220 @@ +"""Equipment plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.extensions import db +from shopdb.core.models import AssetType, AssetStatus + +from .models import Equipment, EquipmentType +from .api import equipment_bp + +logger = logging.getLogger(__name__) + + +class EquipmentPlugin(BasePlugin): + """ + Equipment plugin - manages manufacturing equipment assets. + + Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc. + Uses the new Asset architecture with Equipment extension table. + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifestpath = Path(__file__).parent / 'manifest.json' + if manifestpath.exists(): + with open(manifestpath, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'equipment'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Equipment management for manufacturing assets' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/equipment'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return equipment_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [Equipment, EquipmentType] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Equipment plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_asset_type() + self._ensure_asset_statuses() + self._ensure_equipment_types() + logger.info("Equipment plugin installed") + + def _ensure_asset_type(self) -> None: + """Ensure equipment asset type exists.""" + existing = AssetType.query.filter_by(assettype='equipment').first() + if not existing: + at = AssetType( + assettype='equipment', + plugin_name='equipment', + table_name='equipment', + description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)', + icon='cog' + ) + db.session.add(at) + logger.debug("Created asset type: equipment") + db.session.commit() + + def _ensure_asset_statuses(self) -> None: + """Ensure standard asset statuses exist.""" + statuses = [ + ('In Use', 'Asset is currently in use', '#28a745'), + ('Spare', 'Spare/backup asset', '#17a2b8'), + ('Retired', 'Asset has been retired', '#6c757d'), + ('Maintenance', 'Asset is under maintenance', '#ffc107'), + ('Decommissioned', 'Asset has been decommissioned', '#dc3545'), + ] + + for name, description, color in statuses: + existing = AssetStatus.query.filter_by(status=name).first() + if not existing: + s = AssetStatus( + status=name, + description=description, + color=color + ) + db.session.add(s) + logger.debug(f"Created asset status: {name}") + + db.session.commit() + + def _ensure_equipment_types(self) -> None: + """Ensure basic equipment types exist.""" + equipment_types = [ + ('CNC', 'Computer Numerical Control machine', 'cnc'), + ('CMM', 'Coordinate Measuring Machine', 'cmm'), + ('Lathe', 'Lathe machine', 'lathe'), + ('Grinder', 'Grinding machine', 'grinder'), + ('EDM', 'Electrical Discharge Machine', 'edm'), + ('Part Marker', 'Part marking/engraving equipment', 'marker'), + ('Mill', 'Milling machine', 'mill'), + ('Press', 'Press machine', 'press'), + ('Robot', 'Industrial robot', 'robot'), + ('Other', 'Other equipment type', 'cog'), + ] + + for name, description, icon in equipment_types: + existing = EquipmentType.query.filter_by(equipmenttype=name).first() + if not existing: + et = EquipmentType( + equipmenttype=name, + description=description, + icon=icon + ) + db.session.add(et) + logger.debug(f"Created equipment type: {name}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Equipment plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('equipment') + def equipmentcli(): + """Equipment plugin commands.""" + pass + + @equipmentcli.command('list-types') + def list_types(): + """List all equipment types.""" + from flask import current_app + + with current_app.app_context(): + types = EquipmentType.query.filter_by(isactive=True).all() + if not types: + click.echo('No equipment types found.') + return + + click.echo('Equipment Types:') + for t in types: + click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}") + + @equipmentcli.command('stats') + def stats(): + """Show equipment statistics.""" + from flask import current_app + from shopdb.core.models import Asset + + with current_app.app_context(): + total = db.session.query(Equipment).join(Asset).filter( + Asset.isactive == True + ).count() + + click.echo(f"Total active equipment: {total}") + + # By type + by_type = db.session.query( + EquipmentType.equipmenttype, + db.func.count(Equipment.equipmentid) + ).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid + ).join(Asset, Asset.assetid == Equipment.assetid + ).filter(Asset.isactive == True + ).group_by(EquipmentType.equipmenttype + ).all() + + if by_type: + click.echo("\nBy Type:") + for t, c in by_type: + click.echo(f" {t}: {c}") + + return [equipmentcli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Equipment Status', + 'component': 'EquipmentStatusWidget', + 'endpoint': '/api/equipment/dashboard/summary', + 'size': 'medium', + 'position': 5, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Equipment', + 'icon': 'cog', + 'route': '/equipment', + 'position': 10, + }, + ] diff --git a/plugins/network/__init__.py b/plugins/network/__init__.py new file mode 100644 index 0000000..8fd878c --- /dev/null +++ b/plugins/network/__init__.py @@ -0,0 +1,5 @@ +"""Network plugin for ShopDB.""" + +from .plugin import NetworkPlugin + +__all__ = ['NetworkPlugin'] diff --git a/plugins/network/api/__init__.py b/plugins/network/api/__init__.py new file mode 100644 index 0000000..aad02b4 --- /dev/null +++ b/plugins/network/api/__init__.py @@ -0,0 +1,5 @@ +"""Network plugin API.""" + +from .routes import network_bp + +__all__ = ['network_bp'] diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py new file mode 100644 index 0000000..e68e5f7 --- /dev/null +++ b/plugins/network/api/routes.py @@ -0,0 +1,817 @@ +"""Network plugin API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType, Vendor +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN + +network_bp = Blueprint('network', __name__) + + +# ============================================================================= +# Network Device Types +# ============================================================================= + +@network_bp.route('/types', methods=['GET']) +@jwt_required() +def list_network_device_types(): + """List all network device types.""" + page, per_page = get_pagination_params(request) + + query = NetworkDeviceType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(NetworkDeviceType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(NetworkDeviceType.networkdevicetype.ilike(f'%{search}%')) + + query = query.order_by(NetworkDeviceType.networkdevicetype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@network_bp.route('/types/', methods=['GET']) +@jwt_required() +def get_network_device_type(type_id: int): + """Get a single network device type.""" + t = NetworkDeviceType.query.get(type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@network_bp.route('/types', methods=['POST']) +@jwt_required() +def create_network_device_type(): + """Create a new network device type.""" + data = request.get_json() + + if not data or not data.get('networkdevicetype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'networkdevicetype is required') + + if NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Network device type '{data['networkdevicetype']}' already exists", + http_code=409 + ) + + t = NetworkDeviceType( + networkdevicetype=data['networkdevicetype'], + description=data.get('description'), + icon=data.get('icon') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Network device type created', http_code=201) + + +@network_bp.route('/types/', methods=['PUT']) +@jwt_required() +def update_network_device_type(type_id: int): + """Update a network device type.""" + t = NetworkDeviceType.query.get(type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device type with ID {type_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'networkdevicetype' in data and data['networkdevicetype'] != t.networkdevicetype: + if NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Network device type '{data['networkdevicetype']}' already exists", + http_code=409 + ) + + for key in ['networkdevicetype', 'description', 'icon', 'isactive']: + if key in data: + setattr(t, key, data[key]) + + db.session.commit() + return success_response(t.to_dict(), message='Network device type updated') + + +# ============================================================================= +# Network Devices CRUD +# ============================================================================= + +@network_bp.route('', methods=['GET']) +@jwt_required() +def list_network_devices(): + """ + List all network devices with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status + - search: Search by asset number, name, or hostname + - type_id: Filter by network device type ID + - vendor_id: Filter by vendor ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + - poe: Filter by PoE capability (true/false) + - managed: Filter by managed status (true/false) + """ + page, per_page = get_pagination_params(request) + + # Join NetworkDevice with Asset + query = db.session.query(NetworkDevice).join(Asset) + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%'), + NetworkDevice.hostname.ilike(f'%{search}%') + ) + ) + + # Type filter + if type_id := request.args.get('type_id'): + query = query.filter(NetworkDevice.networkdevicetypeid == int(type_id)) + + # Vendor filter + if vendor_id := request.args.get('vendor_id'): + query = query.filter(NetworkDevice.vendorid == int(vendor_id)) + + # Location filter + if location_id := request.args.get('location_id'): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunit_id'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # PoE filter + if poe := request.args.get('poe'): + query = query.filter(NetworkDevice.ispoe == (poe.lower() == 'true')) + + # Managed filter + if managed := request.args.get('managed'): + query = query.filter(NetworkDevice.ismanaged == (managed.lower() == 'true')) + + # Sorting + sort_by = request.args.get('sort', 'hostname') + sort_dir = request.args.get('dir', 'asc') + + if sort_by == 'hostname': + col = NetworkDevice.hostname + elif sort_by == 'assetnumber': + col = Asset.assetnumber + elif sort_by == 'name': + col = Asset.name + else: + col = NetworkDevice.hostname + + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + + items, total = paginate_query(query, page, per_page) + + # Build response with both asset and network device data + data = [] + for netdev in items: + item = netdev.asset.to_dict() if netdev.asset else {} + item['network_device'] = netdev.to_dict() + data.append(item) + + return paginated_response(data, page, per_page, total) + + +@network_bp.route('/', methods=['GET']) +@jwt_required() +def get_network_device(device_id: int): + """Get a single network device with full details.""" + netdev = NetworkDevice.query.get(device_id) + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device with ID {device_id} not found', + http_code=404 + ) + + result = netdev.asset.to_dict() if netdev.asset else {} + result['network_device'] = netdev.to_dict() + + return success_response(result) + + +@network_bp.route('/by-asset/', methods=['GET']) +@jwt_required() +def get_network_device_by_asset(asset_id: int): + """Get network device data by asset ID.""" + netdev = NetworkDevice.query.filter_by(assetid=asset_id).first() + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device for asset {asset_id} not found', + http_code=404 + ) + + result = netdev.asset.to_dict() if netdev.asset else {} + result['network_device'] = netdev.to_dict() + + return success_response(result) + + +@network_bp.route('/by-hostname/', methods=['GET']) +@jwt_required() +def get_network_device_by_hostname(hostname: str): + """Get network device by hostname.""" + netdev = NetworkDevice.query.filter_by(hostname=hostname).first() + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device with hostname {hostname} not found', + http_code=404 + ) + + result = netdev.asset.to_dict() if netdev.asset else {} + result['network_device'] = netdev.to_dict() + + return success_response(result) + + +@network_bp.route('', methods=['POST']) +@jwt_required() +def create_network_device(): + """ + Create new network device (creates both Asset and NetworkDevice records). + + Required fields: + - assetnumber: Business identifier + + Optional fields: + - name, serialnumber, statusid, locationid, businessunitid + - networkdevicetypeid, vendorid, hostname + - firmwareversion, portcount, ispoe, ismanaged, rackunit + - mapleft, maptop, notes + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Check for duplicate hostname + if data.get('hostname'): + if NetworkDevice.query.filter_by(hostname=data['hostname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Network device with hostname '{data['hostname']}' already exists", + http_code=409 + ) + + # Get network device asset type + network_type = AssetType.query.filter_by(assettype='network_device').first() + if not network_type: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Network device asset type not found. Plugin may not be properly installed.', + http_code=500 + ) + + # Create the core asset + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + assettypeid=network_type.assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapleft=data.get('mapleft'), + maptop=data.get('maptop'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.flush() # Get the assetid + + # Create the network device extension + netdev = NetworkDevice( + assetid=asset.assetid, + networkdevicetypeid=data.get('networkdevicetypeid'), + vendorid=data.get('vendorid'), + hostname=data.get('hostname'), + firmwareversion=data.get('firmwareversion'), + portcount=data.get('portcount'), + ispoe=data.get('ispoe', False), + ismanaged=data.get('ismanaged', False), + rackunit=data.get('rackunit') + ) + + db.session.add(netdev) + db.session.commit() + + result = asset.to_dict() + result['network_device'] = netdev.to_dict() + + return success_response(result, message='Network device created', http_code=201) + + +@network_bp.route('/', methods=['PUT']) +@jwt_required() +def update_network_device(device_id: int): + """Update network device (both Asset and NetworkDevice records).""" + netdev = NetworkDevice.query.get(device_id) + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device with ID {device_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + asset = netdev.asset + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Check for conflicting hostname + if 'hostname' in data and data['hostname'] != netdev.hostname: + existing = NetworkDevice.query.filter_by(hostname=data['hostname']).first() + if existing and existing.networkdeviceid != device_id: + return error_response( + ErrorCodes.CONFLICT, + f"Network device with hostname '{data['hostname']}' already exists", + http_code=409 + ) + + # Update asset fields + asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', + 'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive'] + for key in asset_fields: + if key in data: + setattr(asset, key, data[key]) + + # Update network device fields + netdev_fields = ['networkdevicetypeid', 'vendorid', 'hostname', + 'firmwareversion', 'portcount', 'ispoe', 'ismanaged', 'rackunit'] + for key in netdev_fields: + if key in data: + setattr(netdev, key, data[key]) + + db.session.commit() + + result = asset.to_dict() + result['network_device'] = netdev.to_dict() + + return success_response(result, message='Network device updated') + + +@network_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_network_device(device_id: int): + """Delete (soft delete) network device.""" + netdev = NetworkDevice.query.get(device_id) + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device with ID {device_id} not found', + http_code=404 + ) + + # Soft delete the asset + netdev.asset.isactive = False + db.session.commit() + + return success_response(message='Network device deleted') + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@network_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required() +def dashboard_summary(): + """Get network device dashboard summary data.""" + # Total active network devices + total = db.session.query(NetworkDevice).join(Asset).filter( + Asset.isactive == True + ).count() + + # Count by device type + by_type = db.session.query( + NetworkDeviceType.networkdevicetype, + db.func.count(NetworkDevice.networkdeviceid) + ).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid + ).join(Asset, Asset.assetid == NetworkDevice.assetid + ).filter(Asset.isactive == True + ).group_by(NetworkDeviceType.networkdevicetype + ).all() + + # Count by vendor + by_vendor = db.session.query( + Vendor.vendor, + db.func.count(NetworkDevice.networkdeviceid) + ).join(NetworkDevice, NetworkDevice.vendorid == Vendor.vendorid + ).join(Asset, Asset.assetid == NetworkDevice.assetid + ).filter(Asset.isactive == True + ).group_by(Vendor.vendor + ).all() + + # Count PoE vs non-PoE + poe_count = db.session.query(NetworkDevice).join(Asset).filter( + Asset.isactive == True, + NetworkDevice.ispoe == True + ).count() + + return success_response({ + 'total': total, + 'by_type': [{'type': t, 'count': c} for t, c in by_type], + 'by_vendor': [{'vendor': v, 'count': c} for v, c in by_vendor], + 'poe': poe_count, + 'non_poe': total - poe_count + }) + + +# ============================================================================= +# VLANs +# ============================================================================= + +@network_bp.route('/vlans', methods=['GET']) +@jwt_required() +def list_vlans(): + """List all VLANs with filtering and pagination.""" + page, per_page = get_pagination_params(request) + + query = VLAN.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(VLAN.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + VLAN.name.ilike(f'%{search}%'), + VLAN.description.ilike(f'%{search}%'), + db.cast(VLAN.vlannumber, db.String).ilike(f'%{search}%') + ) + ) + + # Type filter + if vlan_type := request.args.get('type'): + query = query.filter(VLAN.vlantype == vlan_type) + + query = query.order_by(VLAN.vlannumber) + + items, total = paginate_query(query, page, per_page) + data = [v.to_dict() for v in items] + + return paginated_response(data, page, per_page, total) + + +@network_bp.route('/vlans/', methods=['GET']) +@jwt_required() +def get_vlan(vlan_id: int): + """Get a single VLAN with its subnets.""" + vlan = VLAN.query.get(vlan_id) + + if not vlan: + return error_response( + ErrorCodes.NOT_FOUND, + f'VLAN with ID {vlan_id} not found', + http_code=404 + ) + + result = vlan.to_dict() + # Include associated subnets + result['subnets'] = [s.to_dict() for s in vlan.subnets.filter_by(isactive=True).all()] + + return success_response(result) + + +@network_bp.route('/vlans', methods=['POST']) +@jwt_required() +def create_vlan(): + """Create a new VLAN.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('vlannumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'vlannumber is required') + if not data.get('name'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + + # Check for duplicate VLAN number + if VLAN.query.filter_by(vlannumber=data['vlannumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"VLAN {data['vlannumber']} already exists", + http_code=409 + ) + + vlan = VLAN( + vlannumber=data['vlannumber'], + name=data['name'], + description=data.get('description'), + vlantype=data.get('vlantype') + ) + + db.session.add(vlan) + db.session.commit() + + return success_response(vlan.to_dict(), message='VLAN created', http_code=201) + + +@network_bp.route('/vlans/', methods=['PUT']) +@jwt_required() +def update_vlan(vlan_id: int): + """Update a VLAN.""" + vlan = VLAN.query.get(vlan_id) + + if not vlan: + return error_response( + ErrorCodes.NOT_FOUND, + f'VLAN with ID {vlan_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check for conflicting VLAN number + if 'vlannumber' in data and data['vlannumber'] != vlan.vlannumber: + if VLAN.query.filter_by(vlannumber=data['vlannumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"VLAN {data['vlannumber']} already exists", + http_code=409 + ) + + for key in ['vlannumber', 'name', 'description', 'vlantype', 'isactive']: + if key in data: + setattr(vlan, key, data[key]) + + db.session.commit() + return success_response(vlan.to_dict(), message='VLAN updated') + + +@network_bp.route('/vlans/', methods=['DELETE']) +@jwt_required() +def delete_vlan(vlan_id: int): + """Delete (soft delete) a VLAN.""" + vlan = VLAN.query.get(vlan_id) + + if not vlan: + return error_response( + ErrorCodes.NOT_FOUND, + f'VLAN with ID {vlan_id} not found', + http_code=404 + ) + + # Check if VLAN has associated subnets + if vlan.subnets.filter_by(isactive=True).count() > 0: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Cannot delete VLAN with associated subnets', + http_code=400 + ) + + vlan.isactive = False + db.session.commit() + + return success_response(message='VLAN deleted') + + +# ============================================================================= +# Subnets +# ============================================================================= + +@network_bp.route('/subnets', methods=['GET']) +@jwt_required() +def list_subnets(): + """List all subnets with filtering and pagination.""" + page, per_page = get_pagination_params(request) + + query = Subnet.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Subnet.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Subnet.cidr.ilike(f'%{search}%'), + Subnet.name.ilike(f'%{search}%'), + Subnet.description.ilike(f'%{search}%') + ) + ) + + # VLAN filter + if vlan_id := request.args.get('vlanid'): + query = query.filter(Subnet.vlanid == int(vlan_id)) + + # Location filter + if location_id := request.args.get('locationid'): + query = query.filter(Subnet.locationid == int(location_id)) + + # Type filter + if subnet_type := request.args.get('type'): + query = query.filter(Subnet.subnettype == subnet_type) + + query = query.order_by(Subnet.cidr) + + items, total = paginate_query(query, page, per_page) + data = [s.to_dict() for s in items] + + return paginated_response(data, page, per_page, total) + + +@network_bp.route('/subnets/', methods=['GET']) +@jwt_required() +def get_subnet(subnet_id: int): + """Get a single subnet.""" + subnet = Subnet.query.get(subnet_id) + + if not subnet: + return error_response( + ErrorCodes.NOT_FOUND, + f'Subnet with ID {subnet_id} not found', + http_code=404 + ) + + return success_response(subnet.to_dict()) + + +@network_bp.route('/subnets', methods=['POST']) +@jwt_required() +def create_subnet(): + """Create a new subnet.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('cidr'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr is required') + if not data.get('name'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + + # Validate CIDR format (basic check) + cidr = data['cidr'] + if '/' not in cidr: + return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr must be in CIDR notation (e.g., 10.1.1.0/24)') + + # Check for duplicate CIDR + if Subnet.query.filter_by(cidr=cidr).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Subnet {cidr} already exists", + http_code=409 + ) + + # Validate VLAN if provided + if data.get('vlanid'): + if not VLAN.query.get(data['vlanid']): + return error_response( + ErrorCodes.VALIDATION_ERROR, + f"VLAN with ID {data['vlanid']} not found" + ) + + subnet = Subnet( + cidr=cidr, + name=data['name'], + description=data.get('description'), + gatewayip=data.get('gatewayip'), + subnetmask=data.get('subnetmask'), + networkaddress=data.get('networkaddress'), + broadcastaddress=data.get('broadcastaddress'), + vlanid=data.get('vlanid'), + subnettype=data.get('subnettype'), + locationid=data.get('locationid'), + dhcpenabled=data.get('dhcpenabled', True), + dhcprangestart=data.get('dhcprangestart'), + dhcprangeend=data.get('dhcprangeend'), + dns1=data.get('dns1'), + dns2=data.get('dns2') + ) + + db.session.add(subnet) + db.session.commit() + + return success_response(subnet.to_dict(), message='Subnet created', http_code=201) + + +@network_bp.route('/subnets/', methods=['PUT']) +@jwt_required() +def update_subnet(subnet_id: int): + """Update a subnet.""" + subnet = Subnet.query.get(subnet_id) + + if not subnet: + return error_response( + ErrorCodes.NOT_FOUND, + f'Subnet with ID {subnet_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check for conflicting CIDR + if 'cidr' in data and data['cidr'] != subnet.cidr: + if Subnet.query.filter_by(cidr=data['cidr']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Subnet {data['cidr']} already exists", + http_code=409 + ) + + allowed_fields = ['cidr', 'name', 'description', 'gatewayip', 'subnetmask', + 'networkaddress', 'broadcastaddress', 'vlanid', 'subnettype', + 'locationid', 'dhcpenabled', 'dhcprangestart', 'dhcprangeend', + 'dns1', 'dns2', 'isactive'] + + for key in allowed_fields: + if key in data: + setattr(subnet, key, data[key]) + + db.session.commit() + return success_response(subnet.to_dict(), message='Subnet updated') + + +@network_bp.route('/subnets/', methods=['DELETE']) +@jwt_required() +def delete_subnet(subnet_id: int): + """Delete (soft delete) a subnet.""" + subnet = Subnet.query.get(subnet_id) + + if not subnet: + return error_response( + ErrorCodes.NOT_FOUND, + f'Subnet with ID {subnet_id} not found', + http_code=404 + ) + + subnet.isactive = False + db.session.commit() + + return success_response(message='Subnet deleted') diff --git a/plugins/network/manifest.json b/plugins/network/manifest.json new file mode 100644 index 0000000..352d477 --- /dev/null +++ b/plugins/network/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "network", + "version": "1.0.0", + "description": "Network device management plugin for switches, APs, cameras, and IDFs", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=1.0.0", + "api_prefix": "/api/network", + "provides": { + "asset_type": "network_device", + "features": [ + "network_device_tracking", + "port_management", + "firmware_tracking", + "poe_monitoring" + ] + }, + "settings": { + "enable_snmp_polling": false, + "snmp_community": "public" + } +} diff --git a/plugins/network/models/__init__.py b/plugins/network/models/__init__.py new file mode 100644 index 0000000..131748d --- /dev/null +++ b/plugins/network/models/__init__.py @@ -0,0 +1,11 @@ +"""Network plugin models.""" + +from .network_device import NetworkDevice, NetworkDeviceType +from .subnet import Subnet, VLAN + +__all__ = [ + 'NetworkDevice', + 'NetworkDeviceType', + 'Subnet', + 'VLAN', +] diff --git a/plugins/network/models/network_device.py b/plugins/network/models/network_device.py new file mode 100644 index 0000000..6ccbd4e --- /dev/null +++ b/plugins/network/models/network_device.py @@ -0,0 +1,121 @@ +"""Network device plugin models.""" + +from shopdb.extensions import db +from shopdb.core.models.base import BaseModel + + +class NetworkDeviceType(BaseModel): + """ + Network device type classification. + + Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc. + """ + __tablename__ = 'networkdevicetypes' + + networkdevicetypeid = db.Column(db.Integer, primary_key=True) + networkdevicetype = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class NetworkDevice(BaseModel): + """ + Network device-specific extension data. + + Links to core Asset table via assetid. + Stores network device-specific fields like hostname, firmware, ports, etc. + """ + __tablename__ = 'networkdevices' + + networkdeviceid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Network device classification + networkdevicetypeid = db.Column( + db.Integer, + db.ForeignKey('networkdevicetypes.networkdevicetypeid'), + nullable=True + ) + + # Vendor + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + + # Network identity + hostname = db.Column( + db.String(100), + index=True, + comment='Network hostname' + ) + + # Firmware/software version + firmwareversion = db.Column(db.String(100), nullable=True) + + # Physical characteristics + portcount = db.Column( + db.Integer, + nullable=True, + comment='Number of ports (for switches)' + ) + + # Features + ispoe = db.Column( + db.Boolean, + default=False, + comment='Power over Ethernet capable' + ) + ismanaged = db.Column( + db.Boolean, + default=False, + comment='Managed device (SNMP, web interface, etc.)' + ) + + # For IDF/closet locations + rackunit = db.Column( + db.String(20), + nullable=True, + comment='Rack unit position (e.g., U1, U5)' + ) + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('network_device', uselist=False, lazy='joined') + ) + networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices') + vendor = db.relationship('Vendor', backref='network_devices') + + __table_args__ = ( + db.Index('idx_netdev_type', 'networkdevicetypeid'), + db.Index('idx_netdev_hostname', 'hostname'), + db.Index('idx_netdev_vendor', 'vendorid'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.networkdevicetype: + result['networkdevicetype_name'] = self.networkdevicetype.networkdevicetype + if self.vendor: + result['vendor_name'] = self.vendor.vendor + + return result diff --git a/plugins/network/models/subnet.py b/plugins/network/models/subnet.py new file mode 100644 index 0000000..e2332f2 --- /dev/null +++ b/plugins/network/models/subnet.py @@ -0,0 +1,146 @@ +"""Subnet and VLAN models for network plugin.""" + +from shopdb.extensions import db +from shopdb.core.models.base import BaseModel + + +class VLAN(BaseModel): + """ + VLAN definition. + + Represents a virtual LAN for network segmentation. + """ + __tablename__ = 'vlans' + + vlanid = db.Column(db.Integer, primary_key=True) + vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number') + name = db.Column(db.String(100), nullable=False, comment='VLAN name') + description = db.Column(db.Text, nullable=True) + + # Optional classification + vlantype = db.Column( + db.String(50), + nullable=True, + comment='Type: data, voice, management, guest, etc.' + ) + + # Relationships + subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic') + + __table_args__ = ( + db.Index('idx_vlan_number', 'vlannumber'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary.""" + result = super().to_dict() + result['subnetcount'] = self.subnets.count() if self.subnets else 0 + return result + + +class Subnet(BaseModel): + """ + Subnet/IP network definition. + + Represents an IP subnet with optional VLAN association. + """ + __tablename__ = 'subnets' + + subnetid = db.Column(db.Integer, primary_key=True) + + # Network definition + cidr = db.Column( + db.String(18), + unique=True, + nullable=False, + comment='CIDR notation (e.g., 10.1.1.0/24)' + ) + name = db.Column(db.String(100), nullable=False, comment='Subnet name') + description = db.Column(db.Text, nullable=True) + + # Network details + gatewayip = db.Column( + db.String(15), + nullable=True, + comment='Default gateway IP address' + ) + subnetmask = db.Column( + db.String(15), + nullable=True, + comment='Subnet mask (e.g., 255.255.255.0)' + ) + networkaddress = db.Column( + db.String(15), + nullable=True, + comment='Network address (e.g., 10.1.1.0)' + ) + broadcastaddress = db.Column( + db.String(15), + nullable=True, + comment='Broadcast address (e.g., 10.1.1.255)' + ) + + # VLAN association + vlanid = db.Column( + db.Integer, + db.ForeignKey('vlans.vlanid'), + nullable=True + ) + + # Classification + subnettype = db.Column( + db.String(50), + nullable=True, + comment='Type: production, development, management, dmz, etc.' + ) + + # Location association + locationid = db.Column( + db.Integer, + db.ForeignKey('locations.locationid'), + nullable=True + ) + + # DHCP settings + dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet') + dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP') + dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP') + + # DNS settings + dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server') + dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server') + + # Relationships + location = db.relationship('Location', backref='subnets') + + __table_args__ = ( + db.Index('idx_subnet_cidr', 'cidr'), + db.Index('idx_subnet_vlan', 'vlanid'), + db.Index('idx_subnet_location', 'locationid'), + ) + + def __repr__(self): + return f"" + + @property + def vlan_number(self): + """Get the VLAN number.""" + return self.vlan.vlannumber if self.vlan else None + + def to_dict(self): + """Convert to dictionary with related data.""" + result = super().to_dict() + + # Add VLAN info + if self.vlan: + result['vlannumber'] = self.vlan.vlannumber + result['vlanname'] = self.vlan.name + + # Add location info + if self.location: + result['locationname'] = self.location.locationname + + return result diff --git a/plugins/network/plugin.py b/plugins/network/plugin.py new file mode 100644 index 0000000..44036a1 --- /dev/null +++ b/plugins/network/plugin.py @@ -0,0 +1,217 @@ +"""Network plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.extensions import db +from shopdb.core.models import AssetType + +from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN +from .api import network_bp + +logger = logging.getLogger(__name__) + + +class NetworkPlugin(BasePlugin): + """ + Network plugin - manages network device assets. + + Network devices include switches, routers, access points, cameras, IDFs, etc. + Uses the new Asset architecture with NetworkDevice extension table. + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifestpath = Path(__file__).parent / 'manifest.json' + if manifestpath.exists(): + with open(manifestpath, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'network'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Network device management for switches, APs, and cameras' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/network'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return network_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [NetworkDevice, NetworkDeviceType, Subnet, VLAN] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Network plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_asset_type() + self._ensure_network_device_types() + logger.info("Network plugin installed") + + def _ensure_asset_type(self) -> None: + """Ensure network_device asset type exists.""" + existing = AssetType.query.filter_by(assettype='network_device').first() + if not existing: + at = AssetType( + assettype='network_device', + plugin_name='network', + table_name='networkdevices', + description='Network infrastructure devices (switches, APs, cameras, etc.)', + icon='network-wired' + ) + db.session.add(at) + logger.debug("Created asset type: network_device") + db.session.commit() + + def _ensure_network_device_types(self) -> None: + """Ensure basic network device types exist.""" + device_types = [ + ('Switch', 'Network switch', 'network-wired'), + ('Router', 'Network router', 'router'), + ('Access Point', 'Wireless access point', 'wifi'), + ('Firewall', 'Network firewall', 'shield'), + ('Camera', 'IP camera', 'video'), + ('IDF', 'Intermediate Distribution Frame/closet', 'box'), + ('MDF', 'Main Distribution Frame', 'building'), + ('Patch Panel', 'Patch panel', 'th'), + ('UPS', 'Uninterruptible power supply', 'battery'), + ('Other', 'Other network device', 'network-wired'), + ] + + for name, description, icon in device_types: + existing = NetworkDeviceType.query.filter_by(networkdevicetype=name).first() + if not existing: + ndt = NetworkDeviceType( + networkdevicetype=name, + description=description, + icon=icon + ) + db.session.add(ndt) + logger.debug(f"Created network device type: {name}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Network plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('network') + def networkcli(): + """Network plugin commands.""" + pass + + @networkcli.command('list-types') + def list_types(): + """List all network device types.""" + from flask import current_app + + with current_app.app_context(): + types = NetworkDeviceType.query.filter_by(isactive=True).all() + if not types: + click.echo('No network device types found.') + return + + click.echo('Network Device Types:') + for t in types: + click.echo(f" [{t.networkdevicetypeid}] {t.networkdevicetype}") + + @networkcli.command('stats') + def stats(): + """Show network device statistics.""" + from flask import current_app + from shopdb.core.models import Asset + + with current_app.app_context(): + total = db.session.query(NetworkDevice).join(Asset).filter( + Asset.isactive == True + ).count() + + click.echo(f"Total active network devices: {total}") + + # By type + by_type = db.session.query( + NetworkDeviceType.networkdevicetype, + db.func.count(NetworkDevice.networkdeviceid) + ).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid + ).join(Asset, Asset.assetid == NetworkDevice.assetid + ).filter(Asset.isactive == True + ).group_by(NetworkDeviceType.networkdevicetype + ).all() + + if by_type: + click.echo("\nBy Type:") + for t, c in by_type: + click.echo(f" {t}: {c}") + + @networkcli.command('find') + @click.argument('hostname') + def find_by_hostname(hostname): + """Find a network device by hostname.""" + from flask import current_app + + with current_app.app_context(): + netdev = NetworkDevice.query.filter( + NetworkDevice.hostname.ilike(f'%{hostname}%') + ).first() + + if not netdev: + click.echo(f'No network device found matching hostname: {hostname}') + return + + click.echo(f'Found: {netdev.hostname}') + click.echo(f' Asset: {netdev.asset.assetnumber}') + click.echo(f' Type: {netdev.networkdevicetype.networkdevicetype if netdev.networkdevicetype else "N/A"}') + click.echo(f' Firmware: {netdev.firmwareversion or "N/A"}') + click.echo(f' PoE: {"Yes" if netdev.ispoe else "No"}') + + return [networkcli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Network Status', + 'component': 'NetworkStatusWidget', + 'endpoint': '/api/network/dashboard/summary', + 'size': 'medium', + 'position': 7, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Network', + 'icon': 'network-wired', + 'route': '/network', + 'position': 18, + }, + ] diff --git a/plugins/notifications/__init__.py b/plugins/notifications/__init__.py new file mode 100644 index 0000000..edd7b58 --- /dev/null +++ b/plugins/notifications/__init__.py @@ -0,0 +1,5 @@ +"""Notifications plugin package.""" + +from .plugin import NotificationsPlugin + +__all__ = ['NotificationsPlugin'] diff --git a/plugins/notifications/api/__init__.py b/plugins/notifications/api/__init__.py new file mode 100644 index 0000000..d0385c2 --- /dev/null +++ b/plugins/notifications/api/__init__.py @@ -0,0 +1,5 @@ +"""Notifications plugin API.""" + +from .routes import notifications_bp + +__all__ = ['notifications_bp'] diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py new file mode 100644 index 0000000..6ba83fd --- /dev/null +++ b/plugins/notifications/api/routes.py @@ -0,0 +1,617 @@ +"""Notifications plugin API endpoints - adapted to existing schema.""" + +from datetime import datetime +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from ..models import Notification, NotificationType + +notifications_bp = Blueprint('notifications', __name__) + + +# ============================================================================= +# Notification Types +# ============================================================================= + +@notifications_bp.route('/types', methods=['GET']) +def list_notification_types(): + """List all notification types.""" + page, per_page = get_pagination_params(request) + + query = NotificationType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(NotificationType.isactive == True) + + query = query.order_by(NotificationType.typename) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@notifications_bp.route('/types', methods=['POST']) +@jwt_required() +def create_notification_type(): + """Create a new notification type.""" + data = request.get_json() + + if not data or not data.get('typename'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'typename is required') + + if NotificationType.query.filter_by(typename=data['typename']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Notification type '{data['typename']}' already exists", + http_code=409 + ) + + t = NotificationType( + typename=data['typename'], + typedescription=data.get('typedescription') or data.get('description'), + typecolor=data.get('typecolor') or data.get('color', '#17a2b8') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Notification type created', http_code=201) + + +# ============================================================================= +# Notifications CRUD +# ============================================================================= + +@notifications_bp.route('', methods=['GET']) +def list_notifications(): + """ + List all notifications with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status (default: true) + - type_id: Filter by notification type ID + - current: Filter to currently active notifications only + - search: Search in notification text + """ + page, per_page = get_pagination_params(request) + + query = Notification.query + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Notification.isactive == True) + + # Type filter + if type_id := request.args.get('type_id'): + query = query.filter(Notification.notificationtypeid == int(type_id)) + + # Current filter (active based on dates) + if request.args.get('current', 'false').lower() == 'true': + now = datetime.utcnow() + query = query.filter( + Notification.starttime <= now, + db.or_( + Notification.endtime.is_(None), + Notification.endtime >= now + ) + ) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + Notification.notification.ilike(f'%{search}%') + ) + + # Sorting by start time (newest first) + query = query.order_by(Notification.starttime.desc()) + + items, total = paginate_query(query, page, per_page) + data = [n.to_dict() for n in items] + + return paginated_response(data, page, per_page, total) + + +@notifications_bp.route('/', methods=['GET']) +def get_notification(notification_id: int): + """Get a single notification.""" + n = Notification.query.get(notification_id) + + if not n: + return error_response( + ErrorCodes.NOT_FOUND, + f'Notification with ID {notification_id} not found', + http_code=404 + ) + + return success_response(n.to_dict()) + + +@notifications_bp.route('', methods=['POST']) +@jwt_required() +def create_notification(): + """Create a new notification.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate required fields + notification_text = data.get('notification') or data.get('message') + if not notification_text: + return error_response(ErrorCodes.VALIDATION_ERROR, 'notification/message is required') + + # Parse dates + starttime = datetime.utcnow() + if data.get('starttime') or data.get('startdate'): + try: + date_str = data.get('starttime') or data.get('startdate') + starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except ValueError: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format') + + endtime = None + if data.get('endtime') or data.get('enddate'): + try: + date_str = data.get('endtime') or data.get('enddate') + endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except ValueError: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format') + + n = Notification( + notification=notification_text, + notificationtypeid=data.get('notificationtypeid'), + businessunitid=data.get('businessunitid'), + appid=data.get('appid'), + starttime=starttime, + endtime=endtime, + ticketnumber=data.get('ticketnumber'), + link=data.get('link') or data.get('linkurl'), + isactive=True, + isshopfloor=data.get('isshopfloor', False), + employeesso=data.get('employeesso'), + employeename=data.get('employeename') + ) + + db.session.add(n) + db.session.commit() + + return success_response(n.to_dict(), message='Notification created', http_code=201) + + +@notifications_bp.route('/', methods=['PUT']) +@jwt_required() +def update_notification(notification_id: int): + """Update a notification.""" + n = Notification.query.get(notification_id) + + if not n: + return error_response( + ErrorCodes.NOT_FOUND, + f'Notification with ID {notification_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Update text content + if 'notification' in data or 'message' in data: + n.notification = data.get('notification') or data.get('message') + + # Update simple fields + if 'notificationtypeid' in data: + n.notificationtypeid = data['notificationtypeid'] + if 'businessunitid' in data: + n.businessunitid = data['businessunitid'] + if 'appid' in data: + n.appid = data['appid'] + if 'ticketnumber' in data: + n.ticketnumber = data['ticketnumber'] + if 'link' in data or 'linkurl' in data: + n.link = data.get('link') or data.get('linkurl') + if 'isactive' in data: + n.isactive = data['isactive'] + if 'isshopfloor' in data: + n.isshopfloor = data['isshopfloor'] + if 'employeesso' in data: + n.employeesso = data['employeesso'] + if 'employeename' in data: + n.employeename = data['employeename'] + + # Parse and update dates + if 'starttime' in data or 'startdate' in data: + date_str = data.get('starttime') or data.get('startdate') + if date_str: + try: + n.starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except ValueError: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format') + else: + n.starttime = datetime.utcnow() + + if 'endtime' in data or 'enddate' in data: + date_str = data.get('endtime') or data.get('enddate') + if date_str: + try: + n.endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except ValueError: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format') + else: + n.endtime = None + + db.session.commit() + return success_response(n.to_dict(), message='Notification updated') + + +@notifications_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_notification(notification_id: int): + """Delete (soft delete) a notification.""" + n = Notification.query.get(notification_id) + + if not n: + return error_response( + ErrorCodes.NOT_FOUND, + f'Notification with ID {notification_id} not found', + http_code=404 + ) + + n.isactive = False + db.session.commit() + + return success_response(message='Notification deleted') + + +# ============================================================================= +# Special Endpoints +# ============================================================================= + +@notifications_bp.route('/active', methods=['GET']) +def get_active_notifications(): + """ + Get currently active notifications for display. + """ + now = datetime.utcnow() + + notifications = Notification.query.filter( + Notification.isactive == True, + db.or_( + Notification.starttime.is_(None), + Notification.starttime <= now + ), + db.or_( + Notification.endtime.is_(None), + Notification.endtime >= now + ) + ).order_by(Notification.starttime.desc()).all() + + data = [n.to_dict() for n in notifications] + + return success_response({ + 'notifications': data, + 'total': len(data) + }) + + +@notifications_bp.route('/calendar', methods=['GET']) +def get_calendar_events(): + """ + Get notifications in FullCalendar event format. + + Query parameters: + - start: Start date (ISO format) + - end: End date (ISO format) + """ + query = Notification.query.filter(Notification.isactive == True) + + # Date range filter + if start := request.args.get('start'): + try: + start_date = datetime.fromisoformat(start.replace('Z', '+00:00')) + query = query.filter( + db.or_( + Notification.endtime >= start_date, + Notification.endtime.is_(None) + ) + ) + except ValueError: + pass + + if end := request.args.get('end'): + try: + end_date = datetime.fromisoformat(end.replace('Z', '+00:00')) + query = query.filter(Notification.starttime <= end_date) + except ValueError: + pass + + notifications = query.order_by(Notification.starttime).all() + + events = [n.to_calendar_event() for n in notifications] + + return success_response(events) + + +@notifications_bp.route('/dashboard/summary', methods=['GET']) +def dashboard_summary(): + """Get notifications dashboard summary.""" + now = datetime.utcnow() + + # Total active notifications + total_active = Notification.query.filter( + Notification.isactive == True, + db.or_( + Notification.starttime.is_(None), + Notification.starttime <= now + ), + db.or_( + Notification.endtime.is_(None), + Notification.endtime >= now + ) + ).count() + + # By type + by_type = db.session.query( + NotificationType.typename, + NotificationType.typecolor, + db.func.count(Notification.notificationid) + ).join(Notification + ).filter( + Notification.isactive == True + ).group_by(NotificationType.typename, NotificationType.typecolor + ).all() + + return success_response({ + 'active': total_active, + 'by_type': [{'type': t, 'color': c, 'count': n} for t, c, n in by_type] + }) + + +@notifications_bp.route('/employee/', methods=['GET']) +def get_employee_recognitions(sso): + """ + Get recognitions for a specific employee by SSO. + + Returns all recognition-type notifications where the employee is mentioned. + """ + if not sso or not sso.isdigit(): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Valid SSO required') + + # Find recognition type(s) + recognition_types = NotificationType.query.filter( + db.or_( + NotificationType.typecolor == 'recognition', + NotificationType.typename.ilike('%recognition%') + ) + ).all() + + recognition_type_ids = [rt.notificationtypeid for rt in recognition_types] + + # Find notifications where this employee is mentioned + # Check both exact match and comma-separated list + query = Notification.query.filter( + Notification.isactive == True, + db.or_( + Notification.employeesso == sso, + Notification.employeesso.like(f'{sso},%'), + Notification.employeesso.like(f'%,{sso}'), + Notification.employeesso.like(f'%,{sso},%') + ) + ) + + # Optionally filter to recognition types only + if recognition_type_ids: + query = query.filter(Notification.notificationtypeid.in_(recognition_type_ids)) + + query = query.order_by(Notification.starttime.desc()) + + notifications = query.all() + data = [n.to_dict() for n in notifications] + + return success_response({ + 'recognitions': data, + 'total': len(data) + }) + + +@notifications_bp.route('/shopfloor', methods=['GET']) +def get_shopfloor_notifications(): + """ + Get notifications for shopfloor TV dashboard. + + Returns current and upcoming notifications with isshopfloor=1. + Splits multi-employee recognition into separate entries. + + Query parameters: + - businessunit: Filter by business unit ID (null = all units) + """ + from datetime import timedelta + + now = datetime.utcnow() + business_unit = request.args.get('businessunit') + + # Base query for shopfloor notifications + base_query = Notification.query.filter(Notification.isshopfloor == True) + + # Business unit filter + if business_unit and business_unit.isdigit(): + # Specific BU: show that BU's notifications AND null (all units) + base_query = base_query.filter( + db.or_( + Notification.businessunitid == int(business_unit), + Notification.businessunitid.is_(None) + ) + ) + else: + # All units: only show notifications with NULL businessunitid + base_query = base_query.filter(Notification.businessunitid.is_(None)) + + # Current notifications (active now or ended within 30 minutes) + thirty_min_ago = now - timedelta(minutes=30) + current_query = base_query.filter( + db.or_( + # Active and currently showing + db.and_( + Notification.isactive == True, + db.or_(Notification.starttime.is_(None), Notification.starttime <= now), + db.or_(Notification.endtime.is_(None), Notification.endtime >= now) + ), + # Recently ended (within 30 min) - show as resolved + db.and_( + Notification.endtime.isnot(None), + Notification.endtime >= thirty_min_ago, + Notification.endtime < now + ) + ) + ).order_by(Notification.notificationid.desc()) + + current_notifications = current_query.all() + + # Upcoming notifications (starts within next 5 days) + five_days = now + timedelta(days=5) + upcoming_query = base_query.filter( + Notification.isactive == True, + Notification.starttime > now, + Notification.starttime <= five_days + ).order_by(Notification.starttime) + + upcoming_notifications = upcoming_query.all() + + def notification_to_shopfloor(n, employee_override=None): + """Convert notification to shopfloor format.""" + is_resolved = n.endtime and n.endtime < now + + result = { + 'notificationid': n.notificationid, + 'notification': n.notification, + 'starttime': n.starttime.isoformat() if n.starttime else None, + 'endtime': n.endtime.isoformat() if n.endtime else None, + 'ticketnumber': n.ticketnumber, + 'link': n.link, + 'isactive': n.isactive, + 'isshopfloor': True, + 'resolved': is_resolved, + 'typename': n.notificationtype.typename if n.notificationtype else None, + 'typecolor': n.notificationtype.typecolor if n.notificationtype else None, + } + + # Employee info + if employee_override: + result['employeesso'] = employee_override.get('sso') + result['employeename'] = employee_override.get('name') + result['employeepicture'] = employee_override.get('picture') + else: + result['employeesso'] = n.employeesso + result['employeename'] = n.employeename + result['employeepicture'] = None + + # Try to get picture from wjf_employees + if n.employeesso and n.employeesso.isdigit(): + try: + import pymysql + conn = pymysql.connect( + host='localhost', user='root', password='rootpassword', + database='wjf_employees', cursorclass=pymysql.cursors.DictCursor + ) + with conn.cursor() as cur: + cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(n.employeesso),)) + emp = cur.fetchone() + if emp and emp.get('Picture'): + result['employeepicture'] = emp['Picture'] + conn.close() + except Exception: + pass + + return result + + # Process current notifications (split multi-employee recognition) + current_data = [] + for n in current_notifications: + is_recognition = n.notificationtype and n.notificationtype.typecolor == 'recognition' + + if is_recognition and n.employeesso and ',' in n.employeesso: + # Split into individual cards for each employee + ssos = [s.strip() for s in n.employeesso.split(',')] + names = n.employeename.split(', ') if n.employeename else [] + + for i, sso in enumerate(ssos): + name = names[i] if i < len(names) else sso + + # Look up picture + picture = None + if sso.isdigit(): + try: + import pymysql + conn = pymysql.connect( + host='localhost', user='root', password='rootpassword', + database='wjf_employees', cursorclass=pymysql.cursors.DictCursor + ) + with conn.cursor() as cur: + cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),)) + emp = cur.fetchone() + if emp: + picture = emp.get('Picture') + conn.close() + except Exception: + pass + + current_data.append(notification_to_shopfloor(n, { + 'sso': sso, + 'name': name, + 'picture': picture + })) + else: + current_data.append(notification_to_shopfloor(n)) + + # Process upcoming notifications + upcoming_data = [] + for n in upcoming_notifications: + is_recognition = n.notificationtype and n.notificationtype.typecolor == 'recognition' + + if is_recognition and n.employeesso and ',' in n.employeesso: + ssos = [s.strip() for s in n.employeesso.split(',')] + names = n.employeename.split(', ') if n.employeename else [] + + for i, sso in enumerate(ssos): + name = names[i] if i < len(names) else sso + picture = None + if sso.isdigit(): + try: + import pymysql + conn = pymysql.connect( + host='localhost', user='root', password='rootpassword', + database='wjf_employees', cursorclass=pymysql.cursors.DictCursor + ) + with conn.cursor() as cur: + cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),)) + emp = cur.fetchone() + if emp: + picture = emp.get('Picture') + conn.close() + except Exception: + pass + + upcoming_data.append(notification_to_shopfloor(n, { + 'sso': sso, + 'name': name, + 'picture': picture + })) + else: + upcoming_data.append(notification_to_shopfloor(n)) + + return success_response({ + 'timestamp': now.isoformat(), + 'current': current_data, + 'upcoming': upcoming_data + }) diff --git a/plugins/notifications/manifest.json b/plugins/notifications/manifest.json new file mode 100644 index 0000000..f6e22c9 --- /dev/null +++ b/plugins/notifications/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "notifications", + "version": "1.0.0", + "description": "Notifications and announcements management plugin", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=1.0.0", + "api_prefix": "/api/notifications", + "provides": { + "features": ["notifications", "announcements", "calendar_events"] + } +} diff --git a/plugins/notifications/models/__init__.py b/plugins/notifications/models/__init__.py new file mode 100644 index 0000000..f6514a9 --- /dev/null +++ b/plugins/notifications/models/__init__.py @@ -0,0 +1,5 @@ +"""Notifications plugin models.""" + +from .notification import Notification, NotificationType + +__all__ = ['Notification', 'NotificationType'] diff --git a/plugins/notifications/models/notification.py b/plugins/notifications/models/notification.py new file mode 100644 index 0000000..bcccd38 --- /dev/null +++ b/plugins/notifications/models/notification.py @@ -0,0 +1,157 @@ +"""Notifications plugin models - adapted to existing database schema.""" + +from datetime import datetime +from shopdb.extensions import db + + +class NotificationType(db.Model): + """ + Notification type classification. + Matches existing notificationtypes table. + """ + __tablename__ = 'notificationtypes' + + notificationtypeid = db.Column(db.Integer, primary_key=True) + typename = db.Column(db.String(50), nullable=False) + typedescription = db.Column(db.Text) + typecolor = db.Column(db.String(20), default='#17a2b8') + isactive = db.Column(db.Boolean, default=True) + + def __repr__(self): + return f"" + + def to_dict(self): + return { + 'notificationtypeid': self.notificationtypeid, + 'typename': self.typename, + 'typedescription': self.typedescription, + 'typecolor': self.typecolor, + 'isactive': self.isactive + } + + +class Notification(db.Model): + """ + Notification/announcement model. + Matches existing notifications table schema. + """ + __tablename__ = 'notifications' + + notificationid = db.Column(db.Integer, primary_key=True) + notificationtypeid = db.Column( + db.Integer, + db.ForeignKey('notificationtypes.notificationtypeid'), + nullable=True + ) + businessunitid = db.Column(db.Integer, nullable=True) + appid = db.Column(db.Integer, nullable=True) + notification = db.Column(db.Text, nullable=False, comment='The message content') + starttime = db.Column(db.DateTime, nullable=True) + endtime = db.Column(db.DateTime, nullable=True) + ticketnumber = db.Column(db.String(50), nullable=True) + link = db.Column(db.String(500), nullable=True) + isactive = db.Column(db.Boolean, default=True) + isshopfloor = db.Column(db.Boolean, default=False) + employeesso = db.Column(db.String(100), nullable=True) + employeename = db.Column(db.String(100), nullable=True) + + # Relationships + notificationtype = db.relationship('NotificationType', backref='notifications') + + def __repr__(self): + return f"" + + @property + def is_current(self): + """Check if notification is currently active based on dates.""" + now = datetime.utcnow() + if not self.isactive: + return False + if self.starttime and now < self.starttime: + return False + if self.endtime and now > self.endtime: + return False + return True + + @property + def title(self): + """Get title - first line or first 100 chars of notification.""" + if not self.notification: + return '' + lines = self.notification.split('\n') + return lines[0][:100] if lines else self.notification[:100] + + def to_dict(self): + """Convert to dictionary with related data.""" + result = { + 'notificationid': self.notificationid, + 'notificationtypeid': self.notificationtypeid, + 'businessunitid': self.businessunitid, + 'appid': self.appid, + 'notification': self.notification, + 'title': self.title, + 'message': self.notification, + 'starttime': self.starttime.isoformat() if self.starttime else None, + 'endtime': self.endtime.isoformat() if self.endtime else None, + 'startdate': self.starttime.isoformat() if self.starttime else None, + 'enddate': self.endtime.isoformat() if self.endtime else None, + 'ticketnumber': self.ticketnumber, + 'link': self.link, + 'linkurl': self.link, + 'isactive': bool(self.isactive) if self.isactive is not None else True, + 'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False, + 'employeesso': self.employeesso, + 'employeename': self.employeename, + 'iscurrent': self.is_current + } + + # Add type info + if self.notificationtype: + result['typename'] = self.notificationtype.typename + result['typecolor'] = self.notificationtype.typecolor + + return result + + def to_calendar_event(self): + """Convert to FullCalendar event format.""" + # Map Bootstrap color names to hex colors + color_map = { + 'success': '#04b962', + 'warning': '#ff8800', + 'danger': '#f5365c', + 'info': '#14abef', + 'primary': '#7934f3', + 'secondary': '#94614f', + 'recognition': '#14abef', # Blue for recognition + } + + raw_color = self.notificationtype.typecolor if self.notificationtype else 'info' + # Use mapped color if it's a Bootstrap name, otherwise use as-is (hex) + color = color_map.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef') + + # For recognition notifications, include employee name (or SSO as fallback) in title + title = self.title + if raw_color == 'recognition': + employee_display = self.employeename or self.employeesso + if employee_display: + title = f"{employee_display}: {title}" + + return { + 'id': self.notificationid, + 'title': title, + 'start': self.starttime.isoformat() if self.starttime else None, + 'end': self.endtime.isoformat() if self.endtime else None, + 'allDay': True, + 'backgroundColor': color, + 'borderColor': color, + 'extendedProps': { + 'notificationid': self.notificationid, + 'message': self.notification, + 'typename': self.notificationtype.typename if self.notificationtype else None, + 'typecolor': raw_color, + 'linkurl': self.link, + 'ticketnumber': self.ticketnumber, + 'employeename': self.employeename, + 'employeesso': self.employeesso, + } + } diff --git a/plugins/notifications/plugin.py b/plugins/notifications/plugin.py new file mode 100644 index 0000000..495a91a --- /dev/null +++ b/plugins/notifications/plugin.py @@ -0,0 +1,204 @@ +"""Notifications plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.extensions import db + +from .models import Notification, NotificationType +from .api import notifications_bp + +logger = logging.getLogger(__name__) + + +class NotificationsPlugin(BasePlugin): + """ + Notifications plugin - manages announcements and notifications. + + Provides functionality for: + - Creating and managing notifications/announcements + - Displaying banner notifications + - Calendar view of notifications + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifest_path = Path(__file__).parent / 'manifest.json' + if manifest_path.exists(): + with open(manifest_path, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'notifications'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Notifications and announcements management' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/notifications'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return notifications_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [Notification, NotificationType] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Notifications plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_notification_types() + logger.info("Notifications plugin installed") + + def _ensure_notification_types(self) -> None: + """Ensure default notification types exist.""" + default_types = [ + ('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'), + ('Change', 'Planned change notification', '#ffc107', 'exchange-alt'), + ('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'), + ('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'), + ('General', 'General announcement', '#28a745', 'bullhorn'), + ] + + for typename, description, color, icon in default_types: + existing = NotificationType.query.filter_by(typename=typename).first() + if not existing: + t = NotificationType( + typename=typename, + description=description, + color=color, + icon=icon + ) + db.session.add(t) + logger.debug(f"Created notification type: {typename}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Notifications plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('notifications') + def notifications_cli(): + """Notifications plugin commands.""" + pass + + @notifications_cli.command('list-types') + def list_types(): + """List all notification types.""" + from flask import current_app + + with current_app.app_context(): + types = NotificationType.query.filter_by(isactive=True).all() + if not types: + click.echo('No notification types found.') + return + + click.echo('Notification Types:') + for t in types: + click.echo(f" [{t.notificationtypeid}] {t.typename} ({t.color})") + + @notifications_cli.command('stats') + def stats(): + """Show notification statistics.""" + from flask import current_app + from datetime import datetime + + with current_app.app_context(): + now = datetime.utcnow() + + total = Notification.query.filter( + Notification.isactive == True + ).count() + + active = Notification.query.filter( + Notification.isactive == True, + Notification.startdate <= now, + db.or_( + Notification.enddate.is_(None), + Notification.enddate >= now + ) + ).count() + + click.echo(f"Total notifications: {total}") + click.echo(f"Currently active: {active}") + + @notifications_cli.command('create') + @click.option('--title', required=True, help='Notification title') + @click.option('--message', required=True, help='Notification message') + @click.option('--type', 'type_name', default='General', help='Notification type') + def create_notification(title, message, type_name): + """Create a new notification.""" + from flask import current_app + + with current_app.app_context(): + ntype = NotificationType.query.filter_by(typename=type_name).first() + if not ntype: + click.echo(f"Error: Notification type '{type_name}' not found.") + return + + n = Notification( + title=title, + message=message, + notificationtypeid=ntype.notificationtypeid + ) + db.session.add(n) + db.session.commit() + + click.echo(f"Created notification #{n.notificationid}: {title}") + + return [notifications_cli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Active Notifications', + 'component': 'NotificationsWidget', + 'endpoint': '/api/notifications/dashboard/summary', + 'size': 'small', + 'position': 1, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Notifications', + 'icon': 'bell', + 'route': '/notifications', + 'position': 5, + }, + { + 'name': 'Calendar', + 'icon': 'calendar', + 'route': '/calendar', + 'position': 6, + }, + ] diff --git a/plugins/printers/api/__init__.py b/plugins/printers/api/__init__.py index 1bc1279..d4bfa10 100644 --- a/plugins/printers/api/__init__.py +++ b/plugins/printers/api/__init__.py @@ -1,5 +1,9 @@ """Printers plugin API.""" -from .routes import printers_bp +from .routes import printers_bp # Legacy Machine-based API +from .asset_routes import printers_asset_bp # New Asset-based API -__all__ = ['printers_bp'] +__all__ = [ + 'printers_bp', # Legacy + 'printers_asset_bp', # New +] diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py new file mode 100644 index 0000000..7456479 --- /dev/null +++ b/plugins/printers/api/asset_routes.py @@ -0,0 +1,472 @@ +"""Printers API routes - new Asset-based architecture.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType, Vendor, Model, Communication, CommunicationType +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from ..models import Printer, PrinterType +from ..services import ZabbixService + +printers_asset_bp = Blueprint('printers_asset', __name__) + + +# ============================================================================= +# Printer Types +# ============================================================================= + +@printers_asset_bp.route('/types', methods=['GET']) +@jwt_required() +def list_printer_types(): + """List all printer types.""" + page, per_page = get_pagination_params(request) + + query = PrinterType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(PrinterType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(PrinterType.printertype.ilike(f'%{search}%')) + + query = query.order_by(PrinterType.printertype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@printers_asset_bp.route('/types/', methods=['GET']) +@jwt_required() +def get_printer_type(type_id: int): + """Get a single printer type.""" + t = PrinterType.query.get(type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@printers_asset_bp.route('/types', methods=['POST']) +@jwt_required() +def create_printer_type(): + """Create a new printer type.""" + data = request.get_json() + + if not data or not data.get('printertype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'printertype is required') + + if PrinterType.query.filter_by(printertype=data['printertype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Printer type '{data['printertype']}' already exists", + http_code=409 + ) + + t = PrinterType( + printertype=data['printertype'], + description=data.get('description'), + icon=data.get('icon') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Printer type created', http_code=201) + + +# ============================================================================= +# Printers CRUD +# ============================================================================= + +@printers_asset_bp.route('', methods=['GET']) +@jwt_required() +def list_printers(): + """ + List all printers with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status + - search: Search by asset number, name, or hostname + - type_id: Filter by printer type ID + - vendor_id: Filter by vendor ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + """ + page, per_page = get_pagination_params(request) + + # Join Printer with Asset + query = db.session.query(Printer).join(Asset) + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%'), + Printer.hostname.ilike(f'%{search}%'), + Printer.windowsname.ilike(f'%{search}%') + ) + ) + + # Type filter + if type_id := request.args.get('type_id'): + query = query.filter(Printer.printertypeid == int(type_id)) + + # Vendor filter + if vendor_id := request.args.get('vendor_id'): + query = query.filter(Printer.vendorid == int(vendor_id)) + + # Location filter + if location_id := request.args.get('location_id'): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunit_id'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Sorting + sort_by = request.args.get('sort', 'hostname') + sort_dir = request.args.get('dir', 'asc') + + if sort_by == 'hostname': + col = Printer.hostname + elif sort_by == 'assetnumber': + col = Asset.assetnumber + elif sort_by == 'name': + col = Asset.name + else: + col = Printer.hostname + + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + + items, total = paginate_query(query, page, per_page) + + # Build response with both asset and printer data + data = [] + for printer in items: + item = printer.asset.to_dict() if printer.asset else {} + item['printer'] = printer.to_dict() + + # Add primary IP address + if printer.asset: + primary_comm = Communication.query.filter_by( + assetid=printer.asset.assetid, + isprimary=True + ).first() + if not primary_comm: + primary_comm = Communication.query.filter_by( + assetid=printer.asset.assetid + ).first() + item['ipaddress'] = primary_comm.ipaddress if primary_comm else None + + data.append(item) + + return paginated_response(data, page, per_page, total) + + +@printers_asset_bp.route('/', methods=['GET']) +@jwt_required() +def get_printer(printer_id: int): + """Get a single printer with full details.""" + printer = Printer.query.get(printer_id) + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer with ID {printer_id} not found', + http_code=404 + ) + + result = printer.asset.to_dict() if printer.asset else {} + result['printer'] = printer.to_dict() + + # Add communications + if printer.asset: + comms = Communication.query.filter_by(assetid=printer.asset.assetid).all() + result['communications'] = [c.to_dict() for c in comms] + + return success_response(result) + + +@printers_asset_bp.route('/by-asset/', methods=['GET']) +@jwt_required() +def get_printer_by_asset(asset_id: int): + """Get printer data by asset ID.""" + printer = Printer.query.filter_by(assetid=asset_id).first() + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer for asset {asset_id} not found', + http_code=404 + ) + + result = printer.asset.to_dict() if printer.asset else {} + result['printer'] = printer.to_dict() + + return success_response(result) + + +@printers_asset_bp.route('', methods=['POST']) +@jwt_required() +def create_printer(): + """ + Create new printer (creates both Asset and Printer records). + + Required fields: + - assetnumber: Business identifier + + Optional fields: + - name, serialnumber, statusid, locationid, businessunitid + - printertypeid, vendorid, modelnumberid, hostname + - windowsname, sharename, iscsf, installpath, pin + - iscolor, isduplex, isnetwork + - mapleft, maptop, notes + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Get printer asset type + printer_type = AssetType.query.filter_by(assettype='printer').first() + if not printer_type: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Printer asset type not found. Plugin may not be properly installed.', + http_code=500 + ) + + # Create the core asset + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + assettypeid=printer_type.assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapleft=data.get('mapleft'), + maptop=data.get('maptop'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.flush() # Get the assetid + + # Create the printer extension + printer = Printer( + assetid=asset.assetid, + printertypeid=data.get('printertypeid'), + vendorid=data.get('vendorid'), + modelnumberid=data.get('modelnumberid'), + hostname=data.get('hostname'), + windowsname=data.get('windowsname'), + sharename=data.get('sharename'), + iscsf=data.get('iscsf', False), + installpath=data.get('installpath'), + pin=data.get('pin'), + iscolor=data.get('iscolor', False), + isduplex=data.get('isduplex', False), + isnetwork=data.get('isnetwork', True) + ) + + db.session.add(printer) + + # Create communication record if IP provided + if data.get('ipaddress'): + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if ip_comtype: + comm = Communication( + assetid=asset.assetid, + comtypeid=ip_comtype.comtypeid, + ipaddress=data['ipaddress'], + isprimary=True + ) + db.session.add(comm) + + db.session.commit() + + result = asset.to_dict() + result['printer'] = printer.to_dict() + + return success_response(result, message='Printer created', http_code=201) + + +@printers_asset_bp.route('/', methods=['PUT']) +@jwt_required() +def update_printer(printer_id: int): + """Update printer (both Asset and Printer records).""" + printer = Printer.query.get(printer_id) + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer with ID {printer_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + asset = printer.asset + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Update asset fields + asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', + 'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive'] + for key in asset_fields: + if key in data: + setattr(asset, key, data[key]) + + # Update printer fields + printer_fields = ['printertypeid', 'vendorid', 'modelnumberid', 'hostname', + 'windowsname', 'sharename', 'iscsf', 'installpath', 'pin', + 'iscolor', 'isduplex', 'isnetwork'] + for key in printer_fields: + if key in data: + setattr(printer, key, data[key]) + + db.session.commit() + + result = asset.to_dict() + result['printer'] = printer.to_dict() + + return success_response(result, message='Printer updated') + + +@printers_asset_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_printer(printer_id: int): + """Delete (soft delete) printer.""" + printer = Printer.query.get(printer_id) + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer with ID {printer_id} not found', + http_code=404 + ) + + # Soft delete the asset + printer.asset.isactive = False + db.session.commit() + + return success_response(message='Printer deleted') + + +# ============================================================================= +# Supply Levels (Zabbix Integration) +# ============================================================================= + +@printers_asset_bp.route('//supplies', methods=['GET']) +@jwt_required(optional=True) +def get_printer_supplies(printer_id: int): + """Get supply levels from Zabbix (real-time lookup).""" + printer = Printer.query.get(printer_id) + + if not printer: + return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) + + # Get IP address from communications + comm = Communication.query.filter_by( + assetid=printer.assetid, + isprimary=True + ).first() + if not comm: + comm = Communication.query.filter_by(assetid=printer.assetid).first() + + if not comm or not comm.ipaddress: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address') + + service = ZabbixService() + if not service.isconfigured: + return error_response(ErrorCodes.BAD_REQUEST, 'Zabbix not configured') + + supplies = service.getsuppliesbyip(comm.ipaddress) + + return success_response({ + 'ipaddress': comm.ipaddress, + 'supplies': supplies or [] + }) + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@printers_asset_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required() +def dashboard_summary(): + """Get printer dashboard summary data.""" + # Total active printers + total = db.session.query(Printer).join(Asset).filter( + Asset.isactive == True + ).count() + + # Count by printer type + by_type = db.session.query( + PrinterType.printertype, + db.func.count(Printer.printerid) + ).join(Printer, Printer.printertypeid == PrinterType.printertypeid + ).join(Asset, Asset.assetid == Printer.assetid + ).filter(Asset.isactive == True + ).group_by(PrinterType.printertype + ).all() + + # Count by vendor + by_vendor = db.session.query( + Vendor.vendor, + db.func.count(Printer.printerid) + ).join(Printer, Printer.vendorid == Vendor.vendorid + ).join(Asset, Asset.assetid == Printer.assetid + ).filter(Asset.isactive == True + ).group_by(Vendor.vendor + ).all() + + return success_response({ + 'total': total, + 'by_type': [{'type': t, 'count': c} for t, c in by_type], + 'by_vendor': [{'vendor': v, 'count': c} for v, c in by_vendor], + }) diff --git a/plugins/printers/models/__init__.py b/plugins/printers/models/__init__.py index a653ed7..93b2333 100644 --- a/plugins/printers/models/__init__.py +++ b/plugins/printers/models/__init__.py @@ -1,7 +1,10 @@ """Printers plugin models.""" -from .printer_extension import PrinterData +from .printer_extension import PrinterData # Legacy model for Machine-based architecture +from .printer import Printer, PrinterType # New Asset-based models __all__ = [ - 'PrinterData', + 'PrinterData', # Legacy + 'Printer', # New + 'PrinterType', # New ] diff --git a/plugins/printers/models/printer.py b/plugins/printers/models/printer.py new file mode 100644 index 0000000..c925771 --- /dev/null +++ b/plugins/printers/models/printer.py @@ -0,0 +1,122 @@ +"""Printer plugin models - new Asset-based architecture.""" + +from shopdb.extensions import db +from shopdb.core.models.base import BaseModel + + +class PrinterType(BaseModel): + """ + Printer type classification. + + Examples: Laser, Inkjet, Label, MFP, Plotter, etc. + """ + __tablename__ = 'printertypes' + + printertypeid = db.Column(db.Integer, primary_key=True) + printertype = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class Printer(BaseModel): + """ + Printer-specific extension data (new Asset architecture). + + Links to core Asset table via assetid. + Stores printer-specific fields like type, Windows name, share name, etc. + """ + __tablename__ = 'printers' + + printerid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Printer classification + printertypeid = db.Column( + db.Integer, + db.ForeignKey('printertypes.printertypeid'), + nullable=True + ) + + # Vendor + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True + ) + + # Network identity + hostname = db.Column( + db.String(100), + index=True, + comment='Network hostname' + ) + + # Windows/Network naming + windowsname = db.Column( + db.String(255), + comment='Windows printer name (e.g., \\\\server\\printer)' + ) + sharename = db.Column( + db.String(100), + comment='CSF/share name' + ) + + # Installation + iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer') + installpath = db.Column(db.String(255), comment='Driver install path') + + # Printer PIN (for secure print) + pin = db.Column(db.String(20)) + + # Features + iscolor = db.Column(db.Boolean, default=False, comment='Color capable') + isduplex = db.Column(db.Boolean, default=False, comment='Duplex capable') + isnetwork = db.Column(db.Boolean, default=True, comment='Network connected') + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('printer', uselist=False, lazy='joined') + ) + printertype = db.relationship('PrinterType', backref='printers') + vendor = db.relationship('Vendor', backref='printer_items') + model = db.relationship('Model', backref='printer_items') + + __table_args__ = ( + db.Index('idx_printer_type', 'printertypeid'), + db.Index('idx_printer_hostname', 'hostname'), + db.Index('idx_printer_windowsname', 'windowsname'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.printertype: + result['printertype_name'] = self.printertype.printertype + if self.vendor: + result['vendor_name'] = self.vendor.vendor + if self.model: + result['model_name'] = self.model.modelnumber + + return result diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py index 2332ed6..0a416d6 100644 --- a/plugins/printers/plugin.py +++ b/plugins/printers/plugin.py @@ -11,9 +11,10 @@ import click from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.extensions import db from shopdb.core.models.machine import MachineType +from shopdb.core.models import AssetType -from .models import PrinterData -from .api import printers_bp +from .models import PrinterData, Printer, PrinterType +from .api import printers_bp, printers_asset_bp from .services import ZabbixService logger = logging.getLogger(__name__) @@ -21,11 +22,15 @@ logger = logging.getLogger(__name__) class PrintersPlugin(BasePlugin): """ - Printers plugin - extends machines with printer-specific functionality. + Printers plugin - manages printer assets. - Printers use the unified Machine model with machinetype.category = 'Printer'. - This plugin adds: - - PrinterData table for printer-specific fields (windowsname, sharename, etc.) + Supports both legacy Machine-based architecture and new Asset-based architecture: + - Legacy: PrinterData table linked to machines + - New: Printer table linked to assets + + Features: + - PrinterType classification + - Windows/network naming - Zabbix integration for real-time supply level lookups """ @@ -46,7 +51,7 @@ class PrintersPlugin(BasePlugin): """Return plugin metadata.""" return PluginMeta( name=self._manifest.get('name', 'printers'), - version=self._manifest.get('version', '1.0.0'), + version=self._manifest.get('version', '2.0.0'), description=self._manifest.get( 'description', 'Printer management with Zabbix integration' @@ -58,12 +63,21 @@ class PrintersPlugin(BasePlugin): ) def get_blueprint(self) -> Optional[Blueprint]: - """Return Flask Blueprint with API routes.""" - return printers_bp + """ + Return Flask Blueprint with API routes. + + Returns the new Asset-based blueprint. + Legacy Machine-based blueprint is registered separately in init_app. + """ + return printers_asset_bp def get_models(self) -> List[Type]: """Return list of SQLAlchemy model classes.""" - return [PrinterData] + return [ + PrinterData, # Legacy Machine-based + Printer, # New Asset-based + PrinterType, # New printer type classification + ] def get_services(self) -> Dict[str, Type]: """Return plugin services.""" @@ -82,16 +96,63 @@ class PrintersPlugin(BasePlugin): """Initialize plugin with Flask app.""" app.config.setdefault('ZABBIX_URL', '') app.config.setdefault('ZABBIX_TOKEN', '') + + # Register legacy blueprint for backward compatibility + app.register_blueprint(printers_bp, url_prefix='/api/printers/legacy') + logger.info(f"Printers plugin initialized (v{self.meta.version})") def on_install(self, app: Flask) -> None: """Called when plugin is installed.""" with app.app_context(): - self._ensureprintertypes() + self._ensure_asset_type() + self._ensure_printer_types() + self._ensure_legacy_machine_types() logger.info("Printers plugin installed") - def _ensureprintertypes(self) -> None: - """Ensure basic printer machine types exist.""" + def _ensure_asset_type(self) -> None: + """Ensure printer asset type exists.""" + existing = AssetType.query.filter_by(assettype='printer').first() + if not existing: + at = AssetType( + assettype='printer', + plugin_name='printers', + table_name='printers', + description='Printers (laser, inkjet, label, MFP, plotter)', + icon='printer' + ) + db.session.add(at) + logger.debug("Created asset type: printer") + db.session.commit() + + def _ensure_printer_types(self) -> None: + """Ensure basic printer types exist (new architecture).""" + printer_types = [ + ('Laser', 'Standard laser printer', 'printer'), + ('Inkjet', 'Inkjet printer', 'printer'), + ('Label', 'Label/barcode printer', 'barcode'), + ('MFP', 'Multifunction printer with scan/copy/fax', 'printer'), + ('Plotter', 'Large format plotter', 'drafting-compass'), + ('Thermal', 'Thermal printer', 'temperature-high'), + ('Dot Matrix', 'Dot matrix printer', 'th'), + ('Other', 'Other printer type', 'printer'), + ] + + for name, description, icon in printer_types: + existing = PrinterType.query.filter_by(printertype=name).first() + if not existing: + pt = PrinterType( + printertype=name, + description=description, + icon=icon + ) + db.session.add(pt) + logger.debug(f"Created printer type: {name}") + + db.session.commit() + + def _ensure_legacy_machine_types(self) -> None: + """Ensure basic printer machine types exist (legacy architecture).""" printertypes = [ ('Laser Printer', 'Printer', 'Standard laser printer'), ('Inkjet Printer', 'Printer', 'Inkjet printer'), diff --git a/plugins/usb/__init__.py b/plugins/usb/__init__.py new file mode 100644 index 0000000..7fd2d87 --- /dev/null +++ b/plugins/usb/__init__.py @@ -0,0 +1,5 @@ +"""USB device checkout plugin.""" + +from .plugin import USBPlugin + +__all__ = ['USBPlugin'] diff --git a/plugins/usb/api/__init__.py b/plugins/usb/api/__init__.py new file mode 100644 index 0000000..0bb291c --- /dev/null +++ b/plugins/usb/api/__init__.py @@ -0,0 +1,5 @@ +"""USB plugin API.""" + +from .routes import usb_bp + +__all__ = ['usb_bp'] diff --git a/plugins/usb/api/routes.py b/plugins/usb/api/routes.py new file mode 100644 index 0000000..ef83995 --- /dev/null +++ b/plugins/usb/api/routes.py @@ -0,0 +1,275 @@ +"""USB plugin API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required +from datetime import datetime + +from shopdb.extensions import db +from shopdb.core.models import Machine, MachineType, Vendor, Model +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from ..models import USBCheckout + +usb_bp = Blueprint('usb', __name__) + + +def get_usb_machinetype_id(): + """Get the USB Device machine type ID dynamically.""" + usb_type = MachineType.query.filter( + MachineType.machinetype.ilike('%usb%') + ).first() + return usb_type.machinetypeid if usb_type else None + + +@usb_bp.route('', methods=['GET']) +@jwt_required() +def list_usb_devices(): + """ + List all USB devices with checkout status. + + Query parameters: + - page, per_page: Pagination + - search: Search by serial number or alias + - available: Filter to only available (not checked out) devices + """ + page, per_page = get_pagination_params(request) + + usb_type_id = get_usb_machinetype_id() + if not usb_type_id: + return success_response([]) # No USB type found + + # Get USB devices from machines table + query = db.session.query(Machine).filter( + Machine.machinetypeid == usb_type_id, + Machine.isactive == True + ) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Machine.serialnumber.ilike(f'%{search}%'), + Machine.alias.ilike(f'%{search}%'), + Machine.machinenumber.ilike(f'%{search}%') + ) + ) + + query = query.order_by(Machine.alias) + + items, total = paginate_query(query, page, per_page) + + # Build response with checkout status + data = [] + for device in items: + # Check if currently checked out + active_checkout = USBCheckout.query.filter_by( + machineid=device.machineid, + checkin_time=None + ).first() + + item = { + 'machineid': device.machineid, + 'machinenumber': device.machinenumber, + 'alias': device.alias, + 'serialnumber': device.serialnumber, + 'notes': device.notes, + 'vendor_name': device.vendor.vendorname if device.vendor else None, + 'model_name': device.model.modelnumber if device.model else None, + 'is_checked_out': active_checkout is not None, + 'current_checkout': active_checkout.to_dict() if active_checkout else None + } + data.append(item) + + # Filter by availability if requested + if request.args.get('available', '').lower() == 'true': + data = [d for d in data if not d['is_checked_out']] + total = len(data) + + return paginated_response(data, page, per_page, total) + + +@usb_bp.route('/', methods=['GET']) +@jwt_required() +def get_usb_device(device_id: int): + """Get a single USB device with checkout history.""" + device = Machine.query.filter_by( + machineid=device_id, + machinetypeid=get_usb_machinetype_id() + ).first() + + if not device: + return error_response( + ErrorCodes.NOT_FOUND, + f'USB device with ID {device_id} not found', + http_code=404 + ) + + # Get checkout history + checkouts = USBCheckout.query.filter_by( + machineid=device_id + ).order_by(USBCheckout.checkout_time.desc()).limit(50).all() + + # Check current checkout + active_checkout = next((c for c in checkouts if c.checkin_time is None), None) + + result = { + 'machineid': device.machineid, + 'machinenumber': device.machinenumber, + 'alias': device.alias, + 'serialnumber': device.serialnumber, + 'notes': device.notes, + 'vendor_name': device.vendor.vendorname if device.vendor else None, + 'model_name': device.model.modelnumber if device.model else None, + 'is_checked_out': active_checkout is not None, + 'current_checkout': active_checkout.to_dict() if active_checkout else None, + 'checkout_history': [c.to_dict() for c in checkouts] + } + + return success_response(result) + + +@usb_bp.route('//checkout', methods=['POST']) +@jwt_required() +def checkout_device(device_id: int): + """Check out a USB device.""" + device = Machine.query.filter_by( + machineid=device_id, + machinetypeid=get_usb_machinetype_id() + ).first() + + if not device: + return error_response( + ErrorCodes.NOT_FOUND, + f'USB device with ID {device_id} not found', + http_code=404 + ) + + # Check if already checked out + active_checkout = USBCheckout.query.filter_by( + machineid=device_id, + checkin_time=None + ).first() + + if active_checkout: + return error_response( + ErrorCodes.CONFLICT, + f'Device is already checked out by {active_checkout.sso}', + http_code=409 + ) + + data = request.get_json() or {} + + if not data.get('sso'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'sso is required') + + checkout = USBCheckout( + machineid=device_id, + sso=data['sso'], + checkout_name=data.get('name'), + checkout_reason=data.get('reason'), + checkout_time=datetime.utcnow() + ) + + db.session.add(checkout) + db.session.commit() + + return success_response(checkout.to_dict(), message='Device checked out', http_code=201) + + +@usb_bp.route('//checkin', methods=['POST']) +@jwt_required() +def checkin_device(device_id: int): + """Check in a USB device.""" + device = Machine.query.filter_by( + machineid=device_id, + machinetypeid=get_usb_machinetype_id() + ).first() + + if not device: + return error_response( + ErrorCodes.NOT_FOUND, + f'USB device with ID {device_id} not found', + http_code=404 + ) + + # Find active checkout + active_checkout = USBCheckout.query.filter_by( + machineid=device_id, + checkin_time=None + ).first() + + if not active_checkout: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Device is not currently checked out', + http_code=400 + ) + + data = request.get_json() or {} + + active_checkout.checkin_time = datetime.utcnow() + active_checkout.was_wiped = data.get('was_wiped', False) + active_checkout.checkin_notes = data.get('notes') + + db.session.commit() + + return success_response(active_checkout.to_dict(), message='Device checked in') + + +@usb_bp.route('//history', methods=['GET']) +@jwt_required() +def get_checkout_history(device_id: int): + """Get checkout history for a USB device.""" + page, per_page = get_pagination_params(request) + + query = USBCheckout.query.filter_by( + machineid=device_id + ).order_by(USBCheckout.checkout_time.desc()) + + items, total = paginate_query(query, page, per_page) + data = [c.to_dict() for c in items] + + return paginated_response(data, page, per_page, total) + + +@usb_bp.route('/checkouts', methods=['GET']) +@jwt_required() +def list_all_checkouts(): + """List all checkouts (active and historical).""" + page, per_page = get_pagination_params(request) + + query = db.session.query(USBCheckout).join( + Machine, USBCheckout.machineid == Machine.machineid + ) + + # Filter by active only + if request.args.get('active', '').lower() == 'true': + query = query.filter(USBCheckout.checkin_time == None) + + # Filter by user + if sso := request.args.get('sso'): + query = query.filter(USBCheckout.sso == sso) + + query = query.order_by(USBCheckout.checkout_time.desc()) + + items, total = paginate_query(query, page, per_page) + + # Include device info + data = [] + for checkout in items: + device = Machine.query.get(checkout.machineid) + item = checkout.to_dict() + item['device'] = { + 'machineid': device.machineid, + 'alias': device.alias, + 'serialnumber': device.serialnumber + } if device else None + data.append(item) + + return paginated_response(data, page, per_page, total) diff --git a/plugins/usb/manifest.json b/plugins/usb/manifest.json new file mode 100644 index 0000000..7a3c439 --- /dev/null +++ b/plugins/usb/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "usb", + "version": "1.0.0", + "description": "USB device checkout management", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=1.0.0", + "api_prefix": "/api/usb" +} diff --git a/plugins/usb/models/__init__.py b/plugins/usb/models/__init__.py new file mode 100644 index 0000000..8587b7f --- /dev/null +++ b/plugins/usb/models/__init__.py @@ -0,0 +1,5 @@ +"""USB plugin models.""" + +from .usb_checkout import USBCheckout + +__all__ = ['USBCheckout'] diff --git a/plugins/usb/models/usb_checkout.py b/plugins/usb/models/usb_checkout.py new file mode 100644 index 0000000..c3929f8 --- /dev/null +++ b/plugins/usb/models/usb_checkout.py @@ -0,0 +1,38 @@ +"""USB Checkout model.""" + +from shopdb.extensions import db +from datetime import datetime + + +class USBCheckout(db.Model): + """ + USB device checkout tracking. + + References machines table (USB devices have machinetypeid=44). + """ + __tablename__ = 'usbcheckouts' + + checkoutid = db.Column(db.Integer, primary_key=True) + machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid'), nullable=False, index=True) + sso = db.Column(db.String(20), nullable=False, index=True) + checkout_name = db.Column(db.String(100)) + checkout_reason = db.Column(db.Text) + checkout_time = db.Column(db.DateTime, default=datetime.utcnow, index=True) + checkin_time = db.Column(db.DateTime, index=True) + was_wiped = db.Column(db.Boolean, default=False) + checkin_notes = db.Column(db.Text) + + def to_dict(self): + """Convert to dictionary.""" + return { + 'checkoutid': self.checkoutid, + 'machineid': self.machineid, + 'sso': self.sso, + 'checkout_name': self.checkout_name, + 'checkout_reason': self.checkout_reason, + 'checkout_time': self.checkout_time.isoformat() if self.checkout_time else None, + 'checkin_time': self.checkin_time.isoformat() if self.checkin_time else None, + 'was_wiped': self.was_wiped, + 'checkin_notes': self.checkin_notes, + 'is_checked_out': self.checkin_time is None + } diff --git a/plugins/usb/models/usb_device.py b/plugins/usb/models/usb_device.py new file mode 100644 index 0000000..8f15bee --- /dev/null +++ b/plugins/usb/models/usb_device.py @@ -0,0 +1,169 @@ +"""USB device plugin models.""" + +from datetime import datetime +from shopdb.extensions import db +from shopdb.core.models.base import BaseModel, AuditMixin + + +class USBDeviceType(BaseModel): + """ + USB device type classification. + + Examples: Flash Drive, External HDD, External SSD, Card Reader + """ + __tablename__ = 'usbdevicetypes' + + usbdevicetypeid = db.Column(db.Integer, primary_key=True) + typename = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), default='usb', comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class USBDevice(BaseModel, AuditMixin): + """ + USB device model. + + Tracks USB storage devices that can be checked out by users. + """ + __tablename__ = 'usbdevices' + + usbdeviceid = db.Column(db.Integer, primary_key=True) + + # Identification + serialnumber = db.Column(db.String(100), unique=True, nullable=False) + label = db.Column(db.String(100), nullable=True, comment='Human-readable label') + assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag') + + # Classification + usbdevicetypeid = db.Column( + db.Integer, + db.ForeignKey('usbdevicetypes.usbdevicetypeid'), + nullable=True + ) + + # Specifications + capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB') + vendorid = db.Column(db.String(10), nullable=True, comment='USB Vendor ID (hex)') + productid = db.Column(db.String(10), nullable=True, comment='USB Product ID (hex)') + manufacturer = db.Column(db.String(100), nullable=True) + productname = db.Column(db.String(100), nullable=True) + + # Current status + ischeckedout = db.Column(db.Boolean, default=False) + currentuserid = db.Column(db.String(50), nullable=True, comment='SSO of current user') + currentusername = db.Column(db.String(100), nullable=True, comment='Name of current user') + currentcheckoutdate = db.Column(db.DateTime, nullable=True) + + # Location + storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out') + + # Notes + notes = db.Column(db.Text, nullable=True) + + # Relationships + devicetype = db.relationship('USBDeviceType', backref='devices') + + # Indexes + __table_args__ = ( + db.Index('idx_usb_serial', 'serialnumber'), + db.Index('idx_usb_checkedout', 'ischeckedout'), + db.Index('idx_usb_type', 'usbdevicetypeid'), + db.Index('idx_usb_currentuser', 'currentuserid'), + ) + + def __repr__(self): + return f"" + + @property + def display_name(self): + """Get display name (label if set, otherwise serial number).""" + return self.label or self.serialnumber + + def to_dict(self): + """Convert to dictionary with related data.""" + result = super().to_dict() + + # Add type info + if self.devicetype: + result['typename'] = self.devicetype.typename + result['typeicon'] = self.devicetype.icon + + # Add computed property + result['displayname'] = self.display_name + + return result + + +class USBCheckout(BaseModel): + """ + USB device checkout history. + + Tracks when devices are checked out and returned. + """ + __tablename__ = 'usbcheckouts' + + usbcheckoutid = db.Column(db.Integer, primary_key=True) + + # Device reference + usbdeviceid = db.Column( + db.Integer, + db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'), + nullable=False + ) + + # User info + userid = db.Column(db.String(50), nullable=False, comment='SSO of user') + username = db.Column(db.String(100), nullable=True, comment='Name of user') + + # Checkout details + checkoutdate = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + checkindate = db.Column(db.DateTime, nullable=True) + expectedreturndate = db.Column(db.DateTime, nullable=True) + + # Metadata + purpose = db.Column(db.String(500), nullable=True, comment='Reason for checkout') + notes = db.Column(db.Text, nullable=True) + checkedoutby = db.Column(db.String(50), nullable=True, comment='Admin who processed checkout') + checkedinby = db.Column(db.String(50), nullable=True, comment='Admin who processed checkin') + + # Relationships + device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic')) + + # Indexes + __table_args__ = ( + db.Index('idx_usbcheckout_device', 'usbdeviceid'), + db.Index('idx_usbcheckout_user', 'userid'), + db.Index('idx_usbcheckout_dates', 'checkoutdate', 'checkindate'), + ) + + def __repr__(self): + return f"" + + @property + def is_active(self): + """Check if this checkout is currently active (not returned).""" + return self.checkindate is None + + @property + def duration_days(self): + """Get duration of checkout in days.""" + end = self.checkindate or datetime.utcnow() + delta = end - self.checkoutdate + return delta.days + + def to_dict(self): + """Convert to dictionary with computed fields.""" + result = super().to_dict() + + result['isactivecheckout'] = self.is_active + result['durationdays'] = self.duration_days + + # Add device info if loaded + if self.device: + result['devicelabel'] = self.device.label + result['deviceserialnumber'] = self.device.serialnumber + + return result diff --git a/plugins/usb/plugin.py b/plugins/usb/plugin.py new file mode 100644 index 0000000..a4b64e3 --- /dev/null +++ b/plugins/usb/plugin.py @@ -0,0 +1,80 @@ +"""USB plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.extensions import db + +from .models import USBCheckout +from .api import usb_bp + +logger = logging.getLogger(__name__) + + +class USBPlugin(BasePlugin): + """ + USB plugin - manages USB device checkouts. + + USB devices are stored in the machines table (machinetypeid=44). + This plugin provides checkout/checkin tracking. + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifest_path = Path(__file__).parent / 'manifest.json' + if manifest_path.exists(): + with open(manifest_path, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'usb'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get('description', 'USB device checkout management'), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/usb'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return usb_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [USBCheckout] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"USB plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + logger.info("USB plugin installed") + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("USB plugin uninstalled") + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'USB Devices', + 'icon': 'usb', + 'route': '/usb', + 'position': 45, + }, + ] diff --git a/scripts/migration/__init__.py b/scripts/migration/__init__.py new file mode 100644 index 0000000..1d5670f --- /dev/null +++ b/scripts/migration/__init__.py @@ -0,0 +1 @@ +"""Data migration scripts for VBScript ShopDB to Flask migration.""" diff --git a/scripts/migration/fix_legacy_schema.sql b/scripts/migration/fix_legacy_schema.sql new file mode 100644 index 0000000..1ddd206 --- /dev/null +++ b/scripts/migration/fix_legacy_schema.sql @@ -0,0 +1,88 @@ +-- ============================================================================= +-- Legacy Schema Migration Script +-- Adds missing columns to make VBScript ShopDB compatible with Flask ShopDB +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- appowners table +-- ----------------------------------------------------------------------------- +ALTER TABLE appowners + ADD COLUMN IF NOT EXISTS email VARCHAR(100) NULL, + ADD COLUMN IF NOT EXISTS phone VARCHAR(50) NULL, + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- pctypes / computertypes alignment +-- ----------------------------------------------------------------------------- +-- The Flask app uses pctype table but expects certain columns +ALTER TABLE pctype + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(); + +-- Ensure isactive is correct type +ALTER TABLE pctype MODIFY COLUMN isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- statuses table (if needed - Flask uses assetstatuses) +-- ----------------------------------------------------------------------------- +-- assetstatuses already populated earlier + +-- ----------------------------------------------------------------------------- +-- subnets table +-- ----------------------------------------------------------------------------- +ALTER TABLE subnets + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- vlans table +-- ----------------------------------------------------------------------------- +ALTER TABLE vlans + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- usbdevices table +-- ----------------------------------------------------------------------------- +ALTER TABLE usbdevices + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- usbcheckouts table +-- ----------------------------------------------------------------------------- +ALTER TABLE usbcheckouts + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(); + +-- ----------------------------------------------------------------------------- +-- notifications table +-- ----------------------------------------------------------------------------- +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(); + +-- Copy dates from existing columns if they exist +UPDATE notifications SET createddate = startdate WHERE createddate IS NULL; +UPDATE notifications SET modifieddate = startdate WHERE modifieddate IS NULL; + +-- ----------------------------------------------------------------------------- +-- Verify key counts +-- ----------------------------------------------------------------------------- +SELECT 'Migration complete. Record counts:' as status; +SELECT 'vendors' as tbl, COUNT(*) as cnt FROM vendors +UNION ALL SELECT 'models', COUNT(*) FROM models +UNION ALL SELECT 'machinetypes', COUNT(*) FROM machinetypes +UNION ALL SELECT 'operatingsystems', COUNT(*) FROM operatingsystems +UNION ALL SELECT 'businessunits', COUNT(*) FROM businessunits +UNION ALL SELECT 'applications', COUNT(*) FROM applications +UNION ALL SELECT 'machines', COUNT(*) FROM machines +UNION ALL SELECT 'printers', COUNT(*) FROM printers +UNION ALL SELECT 'assets', COUNT(*) FROM assets +UNION ALL SELECT 'knowledgebase', COUNT(*) FROM knowledgebase +UNION ALL SELECT 'notifications', COUNT(*) FROM notifications; diff --git a/scripts/migration/migrate_assets.py b/scripts/migration/migrate_assets.py new file mode 100644 index 0000000..7519159 --- /dev/null +++ b/scripts/migration/migrate_assets.py @@ -0,0 +1,285 @@ +""" +Migrate machines table to assets + extension tables. + +This script migrates data from the legacy machines table to the new +Asset architecture with plugin-owned extension tables. + +Strategy: +1. Preserve IDs: assets.assetid = original machines.machineid +2. Create asset record, then type-specific extension record +3. Map machine types to asset types: + - MachineType = Equipment -> equipment extension + - MachineType = PC -> computers extension + - MachineType = Network/Camera/etc -> network_devices extension + - Printers -> handled separately by printers plugin + +Usage: + python -m scripts.migration.migrate_assets --source +""" + +import argparse +import logging +from datetime import datetime +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def get_machine_type_mapping(): + """Map legacy machine type IDs to asset types.""" + return { + # Equipment types + 'CNC': 'equipment', + 'CMM': 'equipment', + 'Lathe': 'equipment', + 'Grinder': 'equipment', + 'EDM': 'equipment', + 'Mill': 'equipment', + 'Press': 'equipment', + 'Robot': 'equipment', + 'Part Marker': 'equipment', + # PC types + 'PC': 'computer', + 'Workstation': 'computer', + 'Laptop': 'computer', + 'Server': 'computer', + # Network types + 'Switch': 'network_device', + 'Router': 'network_device', + 'Access Point': 'network_device', + 'Camera': 'network_device', + 'IDF': 'network_device', + 'MDF': 'network_device', + 'Firewall': 'network_device', + } + + +def migrate_machine_to_asset(machine_row, asset_type_id, target_session): + """ + Create an Asset record from a Machine record. + + Args: + machine_row: Row from source machines table + asset_type_id: Target asset type ID + target_session: SQLAlchemy session for target database + + Returns: + Created asset ID + """ + # Insert into assets table + target_session.execute(text(""" + INSERT INTO assets ( + assetid, assetnumber, name, serialnumber, + assettypeid, statusid, locationid, businessunitid, + mapleft, maptop, notes, isactive, createddate, modifieddate + ) VALUES ( + :assetid, :assetnumber, :name, :serialnumber, + :assettypeid, :statusid, :locationid, :businessunitid, + :mapleft, :maptop, :notes, :isactive, :createddate, :modifieddate + ) + """), { + 'assetid': machine_row['machineid'], + 'assetnumber': machine_row['machinenumber'], + 'name': machine_row.get('alias'), + 'serialnumber': machine_row.get('serialnumber'), + 'assettypeid': asset_type_id, + 'statusid': machine_row.get('statusid', 1), + 'locationid': machine_row.get('locationid'), + 'businessunitid': machine_row.get('businessunitid'), + 'mapleft': machine_row.get('mapleft'), + 'maptop': machine_row.get('maptop'), + 'notes': machine_row.get('notes'), + 'isactive': machine_row.get('isactive', True), + 'createddate': machine_row.get('createddate', datetime.utcnow()), + 'modifieddate': machine_row.get('modifieddate', datetime.utcnow()), + }) + + return machine_row['machineid'] + + +def migrate_equipment(machine_row, asset_id, target_session): + """Create equipment extension record.""" + target_session.execute(text(""" + INSERT INTO equipment ( + assetid, equipmenttypeid, vendorid, modelnumberid, + requiresmanualconfig, islocationonly, isactive, createddate + ) VALUES ( + :assetid, :equipmenttypeid, :vendorid, :modelnumberid, + :requiresmanualconfig, :islocationonly, :isactive, :createddate + ) + """), { + 'assetid': asset_id, + 'equipmenttypeid': machine_row.get('machinetypeid'), # May need mapping + 'vendorid': machine_row.get('vendorid'), + 'modelnumberid': machine_row.get('modelnumberid'), + 'requiresmanualconfig': machine_row.get('requiresmanualconfig', False), + 'islocationonly': machine_row.get('islocationonly', False), + 'isactive': True, + 'createddate': datetime.utcnow(), + }) + + +def migrate_computer(machine_row, asset_id, target_session): + """Create computer extension record.""" + target_session.execute(text(""" + INSERT INTO computers ( + assetid, computertypeid, vendorid, operatingsystemid, + hostname, currentuserid, lastuserid, lastboottime, + lastzabbixsync, isvnc, isactive, createddate + ) VALUES ( + :assetid, :computertypeid, :vendorid, :operatingsystemid, + :hostname, :currentuserid, :lastuserid, :lastboottime, + :lastzabbixsync, :isvnc, :isactive, :createddate + ) + """), { + 'assetid': asset_id, + 'computertypeid': machine_row.get('pctypeid'), + 'vendorid': machine_row.get('vendorid'), + 'operatingsystemid': machine_row.get('operatingsystemid'), + 'hostname': machine_row.get('hostname'), + 'currentuserid': machine_row.get('currentuserid'), + 'lastuserid': machine_row.get('lastuserid'), + 'lastboottime': machine_row.get('lastboottime'), + 'lastzabbixsync': machine_row.get('lastzabbixsync'), + 'isvnc': machine_row.get('isvnc', False), + 'isactive': True, + 'createddate': datetime.utcnow(), + }) + + +def migrate_network_device(machine_row, asset_id, target_session): + """Create network device extension record.""" + target_session.execute(text(""" + INSERT INTO networkdevices ( + assetid, networkdevicetypeid, vendorid, hostname, + firmwareversion, portcount, ispoe, ismanaged, + isactive, createddate + ) VALUES ( + :assetid, :networkdevicetypeid, :vendorid, :hostname, + :firmwareversion, :portcount, :ispoe, :ismanaged, + :isactive, :createddate + ) + """), { + 'assetid': asset_id, + 'networkdevicetypeid': machine_row.get('machinetypeid'), # May need mapping + 'vendorid': machine_row.get('vendorid'), + 'hostname': machine_row.get('hostname'), + 'firmwareversion': machine_row.get('firmwareversion'), + 'portcount': machine_row.get('portcount'), + 'ispoe': machine_row.get('ispoe', False), + 'ismanaged': machine_row.get('ismanaged', False), + 'isactive': True, + 'createddate': datetime.utcnow(), + }) + + +def run_migration(source_conn_str, target_conn_str, dry_run=False): + """ + Run the full migration from machines to assets. + + Args: + source_conn_str: Connection string for source (VBScript) database + target_conn_str: Connection string for target (Flask) database + dry_run: If True, don't commit changes + """ + source_engine = create_engine(source_conn_str) + target_engine = create_engine(target_conn_str) + + SourceSession = sessionmaker(bind=source_engine) + TargetSession = sessionmaker(bind=target_engine) + + source_session = SourceSession() + target_session = TargetSession() + + try: + # Get asset type mappings from target database + asset_types = {} + result = target_session.execute(text("SELECT assettypeid, assettype FROM assettypes")) + for row in result: + asset_types[row.assettype] = row.assettypeid + + # Get machine type to asset type mapping + type_mapping = get_machine_type_mapping() + + # Fetch all machines from source + machines = source_session.execute(text(""" + SELECT m.*, mt.machinetype + FROM machines m + LEFT JOIN machinetypes mt ON m.machinetypeid = mt.machinetypeid + """)) + + migrated = 0 + errors = 0 + + for machine in machines: + machine_dict = dict(machine._mapping) + + try: + # Determine asset type + machine_type_name = machine_dict.get('machinetype', '') + asset_type_name = type_mapping.get(machine_type_name, 'equipment') + asset_type_id = asset_types.get(asset_type_name) + + if not asset_type_id: + logger.warning(f"Unknown asset type for machine {machine_dict['machineid']}: {machine_type_name}") + errors += 1 + continue + + # Create asset record + asset_id = migrate_machine_to_asset(machine_dict, asset_type_id, target_session) + + # Create extension record based on type + if asset_type_name == 'equipment': + migrate_equipment(machine_dict, asset_id, target_session) + elif asset_type_name == 'computer': + migrate_computer(machine_dict, asset_id, target_session) + elif asset_type_name == 'network_device': + migrate_network_device(machine_dict, asset_id, target_session) + + migrated += 1 + + if migrated % 100 == 0: + logger.info(f"Migrated {migrated} machines...") + + except Exception as e: + logger.error(f"Error migrating machine {machine_dict.get('machineid')}: {e}") + errors += 1 + + if dry_run: + logger.info("Dry run - rolling back changes") + target_session.rollback() + else: + target_session.commit() + + logger.info(f"Migration complete: {migrated} migrated, {errors} errors") + + finally: + source_session.close() + target_session.close() + + +def main(): + parser = argparse.ArgumentParser(description='Migrate machines to assets') + parser.add_argument('--source', required=True, help='Source database connection string') + parser.add_argument('--target', help='Target database connection string (default: app config)') + parser.add_argument('--dry-run', action='store_true', help='Dry run without committing') + + args = parser.parse_args() + + target = args.target + if not target: + # Load from Flask config + import os + import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + from shopdb import create_app + app = create_app() + target = app.config['SQLALCHEMY_DATABASE_URI'] + + run_migration(args.source, target, args.dry_run) + + +if __name__ == '__main__': + main() diff --git a/scripts/migration/migrate_communications.py b/scripts/migration/migrate_communications.py new file mode 100644 index 0000000..b2f300a --- /dev/null +++ b/scripts/migration/migrate_communications.py @@ -0,0 +1,113 @@ +""" +Migrate communications table to use assetid instead of machineid. + +This script updates the communications table FK from machineid to assetid. +Since assetid matches the original machineid, this is mostly a schema update. + +Usage: + python -m scripts.migration.migrate_communications +""" + +import logging +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def run_migration(conn_str, dry_run=False): + """ + Update communications to use assetid. + + Args: + conn_str: Database connection string + dry_run: If True, don't commit changes + """ + engine = create_engine(conn_str) + Session = sessionmaker(bind=engine) + session = Session() + + try: + # Check if assetid column already exists + result = session.execute(text(""" + SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = 'communications' AND column_name = 'assetid' + """)) + has_assetid = result.scalar() > 0 + + if not has_assetid: + logger.info("Adding assetid column to communications table...") + + # Add assetid column + session.execute(text(""" + ALTER TABLE communications + ADD COLUMN assetid INT NULL + """)) + + # Copy machineid values to assetid + session.execute(text(""" + UPDATE communications + SET assetid = machineid + WHERE machineid IS NOT NULL + """)) + + # Add FK constraint (optional, depends on DB) + try: + session.execute(text(""" + ALTER TABLE communications + ADD CONSTRAINT fk_comm_asset + FOREIGN KEY (assetid) REFERENCES assets(assetid) + """)) + except Exception as e: + logger.warning(f"Could not add FK constraint: {e}") + + logger.info("assetid column added and populated") + else: + logger.info("assetid column already exists") + + # Count records + result = session.execute(text(""" + SELECT COUNT(*) FROM communications WHERE assetid IS NOT NULL + """)) + count = result.scalar() + logger.info(f"Communications with assetid: {count}") + + if dry_run: + logger.info("Dry run - rolling back changes") + session.rollback() + else: + session.commit() + logger.info("Migration complete") + + except Exception as e: + logger.error(f"Migration error: {e}") + session.rollback() + raise + finally: + session.close() + + +def main(): + import argparse + import os + import sys + + parser = argparse.ArgumentParser(description='Migrate communications to use assetid') + parser.add_argument('--connection', help='Database connection string') + parser.add_argument('--dry-run', action='store_true', help='Dry run without committing') + + args = parser.parse_args() + + conn_str = args.connection + if not conn_str: + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + from shopdb import create_app + app = create_app() + conn_str = app.config['SQLALCHEMY_DATABASE_URI'] + + run_migration(conn_str, args.dry_run) + + +if __name__ == '__main__': + main() diff --git a/scripts/migration/migrate_notifications.py b/scripts/migration/migrate_notifications.py new file mode 100644 index 0000000..859e24e --- /dev/null +++ b/scripts/migration/migrate_notifications.py @@ -0,0 +1,139 @@ +""" +Migrate notifications from legacy database. + +This script migrates notification data from the VBScript database +to the new notifications plugin schema. + +Usage: + python -m scripts.migration.migrate_notifications --source +""" + +import argparse +import logging +from datetime import datetime +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def get_notification_type_mapping(target_session): + """Get mapping of type names to IDs in target database.""" + result = target_session.execute(text( + "SELECT notificationtypeid, typename FROM notificationtypes" + )) + return {row.typename.lower(): row.notificationtypeid for row in result} + + +def run_migration(source_conn_str, target_conn_str, dry_run=False): + """ + Run notification migration. + + Args: + source_conn_str: Source database connection string + target_conn_str: Target database connection string + dry_run: If True, don't commit changes + """ + source_engine = create_engine(source_conn_str) + target_engine = create_engine(target_conn_str) + + SourceSession = sessionmaker(bind=source_engine) + TargetSession = sessionmaker(bind=target_engine) + + source_session = SourceSession() + target_session = TargetSession() + + try: + # Get type mappings + type_mapping = get_notification_type_mapping(target_session) + + # Default type if not found + default_type_id = type_mapping.get('general', 1) + + # Fetch notifications from source + # Adjust column names based on actual legacy schema + notifications = source_session.execute(text(""" + SELECT n.*, nt.typename + FROM notifications n + LEFT JOIN notificationtypes nt ON n.notificationtypeid = nt.notificationtypeid + """)) + + migrated = 0 + errors = 0 + + for notif in notifications: + notif_dict = dict(notif._mapping) + + try: + # Map notification type + type_name = (notif_dict.get('typename') or 'general').lower() + type_id = type_mapping.get(type_name, default_type_id) + + # Insert into target + target_session.execute(text(""" + INSERT INTO notifications ( + title, message, notificationtypeid, + startdate, enddate, ispinned, showbanner, allday, + linkurl, affectedsystems, isactive, createddate + ) VALUES ( + :title, :message, :notificationtypeid, + :startdate, :enddate, :ispinned, :showbanner, :allday, + :linkurl, :affectedsystems, :isactive, :createddate + ) + """), { + 'title': notif_dict.get('title', 'Untitled'), + 'message': notif_dict.get('message', ''), + 'notificationtypeid': type_id, + 'startdate': notif_dict.get('startdate', datetime.utcnow()), + 'enddate': notif_dict.get('enddate'), + 'ispinned': notif_dict.get('ispinned', False), + 'showbanner': notif_dict.get('showbanner', True), + 'allday': notif_dict.get('allday', True), + 'linkurl': notif_dict.get('linkurl'), + 'affectedsystems': notif_dict.get('affectedsystems'), + 'isactive': notif_dict.get('isactive', True), + 'createddate': notif_dict.get('createddate', datetime.utcnow()), + }) + + migrated += 1 + + except Exception as e: + logger.error(f"Error migrating notification: {e}") + errors += 1 + + if dry_run: + logger.info("Dry run - rolling back changes") + target_session.rollback() + else: + target_session.commit() + + logger.info(f"Migration complete: {migrated} migrated, {errors} errors") + + finally: + source_session.close() + target_session.close() + + +def main(): + parser = argparse.ArgumentParser(description='Migrate notifications') + parser.add_argument('--source', required=True, help='Source database connection string') + parser.add_argument('--target', help='Target database connection string') + parser.add_argument('--dry-run', action='store_true', help='Dry run without committing') + + args = parser.parse_args() + + target = args.target + if not target: + import os + import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + from shopdb import create_app + app = create_app() + target = app.config['SQLALCHEMY_DATABASE_URI'] + + run_migration(args.source, target, args.dry_run) + + +if __name__ == '__main__': + main() diff --git a/scripts/migration/migrate_usb.py b/scripts/migration/migrate_usb.py new file mode 100644 index 0000000..d431ab7 --- /dev/null +++ b/scripts/migration/migrate_usb.py @@ -0,0 +1,199 @@ +""" +Migrate USB checkout data from legacy database. + +This script migrates USB device and checkout data from the VBScript database +to the new USB plugin schema. + +Usage: + python -m scripts.migration.migrate_usb --source +""" + +import argparse +import logging +from datetime import datetime +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def get_device_type_mapping(target_session): + """Get mapping of type names to IDs in target database.""" + result = target_session.execute(text( + "SELECT usbdevicetypeid, typename FROM usbdevicetypes" + )) + return {row.typename.lower(): row.usbdevicetypeid for row in result} + + +def run_migration(source_conn_str, target_conn_str, dry_run=False): + """ + Run USB device migration. + + Args: + source_conn_str: Source database connection string + target_conn_str: Target database connection string + dry_run: If True, don't commit changes + """ + source_engine = create_engine(source_conn_str) + target_engine = create_engine(target_conn_str) + + SourceSession = sessionmaker(bind=source_engine) + TargetSession = sessionmaker(bind=target_engine) + + source_session = SourceSession() + target_session = TargetSession() + + try: + # Get type mappings + type_mapping = get_device_type_mapping(target_session) + default_type_id = type_mapping.get('flash drive', 1) + + # Migrate USB devices + # Adjust table/column names based on actual legacy schema + logger.info("Migrating USB devices...") + + try: + devices = source_session.execute(text(""" + SELECT * FROM usbdevices + """)) + + device_id_map = {} # Map old IDs to new IDs + + for device in devices: + device_dict = dict(device._mapping) + + # Determine device type + type_name = (device_dict.get('typename') or 'flash drive').lower() + type_id = type_mapping.get(type_name, default_type_id) + + result = target_session.execute(text(""" + INSERT INTO usbdevices ( + serialnumber, label, assetnumber, usbdevicetypeid, + capacitygb, vendorid, productid, manufacturer, productname, + ischeckedout, currentuserid, currentusername, + storagelocation, notes, isactive, createddate + ) VALUES ( + :serialnumber, :label, :assetnumber, :usbdevicetypeid, + :capacitygb, :vendorid, :productid, :manufacturer, :productname, + :ischeckedout, :currentuserid, :currentusername, + :storagelocation, :notes, :isactive, :createddate + ) + """), { + 'serialnumber': device_dict.get('serialnumber', f"UNKNOWN_{device_dict.get('usbdeviceid', 0)}"), + 'label': device_dict.get('label'), + 'assetnumber': device_dict.get('assetnumber'), + 'usbdevicetypeid': type_id, + 'capacitygb': device_dict.get('capacitygb'), + 'vendorid': device_dict.get('vendorid'), + 'productid': device_dict.get('productid'), + 'manufacturer': device_dict.get('manufacturer'), + 'productname': device_dict.get('productname'), + 'ischeckedout': device_dict.get('ischeckedout', False), + 'currentuserid': device_dict.get('currentuserid'), + 'currentusername': device_dict.get('currentusername'), + 'storagelocation': device_dict.get('storagelocation'), + 'notes': device_dict.get('notes'), + 'isactive': device_dict.get('isactive', True), + 'createddate': device_dict.get('createddate', datetime.utcnow()), + }) + + # Get the new ID + new_id = target_session.execute(text("SELECT LAST_INSERT_ID()")).scalar() + device_id_map[device_dict.get('usbdeviceid')] = new_id + + logger.info(f"Migrated {len(device_id_map)} USB devices") + + except Exception as e: + logger.warning(f"Could not migrate USB devices: {e}") + device_id_map = {} + + # Migrate checkout history + logger.info("Migrating USB checkout history...") + + try: + checkouts = source_session.execute(text(""" + SELECT * FROM usbcheckouts + """)) + + checkout_count = 0 + + for checkout in checkouts: + checkout_dict = dict(checkout._mapping) + + old_device_id = checkout_dict.get('usbdeviceid') + new_device_id = device_id_map.get(old_device_id) + + if not new_device_id: + logger.warning(f"Skipping checkout - device ID {old_device_id} not found in mapping") + continue + + target_session.execute(text(""" + INSERT INTO usbcheckouts ( + usbdeviceid, userid, username, + checkoutdate, checkindate, expectedreturndate, + purpose, notes, checkedoutby, checkedinby, + isactive, createddate + ) VALUES ( + :usbdeviceid, :userid, :username, + :checkoutdate, :checkindate, :expectedreturndate, + :purpose, :notes, :checkedoutby, :checkedinby, + :isactive, :createddate + ) + """), { + 'usbdeviceid': new_device_id, + 'userid': checkout_dict.get('userid', 'unknown'), + 'username': checkout_dict.get('username'), + 'checkoutdate': checkout_dict.get('checkoutdate', datetime.utcnow()), + 'checkindate': checkout_dict.get('checkindate'), + 'expectedreturndate': checkout_dict.get('expectedreturndate'), + 'purpose': checkout_dict.get('purpose'), + 'notes': checkout_dict.get('notes'), + 'checkedoutby': checkout_dict.get('checkedoutby'), + 'checkedinby': checkout_dict.get('checkedinby'), + 'isactive': True, + 'createddate': checkout_dict.get('createddate', datetime.utcnow()), + }) + + checkout_count += 1 + + logger.info(f"Migrated {checkout_count} checkout records") + + except Exception as e: + logger.warning(f"Could not migrate USB checkouts: {e}") + + if dry_run: + logger.info("Dry run - rolling back changes") + target_session.rollback() + else: + target_session.commit() + + logger.info("USB migration complete") + + finally: + source_session.close() + target_session.close() + + +def main(): + parser = argparse.ArgumentParser(description='Migrate USB devices and checkouts') + parser.add_argument('--source', required=True, help='Source database connection string') + parser.add_argument('--target', help='Target database connection string') + parser.add_argument('--dry-run', action='store_true', help='Dry run without committing') + + args = parser.parse_args() + + target = args.target + if not target: + import os + import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + from shopdb import create_app + app = create_app() + target = app.config['SQLALCHEMY_DATABASE_URI'] + + run_migration(args.source, target, args.dry_run) + + +if __name__ == '__main__': + main() diff --git a/scripts/migration/run_migration.py b/scripts/migration/run_migration.py new file mode 100644 index 0000000..6da196e --- /dev/null +++ b/scripts/migration/run_migration.py @@ -0,0 +1,139 @@ +""" +Migration orchestrator - runs all migration steps in order. + +This script coordinates the full migration from VBScript ShopDB to Flask. + +Usage: + python -m scripts.migration.run_migration --source +""" + +import argparse +import logging +import sys +from datetime import datetime + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +def run_full_migration(source_conn_str, target_conn_str, dry_run=False, steps=None): + """ + Run the full migration process. + + Args: + source_conn_str: Source database connection string + target_conn_str: Target database connection string + dry_run: If True, don't commit changes + steps: List of specific steps to run, or None for all + """ + from . import migrate_assets + from . import migrate_communications + from . import migrate_notifications + from . import migrate_usb + from . import verify_migration + + all_steps = [ + ('assets', 'Migrate machines to assets', migrate_assets.run_migration), + ('communications', 'Update communications FKs', migrate_communications.run_migration), + ('notifications', 'Migrate notifications', migrate_notifications.run_migration), + ('usb', 'Migrate USB devices', migrate_usb.run_migration), + ] + + logger.info("=" * 60) + logger.info("SHOPDB MIGRATION") + logger.info(f"Started: {datetime.utcnow().isoformat()}") + logger.info(f"Dry Run: {dry_run}") + logger.info("=" * 60) + + results = {} + + for step_name, description, migration_func in all_steps: + if steps and step_name not in steps: + logger.info(f"\nSkipping: {description}") + continue + + logger.info(f"\n{'=' * 40}") + logger.info(f"Step: {description}") + logger.info('=' * 40) + + try: + # Different migrations have different signatures + if step_name == 'communications': + migration_func(target_conn_str, dry_run) + else: + migration_func(source_conn_str, target_conn_str, dry_run) + + results[step_name] = 'SUCCESS' + logger.info(f"Step completed: {step_name}") + + except Exception as e: + results[step_name] = f'FAILED: {e}' + logger.error(f"Step failed: {step_name} - {e}") + + # Ask to continue + if not dry_run: + response = input("Continue with next step? (y/n): ") + if response.lower() != 'y': + logger.info("Migration aborted by user") + break + + # Run verification + logger.info(f"\n{'=' * 40}") + logger.info("Running verification...") + logger.info('=' * 40) + + try: + verify_migration.run_verification(source_conn_str, target_conn_str) + results['verification'] = 'SUCCESS' + except Exception as e: + results['verification'] = f'FAILED: {e}' + logger.error(f"Verification failed: {e}") + + # Summary + logger.info("\n" + "=" * 60) + logger.info("MIGRATION SUMMARY") + logger.info("=" * 60) + + for step, result in results.items(): + status = "OK" if result == 'SUCCESS' else "FAILED" + logger.info(f" {step}: {status}") + if result != 'SUCCESS': + logger.info(f" {result}") + + logger.info("=" * 60) + logger.info(f"Completed: {datetime.utcnow().isoformat()}") + + return results + + +def main(): + parser = argparse.ArgumentParser(description='Run full ShopDB migration') + parser.add_argument('--source', required=True, help='Source database connection string') + parser.add_argument('--target', help='Target database connection string') + parser.add_argument('--dry-run', action='store_true', help='Dry run without committing') + parser.add_argument('--steps', nargs='+', + choices=['assets', 'communications', 'notifications', 'usb'], + help='Specific steps to run') + + args = parser.parse_args() + + target = args.target + if not target: + import os + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + from shopdb import create_app + app = create_app() + target = app.config['SQLALCHEMY_DATABASE_URI'] + + results = run_full_migration(args.source, target, args.dry_run, args.steps) + + # Exit with error if any step failed + if any(r != 'SUCCESS' for r in results.values()): + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/scripts/migration/verify_migration.py b/scripts/migration/verify_migration.py new file mode 100644 index 0000000..6e868cf --- /dev/null +++ b/scripts/migration/verify_migration.py @@ -0,0 +1,174 @@ +""" +Verify data migration integrity. + +This script compares record counts between source and target databases +and performs spot checks on data integrity. + +Usage: + python -m scripts.migration.verify_migration --source +""" + +import argparse +import logging +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def verify_counts(source_session, target_session): + """Compare record counts between source and target.""" + results = {} + + # Define table mappings (source -> target) + table_mappings = [ + ('machines', 'assets', 'Machine to Asset'), + ('communications', 'communications', 'Communications'), + ('vendors', 'vendors', 'Vendors'), + ('locations', 'locations', 'Locations'), + ('businessunits', 'businessunits', 'Business Units'), + ] + + for source_table, target_table, description in table_mappings: + try: + source_count = source_session.execute(text(f"SELECT COUNT(*) FROM {source_table}")).scalar() + except Exception as e: + source_count = f"Error: {e}" + + try: + target_count = target_session.execute(text(f"SELECT COUNT(*) FROM {target_table}")).scalar() + except Exception as e: + target_count = f"Error: {e}" + + match = source_count == target_count if isinstance(source_count, int) and isinstance(target_count, int) else False + + results[description] = { + 'source': source_count, + 'target': target_count, + 'match': match + } + + return results + + +def verify_sample_records(source_session, target_session, sample_size=10): + """Spot-check sample records for data integrity.""" + issues = [] + + # Sample machine -> asset migration + try: + sample_machines = source_session.execute(text(f""" + SELECT machineid, machinenumber, serialnumber, alias + FROM machines + ORDER BY RAND() + LIMIT {sample_size} + """)) + + for machine in sample_machines: + machine_dict = dict(machine._mapping) + + # Check if asset exists with same ID + asset = target_session.execute(text(""" + SELECT assetid, assetnumber, serialnumber, name + FROM assets + WHERE assetid = :assetid + """), {'assetid': machine_dict['machineid']}).fetchone() + + if not asset: + issues.append(f"Machine {machine_dict['machineid']} not found in assets") + continue + + asset_dict = dict(asset._mapping) + + # Verify data matches + if machine_dict['machinenumber'] != asset_dict['assetnumber']: + issues.append(f"Asset {asset_dict['assetid']}: machinenumber mismatch") + if machine_dict.get('serialnumber') != asset_dict.get('serialnumber'): + issues.append(f"Asset {asset_dict['assetid']}: serialnumber mismatch") + + except Exception as e: + issues.append(f"Could not verify machines: {e}") + + return issues + + +def run_verification(source_conn_str, target_conn_str): + """ + Run migration verification. + + Args: + source_conn_str: Source database connection string + target_conn_str: Target database connection string + """ + source_engine = create_engine(source_conn_str) + target_engine = create_engine(target_conn_str) + + SourceSession = sessionmaker(bind=source_engine) + TargetSession = sessionmaker(bind=target_engine) + + source_session = SourceSession() + target_session = TargetSession() + + try: + logger.info("=" * 60) + logger.info("MIGRATION VERIFICATION REPORT") + logger.info("=" * 60) + + # Verify counts + logger.info("\nRecord Count Comparison:") + logger.info("-" * 40) + counts = verify_counts(source_session, target_session) + + all_match = True + for table, result in counts.items(): + status = "OK" if result['match'] else "MISMATCH" + if not result['match']: + all_match = False + logger.info(f" {table}: Source={result['source']}, Target={result['target']} [{status}]") + + # Verify sample records + logger.info("\nSample Record Verification:") + logger.info("-" * 40) + issues = verify_sample_records(source_session, target_session) + + if issues: + for issue in issues: + logger.warning(f" ! {issue}") + else: + logger.info(" All sample records verified OK") + + # Summary + logger.info("\n" + "=" * 60) + if all_match and not issues: + logger.info("VERIFICATION PASSED - Migration looks good!") + else: + logger.warning("VERIFICATION FOUND ISSUES - Review above") + logger.info("=" * 60) + + finally: + source_session.close() + target_session.close() + + +def main(): + parser = argparse.ArgumentParser(description='Verify migration integrity') + parser.add_argument('--source', required=True, help='Source database connection string') + parser.add_argument('--target', help='Target database connection string') + + args = parser.parse_args() + + target = args.target + if not target: + import os + import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + from shopdb import create_app + app = create_app() + target = app.config['SQLALCHEMY_DATABASE_URI'] + + run_verification(args.source, target) + + +if __name__ == '__main__': + main() diff --git a/shopdb/__init__.py b/shopdb/__init__.py index f066481..4ddb594 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -69,6 +69,7 @@ def register_blueprints(app: Flask): """Register core API blueprints.""" from .core.api import ( auth_bp, + assets_bp, machines_bp, machinetypes_bp, pctypes_bp, @@ -82,11 +83,16 @@ def register_blueprints(app: Flask): applications_bp, knowledgebase_bp, search_bp, + reports_bp, + collector_bp, + employees_bp, + slides_bp, ) api_prefix = '/api' app.register_blueprint(auth_bp, url_prefix=f'{api_prefix}/auth') + app.register_blueprint(assets_bp, url_prefix=f'{api_prefix}/assets') app.register_blueprint(machines_bp, url_prefix=f'{api_prefix}/machines') app.register_blueprint(machinetypes_bp, url_prefix=f'{api_prefix}/machinetypes') app.register_blueprint(pctypes_bp, url_prefix=f'{api_prefix}/pctypes') @@ -100,6 +106,10 @@ def register_blueprints(app: Flask): app.register_blueprint(applications_bp, url_prefix=f'{api_prefix}/applications') app.register_blueprint(knowledgebase_bp, url_prefix=f'{api_prefix}/knowledgebase') app.register_blueprint(search_bp, url_prefix=f'{api_prefix}/search') + app.register_blueprint(reports_bp, url_prefix=f'{api_prefix}/reports') + 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(slides_bp, url_prefix=f'{api_prefix}/slides') def register_cli_commands(app: Flask): diff --git a/shopdb/core/api/__init__.py b/shopdb/core/api/__init__.py index 0678558..3fc1e59 100644 --- a/shopdb/core/api/__init__.py +++ b/shopdb/core/api/__init__.py @@ -1,6 +1,7 @@ """Core API blueprints.""" from .auth import auth_bp +from .assets import assets_bp from .machines import machines_bp from .machinetypes import machinetypes_bp from .pctypes import pctypes_bp @@ -14,9 +15,14 @@ from .dashboard import dashboard_bp from .applications import applications_bp from .knowledgebase import knowledgebase_bp from .search import search_bp +from .reports import reports_bp +from .collector import collector_bp +from .employees import employees_bp +from .slides import slides_bp __all__ = [ 'auth_bp', + 'assets_bp', 'machines_bp', 'machinetypes_bp', 'pctypes_bp', @@ -30,4 +36,8 @@ __all__ = [ 'applications_bp', 'knowledgebase_bp', 'search_bp', + 'reports_bp', + 'collector_bp', + 'employees_bp', + 'slides_bp', ] diff --git a/shopdb/core/api/assets.py b/shopdb/core/api/assets.py new file mode 100644 index 0000000..fb9c5d3 --- /dev/null +++ b/shopdb/core/api/assets.py @@ -0,0 +1,659 @@ +"""Assets API endpoints - unified asset queries.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType, AssetStatus, AssetRelationship, RelationshipType +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +assets_bp = Blueprint('assets', __name__) + + +# ============================================================================= +# Asset Types +# ============================================================================= + +@assets_bp.route('/types', methods=['GET']) +@jwt_required() +def list_asset_types(): + """List all asset types.""" + page, per_page = get_pagination_params(request) + + query = AssetType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(AssetType.isactive == True) + + query = query.order_by(AssetType.assettype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@assets_bp.route('/types/', methods=['GET']) +@jwt_required() +def get_asset_type(type_id: int): + """Get a single asset type.""" + t = AssetType.query.get(type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@assets_bp.route('/types', methods=['POST']) +@jwt_required() +def create_asset_type(): + """Create a new asset type.""" + data = request.get_json() + + if not data or not data.get('assettype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assettype is required') + + if AssetType.query.filter_by(assettype=data['assettype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset type '{data['assettype']}' already exists", + http_code=409 + ) + + t = AssetType( + assettype=data['assettype'], + plugin_name=data.get('plugin_name'), + table_name=data.get('table_name'), + description=data.get('description'), + icon=data.get('icon') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Asset type created', http_code=201) + + +# ============================================================================= +# Asset Statuses +# ============================================================================= + +@assets_bp.route('/statuses', methods=['GET']) +@jwt_required() +def list_asset_statuses(): + """List all asset statuses.""" + page, per_page = get_pagination_params(request) + + query = AssetStatus.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(AssetStatus.isactive == True) + + query = query.order_by(AssetStatus.status) + + items, total = paginate_query(query, page, per_page) + data = [s.to_dict() for s in items] + + return paginated_response(data, page, per_page, total) + + +@assets_bp.route('/statuses/', methods=['GET']) +@jwt_required() +def get_asset_status(status_id: int): + """Get a single asset status.""" + s = AssetStatus.query.get(status_id) + + if not s: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset status with ID {status_id} not found', + http_code=404 + ) + + return success_response(s.to_dict()) + + +@assets_bp.route('/statuses', methods=['POST']) +@jwt_required() +def create_asset_status(): + """Create a new asset status.""" + data = request.get_json() + + if not data or not data.get('status'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'status is required') + + if AssetStatus.query.filter_by(status=data['status']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset status '{data['status']}' already exists", + http_code=409 + ) + + s = AssetStatus( + status=data['status'], + description=data.get('description'), + color=data.get('color') + ) + + db.session.add(s) + db.session.commit() + + return success_response(s.to_dict(), message='Asset status created', http_code=201) + + +# ============================================================================= +# Assets +# ============================================================================= + +@assets_bp.route('', methods=['GET']) +@jwt_required() +def list_assets(): + """ + List all assets with filtering and pagination. + + Query parameters: + - page: Page number (default: 1) + - per_page: Items per page (default: 20, max: 100) + - active: Filter by active status (default: true) + - search: Search by assetnumber or name + - type: Filter by asset type name (e.g., 'equipment', 'computer') + - type_id: Filter by asset type ID + - status_id: Filter by status ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + - include_type_data: Include category-specific extension data (default: false) + """ + page, per_page = get_pagination_params(request) + + query = Asset.query + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%') + ) + ) + + # Type filter by name + if type_name := request.args.get('type'): + query = query.join(AssetType).filter(AssetType.assettype == type_name) + + # Type filter by ID + if type_id := request.args.get('type_id'): + query = query.filter(Asset.assettypeid == int(type_id)) + + # Status filter + if status_id := request.args.get('status_id'): + query = query.filter(Asset.statusid == int(status_id)) + + # Location filter + if location_id := request.args.get('location_id'): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunit_id'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Sorting + sort_by = request.args.get('sort', 'assetnumber') + sort_dir = request.args.get('dir', 'asc') + + sort_columns = { + 'assetnumber': Asset.assetnumber, + 'name': Asset.name, + 'createddate': Asset.createddate, + 'modifieddate': Asset.modifieddate, + } + + if sort_by in sort_columns: + col = sort_columns[sort_by] + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + else: + query = query.order_by(Asset.assetnumber) + + items, total = paginate_query(query, page, per_page) + + # Include type data if requested + include_type_data = request.args.get('include_type_data', 'false').lower() == 'true' + data = [a.to_dict(include_type_data=include_type_data) for a in items] + + return paginated_response(data, page, per_page, total) + + +@assets_bp.route('/', methods=['GET']) +@jwt_required() +def get_asset(asset_id: int): + """ + Get a single asset with full details. + + Query parameters: + - include_type_data: Include category-specific extension data (default: true) + """ + asset = Asset.query.get(asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + include_type_data = request.args.get('include_type_data', 'true').lower() != 'false' + return success_response(asset.to_dict(include_type_data=include_type_data)) + + +@assets_bp.route('', methods=['POST']) +@jwt_required() +def create_asset(): + """Create a new asset.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate required fields + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + if not data.get('assettypeid'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assettypeid is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Validate foreign keys exist + if not AssetType.query.get(data['assettypeid']): + return error_response( + ErrorCodes.VALIDATION_ERROR, + f"Asset type with ID {data['assettypeid']} not found" + ) + + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + assettypeid=data['assettypeid'], + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapleft=data.get('mapleft'), + maptop=data.get('maptop'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.commit() + + return success_response(asset.to_dict(), message='Asset created', http_code=201) + + +@assets_bp.route('/', methods=['PUT']) +@jwt_required() +def update_asset(asset_id: int): + """Update an asset.""" + asset = Asset.query.get(asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Update allowed fields + allowed_fields = [ + 'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid', + 'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive' + ] + + for key in allowed_fields: + if key in data: + setattr(asset, key, data[key]) + + db.session.commit() + return success_response(asset.to_dict(), message='Asset updated') + + +@assets_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_asset(asset_id: int): + """Delete (soft delete) an asset.""" + asset = Asset.query.get(asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + asset.isactive = False + db.session.commit() + + return success_response(message='Asset deleted') + + +@assets_bp.route('/lookup/', methods=['GET']) +@jwt_required() +def lookup_asset_by_number(assetnumber: str): + """ + Look up an asset by its asset number. + + Useful for finding the asset ID when you only have the machine/asset number. + """ + asset = Asset.query.filter_by(assetnumber=assetnumber, isactive=True).first() + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with number {assetnumber} not found', + http_code=404 + ) + + return success_response(asset.to_dict(include_type_data=True)) + + +# ============================================================================= +# Asset Relationships +# ============================================================================= + +@assets_bp.route('//relationships', methods=['GET']) +@jwt_required() +def get_asset_relationships(asset_id: int): + """ + Get all relationships for an asset. + + Returns both outgoing (source) and incoming (target) relationships. + """ + asset = Asset.query.get(asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + # Get outgoing relationships (this asset is source) + outgoing = AssetRelationship.query.filter_by( + source_assetid=asset_id + ).filter(AssetRelationship.isactive == True).all() + + # Get incoming relationships (this asset is target) + incoming = AssetRelationship.query.filter_by( + target_assetid=asset_id + ).filter(AssetRelationship.isactive == True).all() + + outgoing_data = [] + for rel in outgoing: + r = rel.to_dict() + r['target_asset'] = rel.target_asset.to_dict() if rel.target_asset else None + r['relationship_type_name'] = rel.relationship_type.relationshiptype if rel.relationship_type else None + outgoing_data.append(r) + + incoming_data = [] + for rel in incoming: + r = rel.to_dict() + r['source_asset'] = rel.source_asset.to_dict() if rel.source_asset else None + r['relationship_type_name'] = rel.relationship_type.relationshiptype if rel.relationship_type else None + incoming_data.append(r) + + return success_response({ + 'outgoing': outgoing_data, + 'incoming': incoming_data + }) + + +@assets_bp.route('/relationships', methods=['POST']) +@jwt_required() +def create_asset_relationship(): + """Create a relationship between two assets.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate required fields + required = ['source_assetid', 'target_assetid', 'relationshiptypeid'] + for field in required: + if not data.get(field): + return error_response(ErrorCodes.VALIDATION_ERROR, f'{field} is required') + + source_id = data['source_assetid'] + target_id = data['target_assetid'] + type_id = data['relationshiptypeid'] + + # Validate assets exist + if not Asset.query.get(source_id): + return error_response(ErrorCodes.NOT_FOUND, f'Source asset {source_id} not found', http_code=404) + if not Asset.query.get(target_id): + return error_response(ErrorCodes.NOT_FOUND, f'Target asset {target_id} not found', http_code=404) + if not RelationshipType.query.get(type_id): + return error_response(ErrorCodes.NOT_FOUND, f'Relationship type {type_id} not found', http_code=404) + + # Check for duplicate relationship + existing = AssetRelationship.query.filter_by( + source_assetid=source_id, + target_assetid=target_id, + relationshiptypeid=type_id + ).first() + + if existing: + return error_response( + ErrorCodes.CONFLICT, + 'This relationship already exists', + http_code=409 + ) + + rel = AssetRelationship( + source_assetid=source_id, + target_assetid=target_id, + relationshiptypeid=type_id, + notes=data.get('notes') + ) + + db.session.add(rel) + db.session.commit() + + return success_response(rel.to_dict(), message='Relationship created', http_code=201) + + +@assets_bp.route('/relationships/', methods=['DELETE']) +@jwt_required() +def delete_asset_relationship(rel_id: int): + """Delete an asset relationship.""" + rel = AssetRelationship.query.get(rel_id) + + if not rel: + return error_response( + ErrorCodes.NOT_FOUND, + f'Relationship with ID {rel_id} not found', + http_code=404 + ) + + rel.isactive = False + db.session.commit() + + return success_response(message='Relationship deleted') + + +# ============================================================================= +# Asset Communications +# ============================================================================= + +# ============================================================================= +# Unified Asset Map +# ============================================================================= + +@assets_bp.route('/map', methods=['GET']) +@jwt_required() +def get_assets_map(): + """ + Get all assets with map positions for unified floor map display. + + Returns assets with mapleft/maptop coordinates, joined with type-specific data. + + Query parameters: + - assettype: Filter by asset type name (equipment, computer, network, printer) + - businessunitid: Filter by business unit ID + - statusid: Filter by status ID + - locationid: Filter by location ID + - search: Search by assetnumber, name, or serialnumber + """ + from shopdb.core.models import Location, BusinessUnit + + query = Asset.query.filter( + Asset.isactive == True, + Asset.mapleft.isnot(None), + Asset.maptop.isnot(None) + ) + + # Filter by asset type name + if assettype := request.args.get('assettype'): + types = assettype.split(',') + query = query.join(AssetType).filter(AssetType.assettype.in_(types)) + + # Filter by business unit + if bu_id := request.args.get('businessunitid'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Filter by status + if status_id := request.args.get('statusid'): + query = query.filter(Asset.statusid == int(status_id)) + + # Filter by location + if location_id := request.args.get('locationid'): + query = query.filter(Asset.locationid == int(location_id)) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%') + ) + ) + + assets = query.all() + + # Build response with type-specific data + data = [] + for asset in assets: + item = { + 'assetid': asset.assetid, + 'assetnumber': asset.assetnumber, + 'name': asset.name, + 'displayname': asset.display_name, + 'serialnumber': asset.serialnumber, + 'mapleft': asset.mapleft, + 'maptop': asset.maptop, + 'assettype': asset.assettype.assettype if asset.assettype else None, + 'assettypeid': asset.assettypeid, + 'status': asset.status.status if asset.status else None, + 'statusid': asset.statusid, + 'statuscolor': asset.status.color if asset.status else None, + 'location': asset.location.locationname if asset.location else None, + 'locationid': asset.locationid, + 'businessunit': asset.businessunit.businessunit if asset.businessunit else None, + 'businessunitid': asset.businessunitid, + 'primaryip': asset.primary_ip, + } + + # Add type-specific data + type_data = asset._get_extension_data() + if type_data: + item['typedata'] = type_data + + data.append(item) + + # Get available asset types for filters + asset_types = AssetType.query.filter(AssetType.isactive == True).all() + types_data = [{'assettypeid': t.assettypeid, 'assettype': t.assettype, 'icon': t.icon} for t in asset_types] + + # Get status options + statuses = AssetStatus.query.filter(AssetStatus.isactive == True).all() + status_data = [{'statusid': s.statusid, 'status': s.status, 'color': s.color} for s in statuses] + + # Get business units + business_units = BusinessUnit.query.filter(BusinessUnit.isactive == True).all() + bu_data = [{'businessunitid': bu.businessunitid, 'businessunit': bu.businessunit} for bu in business_units] + + # Get locations + locations = Location.query.filter(Location.isactive == True).all() + loc_data = [{'locationid': loc.locationid, 'locationname': loc.locationname} for loc in locations] + + return success_response({ + 'assets': data, + 'total': len(data), + 'filters': { + 'assettypes': types_data, + 'statuses': status_data, + 'businessunits': bu_data, + 'locations': loc_data + } + }) + + +@assets_bp.route('//communications', methods=['GET']) +@jwt_required() +def get_asset_communications(asset_id: int): + """Get all communications for an asset.""" + from shopdb.core.models import Communication + + asset = Asset.query.get(asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + comms = Communication.query.filter_by( + assetid=asset_id, + isactive=True + ).all() + + data = [] + for comm in comms: + c = comm.to_dict() + c['comtype_name'] = comm.comtype.comtype if comm.comtype else None + data.append(c) + + return success_response(data) diff --git a/shopdb/core/api/collector.py b/shopdb/core/api/collector.py new file mode 100644 index 0000000..602e80e --- /dev/null +++ b/shopdb/core/api/collector.py @@ -0,0 +1,374 @@ +""" +PowerShell Data Collection API endpoints. + +Compatibility layer for existing PowerShell scripts that update PC data. +Uses API key authentication instead of JWT for automated scripts. +""" + +from datetime import datetime +from functools import wraps +from flask import Blueprint, request, current_app + +from shopdb.extensions import db +from shopdb.core.models import Machine, Application, InstalledApp +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +collector_bp = Blueprint('collector', __name__) + + +def require_api_key(f): + """Decorator to require API key authentication.""" + @wraps(f) + def decorated(*args, **kwargs): + api_key = request.headers.get('X-API-Key') + if not api_key: + api_key = request.args.get('api_key') + + expected_key = current_app.config.get('COLLECTOR_API_KEY') + + if not expected_key: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Collector API key not configured', + http_code=500 + ) + + if api_key != expected_key: + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Invalid API key', + http_code=401 + ) + + return f(*args, **kwargs) + return decorated + + +@collector_bp.route('/pc', methods=['POST']) +@require_api_key +def update_pc_info(): + """ + Update PC information from PowerShell collection script. + + Expected JSON payload: + { + "hostname": "PC-1234", + "osname": "Windows 10 Enterprise", + "osversion": "10.0.19045", + "lastboottime": "2024-01-15T08:30:00", + "currentuser": "jsmith", + "ipaddress": "10.1.2.100", + "macaddress": "00:11:22:33:44:55", + "serialnumber": "ABC123", + "manufacturer": "Dell", + "model": "OptiPlex 7090" + } + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + hostname = data.get('hostname') + if not hostname: + return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required') + + # Find the PC by hostname + pc = Machine.query.filter( + Machine.hostname.ilike(hostname), + Machine.pctypeid.isnot(None) + ).first() + + if not pc: + # Try to find by machine number if hostname not found + pc = Machine.query.filter( + Machine.machinenumber.ilike(hostname), + Machine.pctypeid.isnot(None) + ).first() + + if not pc: + return error_response( + ErrorCodes.NOT_FOUND, + f'PC with hostname {hostname} not found', + http_code=404 + ) + + # Update PC fields + update_fields = { + 'lastzabbixsync': datetime.utcnow(), # Track last collection time + } + + if data.get('lastboottime'): + try: + update_fields['lastboottime'] = datetime.fromisoformat( + data['lastboottime'].replace('Z', '+00:00') + ) + except ValueError: + pass + + if data.get('currentuser'): + # Store previous user before updating + if pc.currentuserid != data['currentuser']: + update_fields['lastuserid'] = pc.currentuserid + update_fields['currentuserid'] = data['currentuser'] + + if data.get('serialnumber'): + update_fields['serialnumber'] = data['serialnumber'] + + # Update the record + for key, value in update_fields.items(): + if hasattr(pc, key): + setattr(pc, key, value) + + db.session.commit() + + return success_response({ + 'machineid': pc.machineid, + 'hostname': pc.hostname, + 'updated': True + }, message='PC info updated') + + +@collector_bp.route('/apps', methods=['POST']) +@require_api_key +def update_installed_apps(): + """ + Update installed applications for a PC. + + Expected JSON payload: + { + "hostname": "PC-1234", + "apps": [ + { + "appname": "Microsoft Office", + "version": "16.0.14326.20454", + "installdate": "2024-01-10" + }, + ... + ] + } + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + hostname = data.get('hostname') + if not hostname: + return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required') + + apps = data.get('apps', []) + if not apps: + return error_response(ErrorCodes.VALIDATION_ERROR, 'apps list is required') + + # Find the PC + pc = Machine.query.filter( + Machine.hostname.ilike(hostname), + Machine.pctypeid.isnot(None) + ).first() + + if not pc: + return error_response( + ErrorCodes.NOT_FOUND, + f'PC with hostname {hostname} not found', + http_code=404 + ) + + updated_count = 0 + created_count = 0 + skipped_count = 0 + + for app_data in apps: + app_name = app_data.get('appname') + if not app_name: + skipped_count += 1 + continue + + # Find the application in the database + app = Application.query.filter( + Application.appname.ilike(app_name) + ).first() + + if not app: + # Skip apps not in our tracked list + skipped_count += 1 + continue + + # Check if already installed + installed = InstalledApp.query.filter_by( + machineid=pc.machineid, + appid=app.appid + ).first() + + if installed: + # Update version if changed + new_version = app_data.get('version') + if new_version and installed.installedversion != new_version: + installed.installedversion = new_version + installed.modifieddate = datetime.utcnow() + updated_count += 1 + else: + # Create new installed app record + installed = InstalledApp( + machineid=pc.machineid, + appid=app.appid, + installedversion=app_data.get('version'), + installdate=datetime.utcnow() + ) + db.session.add(installed) + created_count += 1 + + db.session.commit() + + return success_response({ + 'hostname': hostname, + 'machineid': pc.machineid, + 'created': created_count, + 'updated': updated_count, + 'skipped': skipped_count + }, message='Installed apps updated') + + +@collector_bp.route('/heartbeat', methods=['POST']) +@require_api_key +def pc_heartbeat(): + """ + Record PC online status / heartbeat. + + Expected JSON payload: + { + "hostname": "PC-1234" + } + + Or batch update: + { + "hostnames": ["PC-1234", "PC-1235", "PC-1236"] + } + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + hostnames = data.get('hostnames', []) + if not hostnames and data.get('hostname'): + hostnames = [data['hostname']] + + if not hostnames: + return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname or hostnames required') + + updated = 0 + not_found = [] + + for hostname in hostnames: + pc = Machine.query.filter( + Machine.hostname.ilike(hostname), + Machine.pctypeid.isnot(None) + ).first() + + if pc: + pc.lastzabbixsync = datetime.utcnow() + updated += 1 + else: + not_found.append(hostname) + + db.session.commit() + + return success_response({ + 'updated': updated, + 'not_found': not_found, + 'timestamp': datetime.utcnow().isoformat() + }, message=f'{updated} PC(s) heartbeat recorded') + + +@collector_bp.route('/bulk', methods=['POST']) +@require_api_key +def bulk_update(): + """ + Bulk update multiple PCs at once. + + Expected JSON payload: + { + "pcs": [ + { + "hostname": "PC-1234", + "currentuser": "jsmith", + "lastboottime": "2024-01-15T08:30:00" + }, + ... + ] + } + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + pcs = data.get('pcs', []) + if not pcs: + return error_response(ErrorCodes.VALIDATION_ERROR, 'pcs list is required') + + updated = 0 + not_found = [] + errors = [] + + for pc_data in pcs: + hostname = pc_data.get('hostname') + if not hostname: + continue + + pc = Machine.query.filter( + Machine.hostname.ilike(hostname), + Machine.pctypeid.isnot(None) + ).first() + + if not pc: + not_found.append(hostname) + continue + + try: + pc.lastzabbixsync = datetime.utcnow() + + if pc_data.get('currentuser'): + if pc.currentuserid != pc_data['currentuser']: + pc.lastuserid = pc.currentuserid + pc.currentuserid = pc_data['currentuser'] + + if pc_data.get('lastboottime'): + try: + pc.lastboottime = datetime.fromisoformat( + pc_data['lastboottime'].replace('Z', '+00:00') + ) + except ValueError: + pass + + updated += 1 + + except Exception as e: + errors.append({'hostname': hostname, 'error': str(e)}) + + db.session.commit() + + return success_response({ + 'updated': updated, + 'not_found': not_found, + 'errors': errors, + 'timestamp': datetime.utcnow().isoformat() + }, message=f'{updated} PC(s) updated') + + +@collector_bp.route('/status', methods=['GET']) +@require_api_key +def collector_status(): + """Check collector API status and configuration.""" + return success_response({ + 'status': 'ok', + 'timestamp': datetime.utcnow().isoformat(), + 'endpoints': [ + 'POST /api/collector/pc', + 'POST /api/collector/apps', + 'POST /api/collector/heartbeat', + 'POST /api/collector/bulk', + 'GET /api/collector/status' + ] + }) diff --git a/shopdb/core/api/employees.py b/shopdb/core/api/employees.py new file mode 100644 index 0000000..42791e8 --- /dev/null +++ b/shopdb/core/api/employees.py @@ -0,0 +1,161 @@ +"""Employee lookup API endpoints.""" + +from flask import Blueprint, request +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +employees_bp = Blueprint('employees', __name__) + + +@employees_bp.route('/search', methods=['GET']) +def search_employees(): + """ + Search employees by name. + + Query parameters: + - q: Search query (searches first and last name) + - limit: Max results (default 10) + """ + query = request.args.get('q', '').strip() + limit = min(int(request.args.get('limit', 10)), 50) + + if len(query) < 2: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Search query must be at least 2 characters' + ) + + try: + import pymysql + conn = pymysql.connect( + host='localhost', + user='root', + password='rootpassword', + database='wjf_employees', + cursorclass=pymysql.cursors.DictCursor + ) + + with conn.cursor() as cur: + # Search by first name, last name, or SSO + cur.execute(''' + SELECT SSO, First_Name, Last_Name, Team, Role, Picture + FROM employees + WHERE First_Name LIKE %s + OR Last_Name LIKE %s + OR CAST(SSO AS CHAR) LIKE %s + ORDER BY Last_Name, First_Name + LIMIT %s + ''', (f'%{query}%', f'%{query}%', f'%{query}%', limit)) + + employees = cur.fetchall() + + conn.close() + + return success_response(employees) + + except Exception as e: + return error_response( + ErrorCodes.DATABASE_ERROR, + f'Employee lookup failed: {str(e)}', + http_code=500 + ) + + +@employees_bp.route('/lookup/', methods=['GET']) +def lookup_employee(sso): + """Look up a single employee by SSO.""" + if not sso.isdigit(): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'SSO must be numeric' + ) + + try: + import pymysql + conn = pymysql.connect( + host='localhost', + user='root', + password='rootpassword', + database='wjf_employees', + cursorclass=pymysql.cursors.DictCursor + ) + + with conn.cursor() as cur: + cur.execute( + 'SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO = %s', + (int(sso),) + ) + employee = cur.fetchone() + + conn.close() + + if not employee: + return error_response( + ErrorCodes.NOT_FOUND, + f'Employee with SSO {sso} not found', + http_code=404 + ) + + return success_response(employee) + + except Exception as e: + return error_response( + ErrorCodes.DATABASE_ERROR, + f'Employee lookup failed: {str(e)}', + http_code=500 + ) + + +@employees_bp.route('/lookup', methods=['GET']) +def lookup_employees(): + """ + Look up multiple employees by SSO list. + + Query parameters: + - sso: Comma-separated list of SSOs + """ + sso_list = request.args.get('sso', '') + ssos = [s.strip() for s in sso_list.split(',') if s.strip().isdigit()] + + if not ssos: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'At least one valid SSO is required' + ) + + try: + import pymysql + conn = pymysql.connect( + host='localhost', + user='root', + password='rootpassword', + database='wjf_employees', + cursorclass=pymysql.cursors.DictCursor + ) + + with conn.cursor() as cur: + placeholders = ','.join(['%s'] * len(ssos)) + cur.execute( + f'SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO IN ({placeholders})', + [int(s) for s in ssos] + ) + employees = cur.fetchall() + + conn.close() + + # Build name string + names = ', '.join( + f"{e['First_Name'].strip()} {e['Last_Name'].strip()}" + for e in employees + ) + + return success_response({ + 'employees': employees, + 'names': names + }) + + except Exception as e: + return error_response( + ErrorCodes.DATABASE_ERROR, + f'Employee lookup failed: {str(e)}', + http_code=500 + ) diff --git a/shopdb/core/api/machines.py b/shopdb/core/api/machines.py index c7b757a..7245f83 100644 --- a/shopdb/core/api/machines.py +++ b/shopdb/core/api/machines.py @@ -1,6 +1,18 @@ -"""Machines API endpoints.""" +""" +Machines API endpoints. -from flask import Blueprint, request +DEPRECATED: This API is deprecated and will be removed in a future version. +Please migrate to the new asset-based APIs: +- /api/assets - Unified asset queries +- /api/equipment - Equipment CRUD +- /api/computers - Computers CRUD +- /api/network - Network devices CRUD +- /api/printers - Printers CRUD +""" + +import logging +from functools import wraps +from flask import Blueprint, request, g from flask_jwt_extended import jwt_required, current_user from shopdb.extensions import db @@ -14,11 +26,40 @@ from shopdb.utils.responses import ( ) from shopdb.utils.pagination import get_pagination_params, paginate_query +logger = logging.getLogger(__name__) + machines_bp = Blueprint('machines', __name__) +def add_deprecation_headers(f): + """Decorator to add deprecation headers to responses.""" + @wraps(f) + def decorated_function(*args, **kwargs): + response = f(*args, **kwargs) + + # Add deprecation headers + if hasattr(response, 'headers'): + response.headers['X-Deprecated'] = 'true' + response.headers['X-Deprecated-Message'] = ( + 'This endpoint is deprecated. ' + 'Please migrate to /api/assets, /api/equipment, /api/computers, /api/network, or /api/printers.' + ) + response.headers['Sunset'] = '2026-12-31' # Target sunset date + + # Log deprecation warning (once per request) + if not getattr(g, '_deprecation_logged', False): + logger.warning( + f"Deprecated /api/machines endpoint called: {request.method} {request.path}" + ) + g._deprecation_logged = True + + return response + return decorated_function + + @machines_bp.route('', methods=['GET']) @jwt_required(optional=True) +@add_deprecation_headers def list_machines(): """ List all machines with filtering and pagination. @@ -149,6 +190,7 @@ def list_machines(): @machines_bp.route('/', methods=['GET']) @jwt_required(optional=True) +@add_deprecation_headers def get_machine(machine_id: int): """Get a single machine by ID.""" machine = Machine.query.get(machine_id) @@ -180,6 +222,7 @@ def get_machine(machine_id: int): @machines_bp.route('', methods=['POST']) @jwt_required() +@add_deprecation_headers def create_machine(): """Create a new machine.""" data = request.get_json() @@ -227,6 +270,7 @@ def create_machine(): @machines_bp.route('/', methods=['PUT']) @jwt_required() +@add_deprecation_headers def update_machine(machine_id: int): """Update an existing machine.""" machine = Machine.query.get(machine_id) @@ -274,6 +318,7 @@ def update_machine(machine_id: int): @machines_bp.route('/', methods=['DELETE']) @jwt_required() +@add_deprecation_headers def delete_machine(machine_id: int): """Soft delete a machine.""" machine = Machine.query.get(machine_id) @@ -293,6 +338,7 @@ def delete_machine(machine_id: int): @machines_bp.route('//communications', methods=['GET']) @jwt_required() +@add_deprecation_headers def get_machine_communications(machine_id: int): """Get all communications for a machine.""" machine = Machine.query.get(machine_id) @@ -310,6 +356,7 @@ def get_machine_communications(machine_id: int): @machines_bp.route('//communication', methods=['PUT']) @jwt_required() +@add_deprecation_headers def update_machine_communication(machine_id: int): """Update machine communication (IP address).""" from shopdb.core.models.communication import Communication, CommunicationType @@ -364,6 +411,7 @@ def update_machine_communication(machine_id: int): @machines_bp.route('//relationships', methods=['GET']) @jwt_required(optional=True) +@add_deprecation_headers def get_machine_relationships(machine_id: int): """Get all relationships for a machine (both parent and child).""" machine = Machine.query.get(machine_id) @@ -429,6 +477,7 @@ def get_machine_relationships(machine_id: int): @machines_bp.route('//relationships', methods=['POST']) @jwt_required() +@add_deprecation_headers def create_machine_relationship(machine_id: int): """Create a relationship for a machine.""" machine = Machine.query.get(machine_id) @@ -504,6 +553,7 @@ def create_machine_relationship(machine_id: int): @machines_bp.route('/relationships/', methods=['DELETE']) @jwt_required() +@add_deprecation_headers def delete_machine_relationship(relationship_id: int): """Delete a machine relationship.""" relationship = MachineRelationship.query.get(relationship_id) @@ -523,6 +573,7 @@ def delete_machine_relationship(relationship_id: int): @machines_bp.route('/relationshiptypes', methods=['GET']) @jwt_required(optional=True) +@add_deprecation_headers def list_relationship_types(): """List all relationship types.""" types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all() @@ -535,6 +586,7 @@ def list_relationship_types(): @machines_bp.route('/relationshiptypes', methods=['POST']) @jwt_required() +@add_deprecation_headers def create_relationship_type(): """Create a new relationship type.""" data = request.get_json() diff --git a/shopdb/core/api/reports.py b/shopdb/core/api/reports.py new file mode 100644 index 0000000..0cd9ad5 --- /dev/null +++ b/shopdb/core/api/reports.py @@ -0,0 +1,573 @@ +"""Reports API endpoints.""" + +import csv +import io +from datetime import datetime, timedelta +from flask import Blueprint, request, Response +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import ( + Asset, AssetType, AssetStatus, Machine, MachineStatus, + Application, KnowledgeBase, InstalledApp +) +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +reports_bp = Blueprint('reports', __name__) + + +def generate_csv(data: list, columns: list) -> str: + """Generate CSV string from data.""" + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(columns) + for row in data: + writer.writerow([row.get(col, '') for col in columns]) + return output.getvalue() + + +# ============================================================================= +# Report: Equipment by Type +# ============================================================================= + +@reports_bp.route('/equipment-by-type', methods=['GET']) +@jwt_required() +def equipment_by_type(): + """ + Report: Equipment count grouped by equipment type. + + Query parameters: + - businessunitid: Filter by business unit + - format: 'json' (default) or 'csv' + """ + try: + from plugins.equipment.models import Equipment, EquipmentType + except ImportError: + return error_response( + ErrorCodes.NOT_FOUND, + 'Equipment plugin not installed', + http_code=404 + ) + + query = db.session.query( + EquipmentType.equipmenttype, + EquipmentType.description, + db.func.count(Equipment.equipmentid).label('count') + ).outerjoin(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid + ).outerjoin(Asset, Asset.assetid == Equipment.assetid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + EquipmentType.isactive == True + ) + + # Business unit filter + if bu_id := request.args.get('businessunitid'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + query = query.group_by( + EquipmentType.equipmenttypeid, + EquipmentType.equipmenttype, + EquipmentType.description + ).order_by(EquipmentType.equipmenttype) + + results = query.all() + data = [{'equipmenttype': r.equipmenttype, 'description': r.description or '', 'count': r.count} for r in results] + total = sum(r['count'] for r in data) + + if request.args.get('format') == 'csv': + csv_data = generate_csv(data, ['equipmenttype', 'description', 'count']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=equipment_by_type.csv'} + ) + + return success_response({ + 'report': 'equipment_by_type', + 'generated': datetime.utcnow().isoformat(), + 'data': data, + 'total': total + }) + + +# ============================================================================= +# Report: Assets by Status +# ============================================================================= + +@reports_bp.route('/assets-by-status', methods=['GET']) +@jwt_required() +def assets_by_status(): + """ + Report: Asset count grouped by status. + + Query parameters: + - assettypeid: Filter by asset type + - businessunitid: Filter by business unit + - format: 'json' (default) or 'csv' + """ + query = db.session.query( + AssetStatus.status, + AssetStatus.color, + db.func.count(Asset.assetid).label('count') + ).outerjoin(Asset, Asset.statusid == AssetStatus.statusid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + AssetStatus.isactive == True + ) + + # Asset type filter + if type_id := request.args.get('assettypeid'): + query = query.filter(Asset.assettypeid == int(type_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + query = query.group_by( + AssetStatus.statusid, + AssetStatus.status, + AssetStatus.color + ).order_by(AssetStatus.status) + + results = query.all() + data = [{'status': r.status, 'color': r.color or '', 'count': r.count} for r in results] + total = sum(r['count'] for r in data) + + if request.args.get('format') == 'csv': + csv_data = generate_csv(data, ['status', 'count']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=assets_by_status.csv'} + ) + + return success_response({ + 'report': 'assets_by_status', + 'generated': datetime.utcnow().isoformat(), + 'data': data, + 'total': total + }) + + +# ============================================================================= +# Report: KB Popularity +# ============================================================================= + +@reports_bp.route('/kb-popularity', methods=['GET']) +@jwt_required() +def kb_popularity(): + """ + Report: Most clicked knowledge base articles. + + Query parameters: + - limit: Number of results (default 20) + - format: 'json' (default) or 'csv' + """ + limit = min(int(request.args.get('limit', 20)), 100) + + articles = KnowledgeBase.query.filter( + KnowledgeBase.isactive == True + ).order_by( + KnowledgeBase.clicks.desc() + ).limit(limit).all() + + data = [] + for kb in articles: + data.append({ + 'linkid': kb.linkid, + 'shortdescription': kb.shortdescription, + 'application': kb.application.appname if kb.application else None, + 'clicks': kb.clicks or 0, + 'linkurl': kb.linkurl + }) + + if request.args.get('format') == 'csv': + csv_data = generate_csv(data, ['linkid', 'shortdescription', 'application', 'clicks', 'linkurl']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=kb_popularity.csv'} + ) + + return success_response({ + 'report': 'kb_popularity', + 'generated': datetime.utcnow().isoformat(), + 'data': data, + 'total': len(data) + }) + + +# ============================================================================= +# Report: Warranty Status +# ============================================================================= + +@reports_bp.route('/warranty-status', methods=['GET']) +@jwt_required() +def warranty_status(): + """ + Report: Assets by warranty expiration status. + + Categories: Expired, Expiring Soon (90 days), Valid, No Warranty Data + + Query parameters: + - assettypeid: Filter by asset type + - format: 'json' (default) or 'csv' + """ + now = datetime.utcnow() + expiring_threshold = now + timedelta(days=90) + + # Try to get warranty data from equipment or machines + try: + from plugins.equipment.models import Equipment + from plugins.computers.models import Computer + + # Equipment warranty + equipment_query = db.session.query( + Asset.assetid, + Asset.assetnumber, + Asset.name, + Equipment.warrantyenddate + ).join(Equipment, Equipment.assetid == Asset.assetid + ).filter(Asset.isactive == True) + + if type_id := request.args.get('assettypeid'): + equipment_query = equipment_query.filter(Asset.assettypeid == int(type_id)) + + equipment_data = equipment_query.all() + + expired = [] + expiring_soon = [] + valid = [] + no_data = [] + + for row in equipment_data: + item = { + 'assetid': row.assetid, + 'assetnumber': row.assetnumber, + 'name': row.name, + 'warrantyenddate': row.warrantyenddate.isoformat() if row.warrantyenddate else None + } + + if row.warrantyenddate is None: + no_data.append(item) + elif row.warrantyenddate < now: + expired.append(item) + elif row.warrantyenddate < expiring_threshold: + expiring_soon.append(item) + else: + valid.append(item) + + data = { + 'expired': {'count': len(expired), 'items': expired}, + 'expiringsoon': {'count': len(expiring_soon), 'items': expiring_soon}, + 'valid': {'count': len(valid), 'items': valid}, + 'nodata': {'count': len(no_data), 'items': no_data} + } + + except (ImportError, AttributeError): + # Fallback: no warranty data available + data = { + 'expired': {'count': 0, 'items': []}, + 'expiringsoon': {'count': 0, 'items': []}, + 'valid': {'count': 0, 'items': []}, + 'nodata': {'count': 0, 'items': []} + } + + if request.args.get('format') == 'csv': + # Flatten for CSV + flat_data = [] + for status, info in data.items(): + for item in info['items']: + item['warrantystatus'] = status + flat_data.append(item) + csv_data = generate_csv(flat_data, ['assetid', 'assetnumber', 'name', 'warrantyenddate', 'warrantystatus']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=warranty_status.csv'} + ) + + return success_response({ + 'report': 'warranty_status', + 'generated': datetime.utcnow().isoformat(), + 'data': data, + 'summary': { + 'expired': data['expired']['count'], + 'expiringsoon': data['expiringsoon']['count'], + 'valid': data['valid']['count'], + 'nodata': data['nodata']['count'] + } + }) + + +# ============================================================================= +# Report: Software Compliance +# ============================================================================= + +@reports_bp.route('/software-compliance', methods=['GET']) +@jwt_required() +def software_compliance(): + """ + Report: Required applications vs installed (per PC). + + Shows which PCs have required applications installed. + + Query parameters: + - appid: Filter to specific application + - format: 'json' (default) or 'csv' + """ + # Get required applications + required_apps = Application.query.filter( + Application.isactive == True, + Application.isrequired == True + ).all() + + if not required_apps: + return success_response({ + 'report': 'software_compliance', + 'generated': datetime.utcnow().isoformat(), + 'data': [], + 'message': 'No required applications defined' + }) + + app_filter = request.args.get('appid') + + data = [] + for app in required_apps: + if app_filter and str(app.appid) != app_filter: + continue + + # Get all PCs + total_pcs = Machine.query.filter( + Machine.isactive == True, + Machine.pctypeid.isnot(None) + ).count() + + # Get PCs with this app installed + installed_count = db.session.query(InstalledApp).join( + Machine, Machine.machineid == InstalledApp.machineid + ).filter( + InstalledApp.appid == app.appid, + Machine.isactive == True + ).count() + + # Get list of non-compliant PCs + compliant_pc_ids = db.session.query(InstalledApp.machineid).filter( + InstalledApp.appid == app.appid + ).subquery() + + non_compliant_pcs = Machine.query.filter( + Machine.isactive == True, + Machine.pctypeid.isnot(None), + ~Machine.machineid.in_(compliant_pc_ids) + ).limit(100).all() + + compliance_rate = (installed_count / total_pcs * 100) if total_pcs > 0 else 0 + + data.append({ + 'appid': app.appid, + 'appname': app.appname, + 'totalpcs': total_pcs, + 'installed': installed_count, + 'notinstalled': total_pcs - installed_count, + 'compliancerate': round(compliance_rate, 1), + 'noncompliantpcs': [ + {'machineid': pc.machineid, 'hostname': pc.hostname or pc.machinenumber} + for pc in non_compliant_pcs + ] + }) + + if request.args.get('format') == 'csv': + # Simplified CSV (no nested data) + csv_rows = [] + for item in data: + csv_rows.append({ + 'appid': item['appid'], + 'appname': item['appname'], + 'totalpcs': item['totalpcs'], + 'installed': item['installed'], + 'notinstalled': item['notinstalled'], + 'compliancerate': item['compliancerate'] + }) + csv_data = generate_csv(csv_rows, ['appid', 'appname', 'totalpcs', 'installed', 'notinstalled', 'compliancerate']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=software_compliance.csv'} + ) + + return success_response({ + 'report': 'software_compliance', + 'generated': datetime.utcnow().isoformat(), + 'data': data, + 'total': len(data) + }) + + +# ============================================================================= +# Report: Asset Inventory Summary +# ============================================================================= + +@reports_bp.route('/asset-inventory', methods=['GET']) +@jwt_required() +def asset_inventory(): + """ + Report: Complete asset inventory summary. + + Query parameters: + - businessunitid: Filter by business unit + - locationid: Filter by location + - format: 'json' (default) or 'csv' + """ + # By asset type + by_type = db.session.query( + AssetType.assettype, + db.func.count(Asset.assetid).label('count') + ).outerjoin(Asset, Asset.assettypeid == AssetType.assettypeid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + AssetType.isactive == True + ) + + if bu_id := request.args.get('businessunitid'): + by_type = by_type.filter(Asset.businessunitid == int(bu_id)) + if loc_id := request.args.get('locationid'): + by_type = by_type.filter(Asset.locationid == int(loc_id)) + + by_type = by_type.group_by(AssetType.assettype).all() + + # By status + by_status = db.session.query( + AssetStatus.status, + AssetStatus.color, + db.func.count(Asset.assetid).label('count') + ).outerjoin(Asset, Asset.statusid == AssetStatus.statusid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + AssetStatus.isactive == True + ) + + if bu_id := request.args.get('businessunitid'): + by_status = by_status.filter(Asset.businessunitid == int(bu_id)) + if loc_id := request.args.get('locationid'): + by_status = by_status.filter(Asset.locationid == int(loc_id)) + + by_status = by_status.group_by(AssetStatus.status, AssetStatus.color).all() + + # By location + from shopdb.core.models import Location + by_location = db.session.query( + Location.locationname, + db.func.count(Asset.assetid).label('count') + ).outerjoin(Asset, Asset.locationid == Location.locationid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + Location.isactive == True + ) + + if bu_id := request.args.get('businessunitid'): + by_location = by_location.filter(Asset.businessunitid == int(bu_id)) + + by_location = by_location.group_by(Location.locationname).all() + + data = { + 'bytype': [{'type': r.assettype, 'count': r.count} for r in by_type], + 'bystatus': [{'status': r.status, 'color': r.color, 'count': r.count} for r in by_status], + 'bylocation': [{'location': r.locationname, 'count': r.count} for r in by_location] + } + + total = sum(r['count'] for r in data['bytype']) + + if request.args.get('format') == 'csv': + # Create multiple sections in CSV + csv_output = io.StringIO() + + csv_output.write("Asset Inventory Report\n") + csv_output.write(f"Generated: {datetime.utcnow().isoformat()}\n\n") + + csv_output.write("By Type\n") + csv_output.write("Type,Count\n") + for r in data['bytype']: + csv_output.write(f"{r['type']},{r['count']}\n") + + csv_output.write("\nBy Status\n") + csv_output.write("Status,Count\n") + for r in data['bystatus']: + csv_output.write(f"{r['status']},{r['count']}\n") + + csv_output.write("\nBy Location\n") + csv_output.write("Location,Count\n") + for r in data['bylocation']: + csv_output.write(f"{r['location']},{r['count']}\n") + + return Response( + csv_output.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=asset_inventory.csv'} + ) + + return success_response({ + 'report': 'asset_inventory', + 'generated': datetime.utcnow().isoformat(), + 'data': data, + 'total': total + }) + + +# ============================================================================= +# Available Reports List +# ============================================================================= + +@reports_bp.route('', methods=['GET']) +@jwt_required() +def list_reports(): + """List all available reports.""" + reports = [ + { + 'id': 'equipment-by-type', + 'name': 'Equipment by Type', + 'description': 'Equipment count grouped by equipment type', + 'endpoint': '/api/reports/equipment-by-type', + 'category': 'inventory' + }, + { + 'id': 'assets-by-status', + 'name': 'Assets by Status', + 'description': 'Asset count grouped by status', + 'endpoint': '/api/reports/assets-by-status', + 'category': 'inventory' + }, + { + 'id': 'kb-popularity', + 'name': 'KB Popularity', + 'description': 'Most clicked knowledge base articles', + 'endpoint': '/api/reports/kb-popularity', + 'category': 'usage' + }, + { + 'id': 'warranty-status', + 'name': 'Warranty Status', + 'description': 'Assets by warranty expiration status', + 'endpoint': '/api/reports/warranty-status', + 'category': 'compliance' + }, + { + 'id': 'software-compliance', + 'name': 'Software Compliance', + 'description': 'Required applications vs installed', + 'endpoint': '/api/reports/software-compliance', + 'category': 'compliance' + }, + { + 'id': 'asset-inventory', + 'name': 'Asset Inventory Summary', + 'description': 'Complete asset inventory breakdown', + 'endpoint': '/api/reports/asset-inventory', + 'category': 'inventory' + } + ] + + return success_response({ + 'reports': reports, + 'total': len(reports) + }) diff --git a/shopdb/core/api/search.py b/shopdb/core/api/search.py index 2e64a0a..39f03a9 100644 --- a/shopdb/core/api/search.py +++ b/shopdb/core/api/search.py @@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required from shopdb.extensions import db from shopdb.core.models import ( - Machine, Application, KnowledgeBase + Machine, Application, KnowledgeBase, + Asset, AssetType ) from shopdb.utils.responses import success_response @@ -46,16 +47,21 @@ def global_search(): search_term = f'%{query}%' # Search Machines (Equipment and PCs) - machines = Machine.query.filter( - Machine.isactive == True, - db.or_( - Machine.machinenumber.ilike(search_term), - Machine.alias.ilike(search_term), - Machine.hostname.ilike(search_term), - Machine.serialnumber.ilike(search_term), - Machine.notes.ilike(search_term) - ) - ).limit(10).all() + try: + machines = Machine.query.filter( + Machine.isactive == True, + db.or_( + Machine.machinenumber.ilike(search_term), + Machine.alias.ilike(search_term), + Machine.hostname.ilike(search_term), + Machine.serialnumber.ilike(search_term), + Machine.notes.ilike(search_term) + ) + ).limit(10).all() + except Exception as e: + import logging + logging.error(f"Machine search failed: {e}") + machines = [] for m in machines: # Determine type: PC, Printer, or Equipment @@ -110,54 +116,62 @@ def global_search(): }) # Search Applications - apps = Application.query.filter( - Application.isactive == True, - db.or_( - Application.appname.ilike(search_term), - Application.appdescription.ilike(search_term) - ) - ).limit(10).all() + try: + apps = Application.query.filter( + Application.isactive == True, + db.or_( + Application.appname.ilike(search_term), + Application.appdescription.ilike(search_term) + ) + ).limit(10).all() - for app in apps: - relevance = 20 - if query.lower() == app.appname.lower(): - relevance = 100 - elif query.lower() in app.appname.lower(): - relevance = 50 + for app in apps: + relevance = 20 + if query.lower() == app.appname.lower(): + relevance = 100 + elif query.lower() in app.appname.lower(): + relevance = 50 - results.append({ - 'type': 'application', - 'id': app.appid, - 'title': app.appname, - 'subtitle': app.appdescription[:100] if app.appdescription else None, - 'url': f"/applications/{app.appid}", - 'relevance': relevance - }) + results.append({ + 'type': 'application', + 'id': app.appid, + 'title': app.appname, + 'subtitle': app.appdescription[:100] if app.appdescription else None, + 'url': f"/applications/{app.appid}", + 'relevance': relevance + }) + except Exception as e: + import logging + logging.error(f"Application search failed: {e}") # Search Knowledge Base - kb_articles = KnowledgeBase.query.filter( - KnowledgeBase.isactive == True, - db.or_( - KnowledgeBase.shortdescription.ilike(search_term), - KnowledgeBase.keywords.ilike(search_term) - ) - ).limit(20).all() + try: + kb_articles = KnowledgeBase.query.filter( + KnowledgeBase.isactive == True, + db.or_( + KnowledgeBase.shortdescription.ilike(search_term), + KnowledgeBase.keywords.ilike(search_term) + ) + ).limit(20).all() - for kb in kb_articles: - # Weight by clicks and keyword match - relevance = 10 + (kb.clicks or 0) * 0.1 - if kb.keywords and query.lower() in kb.keywords.lower(): - relevance += 15 + for kb in kb_articles: + # Weight by clicks and keyword match + relevance = 10 + (kb.clicks or 0) * 0.1 + if kb.keywords and query.lower() in kb.keywords.lower(): + relevance += 15 - results.append({ - 'type': 'knowledgebase', - 'id': kb.linkid, - 'title': kb.shortdescription, - 'subtitle': kb.application.appname if kb.application else None, - 'url': f"/knowledgebase/{kb.linkid}", - 'linkurl': kb.linkurl, - 'relevance': relevance - }) + results.append({ + 'type': 'knowledgebase', + 'id': kb.linkid, + 'title': kb.shortdescription, + 'subtitle': kb.application.appname if kb.application else None, + 'url': f"/knowledgebase/{kb.linkid}", + 'linkurl': kb.linkurl, + 'relevance': relevance + }) + except Exception as e: + import logging + logging.error(f"KnowledgeBase search failed: {e}") # Search Printers (check if printers model exists) try: @@ -187,17 +201,132 @@ def global_search(): 'url': f"/printers/{p.printerid}", 'relevance': relevance }) - except ImportError: - pass # Printers plugin not installed + except Exception as e: + import logging + logging.error(f"Printer search failed: {e}") + + # Search Employees (separate database) + try: + import pymysql + emp_conn = pymysql.connect( + host='localhost', + user='root', + password='rootpassword', + database='wjf_employees', + cursorclass=pymysql.cursors.DictCursor + ) + + with emp_conn.cursor() as cur: + cur.execute(''' + SELECT SSO, First_Name, Last_Name, Team, Role + FROM employees + WHERE First_Name LIKE %s + OR Last_Name LIKE %s + OR CAST(SSO AS CHAR) LIKE %s + ORDER BY Last_Name, First_Name + LIMIT 10 + ''', (search_term, search_term, search_term)) + employees = cur.fetchall() + + emp_conn.close() + + for emp in employees: + full_name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}" + sso_str = str(emp['SSO']) + + # Calculate relevance + relevance = 20 + if query == sso_str: + relevance = 100 + elif query.lower() == full_name.lower(): + relevance = 95 + elif query.lower() in full_name.lower(): + relevance = 60 + + results.append({ + 'type': 'employee', + 'id': emp['SSO'], + 'title': full_name, + 'subtitle': emp.get('Team') or emp.get('Role') or f"SSO: {sso_str}", + 'url': f"/employees/{emp['SSO']}", + 'relevance': relevance + }) + except Exception as e: + import logging + logging.error(f"Employee search failed: {e}") + + # Search unified Assets table + try: + assets = Asset.query.join(AssetType).filter( + Asset.isactive == True, + db.or_( + Asset.assetnumber.ilike(search_term), + Asset.name.ilike(search_term), + Asset.serialnumber.ilike(search_term), + Asset.notes.ilike(search_term) + ) + ).limit(10).all() + + for asset in assets: + # Calculate relevance + relevance = 15 + if asset.assetnumber and query.lower() == asset.assetnumber.lower(): + relevance = 100 + elif asset.name and query.lower() == asset.name.lower(): + relevance = 90 + elif asset.serialnumber and query.lower() == asset.serialnumber.lower(): + relevance = 85 + elif asset.name and query.lower() in asset.name.lower(): + relevance = 50 + + # Determine URL and type based on asset type + asset_type_name = asset.assettype.assettype if asset.assettype else 'asset' + url_map = { + 'equipment': f"/equipment/{asset.assetid}", + 'computer': f"/pcs/{asset.assetid}", + 'network_device': f"/network/{asset.assetid}", + 'printer': f"/printers/{asset.assetid}", + } + url = url_map.get(asset_type_name, f"/assets/{asset.assetid}") + + display_name = asset.display_name + subtitle = None + if asset.name and asset.assetnumber != asset.name: + subtitle = asset.assetnumber + + # Get location name + location_name = asset.location.locationname if asset.location else None + + results.append({ + 'type': asset_type_name, + 'id': asset.assetid, + 'title': display_name, + 'subtitle': subtitle, + 'location': location_name, + 'url': url, + 'relevance': relevance + }) + except Exception as e: + import logging + logging.error(f"Asset search failed: {e}") # Sort by relevance (highest first) results.sort(key=lambda x: x['relevance'], reverse=True) + # Remove duplicates (prefer higher relevance) + seen_ids = {} + unique_results = [] + for r in results: + key = (r['type'], r['id']) + if key not in seen_ids: + seen_ids[key] = True + unique_results.append(r) + # Limit total results - results = results[:30] + unique_results = unique_results[:30] return success_response({ - 'results': results, + 'results': unique_results, 'query': query, - 'total': len(results) + 'total': len(unique_results) }) diff --git a/shopdb/core/api/slides.py b/shopdb/core/api/slides.py new file mode 100644 index 0000000..69e8801 --- /dev/null +++ b/shopdb/core/api/slides.py @@ -0,0 +1,74 @@ +"""Slides API for TV dashboard slideshow.""" + +import os +from flask import Blueprint, current_app +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +slides_bp = Blueprint('slides', __name__) + +# Valid image extensions +VALID_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'} + + +@slides_bp.route('', methods=['GET']) +def get_slides(): + """ + Get list of slides for TV dashboard. + + Returns image files from the static/slides directory. + """ + # Look for slides in static folder + static_folder = current_app.static_folder + if not static_folder: + static_folder = os.path.join(current_app.root_path, 'static') + + slides_folder = os.path.join(static_folder, 'slides') + + # Also check frontend public folder + frontend_slides = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(current_app.root_path))), + 'frontend', 'public', 'slides' + ) + + # Try multiple possible locations + possible_paths = [ + slides_folder, + frontend_slides, + '/home/camp/projects/shopdb-flask/shopdb/static/slides', + '/home/camp/projects/shopdb-flask/frontend/public/slides', + ] + + slides_path = None + for path in possible_paths: + if os.path.isdir(path): + slides_path = path + break + + if not slides_path: + return success_response({ + 'slides': [], + 'basepath': '/static/slides/', + 'message': 'Slides folder not found' + }) + + # Get list of image files + slides = [] + try: + for filename in sorted(os.listdir(slides_path)): + ext = os.path.splitext(filename)[1].lower() + if ext in VALID_EXTENSIONS: + slides.append({'filename': filename}) + except Exception as e: + return error_response( + ErrorCodes.SERVER_ERROR, + f'Error reading slides: {str(e)}', + http_code=500 + ) + + # Determine base path for serving files + basepath = '/static/slides/' + + return success_response({ + 'slides': slides, + 'basepath': basepath + }) diff --git a/shopdb/core/models/__init__.py b/shopdb/core/models/__init__.py index f465b91..29205c7 100644 --- a/shopdb/core/models/__init__.py +++ b/shopdb/core/models/__init__.py @@ -1,13 +1,14 @@ """Core SQLAlchemy models.""" from .base import BaseModel, SoftDeleteMixin, AuditMixin +from .asset import Asset, AssetType, AssetStatus from .machine import Machine, MachineType, MachineStatus, PCType from .vendor import Vendor from .model import Model from .businessunit import BusinessUnit from .location import Location from .operatingsystem import OperatingSystem -from .relationship import MachineRelationship, RelationshipType +from .relationship import MachineRelationship, AssetRelationship, RelationshipType from .communication import Communication, CommunicationType from .user import User, Role from .application import Application, AppVersion, AppOwner, SupportTeam, InstalledApp @@ -18,7 +19,11 @@ __all__ = [ 'BaseModel', 'SoftDeleteMixin', 'AuditMixin', - # Machine + # Asset (new architecture) + 'Asset', + 'AssetType', + 'AssetStatus', + # Machine (legacy) 'Machine', 'MachineType', 'MachineStatus', @@ -31,6 +36,7 @@ __all__ = [ 'OperatingSystem', # Relationships 'MachineRelationship', + 'AssetRelationship', 'RelationshipType', # Communication 'Communication', diff --git a/shopdb/core/models/application.py b/shopdb/core/models/application.py index 5775349..e2cc75e 100644 --- a/shopdb/core/models/application.py +++ b/shopdb/core/models/application.py @@ -64,7 +64,7 @@ class Application(BaseModel): return f"" -class AppVersion(BaseModel): +class AppVersion(db.Model): """Application version tracking.""" __tablename__ = 'appversions' @@ -74,6 +74,7 @@ class AppVersion(BaseModel): releasedate = db.Column(db.Date) notes = db.Column(db.String(255)) dateadded = db.Column(db.DateTime, default=db.func.now()) + isactive = db.Column(db.Boolean, default=True) # Relationships application = db.relationship('Application', back_populates='versions') @@ -84,6 +85,18 @@ class AppVersion(BaseModel): db.UniqueConstraint('appid', 'version', name='uq_app_version'), ) + def to_dict(self): + """Convert to dictionary.""" + return { + 'appversionid': self.appversionid, + 'appid': self.appid, + 'version': self.version, + 'releasedate': self.releasedate.isoformat() if self.releasedate else None, + 'notes': self.notes, + 'dateadded': self.dateadded.isoformat() + 'Z' if self.dateadded else None, + 'isactive': self.isactive + } + def __repr__(self): return f"" diff --git a/shopdb/core/models/asset.py b/shopdb/core/models/asset.py new file mode 100644 index 0000000..7a8ad74 --- /dev/null +++ b/shopdb/core/models/asset.py @@ -0,0 +1,200 @@ +"""Polymorphic Asset models - core of the new asset architecture.""" + +from shopdb.extensions import db +from .base import BaseModel, SoftDeleteMixin, AuditMixin + + +class AssetType(BaseModel): + """ + Registry of asset categories. + + Each type maps to a plugin-owned extension table. + Examples: equipment, computer, network_device, printer + """ + __tablename__ = 'assettypes' + + assettypeid = db.Column(db.Integer, primary_key=True) + assettype = db.Column( + db.String(50), + unique=True, + nullable=False, + comment='Category name: equipment, computer, network_device, printer' + ) + plugin_name = db.Column( + db.String(100), + nullable=True, + comment='Plugin that owns this type' + ) + table_name = db.Column( + db.String(100), + nullable=True, + comment='Extension table name for this type' + ) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class AssetStatus(BaseModel): + """Asset status options.""" + __tablename__ = 'assetstatuses' + + statusid = db.Column(db.Integer, primary_key=True) + status = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + color = db.Column(db.String(20), comment='CSS color for UI') + + def __repr__(self): + return f"" + + +class Asset(BaseModel, SoftDeleteMixin, AuditMixin): + """ + Core asset model - minimal shared fields. + + Category-specific data lives in plugin extension tables + (equipment, computers, network_devices, printers). + The assetid matches original machineid for migration compatibility. + """ + __tablename__ = 'assets' + + assetid = db.Column(db.Integer, primary_key=True) + + # Identification + assetnumber = db.Column( + db.String(50), + unique=True, + nullable=False, + index=True, + comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)' + ) + name = db.Column( + db.String(100), + comment='Display name/alias' + ) + serialnumber = db.Column( + db.String(100), + index=True, + comment='Hardware serial number' + ) + + # Classification + assettypeid = db.Column( + db.Integer, + db.ForeignKey('assettypes.assettypeid'), + nullable=False + ) + statusid = db.Column( + db.Integer, + db.ForeignKey('assetstatuses.statusid'), + default=1, + comment='In Use, Spare, Retired, etc.' + ) + + # Location and organization + locationid = db.Column( + db.Integer, + db.ForeignKey('locations.locationid'), + nullable=True + ) + businessunitid = db.Column( + db.Integer, + db.ForeignKey('businessunits.businessunitid'), + nullable=True + ) + + # Floor map position + mapleft = db.Column(db.Integer, comment='X coordinate on floor map') + maptop = db.Column(db.Integer, comment='Y coordinate on floor map') + + # Notes + notes = db.Column(db.Text, nullable=True) + + # Relationships + assettype = db.relationship('AssetType', backref='assets') + status = db.relationship('AssetStatus', backref='assets') + location = db.relationship('Location', backref='assets') + businessunit = db.relationship('BusinessUnit', backref='assets') + + # Communications (one-to-many) - will be migrated to use assetid + communications = db.relationship( + 'Communication', + foreign_keys='Communication.assetid', + backref='asset', + cascade='all, delete-orphan', + lazy='dynamic' + ) + + # Indexes + __table_args__ = ( + db.Index('idx_asset_type_bu', 'assettypeid', 'businessunitid'), + db.Index('idx_asset_location', 'locationid'), + db.Index('idx_asset_active', 'isactive'), + db.Index('idx_asset_status', 'statusid'), + ) + + def __repr__(self): + return f"" + + @property + def display_name(self): + """Get display name (name if set, otherwise assetnumber).""" + return self.name or self.assetnumber + + @property + def primary_ip(self): + """Get primary IP address from communications.""" + comm = self.communications.filter_by( + isprimary=True, + comtypeid=1 # IP type + ).first() + if comm: + return comm.ipaddress + # Fall back to any IP + comm = self.communications.filter_by(comtypeid=1).first() + return comm.ipaddress if comm else None + + def to_dict(self, include_type_data=False): + """ + Convert model to dictionary. + + Args: + include_type_data: If True, include category-specific data from extension table + """ + result = super().to_dict() + + # Add related object names for convenience + if self.assettype: + result['assettype_name'] = self.assettype.assettype + if self.status: + result['status_name'] = self.status.status + if self.location: + result['location_name'] = self.location.locationname + if self.businessunit: + result['businessunit_name'] = self.businessunit.businessunit + + # Include extension data if requested + if include_type_data: + ext_data = self._get_extension_data() + if ext_data: + result['type_data'] = ext_data + + return result + + def _get_extension_data(self): + """Get category-specific data from extension table.""" + # Check for equipment extension + if hasattr(self, 'equipment') and self.equipment: + return self.equipment.to_dict() + # Check for computer extension + if hasattr(self, 'computer') and self.computer: + return self.computer.to_dict() + # Check for network_device extension + if hasattr(self, 'network_device') and self.network_device: + return self.network_device.to_dict() + # Check for printer extension + if hasattr(self, 'printer') and self.printer: + return self.printer.to_dict() + return None diff --git a/shopdb/core/models/communication.py b/shopdb/core/models/communication.py index 8e2acea..bf95915 100644 --- a/shopdb/core/models/communication.py +++ b/shopdb/core/models/communication.py @@ -20,18 +20,30 @@ class CommunicationType(BaseModel): class Communication(BaseModel): """ - Communication interface for a machine. + Communication interface for an asset (or legacy machine). Stores network config, serial settings, etc. """ __tablename__ = 'communications' communicationid = db.Column(db.Integer, primary_key=True) + # New asset-based FK (preferred) + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid'), + nullable=True, + index=True, + comment='FK to assets table (new architecture)' + ) + + # Legacy machine FK (for backward compatibility during migration) machineid = db.Column( db.Integer, db.ForeignKey('machines.machineid'), - nullable=False + nullable=True, + comment='DEPRECATED: FK to machines table - use assetid instead' ) + comtypeid = db.Column( db.Integer, db.ForeignKey('communicationtypes.comtypeid'), @@ -82,6 +94,7 @@ class Communication(BaseModel): comtype = db.relationship('CommunicationType', backref='communications') __table_args__ = ( + db.Index('idx_comm_asset', 'assetid'), db.Index('idx_comm_machine', 'machineid'), db.Index('idx_comm_ip', 'ipaddress'), ) diff --git a/shopdb/core/models/relationship.py b/shopdb/core/models/relationship.py index 56d2610..16542a1 100644 --- a/shopdb/core/models/relationship.py +++ b/shopdb/core/models/relationship.py @@ -1,11 +1,11 @@ -"""Machine relationship models.""" +"""Machine and Asset relationship models.""" from shopdb.extensions import db from .base import BaseModel class RelationshipType(BaseModel): - """Types of relationships between machines.""" + """Types of relationships between machines/assets.""" __tablename__ = 'relationshiptypes' relationshiptypeid = db.Column(db.Integer, primary_key=True) @@ -21,6 +21,65 @@ class RelationshipType(BaseModel): return f"" +class AssetRelationship(BaseModel): + """ + Relationships between assets. + + Examples: + - Computer controls Equipment + - Two machines are dualpath partners + - Network device connects to equipment + """ + __tablename__ = 'assetrelationships' + + relationshipid = db.Column(db.Integer, primary_key=True) + + source_assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid'), + nullable=False + ) + target_assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid'), + nullable=False + ) + relationshiptypeid = db.Column( + db.Integer, + db.ForeignKey('relationshiptypes.relationshiptypeid'), + nullable=False + ) + + notes = db.Column(db.Text) + + # Relationships + source_asset = db.relationship( + 'Asset', + foreign_keys=[source_assetid], + backref='outgoing_relationships' + ) + target_asset = db.relationship( + 'Asset', + foreign_keys=[target_assetid], + backref='incoming_relationships' + ) + relationship_type = db.relationship('RelationshipType', backref='asset_relationships') + + __table_args__ = ( + db.UniqueConstraint( + 'source_assetid', + 'target_assetid', + 'relationshiptypeid', + name='uq_asset_relationship' + ), + db.Index('idx_asset_rel_source', 'source_assetid'), + db.Index('idx_asset_rel_target', 'target_assetid'), + ) + + def __repr__(self): + return f" {self.target_assetid}>" + + class MachineRelationship(BaseModel): """ Relationships between machines. diff --git a/shopdb/core/services/employee_service.py b/shopdb/core/services/employee_service.py new file mode 100644 index 0000000..0905f9c --- /dev/null +++ b/shopdb/core/services/employee_service.py @@ -0,0 +1,104 @@ +"""Employee lookup service - queries wjf_employees database.""" + +from typing import Optional, Dict, List +import pymysql +from flask import current_app + + +def get_employee_connection(): + """Get connection to wjf_employees database.""" + return pymysql.connect( + host='localhost', + user='root', + password='rootpassword', + database='wjf_employees', + cursorclass=pymysql.cursors.DictCursor + ) + + +def lookup_employee(sso: str) -> Optional[Dict]: + """ + Look up employee by SSO. + + Returns dict with: SSO, First_Name, Last_Name, full_name, Picture, etc. + """ + if not sso or not sso.strip().isdigit(): + return None + + try: + conn = get_employee_connection() + with conn.cursor() as cur: + cur.execute( + 'SELECT * FROM employees WHERE SSO = %s', + (int(sso.strip()),) + ) + row = cur.fetchone() + if row: + # Add computed full_name + first = (row.get('First_Name') or '').strip() + last = (row.get('Last_Name') or '').strip() + row['full_name'] = f"{first} {last}".strip() + return row + conn.close() + except Exception as e: + current_app.logger.error(f"Employee lookup error: {e}") + return None + + +def lookup_employees(sso_list: str) -> List[Dict]: + """ + Look up multiple employees by comma-separated SSO list. + + Returns list of employee dicts. + """ + if not sso_list: + return [] + + ssos = [s.strip() for s in sso_list.split(',') if s.strip().isdigit()] + if not ssos: + return [] + + try: + conn = get_employee_connection() + with conn.cursor() as cur: + placeholders = ','.join(['%s'] * len(ssos)) + cur.execute( + f'SELECT * FROM employees WHERE SSO IN ({placeholders})', + [int(s) for s in ssos] + ) + rows = cur.fetchall() + + # Add computed full_name to each + for row in rows: + first = (row.get('First_Name') or '').strip() + last = (row.get('Last_Name') or '').strip() + row['full_name'] = f"{first} {last}".strip() + + return rows + conn.close() + except Exception as e: + current_app.logger.error(f"Employee lookup error: {e}") + return [] + + +def get_employee_names(sso_list: str) -> str: + """ + Get comma-separated list of employee names from SSO list. + + Input: "212574611,212637451" + Output: "Brandon Saltz, Jon Kolkmann" + """ + employees = lookup_employees(sso_list) + if not employees: + return sso_list # Return SSOs as fallback + + return ', '.join(emp['full_name'] for emp in employees if emp.get('full_name')) + + +def get_employee_picture_url(sso: str) -> Optional[str]: + """Get URL to employee picture if available.""" + emp = lookup_employee(sso) + if emp and emp.get('Picture'): + # Pictures are stored relative paths like "Support/212574611.png" + return f"/static/employees/{emp['Picture']}" + return None diff --git a/shopdb/plugins/base.py b/shopdb/plugins/base.py index 3805041..b880a23 100644 --- a/shopdb/plugins/base.py +++ b/shopdb/plugins/base.py @@ -120,3 +120,31 @@ class BasePlugin(ABC): } """ return [] + + def get_searchable_fields(self) -> List[Dict]: + """ + Return fields this plugin contributes to global search. + + Each field: { + 'model': Type, # SQLAlchemy model class + 'field': str, # Column name to search + 'result_type': str, # Type identifier for search results + 'url_template': str, # URL template with {id} placeholder + 'title_field': str, # Field to use for result title + 'subtitle_field': str, # Optional field for subtitle + 'relevance_boost': int # Optional relevance score multiplier + } + + Example for equipment plugin: + return [{ + 'model': Equipment, + 'join_model': Asset, + 'join_condition': Equipment.assetid == Asset.assetid, + 'search_fields': ['assetnumber', 'name', 'serialnumber'], + 'result_type': 'equipment', + 'url_template': '/equipment/{id}', + 'title_field': 'assetnumber', + 'subtitle_field': 'name', + }] + """ + return [] diff --git a/start-api.sh b/start-api.sh new file mode 100755 index 0000000..e5168ff --- /dev/null +++ b/start-api.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd /home/camp/projects/shopdb-flask +source venv/bin/activate +flask run --host=0.0.0.0 --port=5001 diff --git a/start-ui.sh b/start-ui.sh new file mode 100755 index 0000000..07d6002 --- /dev/null +++ b/start-ui.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd /home/camp/projects/shopdb-flask/frontend +npm run dev -- --host 0.0.0.0 --port 5173