diff --git a/src/backend/InvenTree/common/notifications.py b/src/backend/InvenTree/common/notifications.py index fe911f1140..dac6f07759 100644 --- a/src/backend/InvenTree/common/notifications.py +++ b/src/backend/InvenTree/common/notifications.py @@ -366,6 +366,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): target_exclude = kwargs.get('target_exclude') context = kwargs.get('context', {}) delivery_methods = kwargs.get('delivery_methods') + check_recent = kwargs.get('check_recent', True) # Check if data is importing currently if isImportingData() or isRebuildingData(): @@ -391,7 +392,9 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): # Check if we have notified recently... delta = timedelta(days=1) - if common.models.NotificationEntry.check_recent(category, obj_ref_value, delta): + if check_recent and common.models.NotificationEntry.check_recent( + category, obj_ref_value, delta + ): logger.info( "Notification '%s' has recently been sent for '%s' - SKIPPING", category, diff --git a/src/backend/InvenTree/plugin/builtin/integration/part_notifications.py b/src/backend/InvenTree/plugin/builtin/integration/part_notifications.py new file mode 100644 index 0000000000..83fd1d31a2 --- /dev/null +++ b/src/backend/InvenTree/plugin/builtin/integration/part_notifications.py @@ -0,0 +1,69 @@ +"""Core set of Notifications as a Plugin.""" + +from django.utils.translation import gettext_lazy as _ + +import structlog + +import common.models +import common.notifications +import InvenTree.helpers +import InvenTree.helpers_email +import InvenTree.helpers_model +import InvenTree.tasks +from part.models import Part +from plugin import InvenTreePlugin +from plugin.mixins import EventMixin, SettingsMixin + +logger = structlog.get_logger('inventree') + + +class PartNotificationsPlugin(SettingsMixin, EventMixin, InvenTreePlugin): + """Core notification methods for InvenTree.""" + + NAME = 'PartNotificationsPlugin' + TITLE = _('Part Notifications') + AUTHOR = _('InvenTree contributors') + DESCRIPTION = _('Notify users about part changes') + VERSION = '1.0.0' + + SETTINGS = { + 'ENABLE_PART_NOTIFICATIONS': { + 'name': _('Send notifications'), + 'description': _('Send notifications for part changes to subscribed users'), + 'default': False, + 'validator': bool, + } + } + + def wants_process_event(self, event): + """Return whether given event should be processed or not.""" + return event.startswith('part_part.') + + def process_event(self, event, *args, **kwargs): + """Custom event processing.""" + if not self.get_setting('ENABLE_PART_NOTIFICATIONS'): + return + part = Part.objects.get(pk=kwargs['id']) + part_action = event.split('.')[-1] + + name = _('Changed part notification') + common.notifications.trigger_notification( + part, + 'part.notification', + target_fnc=part.get_subscribers, + check_recent=False, + context={ + 'part': part, + 'name': name, + 'message': _( + f'The part `{part.name}` has been triggered with a `{part_action}` event' + ), + 'link': InvenTree.helpers_model.construct_absolute_url( + part.get_absolute_url() + ), + 'template': { + 'html': 'email/part_event_notification.html', + 'subject': name, + }, + }, + ) diff --git a/src/backend/InvenTree/templates/email/part_event_notification.html b/src/backend/InvenTree/templates/email/part_event_notification.html new file mode 100644 index 0000000000..c61d2afa92 --- /dev/null +++ b/src/backend/InvenTree/templates/email/part_event_notification.html @@ -0,0 +1,33 @@ +{% extends "email/email.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block title %} +{{ message }} +{% if link %} +

{% trans "Click on the following link to view this part" %}: {{ link }}

+{% endif %} +{% endblock title %} + +{% block body %} + + {% trans "Part" %} + {% trans "Part Category" %} + {% trans "Total Stock" %} + {% trans "Available" %} + {% trans "Minimum Quantity" %} + + + + {{ part.full_name }} + {{ part.category_path }} + {% decimal part.total_stock %} + {% decimal part.available_stock %} + {% decimal part.minimum_stock %} + +{% endblock body %} + +{% block footer_prefix %} +

{% blocktrans with part=part.name %}You are receiving this email because you are subscribed to notifications for this part or a category that it is part of {% endblocktrans %}.

+{% endblock footer_prefix %} diff --git a/src/frontend/src/components/buttons/StarredToggleButton.tsx b/src/frontend/src/components/buttons/StarredToggleButton.tsx new file mode 100644 index 0000000000..6ebc9aaa05 --- /dev/null +++ b/src/frontend/src/components/buttons/StarredToggleButton.tsx @@ -0,0 +1,62 @@ +import { t } from '@lingui/macro'; +import { showNotification } from '@mantine/notifications'; +import { IconBell } from '@tabler/icons-react'; +import { useApi } from '../../contexts/ApiContext'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; +import { apiUrl } from '../../states/ApiState'; +import { ActionButton } from './ActionButton'; + +export default function StarredToggleButton({ + instance, + model, + successFunction +}: Readonly<{ + instance: any; + model: ModelType.part | ModelType.partcategory; + successFunction: () => void; +}>): JSX.Element { + const api = useApi(); + + function change(starred: boolean, partPk: number) { + api + .patch( + apiUrl( + model == ModelType.part + ? ApiEndpoints.part_list + : ApiEndpoints.category_list, + partPk + ), + { starred: !starred } + ) + .then(() => { + showNotification({ + title: 'Subscription updated', + message: `Subscription ${starred ? 'removed' : 'added'}`, + autoClose: 5000, + color: 'blue' + }); + successFunction(); + }) + .catch((error) => { + showNotification({ + title: 'Error', + message: error.message, + autoClose: 5000, + color: 'red' + }); + }); + } + + return ( + } + color={instance.starred ? 'green' : 'blue'} + size='lg' + variant={instance.starred ? 'filled' : 'outline'} + tooltip={t`Unsubscribe from part`} + onClick={() => change(instance.starred, instance.pk)} + tooltipAlignment='bottom' + /> + ); +} diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx index 25b940952c..d059d7efb1 100644 --- a/src/frontend/src/pages/part/CategoryDetail.tsx +++ b/src/frontend/src/pages/part/CategoryDetail.tsx @@ -11,6 +11,7 @@ import { useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import AdminButton from '../../components/buttons/AdminButton'; +import StarredToggleButton from '../../components/buttons/StarredToggleButton'; import { type DetailsField, DetailsTable @@ -227,6 +228,14 @@ export default function CategoryDetail() { model={ModelType.partcategory} id={category.pk} />, + { + refreshInstance(); + }} + />, ]; - }, [id, user, category.pk]); + }, [id, user, category.pk, category.starred]); const panels: PanelType[] = useMemo( () => [ diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 69f340fc86..9f006de70c 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -35,6 +35,7 @@ import Select from 'react-select'; import AdminButton from '../../components/buttons/AdminButton'; import { PrintingActions } from '../../components/buttons/PrintingActions'; +import StarredToggleButton from '../../components/buttons/StarredToggleButton'; import { type DetailsField, DetailsTable @@ -881,6 +882,14 @@ export default function PartDetail() { const partActions = useMemo(() => { return [ , + { + refreshInstance(); + }} + />,