Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Purchase order approvals and "ready" state #6989

Open
wants to merge 47 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
89e6a12
Purchase Order Approvals and state changes
LavissaWoW Apr 9, 2024
05156a7
Merge branch 'master' into approvals
LavissaWoW Apr 9, 2024
c424508
Add migration
LavissaWoW Apr 10, 2024
0baf25d
Add state transition notifications and tests
LavissaWoW Apr 30, 2024
4fed769
Merge branch 'master' into approvals
LavissaWoW Apr 30, 2024
eaf2a6c
Add PUI components
LavissaWoW May 11, 2024
0e41086
Merge branch 'master' into approvals
LavissaWoW May 11, 2024
ab66b7f
temp
LavissaWoW May 23, 2024
28bdb2a
Merge branch 'master' into approvals
LavissaWoW May 23, 2024
f75e0a2
Mantine fixes
LavissaWoW May 23, 2024
bdea3dd
Fix Sonarcloud issues and translation strings
LavissaWoW May 23, 2024
8896895
Bump API version
LavissaWoW May 23, 2024
5cce3c4
.
LavissaWoW May 23, 2024
7bfd04c
Review alterations
LavissaWoW May 26, 2024
e4ba486
Merge branch 'master' into approvals
LavissaWoW May 27, 2024
46027d7
Merge branch 'master' into approvals
LavissaWoW May 27, 2024
1f789a7
Merge branch 'master' into approvals
LavissaWoW Jun 27, 2024
34eef83
Fix conflicts, add docs
LavissaWoW Jun 30, 2024
fff11ff
.
LavissaWoW Jun 30, 2024
29e6755
.
LavissaWoW Jun 30, 2024
698e1a7
.
LavissaWoW Jun 30, 2024
243911c
.
LavissaWoW Jul 1, 2024
996605b
.
LavissaWoW Jul 1, 2024
b8f7765
.
LavissaWoW Jul 1, 2024
52b820a
.
LavissaWoW Jul 1, 2024
bfbdb05
.
LavissaWoW Jul 1, 2024
8ec75fa
Merge branch 'master' into approvals
LavissaWoW Jul 2, 2024
e818019
PUI tests
LavissaWoW Jul 5, 2024
6f67a4e
Review changes
LavissaWoW Jul 5, 2024
b06cbc1
Merge branch 'master' into approvals
LavissaWoW Jul 5, 2024
7c30d3b
.
LavissaWoW Jul 5, 2024
6ebdd60
Merge branch 'master' into approvals
LavissaWoW Jul 15, 2024
a221977
Updated tests
LavissaWoW Jul 15, 2024
89cf671
Fixes
LavissaWoW Jul 15, 2024
7dfcf88
...
LavissaWoW Jul 15, 2024
55d867f
.
LavissaWoW Jul 15, 2024
3e1d5db
.
LavissaWoW Jul 15, 2024
1643f30
.
LavissaWoW Jul 21, 2024
1044c39
Merge branch 'master' into approvals
LavissaWoW Jul 21, 2024
91c8948
.
LavissaWoW Jul 21, 2024
d8076f1
.
LavissaWoW Jul 21, 2024
4f07e13
.
LavissaWoW Jul 21, 2024
4505616
change QC workflow
LavissaWoW Jul 26, 2024
bc604c5
.
LavissaWoW Jul 26, 2024
bccd325
Merge branch 'master' into approvals
LavissaWoW Jul 26, 2024
491bd0d
Merge branch 'master' into approvals
LavissaWoW Jul 26, 2024
d962448
.
LavissaWoW Jul 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/backend/InvenTree/InvenTree/helpers_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ def notify_users(
sender,
content: NotificationBody = InvenTreeNotificationBodies.NewOrder,
exclude=None,
delta=None,
):
"""Notify all passed users or groups.

Expand Down Expand Up @@ -354,11 +355,16 @@ def notify_users(
if content.template:
context['template']['html'] = content.template.format(**content_context)

excluded = exclude
if type(exclude) != list:
excluded = [exclude]

# Create notification
trigger_notification(
instance,
content.slug.format(**content_context),
targets=users,
target_exclude=[exclude],
target_exclude=excluded,
context=context,
delta=delta,
)
9 changes: 8 additions & 1 deletion src/backend/InvenTree/InvenTree/status_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,20 @@ class PurchaseOrderStatus(StatusCode):
CANCELLED = 40, _('Cancelled'), 'danger' # Order was cancelled
LOST = 50, _('Lost'), 'warning' # Order was lost
RETURNED = 60, _('Returned'), 'warning' # Order was returned
IN_APPROVAL = 70, _('Approval needed'), 'warning' # Order waiting for approval
READY = 80, _('Ready'), 'primary' # Order is ready to be issued


class PurchaseOrderStatusGroups:
"""Groups for PurchaseOrderStatus codes."""

# Open orders
OPEN = [PurchaseOrderStatus.PENDING.value, PurchaseOrderStatus.PLACED.value]
OPEN = [
PurchaseOrderStatus.PENDING.value,
PurchaseOrderStatus.PLACED.value,
PurchaseOrderStatus.IN_APPROVAL.value,
PurchaseOrderStatus.READY.value,
]

# Failed orders
FAILED = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -663,3 +663,43 @@
pass

return url


@register.simple_tag()
def approval_allowed(user, order):
LavissaWoW marked this conversation as resolved.
Show resolved Hide resolved
"""Check that the given user is allowed to approve the order."""
active = common.models.InvenTreeSetting.get_setting(
'ENABLE_PURCHASE_ORDER_APPROVAL'
)
master_group = common.models.InvenTreeSetting.get_setting(
'PURCHASE_ORDER_APPROVE_ALL_GROUP'
)

if not active:
return False

user_has_permission = False

Check warning on line 681 in src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py

View check run for this annotation

Codecov / codecov/patch

src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py#L681

Added line #L681 was not covered by tests

if master_group:
user_has_permission = user.groups.filter(name=master_group).exists()

Check warning on line 684 in src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py

View check run for this annotation

Codecov / codecov/patch

src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py#L683-L684

Added lines #L683 - L684 were not covered by tests

if order.project_code and order.project_code.responsible:
user_has_permission = order.project_code.responsible.is_user_allowed(user)

Check warning on line 687 in src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py

View check run for this annotation

Codecov / codecov/patch

src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py#L686-L687

Added lines #L686 - L687 were not covered by tests

return user_has_permission

Check warning on line 689 in src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py

View check run for this annotation

Codecov / codecov/patch

src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py#L689

Added line #L689 was not covered by tests


@register.simple_tag()
def can_issue_order(user):
"""Check if purchasing is limited to a group.

