commit 1046bc2380ca8bb4fd23cb075a2fe1924e2a8860 Author: timmyhadwen <4350849+timmyhadwen@users.noreply.github.com> Date: Tue Feb 24 09:10:31 2026 +1000 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d03cc3 --- /dev/null +++ b/README.md @@ -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 + } +] +``` diff --git a/inventree_print_demand/__init__.py b/inventree_print_demand/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inventree_print_demand/plugin.py b/inventree_print_demand/plugin.py new file mode 100644 index 0000000..d811f5a --- /dev/null +++ b/inventree_print_demand/plugin.py @@ -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) diff --git a/inventree_print_demand/static/print_demand/dashboard.js b/inventree_print_demand/static/print_demand/dashboard.js new file mode 100644 index 0000000..0041c77 --- /dev/null +++ b/inventree_print_demand/static/print_demand/dashboard.js @@ -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 = 'Loading 3D print demand data...'; + + 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 = 'No parts found in the configured category.'; + return; + } + target.innerHTML = buildTable(data); + }) + .catch(err => { + target.innerHTML = `Error: ${escapeHtml(err.message)}`; + }); +} + +function buildTable(parts) { + const rows = parts.map(p => { + const deficitStyle = p.deficit < 0 + ? 'color:#c00;font-weight:bold;' + : (p.deficit > 0 ? 'color:#080;' : ''); + + return `
| Part | +In Stock | +Allocated | +Available | +Required | +Deficit | +
|---|