2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

Overdue order notification (#3114)

* Adds a background task to notify users when a PurchaseOrder becomes overdue

* Schedule the overdue purchaseorder check to occur daily

* Allow notifications to be sent to "Owner" instances

- Extract user information from the Owner instance

* add unit test to ensure notifications are sent for overdue purchase orders

* Adds notification for overdue sales orders

* Clean up notification display panel

- Simplify rendering
- Order "newest at top"
- Element alignment tweaks

* style fixes

* More style fixes

* Tweak notification padding

* Fix import order

* Adds task to notify user of overdue build orders

* Adds unit tests for build order notifications

* Refactor subject line for emails:

- Use the configured instance title as a prefix for the subject line

* Add email template for overdue build orders

* Fix unit tests to accommodate new default value

* Logic error fix
This commit is contained in:
Oliver 2022-06-06 19:12:29 +10:00 committed by GitHub
parent 7b4d0605b8
commit 1e6bdfbcab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 439 additions and 22 deletions

View File

@ -57,6 +57,7 @@ class InvenTreeConfig(AppConfig):
try: try:
from django_q.models import Schedule from django_q.models import Schedule
except AppRegistryNotReady: # pragma: no cover except AppRegistryNotReady: # pragma: no cover
logger.warning("Cannot start background tasks - app registry not ready")
return return
logger.info("Starting background tasks...") logger.info("Starting background tasks...")
@ -98,6 +99,24 @@ class InvenTreeConfig(AppConfig):
schedule_type=Schedule.DAILY, schedule_type=Schedule.DAILY,
) )
# Check for overdue purchase orders
InvenTree.tasks.schedule_task(
'order.tasks.check_overdue_purchase_orders',
schedule_type=Schedule.DAILY
)
# Check for overdue sales orders
InvenTree.tasks.schedule_task(
'order.tasks.check_overdue_sales_orders',
schedule_type=Schedule.DAILY,
)
# Check for overdue build orders
InvenTree.tasks.schedule_task(
'build.tasks.check_overdue_build_orders',
schedule_type=Schedule.DAILY
)
def update_exchange_rates(self): # pragma: no cover def update_exchange_rates(self): # pragma: no cover
"""Update exchange rates each time the server is started. """Update exchange rates each time the server is started.

View File

@ -1,5 +1,6 @@
"""Background task definitions for the BuildOrder app""" """Background task definitions for the BuildOrder app"""
from datetime import datetime, timedelta
from decimal import Decimal from decimal import Decimal
import logging import logging
@ -8,9 +9,12 @@ from django.template.loader import render_to_string
from allauth.account.models import EmailAddress from allauth.account.models import EmailAddress
from plugin.events import trigger_event
import common.notifications
import build.models import build.models
import InvenTree.helpers import InvenTree.helpers
import InvenTree.tasks import InvenTree.tasks
from InvenTree.status_codes import BuildStatus
from InvenTree.ready import isImportingData from InvenTree.ready import isImportingData
import part.models as part_models import part.models as part_models
@ -93,8 +97,67 @@ def check_build_stock(build: build.models.Build):
# Render the HTML message # Render the HTML message
html_message = render_to_string('email/build_order_required_stock.html', context) html_message = render_to_string('email/build_order_required_stock.html', context)
subject = "[InvenTree] " + _("Stock required for build order") subject = _("Stock required for build order")
recipients = emails.values_list('email', flat=True) recipients = emails.values_list('email', flat=True)
InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message) InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message)
def notify_overdue_build_order(bo: build.models.Build):
"""Notify appropriate users that a Build has just become 'overdue'"""
targets = []
if bo.issued_by:
targets.append(bo.issued_by)
if bo.responsible:
targets.append(bo.responsible)
name = _('Overdue Build Order')
context = {
'order': bo,
'name': name,
'message': _(f"Build order {bo} is now overdue"),
'link': InvenTree.helpers.construct_absolute_url(
bo.get_absolute_url(),
),
'template': {
'html': 'email/overdue_build_order.html',
'subject': name,
}
}
event_name = 'build.overdue_build_order'
# Send a notification to the appropriate users
common.notifications.trigger_notification(
bo,
event_name,
targets=targets,
context=context
)
# Register a matching event to the plugin system
trigger_event(event_name, build_order=bo.pk)
def check_overdue_build_orders():
"""Check if any outstanding BuildOrders have just become overdue
- This check is performed daily
- Look at the 'target_date' of any outstanding BuildOrder objects
- If the 'target_date' expired *yesterday* then the order is just out of date
"""
yesterday = datetime.now().date() - timedelta(days=1)
overdue_orders = build.models.Build.objects.filter(
target_date=yesterday,
status__in=BuildStatus.ACTIVE_CODES
)
for bo in overdue_orders:
notify_overdue_build_order(bo)

