2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

Event enum (#8573)

* Add enumeration for stock events

* Update function def

* Refactor build events

* Plugin events

* Update order events

* Overdue order events

* Add documentation

* Revert mkdocs.yml

* Stringify event name

* Enum cleanup

- Support python < 3.11
- Custom __str__

* Add unit tests

* Fix duplicated code

* Update unit tests

* Bump query limit

* Use proper enums in unit tests
This commit is contained in:
Oliver 2024-11-29 12:05:05 +11:00 committed by GitHub
parent 390828d166
commit dd9a6a8a2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 472 additions and 58 deletions

View File

@ -15,6 +15,151 @@ When a certain (server-side) event occurs, the background worker passes the even
{% include 'img.html' %} {% include 'img.html' %}
{% endwith %} {% endwith %}
## Events
Events are passed through using a string identifier, e.g. `build.completed`
The arguments (and keyword arguments) passed to the receiving function depend entirely on the type of event.
!!! info "Read the Code"
Implementing a response to a particular event requires a working knowledge of the InvenTree code base, especially related to that event being received. While the *available* events are documented here, to implement a response to a particular event you will need to read the code to understand what data is passed to the event handler.
## Generic Events
There are a number of *generic* events which are generated on certain database actions. Whenever a database object is created, updated, or deleted, a corresponding event is generated.
#### Object Created
When a new object is created in the database, an event is generated with the following event name: `<app>_<model>.created`, where `<model>` is the name of the model class (e.g. `part`, `stockitem`, etc).
The event is called with the following keywords arguments:
- `model`: The model class of the object that was created
- `id`: The primary key of the object that was created
**Example:**
A new `Part` object is created with primary key `123`, resulting in the following event being generated:
```python
trigger_event('part_part.created', model='part', id=123)
```
### Object Updated
When an object is updated in the database, an event is generated with the following event name: `<app>_<model>.saved`, where `<model>` is the name of the model class (e.g. `part`, `stockitem`, etc).
The event is called with the following keywords arguments:
- `model`: The model class of the object that was updated
- `id`: The primary key of the object that was updated
**Example:**
A `Part` object with primary key `123` is updated, resulting in the following event being generated:
```python
trigger_event('part_part.saved', model='part', id=123)
```
### Object Deleted
When an object is deleted from the database, an event is generated with the following event name: `<app>_<model>.deleted`, where `<model>` is the name of the model class (e.g. `part`, `stockitem`, etc).
The event is called with the following keywords arguments:
- `model`: The model class of the object that was deleted
- `id`: The primary key of the object that was deleted (if available)
**Example:**
A `Part` object with primary key `123` is deleted, resulting in the following event being generated:
```python
trigger_event('part_part.deleted', model='part', id=123)
```
!!! warning "Object Deleted"
Note that the event is triggered *after* the object has been deleted from the database, so the object itself is no longer available.
## Specific Events
In addition to the *generic* events listed above, there are a number of other events which are triggered by *specific* actions within the InvenTree codebase.
The available events are provided in the enumerations listed below. Note that while the names of the events are documented here, the exact arguments passed to the event handler will depend on the specific event being triggered.
### Build Events
::: build.events.BuildEvents
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
### Part Events
::: part.events.PartEvents
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
### Stock Events
::: stock.events.StockEvents
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
### Purchase Order Events
::: order.events.PurchaseOrderEvents
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
### Sales Order Events
::: order.events.SalesOrderEvents
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
### Return Order Events
::: order.events.ReturnOrderEvents
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
### Plugin Events
::: plugin.events.PluginEvents
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
## Samples
### Sample Plugin - All events ### Sample Plugin - All events
Implementing classes must at least provide a `process_event` function: Implementing classes must at least provide a `process_event` function:
@ -40,12 +185,3 @@ Overall this function can reduce the workload on the background workers signific
show_root_toc_entry: False show_root_toc_entry: False
show_source: True show_source: True
members: [] members: []
## Events
Events are passed through using a string identifier, e.g. `build.completed`
The arguments (and keyword arguments) passed to the receiving function depend entirely on the type of event.
Implementing a response to a particular event requires a working knowledge of the InvenTree code base, especially related to that event being received.

View File

@ -212,6 +212,7 @@ PLUGIN_TESTING_SETUP = get_setting(
) # Load plugins from setup hooks in testing? ) # Load plugins from setup hooks in testing?
PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now
PLUGIN_TESTING_EVENTS_ASYNC = False # Flag if events are tested asynchronously
PLUGIN_RETRY = get_setting( PLUGIN_RETRY = get_setting(
'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 3, typecast=int 'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 3, typecast=int

View File

@ -94,6 +94,73 @@ def getNewestMigrationFile(app, exclude_extension=True):
return newest_file return newest_file
def findOffloadedTask(
task_name: str,
clear_after: bool = False,
reverse: bool = False,
matching_args=None,
matching_kwargs=None,
):
"""Find an offloaded tasks in the background worker queue.
Arguments:
task_name: The name of the task to search for
clear_after: Clear the task queue after searching
reverse: Search in reverse order (most recent first)
matching_args: List of argument names to match against
matching_kwargs: List of keyword argument names to match against
"""
from django_q.models import OrmQ
tasks = OrmQ.objects.all()
if reverse:
tasks = tasks.order_by('-pk')
task = None
for t in tasks:
if t.func() == task_name:
found = True
if matching_args:
for arg in matching_args:
if arg not in t.args():
found = False
break
if matching_kwargs:
for kwarg in matching_kwargs:
if kwarg not in t.kwargs():
found = False
break
if found:
task = t
break
if clear_after:
OrmQ.objects.all().delete()
return task
def findOffloadedEvent(
event_name: str,
clear_after: bool = False,
reverse: bool = False,
matching_kwargs=None,
):
"""Find an offloaded event in the background worker queue."""
return findOffloadedTask(
'plugin.base.event.events.register_event',
matching_args=[str(event_name)],
matching_kwargs=matching_kwargs,
clear_after=clear_after,
reverse=reverse,
)
class UserMixin: class UserMixin:
"""Mixin to setup a user and login for tests. """Mixin to setup a user and login for tests.

View File

@ -0,0 +1,18 @@
"""Event definitions and triggers for the build app."""
from generic.events import BaseEventEnum
class BuildEvents(BaseEventEnum):
"""Event enumeration for the Build app."""
# Build order events
HOLD = 'build.hold'
ISSUED = 'build.issued'
CANCELLED = 'build.cancelled'
COMPLETED = 'build.completed'
OVERDUE = 'build.overdue_build_order'
# Build output events
OUTPUT_CREATED = 'buildoutput.created'
OUTPUT_COMPLETED = 'buildoutput.completed'

View File

@ -24,6 +24,7 @@ from rest_framework import serializers
from build.status_codes import BuildStatus, BuildStatusGroups from build.status_codes import BuildStatus, BuildStatusGroups
from stock.status_codes import StockStatus, StockHistoryCode from stock.status_codes import StockStatus, StockHistoryCode
from build.events import BuildEvents
from build.filters import annotate_allocated_quantity from build.filters import annotate_allocated_quantity
from build.validators import generate_next_build_reference, validate_build_order_reference from build.validators import generate_next_build_reference, validate_build_order_reference
from generic.states import StateTransitionMixin from generic.states import StateTransitionMixin
@ -651,7 +652,7 @@ class Build(
raise ValidationError(_("Failed to offload task to complete build allocations")) raise ValidationError(_("Failed to offload task to complete build allocations"))
# Register an event # Register an event
trigger_event('build.completed', id=self.pk) trigger_event(BuildEvents.COMPLETED, id=self.pk)
# Notify users that this build has been completed # Notify users that this build has been completed
targets = [ targets = [
@ -718,7 +719,7 @@ class Build(
self.status = BuildStatus.PRODUCTION.value self.status = BuildStatus.PRODUCTION.value
self.save() self.save()
trigger_event('build.issued', id=self.pk) trigger_event(BuildEvents.ISSUED, id=self.pk)
@transaction.atomic @transaction.atomic
def hold_build(self): def hold_build(self):
@ -743,7 +744,7 @@ class Build(
self.status = BuildStatus.ON_HOLD.value self.status = BuildStatus.ON_HOLD.value
self.save() self.save()
trigger_event('build.hold', id=self.pk) trigger_event(BuildEvents.HOLD, id=self.pk)
@transaction.atomic @transaction.atomic
def cancel_build(self, user, **kwargs): def cancel_build(self, user, **kwargs):
@ -802,7 +803,7 @@ class Build(
content=InvenTreeNotificationBodies.OrderCanceled content=InvenTreeNotificationBodies.OrderCanceled
) )
trigger_event('build.cancelled', id=self.pk) trigger_event(BuildEvents.CANCELLED, id=self.pk)
@transaction.atomic @transaction.atomic
def deallocate_stock(self, build_line=None, output=None): def deallocate_stock(self, build_line=None, output=None):
@ -1157,6 +1158,12 @@ class Build(
deltas=deltas deltas=deltas
) )
trigger_event(
BuildEvents.OUTPUT_COMPLETED,
id=output.pk,
build_id=self.pk,
)
# Increase the completed quantity for this build # Increase the completed quantity for this build
self.completed += output.quantity self.completed += output.quantity

View File

@ -21,6 +21,7 @@ import InvenTree.helpers_email
import InvenTree.helpers_model import InvenTree.helpers_model
import InvenTree.tasks import InvenTree.tasks
import part.models as part_models import part.models as part_models
from build.events import BuildEvents
from build.status_codes import BuildStatusGroups from build.status_codes import BuildStatusGroups
from InvenTree.ready import isImportingData from InvenTree.ready import isImportingData
from plugin.events import trigger_event from plugin.events import trigger_event
@ -272,7 +273,7 @@ def notify_overdue_build_order(bo: build_models.Build):
} }
} }
event_name = 'build.overdue_build_order' event_name = BuildEvents.OVERDUE
# Send a notification to the appropriate users # Send a notification to the appropriate users
common.notifications.trigger_notification( common.notifications.trigger_notification(

View File

@ -45,7 +45,7 @@ class TestBuildAPI(InvenTreeAPITestCase):
self.assertEqual(len(response.data), 5) self.assertEqual(len(response.data), 5)
# Filter query by build status # Filter query by build status
response = self.get(url, {'status': 40}, expected_code=200) response = self.get(url, {'status': BuildStatus.COMPLETE.value}, expected_code=200)
self.assertEqual(len(response.data), 4) self.assertEqual(len(response.data), 4)
@ -221,10 +221,10 @@ class BuildTest(BuildAPITest):
{ {
"outputs": [{"output": output.pk} for output in outputs], "outputs": [{"output": output.pk} for output in outputs],
"location": 1, "location": 1,
"status": 50, # Item requires attention "status": StockStatus.ATTENTION.value,
}, },
expected_code=201, expected_code=201,
max_query_count=450, # TODO: Try to optimize this max_query_count=600, # TODO: Try to optimize this
) )
self.assertEqual(self.build.incomplete_outputs.count(), 0) self.assertEqual(self.build.incomplete_outputs.count(), 0)

View File

@ -4,12 +4,15 @@ from datetime import datetime, timedelta
from django.test import TestCase from django.test import TestCase
from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Sum from django.db.models import Sum
from django.test.utils import override_settings
from InvenTree import status_codes as status from InvenTree import status_codes as status
from InvenTree.unit_test import findOffloadedEvent
import common.models import common.models
from common.settings import set_global_setting from common.settings import set_global_setting
@ -722,6 +725,59 @@ class BuildTest(BuildTestBase):
self.assertTrue(messages.filter(user__pk=4).exists()) self.assertTrue(messages.filter(user__pk=4).exists())
@override_settings(
TESTING_TABLE_EVENTS=True,
PLUGIN_TESTING_EVENTS=True,
PLUGIN_TESTING_EVENTS_ASYNC=True
)
def test_events(self):
"""Test that build events are triggered correctly."""
from django_q.models import OrmQ
from build.events import BuildEvents
set_global_setting('ENABLE_PLUGINS_EVENTS', True)
OrmQ.objects.all().delete()
# Create a new build
build = Build.objects.create(
reference='BO-9999',
title='Some new build',
part=self.assembly,
quantity=5,
issued_by=get_user_model().objects.get(pk=2),
responsible=Owner.create(obj=Group.objects.get(pk=3))
)
# Check that the 'build.created' event was triggered
task = findOffloadedEvent(
'build_build.created',
matching_kwargs=['id', 'model'],
reverse=True,
clear_after=True,
)
# Assert that the task was found
self.assertIsNotNone(task)
# Check that the Build ID matches
self.assertEqual(task.kwargs()['id'], build.pk)
# Issue the build
build.issue_build()
# Check that the 'build.issued' event was triggered
task = findOffloadedEvent(
BuildEvents.ISSUED,
matching_kwargs=['id'],
clear_after=True,
)
self.assertIsNotNone(task)
set_global_setting('ENABLE_PLUGINS_EVENTS', False)
def test_metadata(self): def test_metadata(self):
"""Unit tests for the metadata field.""" """Unit tests for the metadata field."""
# Make sure a BuildItem exists before trying to run this test # Make sure a BuildItem exists before trying to run this test

View File

@ -0,0 +1,11 @@
"""Generic event enumerations for InevnTree."""
import enum
class BaseEventEnum(str, enum.Enum):
"""Base class for representing a set of 'events'."""
def __str__(self):
"""Return the string representation of the event."""
return str(self.value)

View File

@ -0,0 +1,37 @@
"""Event definitions and triggers for the order app."""
from generic.events import BaseEventEnum
class PurchaseOrderEvents(BaseEventEnum):
"""Event enumeration for the PurchaseOrder models."""
PLACED = 'purchaseorder.placed'
COMPLETED = 'purchaseorder.completed'
CANCELLED = 'purchaseorder.cancelled'
HOLD = 'purchaseorder.hold'
OVERDUE = 'order.overdue_purchase_order'
class SalesOrderEvents(BaseEventEnum):
"""Event enumeration for the SalesOrder models."""
ISSUED = 'salesorder.issued'
HOLD = 'salesorder.onhold'
COMPLETED = 'salesorder.completed'
CANCELLED = 'salesorder.cancelled'
OVERDUE = 'order.overdue_sales_order'
SHIPMENT_COMPLETE = 'salesordershipment.completed'
class ReturnOrderEvents(BaseEventEnum):
"""Event enumeration for the Return models."""
ISSUED = 'returnorder.issued'
RECEIVED = 'returnorder.received'
COMPLETED = 'returnorder.completed'
CANCELLED = 'returnorder.cancelled'
HOLD = 'returnorder.hold'

View File

@ -45,6 +45,7 @@ from InvenTree.fields import (
) )
from InvenTree.helpers import decimal2string, pui_url from InvenTree.helpers import decimal2string, pui_url
from InvenTree.helpers_model import notify_responsible from InvenTree.helpers_model import notify_responsible
from order.events import PurchaseOrderEvents, ReturnOrderEvents, SalesOrderEvents
from order.status_codes import ( from order.status_codes import (
PurchaseOrderStatus, PurchaseOrderStatus,
PurchaseOrderStatusGroups, PurchaseOrderStatusGroups,
@ -635,7 +636,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
self.issue_date = InvenTree.helpers.current_date() self.issue_date = InvenTree.helpers.current_date()
self.save() self.save()
trigger_event('purchaseorder.placed', id=self.pk) trigger_event(PurchaseOrderEvents.PLACED, id=self.pk)
# Notify users that the order has been placed # Notify users that the order has been placed
notify_responsible( notify_responsible(
@ -661,7 +662,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
if line.part and line.part.part: if line.part and line.part.part:
line.part.part.schedule_pricing_update(create=True) line.part.part.schedule_pricing_update(create=True)
trigger_event('purchaseorder.completed', id=self.pk) trigger_event(PurchaseOrderEvents.COMPLETED, id=self.pk)
@transaction.atomic @transaction.atomic
def issue_order(self): def issue_order(self):
@ -729,7 +730,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
self.status = PurchaseOrderStatus.CANCELLED.value self.status = PurchaseOrderStatus.CANCELLED.value
self.save() self.save()
trigger_event('purchaseorder.cancelled', id=self.pk) trigger_event(PurchaseOrderEvents.CANCELLED, id=self.pk)
# Notify users that the order has been canceled # Notify users that the order has been canceled
notify_responsible( notify_responsible(
@ -753,7 +754,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
self.status = PurchaseOrderStatus.ON_HOLD.value self.status = PurchaseOrderStatus.ON_HOLD.value
self.save() self.save()
trigger_event('purchaseorder.hold', id=self.pk) trigger_event(PurchaseOrderEvents.HOLD, id=self.pk)
# endregion # endregion
@ -1143,7 +1144,7 @@ class SalesOrder(TotalPriceMixin, Order):
self.issue_date = InvenTree.helpers.current_date() self.issue_date = InvenTree.helpers.current_date()
self.save() self.save()
trigger_event('salesorder.issued', id=self.pk) trigger_event(SalesOrderEvents.ISSUED, id=self.pk)
@property @property
def can_hold(self): def can_hold(self):
@ -1159,7 +1160,7 @@ class SalesOrder(TotalPriceMixin, Order):
self.status = SalesOrderStatus.ON_HOLD.value self.status = SalesOrderStatus.ON_HOLD.value
self.save() self.save()
trigger_event('salesorder.onhold', id=self.pk) trigger_event(SalesOrderEvents.HOLD, id=self.pk)
def _action_complete(self, *args, **kwargs): def _action_complete(self, *args, **kwargs):
"""Mark this order as "complete.""" """Mark this order as "complete."""
@ -1188,7 +1189,7 @@ class SalesOrder(TotalPriceMixin, Order):
if line.part: if line.part:
line.part.schedule_pricing_update(create=True) line.part.schedule_pricing_update(create=True)
trigger_event('salesorder.completed', id=self.pk) trigger_event(SalesOrderEvents.COMPLETED, id=self.pk)
return True return True
@ -1214,7 +1215,7 @@ class SalesOrder(TotalPriceMixin, Order):
for allocation in line.allocations.all(): for allocation in line.allocations.all():
allocation.delete() allocation.delete()
trigger_event('salesorder.cancelled', id=self.pk) trigger_event(SalesOrderEvents.CANCELLED, id=self.pk)
# Notify users that the order has been canceled # Notify users that the order has been canceled
notify_responsible( notify_responsible(
@ -1956,7 +1957,7 @@ class SalesOrderShipment(
group='sales_order', group='sales_order',
) )
trigger_event('salesordershipment.completed', id=self.pk) trigger_event(SalesOrderEvents.SHIPMENT_COMPLETE, id=self.pk)
class SalesOrderExtraLine(OrderExtraLine): class SalesOrderExtraLine(OrderExtraLine):
@ -2281,7 +2282,7 @@ class ReturnOrder(TotalPriceMixin, Order):
self.status = ReturnOrderStatus.ON_HOLD.value self.status = ReturnOrderStatus.ON_HOLD.value
self.save() self.save()
trigger_event('returnorder.hold', id=self.pk) trigger_event(ReturnOrderEvents.HOLD, id=self.pk)
@property @property
def can_cancel(self): def can_cancel(self):
@ -2294,7 +2295,7 @@ class ReturnOrder(TotalPriceMixin, Order):
self.status = ReturnOrderStatus.CANCELLED.value self.status = ReturnOrderStatus.CANCELLED.value
self.save() self.save()
trigger_event('returnorder.cancelled', id=self.pk) trigger_event(ReturnOrderEvents.CANCELLED, id=self.pk)
# Notify users that the order has been canceled # Notify users that the order has been canceled
notify_responsible( notify_responsible(
@ -2311,7 +2312,7 @@ class ReturnOrder(TotalPriceMixin, Order):
self.complete_date = InvenTree.helpers.current_date() self.complete_date = InvenTree.helpers.current_date()
self.save() self.save()
trigger_event('returnorder.completed', id=self.pk) trigger_event(ReturnOrderEvents.COMPLETED, id=self.pk)
def place_order(self): def place_order(self):
"""Deprecated version of 'issue_order.""" """Deprecated version of 'issue_order."""
@ -2332,7 +2333,7 @@ class ReturnOrder(TotalPriceMixin, Order):
self.issue_date = InvenTree.helpers.current_date() self.issue_date = InvenTree.helpers.current_date()
self.save() self.save()
trigger_event('returnorder.issued', id=self.pk) trigger_event(ReturnOrderEvents.ISSUED, id=self.pk)
@transaction.atomic @transaction.atomic
def hold_order(self): def hold_order(self):
@ -2422,7 +2423,7 @@ class ReturnOrder(TotalPriceMixin, Order):
line.received_date = InvenTree.helpers.current_date() line.received_date = InvenTree.helpers.current_date()
line.save() line.save()
trigger_event('returnorder.received', id=self.pk) trigger_event(ReturnOrderEvents.RECEIVED, id=self.pk)
# Notify responsible users # Notify responsible users
notify_responsible( notify_responsible(

View File

@ -11,6 +11,7 @@ import common.notifications
import InvenTree.helpers_model import InvenTree.helpers_model
import order.models import order.models
from InvenTree.tasks import ScheduledTask, scheduled_task from InvenTree.tasks import ScheduledTask, scheduled_task
from order.events import PurchaseOrderEvents, SalesOrderEvents
from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups
from plugin.events import trigger_event from plugin.events import trigger_event
@ -37,7 +38,7 @@ def notify_overdue_purchase_order(po: order.models.PurchaseOrder):
'template': {'html': 'email/overdue_purchase_order.html', 'subject': name}, 'template': {'html': 'email/overdue_purchase_order.html', 'subject': name},
} }
event_name = 'order.overdue_purchase_order' event_name = PurchaseOrderEvents.OVERDUE
# Send a notification to the appropriate users # Send a notification to the appropriate users
common.notifications.trigger_notification( common.notifications.trigger_notification(
@ -87,7 +88,7 @@ def notify_overdue_sales_order(so: order.models.SalesOrder):
'template': {'html': 'email/overdue_sales_order.html', 'subject': name}, 'template': {'html': 'email/overdue_sales_order.html', 'subject': name},
} }
event_name = 'order.overdue_sales_order' event_name = SalesOrderEvents.OVERDUE
# Send a notification to the appropriate users # Send a notification to the appropriate users
common.notifications.trigger_notification( common.notifications.trigger_notification(

View File

@ -133,8 +133,8 @@ class PurchaseOrderTest(OrderTest):
self.filter({'outstanding': False}, 2) self.filter({'outstanding': False}, 2)
# Filter by "status" # Filter by "status"
self.filter({'status': 10}, 3) self.filter({'status': PurchaseOrderStatus.PENDING.value}, 3)
self.filter({'status': 40}, 1) self.filter({'status': PurchaseOrderStatus.CANCELLED.value}, 1)
# Filter by "reference" # Filter by "reference"
self.filter({'reference': 'PO-0001'}, 1) self.filter({'reference': 'PO-0001'}, 1)
@ -1264,8 +1264,8 @@ class SalesOrderTest(OrderTest):
self.filter({'outstanding': False}, 2) self.filter({'outstanding': False}, 2)
# Filter by status # Filter by status
self.filter({'status': 10}, 3) # PENDING self.filter({'status': SalesOrderStatus.PENDING.value}, 3) # PENDING
self.filter({'status': 20}, 1) # SHIPPED self.filter({'status': SalesOrderStatus.SHIPPED.value}, 1) # SHIPPED
self.filter({'status': 99}, 0) # Invalid self.filter({'status': 99}, 0) # Invalid
# Filter by "reference" # Filter by "reference"
@ -2229,7 +2229,9 @@ class ReturnOrderTests(InvenTreeAPITestCase):
self.assertEqual(result['customer'], cmp_id) self.assertEqual(result['customer'], cmp_id)
# Filter by status # Filter by status
data = self.get(url, {'status': 20}, expected_code=200).data data = self.get(
url, {'status': ReturnOrderStatus.IN_PROGRESS.value}, expected_code=200
).data
self.assertEqual(len(data), 2) self.assertEqual(len(data), 2)

View File

@ -0,0 +1,7 @@
"""Event definitions and triggers for the part app."""
from generic.events import BaseEventEnum
class PartEvents(BaseEventEnum):
"""Event enumeration for the Part models."""

View File

@ -16,16 +16,23 @@ from plugin.registry import registry
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
def trigger_event(event, *args, **kwargs): def trigger_event(event: str, *args, **kwargs) -> None:
"""Trigger an event with optional arguments. """Trigger an event with optional arguments.
This event will be stored in the database, Arguments:
and the worker will respond to it later on. event: The event to trigger
*args: Additional arguments to pass to the event handler
**kwargs: Additional keyword arguments to pass to the event handler
This event will be stored in the database, and the worker will respond to it later on.
""" """
if not get_global_setting('ENABLE_PLUGINS_EVENTS', False): if not get_global_setting('ENABLE_PLUGINS_EVENTS', False):
# Do nothing if plugin events are not enabled # Do nothing if plugin events are not enabled
return return
# Ensure event name is stringified
event = str(event).strip()
# Make sure the database can be accessed and is not being tested rn # Make sure the database can be accessed and is not being tested rn
if ( if (
not canAppAccessDatabase(allow_shell=True) not canAppAccessDatabase(allow_shell=True)
@ -36,9 +43,13 @@ def trigger_event(event, *args, **kwargs):
logger.debug("Event triggered: '%s'", event) logger.debug("Event triggered: '%s'", event)
# By default, force the event to be processed asynchronously force_async = kwargs.pop('force_async', True)
if 'force_async' not in kwargs and not settings.PLUGIN_TESTING_EVENTS:
kwargs['force_async'] = True # If we are running in testing mode, we can enable or disable async processing
if settings.PLUGIN_TESTING_EVENTS:
force_async = settings.PLUGIN_TESTING_EVENTS_ASYNC
kwargs['force_async'] = force_async
offload_task(register_event, event, *args, group='plugin', **kwargs) offload_task(register_event, event, *args, group='plugin', **kwargs)
@ -179,4 +190,9 @@ def after_delete(sender, instance, **kwargs):
if not allow_table_event(table): if not allow_table_event(table):
return return
trigger_event(f'{table}.deleted', model=sender.__name__) instance_id = None
if instance:
instance_id = getattr(instance, 'id', None)
trigger_event(f'{table}.deleted', model=sender.__name__, id=instance_id)

View File

@ -1,5 +1,14 @@
"""Import helper for events.""" """Import helper for events."""
from generic.events import BaseEventEnum
from plugin.base.event.events import process_event, register_event, trigger_event from plugin.base.event.events import process_event, register_event, trigger_event
__all__ = ['process_event', 'register_event', 'trigger_event']
class PluginEvents(BaseEventEnum):
"""Event enumeration for the Plugin app."""
PLUGINS_LOADED = 'plugins_loaded'
PLUGIN_ACTIVATED = 'plugin_activated'
__all__ = ['PluginEvents', 'process_event', 'register_event', 'trigger_event']

View File

@ -14,6 +14,7 @@ import common.models
import InvenTree.models import InvenTree.models
import plugin.staticfiles import plugin.staticfiles
from plugin import InvenTreePlugin, registry from plugin import InvenTreePlugin, registry
from plugin.events import PluginEvents, trigger_event
class PluginConfig(InvenTree.models.MetadataMixin, models.Model): class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
@ -234,6 +235,8 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
self.active = active self.active = active
self.save() self.save()
trigger_event(PluginEvents.PLUGIN_ACTIVATED, slug=self.key, active=active)
if active: if active:
offload_task(check_for_migrations) offload_task(check_for_migrations)
offload_task( offload_task(

View File

@ -15,7 +15,7 @@ from collections import OrderedDict
from importlib.machinery import SourceFileLoader from importlib.machinery import SourceFileLoader
from pathlib import Path from pathlib import Path
from threading import Lock from threading import Lock
from typing import Any from typing import Any, Union
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
@ -104,7 +104,7 @@ class PluginsRegistry:
return plg return plg
def get_plugin_config(self, slug: str, name: [str, None] = None): def get_plugin_config(self, slug: str, name: Union[str, None] = None):
"""Return the matching PluginConfig instance for a given plugin. """Return the matching PluginConfig instance for a given plugin.
Args: Args:
@ -237,9 +237,9 @@ class PluginsRegistry:
# Trigger plugins_loaded event # Trigger plugins_loaded event
if canAppAccessDatabase(): if canAppAccessDatabase():
from plugin.events import trigger_event from plugin.events import PluginEvents, trigger_event
trigger_event('plugins_loaded') trigger_event(PluginEvents.PLUGINS_LOADED)
def _unload_plugins(self, force_reload: bool = False): def _unload_plugins(self, force_reload: bool = False):
"""Unload and deactivate all IntegrationPlugins. """Unload and deactivate all IntegrationPlugins.

View File

@ -0,0 +1,16 @@
"""Event definitions and triggers for the stock app."""
from generic.events import BaseEventEnum
class StockEvents(BaseEventEnum):
"""Event enumeration for the Stock app."""
# StockItem events
ITEM_ASSIGNED_TO_CUSTOMER = 'stockitem.assignedtocustomer'
ITEM_RETURNED_FROM_CUSTOMER = 'stockitem.returnedfromcustomer'
ITEM_SPLIT = 'stockitem.split'
ITEM_MOVED = 'stockitem.moved'
ITEM_COUNTED = 'stockitem.counted'
ITEM_QUANTITY_UPDATED = 'stockitem.quantityupdated'
ITEM_INSTALLED_INTO_ASSEMBLY = 'stockitem.installed'

View File

@ -47,6 +47,7 @@ from InvenTree.status_codes import (
) )
from part import models as PartModels from part import models as PartModels
from plugin.events import trigger_event from plugin.events import trigger_event
from stock.events import StockEvents
from stock.generators import generate_batch_code from stock.generators import generate_batch_code
from users.models import Owner from users.models import Owner
@ -1205,7 +1206,7 @@ class StockItem(
item.add_tracking_entry(code, user, deltas, notes=notes) item.add_tracking_entry(code, user, deltas, notes=notes)
trigger_event( trigger_event(
'stockitem.assignedtocustomer', StockEvents.ITEM_ASSIGNED_TO_CUSTOMER,
id=self.id, id=self.id,
customer=customer.id if customer else None, customer=customer.id if customer else None,
) )
@ -1241,12 +1242,15 @@ class StockItem(
self.belongs_to = None self.belongs_to = None
self.sales_order = None self.sales_order = None
self.location = location self.location = location
self.clearAllocations()
if status := kwargs.get('status'): if status := kwargs.get('status'):
self.status = status self.status = status
tracking_info['status'] = status tracking_info['status'] = status
self.save()
self.clearAllocations()
self.add_tracking_entry( self.add_tracking_entry(
StockHistoryCode.RETURNED_FROM_CUSTOMER, StockHistoryCode.RETURNED_FROM_CUSTOMER,
user, user,
@ -1255,7 +1259,7 @@ class StockItem(
location=location, location=location,
) )
trigger_event('stockitem.returnedfromcustomer', id=self.id) trigger_event(StockEvents.ITEM_RETURNED_FROM_CUSTOMER, id=self.id)
"""If new location is the same as the parent location, merge this stock back in the parent""" """If new location is the same as the parent location, merge this stock back in the parent"""
if self.parent and self.location == self.parent.location: if self.parent and self.location == self.parent.location:
@ -1414,7 +1418,10 @@ class StockItem(
# Assign the other stock item into this one # Assign the other stock item into this one
stock_item.belongs_to = self stock_item.belongs_to = self
stock_item.consumed_by = build
if build is not None:
stock_item.consumed_by = build
stock_item.location = None stock_item.location = None
stock_item.save(add_note=False) stock_item.save(add_note=False)
@ -1436,6 +1443,12 @@ class StockItem(
deltas={'stockitem': stock_item.pk}, deltas={'stockitem': stock_item.pk},
) )
trigger_event(
StockEvents.ITEM_INSTALLED_INTO_ASSEMBLY,
id=stock_item.pk,
assembly_id=self.pk,
)
@transaction.atomic @transaction.atomic
def uninstall_into_location(self, location, user, notes): def uninstall_into_location(self, location, user, notes):
"""Uninstall this stock item from another item, into a location. """Uninstall this stock item from another item, into a location.
@ -2042,7 +2055,7 @@ class StockItem(
except Exception: except Exception:
pass pass
trigger_event('stockitem.split', id=new_stock.id, parent=self.id) trigger_event(StockEvents.ITEM_SPLIT, id=new_stock.id, parent=self.id)
# Return a copy of the "new" stock item # Return a copy of the "new" stock item
return new_stock return new_stock
@ -2129,7 +2142,7 @@ class StockItem(
# Trigger event for the plugin system # Trigger event for the plugin system
trigger_event( trigger_event(
'stockitem.moved', StockEvents.ITEM_MOVED,
id=self.id, id=self.id,
old_location=current_location.id if current_location else None, old_location=current_location.id if current_location else None,
new_location=location.id if location else None, new_location=location.id if location else None,
@ -2167,6 +2180,11 @@ class StockItem(
return False return False
self.save() self.save()
trigger_event(
StockEvents.ITEM_QUANTITY_UPDATED, id=self.id, quantity=float(self.quantity)
)
return True return True
@transaction.atomic @transaction.atomic
@ -2210,6 +2228,13 @@ class StockItem(
deltas=tracking_info, deltas=tracking_info,
) )
trigger_event(
StockEvents.ITEM_COUNTED,
'stockitem.counted',
id=self.id,
quantity=float(self.quantity),
)
return True return True
@transaction.atomic @transaction.atomic

View File

@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication from rest_framework.authentication import TokenAuthentication
from users.models import ApiToken import users.models
class ApiTokenAuthentication(TokenAuthentication): class ApiTokenAuthentication(TokenAuthentication):
@ -18,7 +18,7 @@ class ApiTokenAuthentication(TokenAuthentication):
- Tokens can expire - Tokens can expire
""" """
model = ApiToken model = users.models.ApiToken
def authenticate_credentials(self, key): def authenticate_credentials(self, key):
"""Adds additional checks to the default token authentication method.""" """Adds additional checks to the default token authentication method."""