diff --git a/docs/docs/extend/plugins/event.md b/docs/docs/extend/plugins/event.md index 96a41ca3c5..8ec1ccc6f9 100644 --- a/docs/docs/extend/plugins/event.md +++ b/docs/docs/extend/plugins/event.md @@ -15,6 +15,151 @@ When a certain (server-side) event occurs, the background worker passes the even {% include 'img.html' %} {% 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: `_.created`, where `` 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: `_.saved`, where `` 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: `_.deleted`, where `` 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 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_source: True 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. diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 1567caae14..08f7e1c42f 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -212,6 +212,7 @@ PLUGIN_TESTING_SETUP = get_setting( ) # Load plugins from setup hooks in testing? 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( 'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 3, typecast=int diff --git a/src/backend/InvenTree/InvenTree/unit_test.py b/src/backend/InvenTree/InvenTree/unit_test.py index a5430762b2..6bc758b71d 100644 --- a/src/backend/InvenTree/InvenTree/unit_test.py +++ b/src/backend/InvenTree/InvenTree/unit_test.py @@ -94,6 +94,73 @@ def getNewestMigrationFile(app, exclude_extension=True): 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: """Mixin to setup a user and login for tests. diff --git a/src/backend/InvenTree/build/events.py b/src/backend/InvenTree/build/events.py new file mode 100644 index 0000000000..2e60df6c28 --- /dev/null +++ b/src/backend/InvenTree/build/events.py @@ -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' diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index b397c301bf..342c2b44e2 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -24,6 +24,7 @@ from rest_framework import serializers from build.status_codes import BuildStatus, BuildStatusGroups from stock.status_codes import StockStatus, StockHistoryCode +from build.events import BuildEvents from build.filters import annotate_allocated_quantity from build.validators import generate_next_build_reference, validate_build_order_reference from generic.states import StateTransitionMixin @@ -651,7 +652,7 @@ class Build( raise ValidationError(_("Failed to offload task to complete build allocations")) # 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 targets = [ @@ -718,7 +719,7 @@ class Build( self.status = BuildStatus.PRODUCTION.value self.save() - trigger_event('build.issued', id=self.pk) + trigger_event(BuildEvents.ISSUED, id=self.pk) @transaction.atomic def hold_build(self): @@ -743,7 +744,7 @@ class Build( self.status = BuildStatus.ON_HOLD.value self.save() - trigger_event('build.hold', id=self.pk) + trigger_event(BuildEvents.HOLD, id=self.pk) @transaction.atomic def cancel_build(self, user, **kwargs): @@ -802,7 +803,7 @@ class Build( content=InvenTreeNotificationBodies.OrderCanceled ) - trigger_event('build.cancelled', id=self.pk) + trigger_event(BuildEvents.CANCELLED, id=self.pk) @transaction.atomic def deallocate_stock(self, build_line=None, output=None): @@ -1157,6 +1158,12 @@ class Build( deltas=deltas ) + trigger_event( + BuildEvents.OUTPUT_COMPLETED, + id=output.pk, + build_id=self.pk, + ) + # Increase the completed quantity for this build self.completed += output.quantity diff --git a/src/backend/InvenTree/build/tasks.py b/src/backend/InvenTree/build/tasks.py index 0df3233179..995a897c5f 100644 --- a/src/backend/InvenTree/build/tasks.py +++ b/src/backend/InvenTree/build/tasks.py @@ -21,6 +21,7 @@ import InvenTree.helpers_email import InvenTree.helpers_model import InvenTree.tasks import part.models as part_models +from build.events import BuildEvents from build.status_codes import BuildStatusGroups from InvenTree.ready import isImportingData 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 common.notifications.trigger_notification( diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index a6a463fdc3..07555cb63e 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -45,7 +45,7 @@ class TestBuildAPI(InvenTreeAPITestCase): self.assertEqual(len(response.data), 5) # 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) @@ -221,10 +221,10 @@ class BuildTest(BuildAPITest): { "outputs": [{"output": output.pk} for output in outputs], "location": 1, - "status": 50, # Item requires attention + "status": StockStatus.ATTENTION.value, }, 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) diff --git a/src/backend/InvenTree/build/test_build.py b/src/backend/InvenTree/build/test_build.py index 2df213b370..dcc0c37f37 100644 --- a/src/backend/InvenTree/build/test_build.py +++ b/src/backend/InvenTree/build/test_build.py @@ -4,12 +4,15 @@ from datetime import datetime, timedelta from django.test import TestCase +from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.core.exceptions import ValidationError from django.db.models import Sum +from django.test.utils import override_settings from InvenTree import status_codes as status +from InvenTree.unit_test import findOffloadedEvent import common.models from common.settings import set_global_setting @@ -722,6 +725,59 @@ class BuildTest(BuildTestBase): 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): """Unit tests for the metadata field.""" # Make sure a BuildItem exists before trying to run this test diff --git a/src/backend/InvenTree/generic/events.py b/src/backend/InvenTree/generic/events.py new file mode 100644 index 0000000000..f1b5235e7c --- /dev/null +++ b/src/backend/InvenTree/generic/events.py @@ -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) diff --git a/src/backend/InvenTree/order/events.py b/src/backend/InvenTree/order/events.py new file mode 100644 index 0000000000..a1487409f4 --- /dev/null +++ b/src/backend/InvenTree/order/events.py @@ -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' diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 6eaf37b1c2..654bac4954 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -45,6 +45,7 @@ from InvenTree.fields import ( ) from InvenTree.helpers import decimal2string, pui_url from InvenTree.helpers_model import notify_responsible +from order.events import PurchaseOrderEvents, ReturnOrderEvents, SalesOrderEvents from order.status_codes import ( PurchaseOrderStatus, PurchaseOrderStatusGroups, @@ -635,7 +636,7 @@ class PurchaseOrder(TotalPriceMixin, Order): self.issue_date = InvenTree.helpers.current_date() 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_responsible( @@ -661,7 +662,7 @@ class PurchaseOrder(TotalPriceMixin, Order): if line.part and line.part.part: line.part.part.schedule_pricing_update(create=True) - trigger_event('purchaseorder.completed', id=self.pk) + trigger_event(PurchaseOrderEvents.COMPLETED, id=self.pk) @transaction.atomic def issue_order(self): @@ -729,7 +730,7 @@ class PurchaseOrder(TotalPriceMixin, Order): self.status = PurchaseOrderStatus.CANCELLED.value 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_responsible( @@ -753,7 +754,7 @@ class PurchaseOrder(TotalPriceMixin, Order): self.status = PurchaseOrderStatus.ON_HOLD.value self.save() - trigger_event('purchaseorder.hold', id=self.pk) + trigger_event(PurchaseOrderEvents.HOLD, id=self.pk) # endregion @@ -1143,7 +1144,7 @@ class SalesOrder(TotalPriceMixin, Order): self.issue_date = InvenTree.helpers.current_date() self.save() - trigger_event('salesorder.issued', id=self.pk) + trigger_event(SalesOrderEvents.ISSUED, id=self.pk) @property def can_hold(self): @@ -1159,7 +1160,7 @@ class SalesOrder(TotalPriceMixin, Order): self.status = SalesOrderStatus.ON_HOLD.value self.save() - trigger_event('salesorder.onhold', id=self.pk) + trigger_event(SalesOrderEvents.HOLD, id=self.pk) def _action_complete(self, *args, **kwargs): """Mark this order as "complete.""" @@ -1188,7 +1189,7 @@ class SalesOrder(TotalPriceMixin, Order): if line.part: line.part.schedule_pricing_update(create=True) - trigger_event('salesorder.completed', id=self.pk) + trigger_event(SalesOrderEvents.COMPLETED, id=self.pk) return True @@ -1214,7 +1215,7 @@ class SalesOrder(TotalPriceMixin, Order): for allocation in line.allocations.all(): 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_responsible( @@ -1956,7 +1957,7 @@ class SalesOrderShipment( group='sales_order', ) - trigger_event('salesordershipment.completed', id=self.pk) + trigger_event(SalesOrderEvents.SHIPMENT_COMPLETE, id=self.pk) class SalesOrderExtraLine(OrderExtraLine): @@ -2281,7 +2282,7 @@ class ReturnOrder(TotalPriceMixin, Order): self.status = ReturnOrderStatus.ON_HOLD.value self.save() - trigger_event('returnorder.hold', id=self.pk) + trigger_event(ReturnOrderEvents.HOLD, id=self.pk) @property def can_cancel(self): @@ -2294,7 +2295,7 @@ class ReturnOrder(TotalPriceMixin, Order): self.status = ReturnOrderStatus.CANCELLED.value 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_responsible( @@ -2311,7 +2312,7 @@ class ReturnOrder(TotalPriceMixin, Order): self.complete_date = InvenTree.helpers.current_date() self.save() - trigger_event('returnorder.completed', id=self.pk) + trigger_event(ReturnOrderEvents.COMPLETED, id=self.pk) def place_order(self): """Deprecated version of 'issue_order.""" @@ -2332,7 +2333,7 @@ class ReturnOrder(TotalPriceMixin, Order): self.issue_date = InvenTree.helpers.current_date() self.save() - trigger_event('returnorder.issued', id=self.pk) + trigger_event(ReturnOrderEvents.ISSUED, id=self.pk) @transaction.atomic def hold_order(self): @@ -2422,7 +2423,7 @@ class ReturnOrder(TotalPriceMixin, Order): line.received_date = InvenTree.helpers.current_date() line.save() - trigger_event('returnorder.received', id=self.pk) + trigger_event(ReturnOrderEvents.RECEIVED, id=self.pk) # Notify responsible users notify_responsible( diff --git a/src/backend/InvenTree/order/tasks.py b/src/backend/InvenTree/order/tasks.py index f2f37327da..9b47d2d24b 100644 --- a/src/backend/InvenTree/order/tasks.py +++ b/src/backend/InvenTree/order/tasks.py @@ -11,6 +11,7 @@ import common.notifications import InvenTree.helpers_model import order.models from InvenTree.tasks import ScheduledTask, scheduled_task +from order.events import PurchaseOrderEvents, SalesOrderEvents from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups 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}, } - event_name = 'order.overdue_purchase_order' + event_name = PurchaseOrderEvents.OVERDUE # Send a notification to the appropriate users 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}, } - event_name = 'order.overdue_sales_order' + event_name = SalesOrderEvents.OVERDUE # Send a notification to the appropriate users common.notifications.trigger_notification( diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 3d43484a55..37dd173c83 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -133,8 +133,8 @@ class PurchaseOrderTest(OrderTest): self.filter({'outstanding': False}, 2) # Filter by "status" - self.filter({'status': 10}, 3) - self.filter({'status': 40}, 1) + self.filter({'status': PurchaseOrderStatus.PENDING.value}, 3) + self.filter({'status': PurchaseOrderStatus.CANCELLED.value}, 1) # Filter by "reference" self.filter({'reference': 'PO-0001'}, 1) @@ -1264,8 +1264,8 @@ class SalesOrderTest(OrderTest): self.filter({'outstanding': False}, 2) # Filter by status - self.filter({'status': 10}, 3) # PENDING - self.filter({'status': 20}, 1) # SHIPPED + self.filter({'status': SalesOrderStatus.PENDING.value}, 3) # PENDING + self.filter({'status': SalesOrderStatus.SHIPPED.value}, 1) # SHIPPED self.filter({'status': 99}, 0) # Invalid # Filter by "reference" @@ -2229,7 +2229,9 @@ class ReturnOrderTests(InvenTreeAPITestCase): self.assertEqual(result['customer'], cmp_id) # 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) diff --git a/src/backend/InvenTree/part/events.py b/src/backend/InvenTree/part/events.py new file mode 100644 index 0000000000..2256092d2d --- /dev/null +++ b/src/backend/InvenTree/part/events.py @@ -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.""" diff --git a/src/backend/InvenTree/plugin/base/event/events.py b/src/backend/InvenTree/plugin/base/event/events.py index 992a0a50e2..c99a7547f7 100644 --- a/src/backend/InvenTree/plugin/base/event/events.py +++ b/src/backend/InvenTree/plugin/base/event/events.py @@ -16,16 +16,23 @@ from plugin.registry import registry logger = logging.getLogger('inventree') -def trigger_event(event, *args, **kwargs): +def trigger_event(event: str, *args, **kwargs) -> None: """Trigger an event with optional arguments. - This event will be stored in the database, - and the worker will respond to it later on. + Arguments: + 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): # Do nothing if plugin events are not enabled return + # Ensure event name is stringified + event = str(event).strip() + # Make sure the database can be accessed and is not being tested rn if ( not canAppAccessDatabase(allow_shell=True) @@ -36,9 +43,13 @@ def trigger_event(event, *args, **kwargs): logger.debug("Event triggered: '%s'", event) - # By default, force the event to be processed asynchronously - if 'force_async' not in kwargs and not settings.PLUGIN_TESTING_EVENTS: - kwargs['force_async'] = True + force_async = kwargs.pop('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) @@ -179,4 +190,9 @@ def after_delete(sender, instance, **kwargs): if not allow_table_event(table): 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) diff --git a/src/backend/InvenTree/plugin/events.py b/src/backend/InvenTree/plugin/events.py index 4ac5ff0544..c4f9d8006b 100644 --- a/src/backend/InvenTree/plugin/events.py +++ b/src/backend/InvenTree/plugin/events.py @@ -1,5 +1,14 @@ """Import helper for events.""" +from generic.events import BaseEventEnum 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'] diff --git a/src/backend/InvenTree/plugin/models.py b/src/backend/InvenTree/plugin/models.py index 95263caf52..18d4c6c51f 100644 --- a/src/backend/InvenTree/plugin/models.py +++ b/src/backend/InvenTree/plugin/models.py @@ -14,6 +14,7 @@ import common.models import InvenTree.models import plugin.staticfiles from plugin import InvenTreePlugin, registry +from plugin.events import PluginEvents, trigger_event class PluginConfig(InvenTree.models.MetadataMixin, models.Model): @@ -234,6 +235,8 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): self.active = active self.save() + trigger_event(PluginEvents.PLUGIN_ACTIVATED, slug=self.key, active=active) + if active: offload_task(check_for_migrations) offload_task( diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index 5f75002035..d7eb58a31b 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -15,7 +15,7 @@ from collections import OrderedDict from importlib.machinery import SourceFileLoader from pathlib import Path from threading import Lock -from typing import Any +from typing import Any, Union from django.apps import apps from django.conf import settings @@ -104,7 +104,7 @@ class PluginsRegistry: 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. Args: @@ -237,9 +237,9 @@ class PluginsRegistry: # Trigger plugins_loaded event 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): """Unload and deactivate all IntegrationPlugins. diff --git a/src/backend/InvenTree/stock/events.py b/src/backend/InvenTree/stock/events.py new file mode 100644 index 0000000000..d8d3a8f420 --- /dev/null +++ b/src/backend/InvenTree/stock/events.py @@ -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' diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index d1c5de1849..f7598e5a17 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -47,6 +47,7 @@ from InvenTree.status_codes import ( ) from part import models as PartModels from plugin.events import trigger_event +from stock.events import StockEvents from stock.generators import generate_batch_code from users.models import Owner @@ -1205,7 +1206,7 @@ class StockItem( item.add_tracking_entry(code, user, deltas, notes=notes) trigger_event( - 'stockitem.assignedtocustomer', + StockEvents.ITEM_ASSIGNED_TO_CUSTOMER, id=self.id, customer=customer.id if customer else None, ) @@ -1241,12 +1242,15 @@ class StockItem( self.belongs_to = None self.sales_order = None self.location = location - self.clearAllocations() if status := kwargs.get('status'): self.status = status tracking_info['status'] = status + self.save() + + self.clearAllocations() + self.add_tracking_entry( StockHistoryCode.RETURNED_FROM_CUSTOMER, user, @@ -1255,7 +1259,7 @@ class StockItem( 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 self.parent and self.location == self.parent.location: @@ -1414,7 +1418,10 @@ class StockItem( # Assign the other stock item into this one 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.save(add_note=False) @@ -1436,6 +1443,12 @@ class StockItem( deltas={'stockitem': stock_item.pk}, ) + trigger_event( + StockEvents.ITEM_INSTALLED_INTO_ASSEMBLY, + id=stock_item.pk, + assembly_id=self.pk, + ) + @transaction.atomic def uninstall_into_location(self, location, user, notes): """Uninstall this stock item from another item, into a location. @@ -2042,7 +2055,7 @@ class StockItem( except Exception: 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 new_stock @@ -2129,7 +2142,7 @@ class StockItem( # Trigger event for the plugin system trigger_event( - 'stockitem.moved', + StockEvents.ITEM_MOVED, id=self.id, old_location=current_location.id if current_location else None, new_location=location.id if location else None, @@ -2167,6 +2180,11 @@ class StockItem( return False self.save() + + trigger_event( + StockEvents.ITEM_QUANTITY_UPDATED, id=self.id, quantity=float(self.quantity) + ) + return True @transaction.atomic @@ -2210,6 +2228,13 @@ class StockItem( deltas=tracking_info, ) + trigger_event( + StockEvents.ITEM_COUNTED, + 'stockitem.counted', + id=self.id, + quantity=float(self.quantity), + ) + return True @transaction.atomic diff --git a/src/backend/InvenTree/users/authentication.py b/src/backend/InvenTree/users/authentication.py index 01d7130ee1..4a37d4210d 100644 --- a/src/backend/InvenTree/users/authentication.py +++ b/src/backend/InvenTree/users/authentication.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions from rest_framework.authentication import TokenAuthentication -from users.models import ApiToken +import users.models class ApiTokenAuthentication(TokenAuthentication): @@ -18,7 +18,7 @@ class ApiTokenAuthentication(TokenAuthentication): - Tokens can expire """ - model = ApiToken + model = users.models.ApiToken def authenticate_credentials(self, key): """Adds additional checks to the default token authentication method."""