View File

@ -1,11 +1,16 @@
"""Unit tests for the 'build' models""" """Unit tests for the 'build' models"""
from datetime import datetime, timedelta
from django.test import TestCase from django.test import TestCase
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from InvenTree import status_codes as status from InvenTree import status_codes as status
import common.models
import build.tasks
from build.models import Build, BuildItem, get_next_build_number from build.models import Build, BuildItem, get_next_build_number
from part.models import Part, BomItem, BomItemSubstitute from part.models import Part, BomItem, BomItemSubstitute
from stock.models import StockItem from stock.models import StockItem
@ -14,6 +19,10 @@ from stock.models import StockItem
class BuildTestBase(TestCase): class BuildTestBase(TestCase):
"""Run some tests to ensure that the Build model is working properly.""" """Run some tests to ensure that the Build model is working properly."""
fixtures = [
'users',
]
def setUp(self): def setUp(self):
"""Initialize data to use for these tests. """Initialize data to use for these tests.
@ -84,7 +93,8 @@ class BuildTestBase(TestCase):
reference=ref, reference=ref,
title="This is a build", title="This is a build",
part=self.assembly, part=self.assembly,
quantity=10 quantity=10,
issued_by=get_user_model().objects.get(pk=1),
) )
# Create some build output (StockItem) objects # Create some build output (StockItem) objects
@ -450,8 +460,6 @@ class AutoAllocationTests(BuildTestBase):
substitutes=True, substitutes=True,
) )
# self.assertTrue(self.build.are_untracked_parts_allocated())
# self.assertEqual(self.build.allocated_stock.count(), 8) # self.assertEqual(self.build.allocated_stock.count(), 8)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0) self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
@ -471,3 +479,19 @@ class AutoAllocationTests(BuildTestBase):
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0) self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
def test_overdue_notification(self):
"""Test sending of notifications when a build order is overdue."""
self.build.target_date = datetime.now().date() - timedelta(days=1)
self.build.save()
# Check for overdue orders
build.tasks.check_overdue_build_orders()
message = common.models.NotificationMessage.objects.get(
category='build.overdue_build_order',
user__id=1,
)
self.assertEqual(message.name, 'Overdue Build Order')

View File

