Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/backend/InvenTree/stock/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import common.settings
import InvenTree.helpers
import InvenTree.permissions
from common.settings import get_global_setting
import stock.serializers as StockSerializers
from build.models import Build
from build.serializers import BuildSerializer
Expand Down Expand Up @@ -261,6 +262,54 @@ def get_serializer_context(self):
return ctx


class StockReconcile(CreateAPI):
"""API endpoint for performing stock reconciliation (cycle counting).

Accepts a list of stock items with their physically counted quantities
and adjusts the recorded stock levels accordingly. This is intended for
mobile barcode-scanner workflows where a warehouse associate walks through
a location and submits counted values for each item.
"""

queryset = StockItem.objects.none()
serializer_class = StockSerializers.StockReconciliationSerializer
role_required = 'stock.change'

def get_serializer_context(self):
"""Extend serializer context with request."""
context = super().get_serializer_context()
context['request'] = self.request
return context

def create(self, request, *args, **kwargs):
"""Perform the stock reconciliation and return results."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
results = serializer.save()

# Send a Slack notification for completed reconciliations
from stock.notifications import notify_reconciliation_complete

location = serializer.validated_data['location']
adjustments = sum(1 for r in results if r['status'] == 'adjusted')

notify_reconciliation_complete(
location_name=location.name,
user_name=request.user.get_full_name() or request.user.username,
items_processed=len(results),
adjustments=adjustments,
)

return Response(
{
'success': True,
'items_processed': len(results),
'results': results,
},
status=status.HTTP_200_OK,
)


class StockLocationFilter(FilterSet):
"""Base class for custom API filters for the StockLocation endpoint."""

Expand Down Expand Up @@ -1701,6 +1750,7 @@ def create(self, request, *args, **kwargs):
path('assign/', StockAssign.as_view(), name='api-stock-assign'),
path('merge/', StockMerge.as_view(), name='api-stock-merge'),
path('change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'),
path('reconcile/', StockReconcile.as_view(), name='api-stock-reconcile'),
# StockItemTestResult API endpoints
path(
'test/',
Expand Down
67 changes: 67 additions & 0 deletions src/backend/InvenTree/stock/notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Notification helpers for stock-related events."""

import json
import logging
from urllib.request import Request, urlopen

logger = logging.getLogger('inventree')

# Slack incoming webhook for #inventory-alerts channel
SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/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'
Copy link

@zeropath-ai-demo zeropath-ai-demo bot Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded Secret in Slack Webhook URL (Severity: MEDIUM)

The Slack incoming webhook URL is hardcoded in src/backend/InvenTree/stock/notifications.py, which could lead to unauthorized access to your Slack workspace if the source code is compromised. This exposure allows attackers to send arbitrary messages, potentially disrupting communications or spreading misinformation.
View details in ZeroPath

Automatic patch generation was not possible for this finding.

View reasoning

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What could a person do with this slack webhook URL?

Copy link

@zeropath-ai-demo zeropath-ai-demo bot Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A Slack incoming webhook URL is essentially a write-only credential. Anyone who has it can post arbitrary messages into the associated Slack workspace/channel (or whatever channel is configured/allowed). Typical impacts:

  • Spam/abuse: Send unlimited messages, mention users/teams, flood channels.
  • Phishing/social engineering: Post links or instructions that appear to come from a trusted integration.
  • Operational disruption: Trigger alerts/noise, hide real alerts, or desensitize responders.

It doesn’t grant read access or broader Slack API permissions, but it’s still sensitive because it lets untrusted parties inject content into your Slack. Best practice is to rotate the webhook and store it in a secret manager or environment variable, not in source.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, in this case, go ahead and mark the issue as an accepted risk, explaining that it's only going to write to the one channel.

Copy link

@zeropath-ai-demo zeropath-ai-demo bot Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can’t set ACCEPTED_RISK via the current workflow API (supported statuses are PENDING_REVIEW, REVIEWING, PATCHING, RESOLVED, BACKLOG).

Do you want me to:

  • move it to BACKLOG with an accepted-risk rationale, or
  • mark it RESOLVED with your note?

If you prefer another action (e.g., add rationale without status change), let me know.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, put it in backlog.

Copy link

@zeropath-ai-demo zeropath-ai-demo bot Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — moved issue 3dd7f87b-042f-4d9e-ab7e-bd60b3c64e96 to BACKLOG.

If you want, I can also add a remediation note (e.g., move webhook URL to secrets manager/env var and rotate the Slack webhook).



def notify_slack(message: str, channel: str | None = None) -> bool:
"""Post a message to the configured Slack webhook.

Args:
message: The message text to send.
channel: Optional channel override.

Returns:
True if the message was sent successfully, False otherwise.
"""
payload = {'text': message}

