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

Approval #5545

Closed
wants to merge 15 commits into from
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
/InvenTree/plugin/ @SchrodingersGat @matmair
/InvenTree/plugins/ @SchrodingersGat @matmair

# specialised modules
/InvenTree/approvals @matmair

# Installer functions
.pkgr.yml @matmair
Procfile @matmair
Expand Down
1 change: 1 addition & 0 deletions InvenTree/InvenTree/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@
'users.apps.UsersConfig',
'web',
'generic',
'approval.apps.ApprovalConfig',
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last

# Core django modules
Expand Down
3 changes: 3 additions & 0 deletions InvenTree/InvenTree/status_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class PurchaseOrderStatus(StatusCode):

# Order status codes
PENDING = 10, _("Pending"), 'secondary' # Order is pending (not yet placed)
PENDING_PLACING = 15, _("Pending placing"), 'secondary' # Order is pending an action for being placed
PENDING_APPROVAL = 16, _("Pending approval"), 'secondary' # Order is pending approval
PLACED = 20, _("Placed"), 'primary' # Order has been placed with supplier
COMPLETE = 30, _("Complete"), 'success' # Order has been completed
CANCELLED = 40, _("Cancelled"), 'danger' # Order was cancelled
Expand All @@ -23,6 +25,7 @@ class PurchaseOrderStatusGroups:
# Open orders
OPEN = [
PurchaseOrderStatus.PENDING.value,
PurchaseOrderStatus.PENDING_PLACING.value,
PurchaseOrderStatus.PLACED.value,
]

Expand Down
2 changes: 2 additions & 0 deletions InvenTree/InvenTree/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
from sesame.views import LoginView

from approval.api import approval_api_urls
from build.api import build_api_urls
from build.urls import build_urls
from common.api import admin_api_urls, common_api_urls, settings_api_urls
Expand Down Expand Up @@ -66,6 +67,7 @@
re_path(r'^report/', include(report_api_urls)),
re_path(r'^user/', include(user_urls)),
re_path(r'^admin/', include(admin_api_urls)),
re_path(r'^approval/', include(approval_api_urls)),

# Plugin endpoints
path('', include(plugin_api_urls)),
Expand Down
1 change: 1 addition & 0 deletions InvenTree/approval/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Approvals can be used to implement apprival or voting processes based on sets of rules."""
45 changes: 45 additions & 0 deletions InvenTree/approval/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Admin class definitions for approval app"""

from django.contrib import admin

from .models import Approval, ApprovalDecision


class ApprovalDecisionInline(admin.TabularInline):
"""Inline for ApprovalDecision."""

model = ApprovalDecision


@admin.register(Approval)
class ApprovalAdmin(admin.ModelAdmin):
"""Admin class for Approval model."""

resource_class = ApprovalDecisionInline

list_display = ('name', 'description', 'reference', 'finalised', 'status')

list_filter = [
'status',
'finalised',
'created_by',
'creation_date',
'modified_by',
'modified_date',
'finalised_by',
'finalised_date',
]

search_fields = [
'name',
'description',
'reference',
'created_by',
'creation_date',
'modified_by',
'modified_date',
'finalised_by',
'finalised_date',
]

inlines = [ApprovalDecisionInline,]
177 changes: 177 additions & 0 deletions InvenTree/approval/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""API views for the Approval app."""
from django.urls import include, path, re_path

from rest_framework import serializers

from InvenTree.api import MetadataView
from InvenTree.mixins import CreateAPI, ListCreateAPI, RetrieveUpdateDestroyAPI
from InvenTree.serializers import InvenTreeModelSerializer

from .models import Approval, ApprovalDecision


class TaggedObjectRelatedField(serializers.RelatedField):
"""A custom field to use for the `tagged_object` generic relationship."""

def to_representation(self, value):
"""Serialize tagged objects to a simple textual representation."""
if hasattr(value, 'get_api_url'):
return f'{value.get_api_url()}{value.id}/'
if hasattr(value, 'get_absolute_url'):
return value.get_absolute_url()
else:
return value.id


class ApprovalDecisionSerializer(InvenTreeModelSerializer):
"""Serializes an ApprovalDecision object"""

class Meta:
"""Meta data for ApprovalDecisionSerializer"""
model = ApprovalDecision
exclude = [
'metadata',
]

def is_valid(self, *, raise_exception=False):
"""Insert user to save request."""
request = self.context['request']
self.initial_data['user'] = request.user.pk
return super().is_valid(raise_exception=raise_exception)


class ApprovalSerializer(InvenTreeModelSerializer):
"""Serializes an Approval object"""

status_text = serializers.CharField(source='get_status_display', read_only=True)
content_object = TaggedObjectRelatedField(read_only=True)
decisions = ApprovalDecisionSerializer(many=True, read_only=True)
creation_date = serializers.DateTimeField(format='iso-8601', required=False)
modified_date = serializers.DateTimeField(format='iso-8601', required=False)
finalised_date = serializers.DateTimeField(format='iso-8601', required=False)

class Meta:
"""Meta data for ApprovalSerializer"""
model = Approval
exclude = [
'reference_int',
'metadata',
'data',
# 'object_id',
# 'content_type',
]

read_only_fields = [
'creation_date',
'finalised_date',
'modified_date',
]


class ApprovalList(ListCreateAPI):
"""API endpoint for listing all Approval objects"""

queryset = Approval.objects.all()
serializer_class = ApprovalSerializer
filterset_fields = [
"owner",
"status",
"content_type",
"object_id",
]
search_fields = [
"name",
"description",
"reference",
]
ordering_field_aliases = {
'reference': ['reference_int', 'reference'],
}
ordering_fields = [
"name",
"status",
"finalised_date",
"modified_date",
"created_date",
'reference',
]
ordering = '-reference'

def clean_data(self, data: dict) -> dict:
"""Custom clean_data method to add current user."""
data = super().clean_data(data)
data['created_by'] = self.request.user.pk
return data


class ApprovalDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of an Approval object"""

