From dd9a6a8a2d5f1c849b0f91ebd6f98c37c34b6a42 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 29 Nov 2024 12:05:05 +1100 Subject: [PATCH] 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 --- docs/docs/extend/plugins/event.md | 154 +++++++++++++++++- src/backend/InvenTree/InvenTree/settings.py | 1 + src/backend/InvenTree/InvenTree/unit_test.py | 67 ++++++++ src/backend/InvenTree/build/events.py | 18 ++ src/backend/InvenTree/build/models.py | 15 +- src/backend/InvenTree/build/tasks.py | 3 +- src/backend/InvenTree/build/test_api.py | 6 +- src/backend/InvenTree/build/test_build.py | 56 +++++++ src/backend/InvenTree/generic/events.py | 11 ++ src/backend/InvenTree/order/events.py | 37 +++++ src/backend/InvenTree/order/models.py | 29 ++-- src/backend/InvenTree/order/tasks.py | 5 +- src/backend/InvenTree/order/test_api.py | 12 +- src/backend/InvenTree/part/events.py | 7 + .../InvenTree/plugin/base/event/events.py | 30 +++- src/backend/InvenTree/plugin/events.py | 11 +- src/backend/InvenTree/plugin/models.py | 3 + src/backend/InvenTree/plugin/registry.py | 8 +- src/backend/InvenTree/stock/events.py | 16 ++ src/backend/InvenTree/stock/models.py | 37 ++++- src/backend/InvenTree/users/authentication.py | 4 +- 21 files changed, 472 insertions(+), 58 deletions(-) create mode 100644 src/backend/InvenTree/build/events.py create mode 100644 src/backend/InvenTree/generic/events.py create mode 100644 src/backend/InvenTree/order/events.py create mode 100644 src/backend/InvenTree/part/events.py create mode 100644 src/backend/InvenTree/stock/events.py diff --git a/docs/docs/extend/plugins/event.md b/docs/docs/extend/plugins/event.md index 96a41ca3c5a0..8ec1ccc6f946 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 1567caae14b1..08f7e1c42f83 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -212,6 +212,7 @@ ) # 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 a5430762b2e4..6bc758b71db8 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 000000000000..2e60df6c28c9 --- /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 b397c301bf7d..342c2b44e2ea 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -24,6 +24,7 @@ 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 @@ def _action_complete(self, *args, **kwargs): 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 @@ def _action_issue(self, *args, **kwargs): 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 @@ def _action_hold(self, *args, **kwargs): 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 @@ def _action_cancel(self, *args, **kwargs): 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 @@ def complete_build_output(self, output, user, **kwargs): 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 0df3233179f2..995a897c5fe3 100644 --- a/src/backend/InvenTree/build/tasks.py +++ b/src/backend/InvenTree/build/tasks.py @@ -21,6 +21,7 @@ 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 a6a463fdc3ad..07555cb63e63 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -45,7 +45,7 @@ def test_get_build_list(self): 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 @@ def test_complete(self): { "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 2df213b3705c..dcc0c37f379f 100644 --- a/src/backend/InvenTree/build/test_build.py +++ b/src/backend/InvenTree/build/test_build.py @@ -4,12 +4,15 @@ 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 @@ def test_new_build_notification(self): 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 000000000000..f1b5235e7cb3 --- /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 000000000000..a1487409f438 --- /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 6eaf37b1c2c0..654bac495474 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -45,6 +45,7 @@ ) 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 @@ def _action_place(self, *args, **kwargs): 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 @@ def _action_complete(self, *args, **kwargs): 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 @@ def _action_cancel(self, *args, **kwargs): 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 @@ def _action_hold(self, *args, **kwargs): 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 @@ def _action_place(self, *args, **kwargs): 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 @@ def _action_hold(self, *args, **kwargs): 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 @@ def _action_complete(self, *args, **kwargs): 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 @@ def _action_cancel(self, *args, **kwargs): 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 @@ def complete_shipment(self, user, **kwargs): 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 @@ def _action_hold(self, *args, **kwargs): 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 @@ def _action_cancel(self, *args, **kwargs): 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 @@ def _action_complete(self, *args, **kwargs): 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 @@ def _action_place(self, *args, **kwargs): 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 @@ def receive_line_item(self, line, location, user, **kwargs): 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 f2f37327daaf..9b47d2d24bd4 100644 --- a/src/backend/InvenTree/order/tasks.py +++ b/src/backend/InvenTree/order/tasks.py @@ -11,6 +11,7 @@ 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 3d43484a5586..37dd173c838e 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -133,8 +133,8 @@ def test_po_list(self): 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 @@ def test_so_list(self): 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 @@ def test_list(self): 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 000000000000..2256092d2d2e --- /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 992a0a50e250..c99a7547f7f3 100644 --- a/src/backend/InvenTree/plugin/base/event/events.py +++ b/src/backend/InvenTree/plugin/base/event/events.py @@ -16,16 +16,23 @@ 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 4ac5ff05444d..c4f9d8006bcd 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 95263caf52f1..18d4c6c51fbf 100644 --- a/src/backend/InvenTree/plugin/models.py +++ b/src/backend/InvenTree/plugin/models.py @@ -14,6 +14,7 @@ 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 @@ def activate(self, active: bool) -> None: 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 5f75002035e5..d7eb58a31b34 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -15,7 +15,7 @@ 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 @@ def get_plugin(self, slug, active=None): 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 @@ def _load_plugins(self, full_reload: bool = False): # 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 000000000000..d8d3a8f420c2 --- /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 d1c5de1849d3..f7598e5a1717 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -47,6 +47,7 @@ ) 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 @@ def allocateToCustomer( 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 @@ def return_from_customer(self, location, user=None, **kwargs): 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 @@ def return_from_customer(self, location, user=None, **kwargs): 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 @@ def installStockItem(self, other_item, quantity, user, notes, build=None): # 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 @@ def installStockItem(self, other_item, quantity, user, notes, build=None): 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 @@ def splitStock(self, quantity, location=None, user=None, **kwargs): 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 @@ def move(self, location, notes, user, **kwargs): # 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 @@ def updateQuantity(self, quantity): 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 @@ def stocktake(self, count, user, **kwargs): 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 01d7130ee15c..4a37d4210d47 100644 --- a/src/backend/InvenTree/users/authentication.py +++ b/src/backend/InvenTree/users/authentication.py @@ -7,7 +7,7 @@ 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."""