2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 03:26:45 +00:00

feat: Add email notification when a part is changed (#9275)

* Add function to star / unstar a part

* Also use with category

* Email notification when a part is changed
Fixes #7834

* enable disabling of recent checks

* Add error handler

* remove unneeded function
This commit is contained in:
Matthias Mair 2025-03-24 23:21:11 +01:00 committed by GitHub
parent 8e0f79cdfc
commit 42dcc01f9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 187 additions and 2 deletions

View File

@ -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,

View File

@ -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,
},
},
)

View File

@ -0,0 +1,33 @@
{% extends "email/email.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block title %}
{{ message }}
{% if link %}
<p>{% trans "Click on the following link to view this part" %}: <a href="{{ link }}">{{ link }}</a></p>
{% endif %}
{% endblock title %}
{% block body %}
<tr style="height: 3rem; border-bottom: 1px solid">
<th>{% trans "Part" %}</th>
<th>{% trans "Part Category" %}</th>
<th>{% trans "Total Stock" %}</th>
<th>{% trans "Available" %}</th>
<th>{% trans "Minimum Quantity" %}</th>
</tr>
<tr style="height: 3rem">
<td style="text-align: center;">{{ part.full_name }}</td>
<td style="text-align: center;">{{ part.category_path }}</td>
<td style="text-align: center;">{% decimal part.total_stock %}</td>
<td style="text-align: center;">{% decimal part.available_stock %}</td>
<td style="text-align: center;">{% decimal part.minimum_stock %}</td>
</tr>
{% endblock body %}
{% block footer_prefix %}
<p><em>{% 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 %}.</em></p>
{% endblock footer_prefix %}

View File

@ -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 (
<ActionButton
icon={<IconBell />}
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'
/>
);
}

View File

@ -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}
/>,
<StarredToggleButton
key='starred_change'
instance={category}
model={ModelType.partcategory}
successFunction={() => {
refreshInstance();
}}
/>,
<OptionsActionDropdown
key='category-actions'
tooltip={t`Category Actions`}
@ -244,7 +253,7 @@ export default function CategoryDetail() {
]}
/>
];
}, [id, user, category.pk]);
}, [id, user, category.pk, category.starred]);
const panels: PanelType[] = useMemo(
() => [

View File

@ -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 [
<AdminButton model={ModelType.part} id={part.pk} />,
<StarredToggleButton
key='starred_change'
instance={part}
model={ModelType.part}
successFunction={() => {
refreshInstance();
}}
/>,
<BarcodeActionDropdown
model={ModelType.part}
pk={part.pk}