Initial implementation of 3D Print Demand plugin
Dashboard plugin for InvenTree showing aggregated demand for 3D printed parts across open build and sales orders, with colour-coded deficit column.
This commit is contained in:
47
README.md
Normal file
47
README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# InvenTree 3D Print Demand Plugin
|
||||||
|
|
||||||
|
Dashboard plugin for InvenTree that shows aggregated demand for 3D printed parts across all open build orders and sales orders.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Dashboard panel showing all parts from a configured category
|
||||||
|
- Aggregates stock, allocations, and demand from build and sales orders
|
||||||
|
- Colour-coded deficit column (red = shortage, green = surplus)
|
||||||
|
- Configurable part category with optional subcategory inclusion
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Projects/02-engineering/inventree/inventree-print-demand
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart InvenTree and enable the plugin in **Admin > Plugins**.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
In plugin settings, set:
|
||||||
|
|
||||||
|
- **Part Category** - select the category containing your 3D printed parts
|
||||||
|
- **Include Subcategories** - whether to include parts from subcategories (default: true)
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
`GET /plugin/print-demand/api/demand/` returns a JSON array of parts sorted by deficit:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"pk": 123,
|
||||||
|
"name": "Housing Top",
|
||||||
|
"IPN": "MP-001",
|
||||||
|
"in_stock": 50,
|
||||||
|
"allocated_build": 30,
|
||||||
|
"allocated_sales": 10,
|
||||||
|
"available": 10,
|
||||||
|
"required_build": 45,
|
||||||
|
"required_sales": 15,
|
||||||
|
"deficit": -10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
0
inventree_print_demand/__init__.py
Normal file
0
inventree_print_demand/__init__.py
Normal file
114
inventree_print_demand/plugin.py
Normal file
114
inventree_print_demand/plugin.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""InvenTree plugin to display aggregated 3D print demand."""
|
||||||
|
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from plugin import InvenTreePlugin
|
||||||
|
from plugin.mixins import SettingsMixin, UrlsMixin, UserInterfaceMixin
|
||||||
|
|
||||||
|
from part.models import Part, PartCategory
|
||||||
|
from stock.models import StockItem
|
||||||
|
|
||||||
|
|
||||||
|
class PrintDemandPlugin(SettingsMixin, UrlsMixin, UserInterfaceMixin, InvenTreePlugin):
|
||||||
|
|
||||||
|
AUTHOR = 'Micromelon'
|
||||||
|
DESCRIPTION = 'Dashboard panel showing aggregated 3D print demand across open orders'
|
||||||
|
VERSION = '0.1.0'
|
||||||
|
NAME = 'PrintDemand'
|
||||||
|
SLUG = 'print-demand'
|
||||||
|
TITLE = '3D Print Demand'
|
||||||
|
|
||||||
|
SETTINGS = {
|
||||||
|
'PART_CATEGORY': {
|
||||||
|
'name': 'Part Category',
|
||||||
|
'description': 'Category containing 3D printed parts',
|
||||||
|
'model': 'part.partcategory',
|
||||||
|
},
|
||||||
|
'INCLUDE_SUBCATEGORIES': {
|
||||||
|
'name': 'Include Subcategories',
|
||||||
|
'description': 'Include parts from subcategories of the selected category',
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def setup_urls(self):
|
||||||
|
return [
|
||||||
|
re_path(r'^api/demand/', self.api_demand, name='api-demand'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_ui_panels(self, request, **kwargs):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_ui_dashboard_items(self, request, **kwargs):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'key': 'print-demand',
|
||||||
|
'title': '3D Print Demand',
|
||||||
|
'description': 'Aggregated demand for 3D printed parts',
|
||||||
|
'source': self.plugin_static_file('print_demand/dashboard.js'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def api_demand(self, request):
|
||||||
|
"""Return aggregated demand data for all parts in the configured category."""
|
||||||
|
category_pk = self.get_setting('PART_CATEGORY')
|
||||||
|
|
||||||
|
if not category_pk:
|
||||||
|
return JsonResponse(
|
||||||
|
{'error': 'No part category configured. Set PART_CATEGORY in plugin settings.'},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
category = PartCategory.objects.get(pk=category_pk)
|
||||||
|
except PartCategory.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'Configured category does not exist.'}, status=404)
|
||||||
|
|
||||||
|
include_sub = self.get_setting('INCLUDE_SUBCATEGORIES')
|
||||||
|
|
||||||
|
if include_sub:
|
||||||
|
categories = category.get_descendants(include_self=True)
|
||||||
|
else:
|
||||||
|
categories = PartCategory.objects.filter(pk=category.pk)
|
||||||
|
|
||||||
|
parts = Part.objects.filter(category__in=categories, active=True)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
in_stock = part.total_stock
|
||||||
|
allocated_build = part.allocation_count(
|
||||||
|
build_order_allocations=True,
|
||||||
|
sales_order_allocations=False,
|
||||||
|
)
|
||||||
|
allocated_sales = part.allocation_count(
|
||||||
|
build_order_allocations=False,
|
||||||
|
sales_order_allocations=True,
|
||||||
|
)
|
||||||
|
available = max(0, in_stock - allocated_build - allocated_sales)
|
||||||
|
|
||||||
|
required_build = part.required_build_order_quantity()
|
||||||
|
required_sales = part.required_sales_order_quantity()
|
||||||
|
|
||||||
|
total_required = required_build + required_sales
|
||||||
|
total_allocated = allocated_build + allocated_sales
|
||||||
|
deficit = in_stock - total_required
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'pk': part.pk,
|
||||||
|
'name': part.name,
|
||||||
|
'IPN': part.IPN or '',
|
||||||
|
'in_stock': float(in_stock),
|
||||||
|
'allocated_build': float(allocated_build),
|
||||||
|
'allocated_sales': float(allocated_sales),
|
||||||
|
'available': float(available),
|
||||||
|
'required_build': float(required_build),
|
||||||
|
'required_sales': float(required_sales),
|
||||||
|
'deficit': float(deficit),
|
||||||
|
})
|
||||||
|
|
||||||
|
results.sort(key=lambda x: x['deficit'])
|
||||||
|
|
||||||
|
return JsonResponse(results, safe=False)
|
||||||
89
inventree_print_demand/static/print_demand/dashboard.js
Normal file
89
inventree_print_demand/static/print_demand/dashboard.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 3D Print Demand dashboard panel for InvenTree.
|
||||||
|
*
|
||||||
|
* Fetches aggregated demand data from the plugin API and renders
|
||||||
|
* a colour-coded table showing stock vs demand for every 3D-printed part.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function renderDashboardItem(context) {
|
||||||
|
const target = context.target;
|
||||||
|
|
||||||
|
target.innerHTML = '<em>Loading 3D print demand data...</em>';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include CSRF token if available
|
||||||
|
const csrfToken = getCookie('csrftoken');
|
||||||
|
if (csrfToken) {
|
||||||
|
headers['X-CSRFToken'] = csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/plugin/print-demand/api/demand/', { headers, credentials: 'same-origin' })
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.json().then(data => { throw new Error(data.error || response.statusText); });
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (!data.length) {
|
||||||
|
target.innerHTML = '<em>No parts found in the configured category.</em>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = buildTable(data);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
target.innerHTML = `<em style="color:#c00;">Error: ${escapeHtml(err.message)}</em>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTable(parts) {
|
||||||
|
const rows = parts.map(p => {
|
||||||
|
const deficitStyle = p.deficit < 0
|
||||||
|
? 'color:#c00;font-weight:bold;'
|
||||||
|
: (p.deficit > 0 ? 'color:#080;' : '');
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
|
<td><a href="/part/${p.pk}/">${escapeHtml(p.IPN ? p.IPN + ' - ' + p.name : p.name)}</a></td>
|
||||||
|
<td style="text-align:right;">${fmt(p.in_stock)}</td>
|
||||||
|
<td style="text-align:right;">${fmt(p.allocated_build + p.allocated_sales)}</td>
|
||||||
|
<td style="text-align:right;">${fmt(p.available)}</td>
|
||||||
|
<td style="text-align:right;">${fmt(p.required_build + p.required_sales)}</td>
|
||||||
|
<td style="text-align:right;${deficitStyle}">${fmt(p.deficit)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:0.9em;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:2px solid #ccc;text-align:left;">
|
||||||
|
<th style="padding:6px 8px;">Part</th>
|
||||||
|
<th style="padding:6px 8px;text-align:right;">In Stock</th>
|
||||||
|
<th style="padding:6px 8px;text-align:right;">Allocated</th>
|
||||||
|
<th style="padding:6px 8px;text-align:right;">Available</th>
|
||||||
|
<th style="padding:6px 8px;text-align:right;">Required</th>
|
||||||
|
<th style="padding:6px 8px;text-align:right;">Deficit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
return Number.isInteger(n) ? n.toString() : n.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||||
|
return match ? decodeURIComponent(match[2]) : null;
|
||||||
|
}
|
||||||
17
setup.py
Normal file
17
setup.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='inventree-print-demand',
|
||||||
|
version='0.1.0',
|
||||||
|
description='InvenTree plugin to show aggregated 3D print demand across open orders',
|
||||||
|
author='Micromelon',
|
||||||
|
license='MIT',
|
||||||
|
packages=find_packages(),
|
||||||
|
include_package_data=True,
|
||||||
|
install_requires=['inventree'],
|
||||||
|
entry_points={
|
||||||
|
'inventree_plugins': [
|
||||||
|
'PrintDemandPlugin = inventree_print_demand.plugin:PrintDemandPlugin',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user