Checks if the user is part of the purchaser group
"""
purchaser_group = common.models.InvenTreeSetting.get_setting(
'PURCHASE_ORDER_PURCHASER_GROUP'
)

if purchaser_group:
return user.groups.filter(name=purchaser_group).exists()

Check warning on line 703 in src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py

View check run for this annotation

Codecov / codecov/patch

src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py#L703

Added line #L703 was not covered by tests
else:
return True
35 changes: 34 additions & 1 deletion src/backend/InvenTree/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@
logger = logging.getLogger('inventree')


def all_groups():
"""Make a choice set of all groups, including an empty string for 'no group'."""
groups = [(x.name, x.name) for x in Group.objects.all()]
groups.insert(0, ('', '(No group)'))
return groups


class MetaMixin(models.Model):
"""A base class for InvenTree models to include shared meta fields.

Expand Down Expand Up @@ -2065,6 +2072,32 @@ def save(self, *args, **kwargs):
'default': False,
'validator': bool,
},
'ENABLE_PURCHASE_ORDER_READY_STATUS': {
'name': _('Enable Ready Status'),
'description': _(
'Enable a "Ready" state, indicating an order is ready for issuing. This setting is implicitly active when approvals are active.'
),
'default': False,
'validator': bool,
},
'PURCHASE_ORDER_PURCHASER_GROUP': {
'name': _('Purchaser Group'),
'description': _(
'Limit issuing of orders to a purchaser group. If set, orders can only be issued by members of this group.'
),
'choices': all_groups,
},
'ENABLE_PURCHASE_ORDER_APPROVAL': {
'name': _('Purchase Order Approvals'),
'description': _('Add a required approval step to purchase orders'),
'default': False,
'validator': bool,
},
'PURCHASE_ORDER_APPROVE_ALL_GROUP': {
'name': _('Master approval group'),
'description': _('Set a permission group that can approve ALL orders'),
'choices': all_groups,
},
}

typ = 'inventree'
Expand Down Expand Up @@ -2912,7 +2945,7 @@ class Meta:
@classmethod
def check_recent(cls, key: str, uid: int, delta: timedelta):
"""Test if a particular notification has been sent in the specified time period."""
since = InvenTree.helpers.current_date() - delta
since = InvenTree.helpers.current_time() - delta
LavissaWoW marked this conversation as resolved.
Show resolved Hide resolved

entries = cls.objects.filter(key=key, uid=uid, updated__gte=since)

Expand Down
31 changes: 30 additions & 1 deletion src/backend/InvenTree/common/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,34 @@ class InvenTreeNotificationBodies:
template='email/return_order_received.html',
)

