mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +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:
parent
8e0f79cdfc
commit
42dcc01f9d
@ -366,6 +366,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
|||||||
target_exclude = kwargs.get('target_exclude')
|
target_exclude = kwargs.get('target_exclude')
|
||||||
context = kwargs.get('context', {})
|
context = kwargs.get('context', {})
|
||||||
delivery_methods = kwargs.get('delivery_methods')
|
delivery_methods = kwargs.get('delivery_methods')
|
||||||
|
check_recent = kwargs.get('check_recent', True)
|
||||||
|
|
||||||
# Check if data is importing currently
|
# Check if data is importing currently
|
||||||
if isImportingData() or isRebuildingData():
|
if isImportingData() or isRebuildingData():
|
||||||
@ -391,7 +392,9 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
|||||||
# Check if we have notified recently...
|
# Check if we have notified recently...
|
||||||
delta = timedelta(days=1)
|
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(
|
logger.info(
|
||||||
"Notification '%s' has recently been sent for '%s' - SKIPPING",
|
"Notification '%s' has recently been sent for '%s' - SKIPPING",
|
||||||
category,
|
category,
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
@ -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 %}
|
62
src/frontend/src/components/buttons/StarredToggleButton.tsx
Normal file
62
src/frontend/src/components/buttons/StarredToggleButton.tsx
Normal 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'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -11,6 +11,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import AdminButton from '../../components/buttons/AdminButton';
|
import AdminButton from '../../components/buttons/AdminButton';
|
||||||
|
import StarredToggleButton from '../../components/buttons/StarredToggleButton';
|
||||||
import {
|
import {
|
||||||
type DetailsField,
|
type DetailsField,
|
||||||
DetailsTable
|
DetailsTable
|
||||||
@ -227,6 +228,14 @@ export default function CategoryDetail() {
|
|||||||
model={ModelType.partcategory}
|
model={ModelType.partcategory}
|
||||||
id={category.pk}
|
id={category.pk}
|
||||||
/>,
|
/>,
|
||||||
|
<StarredToggleButton
|
||||||
|
key='starred_change'
|
||||||
|
instance={category}
|
||||||
|
model={ModelType.partcategory}
|
||||||
|
successFunction={() => {
|
||||||
|
refreshInstance();
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
<OptionsActionDropdown
|
<OptionsActionDropdown
|
||||||
key='category-actions'
|
key='category-actions'
|
||||||
tooltip={t`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(
|
const panels: PanelType[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
@ -35,6 +35,7 @@ import Select from 'react-select';
|
|||||||
|
|
||||||
import AdminButton from '../../components/buttons/AdminButton';
|
import AdminButton from '../../components/buttons/AdminButton';
|
||||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||||
|
import StarredToggleButton from '../../components/buttons/StarredToggleButton';
|
||||||
import {
|
import {
|
||||||
type DetailsField,
|
type DetailsField,
|
||||||
DetailsTable
|
DetailsTable
|
||||||
@ -881,6 +882,14 @@ export default function PartDetail() {
|
|||||||
const partActions = useMemo(() => {
|
const partActions = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
<AdminButton model={ModelType.part} id={part.pk} />,
|
<AdminButton model={ModelType.part} id={part.pk} />,
|
||||||
|
<StarredToggleButton
|
||||||
|
key='starred_change'
|
||||||
|
instance={part}
|
||||||
|
model={ModelType.part}
|
||||||
|
successFunction={() => {
|
||||||
|
refreshInstance();
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
<BarcodeActionDropdown
|
<BarcodeActionDropdown
|
||||||
model={ModelType.part}
|
model={ModelType.part}
|
||||||
pk={part.pk}
|
pk={part.pk}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user