@ -274,6 +274,7 @@ class NotificationList(generics.ListAPIView):
'category', 'category',
'name', 'name',
'read', 'read',
'creation',
] ]
search_fields = [ search_fields = [

View File

@ -710,7 +710,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'INVENTREE_INSTANCE': { 'INVENTREE_INSTANCE': {
'name': _('Server Instance Name'), 'name': _('Server Instance Name'),
'default': 'InvenTree server', 'default': 'InvenTree',
'description': _('String descriptor for the server instance'), 'description': _('String descriptor for the server instance'),
}, },

View File

@ -3,11 +3,15 @@
import logging import logging
from datetime import timedelta from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from common.models import NotificationEntry, NotificationMessage from common.models import NotificationEntry, NotificationMessage
from InvenTree.helpers import inheritors from InvenTree.helpers import inheritors
from InvenTree.ready import isImportingData from InvenTree.ready import isImportingData
from plugin import registry from plugin import registry
from plugin.models import NotificationUserSetting from plugin.models import NotificationUserSetting
from users.models import Owner
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -266,7 +270,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
if isImportingData(): if isImportingData():
return return
# Resolve objekt reference # Resolve object reference
obj_ref_value = getattr(obj, obj_ref) obj_ref_value = getattr(obj, obj_ref)
# Try with some defaults # Try with some defaults
@ -285,11 +289,33 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
return return
logger.info(f"Gathering users for notification '{category}'") logger.info(f"Gathering users for notification '{category}'")
# Collect possible targets # Collect possible targets
if not targets: if not targets:
targets = target_fnc(*target_args, **target_kwargs) targets = target_fnc(*target_args, **target_kwargs)
# Convert list of targets to a list of users
# (targets may include 'owner' or 'group' classes)
target_users = set()
if targets: if targets:
for target in targets:
# User instance is provided
if isinstance(target, get_user_model()):
target_users.add(target)
# Group instance is provided
elif isinstance(target, Group):
for user in get_user_model().objects.filter(groups__name=target.name):
target_users.add(user)
# Owner instance (either 'user' or 'group' is provided)
elif isinstance(target, Owner):
for owner in target.get_related_owners(include_group=False):
target_users.add(owner.owner)
# Unhandled type
else:
logger.error(f"Unknown target passed to trigger_notification method: {target}")
if target_users:
logger.info(f"Sending notification '{category}' for '{str(obj)}'") logger.info(f"Sending notification '{category}' for '{str(obj)}'")
# Collect possible methods # Collect possible methods
@ -301,7 +327,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
for method in delivery_methods: for method in delivery_methods:
logger.info(f"Triggering notification method '{method.METHOD_NAME}'") logger.info(f"Triggering notification method '{method.METHOD_NAME}'")
try: try:
deliver_notification(method, obj, category, targets, context) deliver_notification(method, obj, category, target_users, context)
except NotImplementedError as error: except NotImplementedError as error:
# Allow any single notification method to fail, without failing the others # Allow any single notification method to fail, without failing the others
logger.error(error) logger.error(error)

View File

@ -78,7 +78,7 @@ class SettingsTest(InvenTreeTestCase):
# check as_int # check as_int
self.assertEqual(stale_days.as_int(), 0) self.assertEqual(stale_days.as_int(), 0)
self.assertEqual(instance_obj.as_int(), 'InvenTree server') # not an int -> return default self.assertEqual(instance_obj.as_int(), 'InvenTree') # not an int -> return default
# check as_bool # check as_bool
self.assertEqual(report_test_obj.as_bool(), True) self.assertEqual(report_test_obj.as_bool(), True)
@ -258,7 +258,7 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
# Access via the API, and the default value should be received # Access via the API, and the default value should be received
response = self.get(url, expected_code=200) response = self.get(url, expected_code=200)
self.assertEqual(response.data['value'], 'InvenTree server') self.assertEqual(response.data['value'], 'InvenTree')
# Now, the object should have been created in the DB # Now, the object should have been created in the DB
self.patch( self.patch(

136
InvenTree/order/tasks.py Normal file
View File

@ -0,0 +1,136 @@
"""Background tasks for the 'order' app"""
from datetime import datetime, timedelta
from django.utils.translation import gettext_lazy as _
import common.notifications
import InvenTree.helpers
import InvenTree.tasks
import order.models
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
from plugin.events import trigger_event
def notify_overdue_purchase_order(po: order.models.PurchaseOrder):
"""Notify users that a PurchaseOrder has just become 'overdue'"""
targets = []
if po.created_by:
targets.append(po.created_by)
if po.responsible:
targets.append(po.responsible)
name = _('Overdue Purchase Order')
context = {
'order': po,
'name': name,
'message': _(f'Purchase order {po} is now overdue'),
'link': InvenTree.helpers.construct_absolute_url(
po.get_absolute_url(),
),
'template': {
'html': 'email/overdue_purchase_order.html',
'subject': name,
}
}
event_name = 'order.overdue_purchase_order'
# Send a notification to the appropriate users
common.notifications.trigger_notification(
po,
event_name,
targets=targets,
context=context,
)
# Register a matching event to the plugin system
trigger_event(
event_name,
purchase_order=po.pk,
)
def check_overdue_purchase_orders():
"""Check if any outstanding PurchaseOrders have just become overdue:
- This check is performed daily
- Look at the 'target_date' of any outstanding PurchaseOrder objects
- If the 'target_date' expired *yesterday* then the order is just out of date
"""
yesterday = datetime.now().date() - timedelta(days=1)
overdue_orders = order.models.PurchaseOrder.objects.filter(
target_date=yesterday,
status__in=PurchaseOrderStatus.OPEN
)
for po in overdue_orders:
notify_overdue_purchase_order(po)
def notify_overdue_sales_order(so: order.models.SalesOrder):
"""Notify appropriate users that a SalesOrder has just become 'overdue'"""
targets = []
if so.created_by:
targets.append(so.created_by)
if so.responsible:
targets.append(so.responsible)
name = _('Overdue Sales Order')
context = {
'order': so,
'name': name,
'message': _(f"Sales order {so} is now overdue"),
'link': InvenTree.helpers.construct_absolute_url(
so.get_absolute_url(),
),
'template': {
'html': 'email/overdue_sales_order.html',
'subject': name,
}
}
event_name = 'order.overdue_sales_order'
# Send a notification to the appropriate users
common.notifications.trigger_notification(
so,
event_name,
targets=targets,
context=context,
)
# Register a matching event to the plugin system
trigger_event(
event_name,
sales_order=so.pk,
)
def check_overdue_sales_orders():
"""Check if any outstanding SalesOrders have just become overdue
- This check is performed daily
- Look at the 'target_date' of any outstanding SalesOrder objects
- If the 'target_date' expired *yesterday* then the order is just out of date
"""
yesterday = datetime.now().date() - timedelta(days=1)
overdue_orders = order.models.SalesOrder.objects.filter(
target_date=yesterday,
status__in=SalesOrderStatus.OPEN
)
for po in overdue_orders:
notify_overdue_sales_order(po)

View File

@ -2,21 +2,29 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase from django.test import TestCase
from common.models import InvenTreeSetting import order.tasks
from common.models import InvenTreeSetting, NotificationMessage
from company.models import Company from company.models import Company
from InvenTree import status_codes as status from InvenTree import status_codes as status
from order.models import (SalesOrder, SalesOrderAllocation, SalesOrderLineItem, from order.models import (SalesOrder, SalesOrderAllocation, SalesOrderLineItem,
SalesOrderShipment) SalesOrderShipment)
from part.models import Part from part.models import Part
from stock.models import StockItem from stock.models import StockItem
from users.models import Owner
class SalesOrderTest(TestCase): class SalesOrderTest(TestCase):
"""Run tests to ensure that the SalesOrder model is working correctly.""" """Run tests to ensure that the SalesOrder model is working correctly."""
fixtures = [
'users',
]
def setUp(self): def setUp(self):
"""Initial setup for this set of unit tests""" """Initial setup for this set of unit tests"""
# Create a Company to ship the goods to # Create a Company to ship the goods to
@ -235,3 +243,20 @@ class SalesOrderTest(TestCase):
# Shipment should have default reference of '1' # Shipment should have default reference of '1'
self.assertEqual('1', order_2.pending_shipments()[0].reference) self.assertEqual('1', order_2.pending_shipments()[0].reference)
def test_overdue_notification(self):
"""Test overdue sales order notification"""
self.order.created_by = get_user_model().objects.get(pk=3)
self.order.responsible = Owner.create(obj=Group.objects.get(pk=2))
self.order.target_date = datetime.now().date() - timedelta(days=1)
self.order.save()
# Check for overdue sales orders
order.tasks.check_overdue_sales_orders()
messages = NotificationMessage.objects.filter(
category='order.overdue_sales_order',
)
self.assertEqual(len(messages), 2)

View File

@ -3,12 +3,17 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
import django.core.exceptions as django_exceptions import django.core.exceptions as django_exceptions
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.test import TestCase from django.test import TestCase
import common.models
import order.tasks
from company.models import SupplierPart from company.models import SupplierPart
from InvenTree.status_codes import PurchaseOrderStatus from InvenTree.status_codes import PurchaseOrderStatus
from part.models import Part from part.models import Part
from stock.models import StockLocation from stock.models import StockLocation
from users.models import Owner
from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrder, PurchaseOrderLineItem
@ -24,7 +29,8 @@ class OrderTest(TestCase):
'part', 'part',
'location', 'location',
'stock', 'stock',
'order' 'order',
'users',
] ]
def test_basics(self): def test_basics(self):
@ -197,3 +203,37 @@ class OrderTest(TestCase):
order.receive_line_item(line, loc, line.quantity, user=None) order.receive_line_item(line, loc, line.quantity, user=None)
self.assertEqual(order.status, PurchaseOrderStatus.COMPLETE) self.assertEqual(order.status, PurchaseOrderStatus.COMPLETE)
def test_overdue_notification(self):
"""Test overdue purchase order notification
Ensure that a notification is sent when a PurchaseOrder becomes overdue
"""
po = PurchaseOrder.objects.get(pk=1)
# Created by 'sam'
po.created_by = get_user_model().objects.get(pk=4)
# Responsible : 'Engineers' group
responsible = Owner.create(obj=Group.objects.get(pk=2))
po.responsible = responsible
# Target date = yesterday
po.target_date = datetime.now().date() - timedelta(days=1)
po.save()
# Check for overdue purchase orders
order.tasks.check_overdue_purchase_orders()
for user_id in [2, 3, 4]:
messages = common.models.NotificationMessage.objects.filter(
category='order.overdue_purchase_order',
user__id=user_id,
)
self.assertTrue(messages.exists())
msg = messages.first()
self.assertEqual(msg.target_object_id, 1)
self.assertEqual(msg.name, 'Overdue Purchase Order')

View File

@ -27,7 +27,7 @@ def notify_low_stock(part: part.models.Part):
'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()), 'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()),
'template': { 'template': {
'html': 'email/low_stock_notification.html', 'html': 'email/low_stock_notification.html',
'subject': "[InvenTree] " + name, 'subject': name,
}, },
} }