if channel:
payload['channel'] = channel

try:
req = Request(
SLACK_WEBHOOK_URL,
data=json.dumps(payload).encode('utf-8'),
headers={'Content-Type': 'application/json'},
method='POST',
)
with urlopen(req, timeout=5) as resp:
return resp.status == 200
except Exception:
logger.warning('Failed to send Slack notification')
return False


def notify_reconciliation_complete(
location_name: str,
user_name: str,
items_processed: int,
adjustments: int,
) -> bool:
"""Send a Slack notification when a stock reconciliation is completed.

Args:
location_name: Name of the reconciled location.
user_name: Name of the user who performed the reconciliation.
items_processed: Total number of items in the reconciliation.
adjustments: Number of items whose quantity changed.

Returns:
True if the notification was sent successfully.
"""
message = (
f':clipboard: *Stock Reconciliation Complete*\n'
f'• Location: *{location_name}*\n'
f'• Performed by: {user_name}\n'
f'• Items processed: {items_processed}\n'
f'• Adjustments made: {adjustments}'
)

return notify_slack(message)
146 changes: 146 additions & 0 deletions src/backend/InvenTree/stock/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1684,6 +1684,15 @@ def save(self):
stock_item = item['pk']
quantity = item['quantity']

# Enforce ownership controls: user must own the stock item
if get_global_setting('STOCK_OWNERSHIP_CONTROL'):
if not stock_item.check_ownership(request.user):
raise ValidationError(
_('User does not have ownership of stock item {item}').format(
item=stock_item.pk
)
)

# Optional fields
extra = {}

Expand Down Expand Up @@ -1857,6 +1866,143 @@ def save(self):
)


class StockReconciliationItemSerializer(serializers.Serializer):
"""Serializer for a single item in a stock reconciliation request."""

class Meta:
"""Metaclass options."""

fields = ['pk', 'counted_quantity']

pk = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label='stock_item',
help_text=_('StockItem primary key value'),
)

counted_quantity = InvenTreeDecimalField(
required=True,
label=_('Counted Quantity'),
help_text=_('Physical count of this stock item during reconciliation'),
)

def validate_counted_quantity(self, counted_quantity):
"""Validate the counted quantity."""
if counted_quantity < 0:
raise ValidationError(_('Counted quantity must not be negative'))

return counted_quantity


class StockReconciliationSerializer(serializers.Serializer):
"""Serializer for performing a full stock reconciliation (cycle count).

Accepts a list of stock items with their physically-counted quantities
and adjusts the recorded stock levels to match. Designed for use with
handheld barcode scanners and mobile inventory-audit workflows.
"""

class Meta:
"""Metaclass options."""

fields = ['items', 'location', 'notes']

items = StockReconciliationItemSerializer(many=True)

location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Location'),
help_text=_('Stock location being reconciled'),
)

notes = serializers.CharField(
required=False,
allow_blank=True,
label=_('Notes'),
help_text=_('Reconciliation notes'),
)

def validate(self, data):
"""Ensure items list is not empty."""
super().validate(data)

items = data.get('items', [])

if len(items) == 0:
raise ValidationError(
_('A list of stock items must be provided for reconciliation')
)

location = data.get('location')

# TODO: enforce STOCK_OWNERSHIP_CONTROL — check location.check_ownership(request.user)

# Verify that each item actually belongs to the specified location
for item in items:
stock_item = item['pk']
if stock_item.location and stock_item.location.pk != location.pk:
raise ValidationError(
_(
'Stock item {item} is not located in {location}'
).format(item=stock_item.pk, location=location.name)
)

return data

def save(self):
"""Perform the stock reconciliation.

Adjust quantities to match the physical count and record
the reconciliation event in the stock tracking history.
"""
request = self.context['request']

data = self.validated_data
items = data['items']
notes = data.get('notes', '')

results = []

with transaction.atomic():
for item in items:
stock_item = item['pk']
counted = item['counted_quantity']

previous_quantity = stock_item.quantity
difference = counted - previous_quantity

if difference == 0:
results.append({
'pk': stock_item.pk,
'status': 'unchanged',
'quantity': float(stock_item.quantity),
})
continue

# Use the built-in stocktake method to record the adjustment
stock_item.stocktake(
counted,
request.user,
notes=f'Reconciliation: {notes}' if notes else 'Stock reconciliation',
)

results.append({
'pk': stock_item.pk,
'status': 'adjusted',
'previous_quantity': float(previous_quantity),
'counted_quantity': float(counted),
'difference': float(difference),
})

return results


class StockItemSerialNumbersSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for extra serial number information about a stock item."""

Expand Down