PruchaseOrderApprovalRequested = NotificationBody(
name=_('Approval requested'),
slug=_('purchase_order.approval_request'),
message=_('You have been requested to approve a Purchase Order'),
template='email/new_order_assigned.html',
)

PurchaseOrderRejected = NotificationBody(
name=_('Purchase order was rejected'),
slug=_('purchase_order.approval_rejected'),
message=_('Your approval request was rejected'),
template='email/new_order_assigned.html',
)

PurchaseOrderApproved = NotificationBody(
name=_('Purchase order was approved'),
slug=_('purchase_order.approved'),
message=_('Your approval request was accepted'),
template='email/new_order_assigned.html',
)

PurchaseOrderReady = NotificationBody(
name=_('Purchase order is ready to issue'),
slug=_('purchase_order.ready_to_issue'),
message=_('A Purchase order was just marked ready to issue.'),
template='email/new_order_assigned.html',
)


def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
"""Send out a notification."""
Expand All @@ -350,6 +378,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
target_exclude = kwargs.get('target_exclude', None)
context = kwargs.get('context', {})
delivery_methods = kwargs.get('delivery_methods', None)
override_delta = kwargs.get('delta', None)

# Check if data is importing currently
if isImportingData():
Expand All @@ -369,7 +398,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
)

# Check if we have notified recently...
delta = timedelta(days=1)
delta = override_delta or timedelta(days=1)

if common.models.NotificationEntry.check_recent(category, obj_ref_value, delta):
logger.info(
Expand Down
38 changes: 38 additions & 0 deletions src/backend/InvenTree/order/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,30 @@ class PurchaseOrderComplete(PurchaseOrderContextMixin, CreateAPI):
serializer_class = serializers.PurchaseOrderCompleteSerializer


class PurchaseOrderRequestApproval(PurchaseOrderContextMixin, CreateAPI):
"""API endpoint to 'issue' (place) a PurchaseOrder."""

serializer_class = serializers.PurchaseOrderRequestApprovalSerializer


class PurchaseOrderReject(PurchaseOrderContextMixin, CreateAPI):
"""API endpoint to 'issue' (place) a PurchaseOrder."""

serializer_class = serializers.PurchaseOrderRejectSerializer


class PurchaseOrderReady(PurchaseOrderContextMixin, CreateAPI):
"""API endpoint to 'issue' (place) a PurchaseOrder."""

serializer_class = serializers.PurchaseOrderReadySerializer


class PurchaseOrderPending(PurchaseOrderContextMixin, CreateAPI):
"""API endpoint to 'issue' (place) a PurchaseOrder."""

serializer_class = serializers.PurchaseOrderRejectSerializer


class PurchaseOrderIssue(PurchaseOrderContextMixin, CreateAPI):
"""API endpoint to 'issue' (place) a PurchaseOrder."""

Expand Down Expand Up @@ -1617,6 +1641,20 @@ def item_link(self, item):
path(
'<int:pk>/',
include([
path(
'request_approval/',
PurchaseOrderRequestApproval.as_view(),
name='api-po-req-approval',
),
path(
'reject/', PurchaseOrderReject.as_view(), name='api-po-reject'
),
path('ready/', PurchaseOrderReady.as_view(), name='api-po-ready'),
path(
'pending/',
PurchaseOrderPending.as_view(),
name='api-po-pending',
),
path(
'cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'
),
Expand Down
9 changes: 9 additions & 0 deletions src/backend/InvenTree/order/fixtures/order.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@
supplier: 2
status: 10 # Pending

- model: order.purchaseorder
pk: 8
fields:
reference: 'PO-0008'
reference_int: 8
description: 'PO awaiting approval'
supplier: 2
status: 70 #In approval

# Add some line items against PO 0001

# 100 x ACME0001 (M2x4 LPHS)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Generated by Django 4.2.11 on 2024-04-09 22:10

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('order', '0098_auto_20231024_1844'),
]

operations = [
migrations.AddField(
model_name='purchaseorder',
name='approved_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Approved by'),
),
migrations.AddField(
model_name='purchaseorder',
name='approved_date',
field=models.DateField(blank=True, help_text='Date order was approved', null=True, verbose_name='Approve Date'),
),
migrations.AddField(
model_name='purchaseorder',
name='placed_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders_placed', to=settings.AUTH_USER_MODEL, verbose_name='Placed by'),
),
migrations.AddField(
model_name='purchaseorder',
name='reject_reason',
field=models.CharField(blank=True, help_text='The reason for rejecting this order', max_length=128, verbose_name='Reason for rejection'),
),
migrations.AlterField(
model_name='purchaseorder',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Placed'), (30, 'Complete'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned'), (70, 'Approval needed'), (80, 'Ready')], default=10, help_text='Purchase order status'),
),
]
Loading
Loading