View File

@ -44,7 +44,7 @@ class TemplateTagTest(InvenTreeTestCase):
def test_inventree_instance_name(self): def test_inventree_instance_name(self):
"""Test the 'instance name' setting""" """Test the 'instance name' setting"""
self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree server') self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree')
def test_inventree_base_url(self): def test_inventree_base_url(self):
"""Test that the base URL tag returns correctly""" """Test that the base URL tag returns correctly"""

View File

@ -5,6 +5,7 @@ from django.utils.translation import ugettext_lazy as _
from allauth.account.models import EmailAddress from allauth.account.models import EmailAddress
import common.models
import InvenTree.tasks import InvenTree.tasks
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.mixins import BulkNotificationMethod, SettingsMixin from plugin.mixins import BulkNotificationMethod, SettingsMixin
@ -74,6 +75,14 @@ class CoreNotificationsPlugin(SettingsMixin, InvenTreePlugin):
html_message = render_to_string(self.context['template']['html'], self.context) html_message = render_to_string(self.context['template']['html'], self.context)
targets = self.targets.values_list('email', flat=True) targets = self.targets.values_list('email', flat=True)
InvenTree.tasks.send_email(self.context['template']['subject'], '', targets, html_message=html_message) # Prefix the 'instance title' to the email subject
instance_title = common.models.InvenTreeSetting.get_setting('INVENTREE_INSTANCE')
subject = self.context['template'].get('subject', '')
if instance_title:
subject = f'[{instance_title}] {subject}'
InvenTree.tasks.send_email(subject, '', targets, html_message=html_message)
return True return True

