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:
timmyhadwen
2026-02-24 09:10:31 +10:00
committed by Tim Hadwen
commit 1046bc2380
5 changed files with 267 additions and 0 deletions

View File

View 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)

View 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;
}