queryset = Approval.objects.all()
serializer_class = ApprovalSerializer


class ApprovalDecisionList(ListCreateAPI):
"""API endpoint for listing all ApprovalDecision objects"""

queryset = ApprovalDecision.objects.all()
serializer_class = ApprovalDecisionSerializer
filterset_fields = [
"approval",
"user",
"status",
]
search_fields = [
"comment",
]
ordering_fields = [
"approval",
"user",
"status",
"date",
]


class ApprovalDecisionDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of an ApprovalDecision object"""

queryset = ApprovalDecision.objects.all()
serializer_class = ApprovalDecisionSerializer


class ApprovalApproveSerializer(InvenTreeModelSerializer):
"""Serializes an ApprovalDecision object"""

class Meta:
"""Meta data for ApprovalDecisionSerializer"""
model = ApprovalDecision
exclude = ['metadata',]

def is_valid(self, *, raise_exception=False):
"""Insert data to save request."""
request = self.context['request']
self.initial_data['user'] = request.user.pk
self.initial_data['approval'] = request.parser_context['kwargs'].get('pk', None)
self.initial_data['decision'] = True
return super().is_valid(raise_exception=raise_exception)


class ApproveView(CreateAPI):
"""API endpoint to approve approval."""

queryset = ApprovalDecision.objects.all()
serializer_class = ApprovalApproveSerializer


approval_api_urls = [
path(r'<int:pk>/', include([
re_path(r"^decision/", include([
re_path(r"^$", ApprovalDecisionList.as_view(), name="api-approval-decision-list",),
re_path(r"^(?P<pk>\d+)/", ApprovalDecisionDetail.as_view(), name="api-approval-decision-detail",),
])),
re_path('approve/', ApproveView.as_view(), name='api-approval-approve'),
re_path(r'^metadata/', MetadataView.as_view(), {'model': Approval}, name='api-approval-metadata'),
re_path(r'^.*$', ApprovalDetail.as_view(), name='api-approval-detail'),
])),
re_path(r'^.*$', ApprovalList.as_view(), name='api-approval-list'),
]
18 changes: 18 additions & 0 deletions InvenTree/approval/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""AppConfig for approval app."""

from django.apps import AppConfig


class ApprovalConfig(AppConfig):
"""AppConfig for approval app."""
name = 'approval'

def ready(self):
"""Run setup step when app is ready."""
self.collect_notification_methods()

def collect_notification_methods(self):
"""Collect all rule definitions."""
from approval.rules import registry

registry.collect()
44 changes: 44 additions & 0 deletions InvenTree/approval/defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Default Approval Rules for InvenTree."""
from django.utils.translation import gettext_lazy as _

from .rules import ApprovalRule


class GenericMinimumApproversRule(ApprovalRule):
"""Approval Rule that specifies the minimum required number of approvers - set by the database."""
NAME = _('Minimum Approvers')
DESCRIPTION = _('Minimum number of approvers required for approval')
IDENTIFIER = 'schema.inventree.org/rules/generic_minimum_approvers.1-0'
SETTING = 'APPROVAL_MINIMUM_APPROVERS'

def check(self, approval, target, decisions):
"""True if minimum number of approvers is reached."""
positive_decisions = [x for x in decisions if x.decision]
if len(positive_decisions) == 0:
return None
if len(positive_decisions) >= self.settings_value(int):
return True
return None


class GenericMaximumDenierRule(ApprovalRule):
"""Approval Rule that specifies the maximum number of deniers - set by the database."""
NAME = _('Maximum Deniers')
DESCRIPTION = _('Maximum number of deniers required for rejection')
IDENTIFIER = 'schema.inventree.org/rules/generic_maximum_deniers.1-0'
SETTING = 'APPROVAL_MAXIMUM_DENIERS'

def check(self, approval, target, decisions):
"""True if maximum number of deniers is reached."""
negative_decisions = [x for x in decisions if not x.decision]
if len(negative_decisions) == 0:
return None
if len(negative_decisions) >= self.settings_value(int):
return False
return None


DefaultApprovalRules: list[ApprovalRule] = [
GenericMinimumApproversRule,
GenericMaximumDenierRule,
]
Loading