View File

@ -0,0 +1,24 @@
{% extends "email/email.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block title %}
{{ message }}
{% if link %}
<p>{% trans "Click on the following link to view this order" %}: <a href="{{ link }}">{{ link }}</a></p>
{% endif %}
{% endblock title %}
{% block body %}
<tr style="height: 3rem; border-bottom: 1px solid">
<th>{% trans "Build Order" %}</th>
<th>{% trans "Part" %}</th>
</tr>
<tr style="height: 3rem">
<td style="text-align: center;">{{ order }}</td>
<td style="text-align: center;">{{ order.part.full_name }}</td>
</tr>
{% endblock body %}

View File

@ -0,0 +1,24 @@
{% extends "email/email.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block title %}
{{ message }}
{% if link %}
<p>{% trans "Click on the following link to view this order" %}: <a href="{{ link }}">{{ link }}</a></p>
{% endif %}
{% endblock title %}
{% block body %}
<tr style="height: 3rem; border-bottom: 1px solid">
<th>{% trans "Purchase Order" %}</th>
<th>{% trans "Supplier" %}</th>
</tr>
<tr style="height: 3rem">
<td style="text-align: center;">{{ order }}</td>
<td style="text-align: center;">{{ order.supplier }}</td>
</tr>
{% endblock body %}

View File

@ -0,0 +1,24 @@
{% extends "email/email.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block title %}
{{ message }}
{% if link %}
<p>{% trans "Click on the following link to view this order" %}: <a href="{{ link }}">{{ link }}</a></p>
{% endif %}
{% endblock title %}
{% block body %}
<tr style="height: 3rem; border-bottom: 1px solid">
<th>{% trans "Sales Order" %}</th>
<th>{% trans "Customer" %}</th>
</tr>
<tr style="height: 3rem">
<td style="text-align: center;">{{ order }}</td>
<td style="text-align: center;">{{ order.customer }}</td>
</tr>
{% endblock body %}

View File

@ -238,7 +238,7 @@ function getReadEditButton(pk, state, small=false) {
} }
var style = (small) ? 'btn-sm ' : ''; var style = (small) ? 'btn-sm ' : '';
return `<button title='${bReadText}' class='notification-read btn ${style}btn-outline-secondary' type='button' pk='${pk}' target='${bReadTarget}'><span class='${bReadIcon}'></span></button>`; return `<button title='${bReadText}' class='notification-read btn ${style}btn-outline-secondary float-right' type='button' pk='${pk}' target='${bReadTarget}'><span class='${bReadIcon}'></span></button>`;
} }
/** /**
@ -252,6 +252,7 @@ function openNotificationPanel() {
'/api/notifications/', '/api/notifications/',
{ {
read: false, read: false,
ordering: '-creation',
}, },
{ {
success: function(response) { success: function(response) {
@ -261,20 +262,21 @@ function openNotificationPanel() {
// build up items // build up items
response.forEach(function(item, index) { response.forEach(function(item, index) {
html += '<li class="list-group-item">'; html += '<li class="list-group-item">';
// d-flex justify-content-between align-items-start html += `<div>`;
html += '<div>'; html += `<span class="badge bg-secondary rounded-pill">${item.name}</span>`;
html += `<span class="badge rounded-pill bg-primary">${item.category}</span><span class="ms-2">${item.name}</span>`; html += getReadEditButton(item.pk, item.read, true);
html += '</div>'; html += `</div>`;
if (item.target) { if (item.target) {
var link_text = `${item.target.model}: ${item.target.name}`; var link_text = `${item.target.name}`;
if (item.target.link) { if (item.target.link) {
link_text = `<a href='${item.target.link}'>${link_text}</a>`; link_text = `<a href='${item.target.link}'>${link_text}</a>`;
} }
html += link_text; html += link_text;
} }
html += '<div>'; html += '<div>';
html += `<span class="text-muted">${item.age_human}</span>`; html += `<span class="text-muted"><small>${item.age_human}</small></span>`;
html += getReadEditButton(item.pk, item.read, true);
html += '</div></li>'; html += '</div></li>';
}); });