Add ProjectCode support to build orders (#4808)

* Add "project_code" field to Build model

* Add "project_code" field to Build model

* build javascript updates

(cherry picked from commit 3e27a3b739)

* Update table filters

(cherry picked from commit 196c675585)

* Adds API filtering

* Bump API version

* Hide project code field from build form if project codes not enabled

(cherry picked from commit 4e210e3dfa)

* refactoring to attempt to fix circular imports

* Upgrade django-test-migrations package

* Fix broken import

* Further fixes for unit tests

* Update unit tests for migration files

* Fix typo in build.js

* Migration test updates

- Need to specify MPTT stuff

* Fix build.js

* Fix migration order

* Update API version
This commit is contained in:
Oliver 2023-06-14 11:23:35 +10:00 committed by GitHub
parent c8365ccd0c
commit 00bb740216
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 151 additions and 62 deletions

View File

@ -2,17 +2,23 @@
# InvenTree API version
INVENTREE_API_VERSION = 120
INVENTREE_API_VERSION = 121
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v121 -> 2023-06-14 : https://github.com/inventree/InvenTree/pull/4808
- Adds "ProjectCode" link to Build model
v120 -> 2023-06-07 : https://github.com/inventree/InvenTree/pull/4855
- Major overhaul of the build order API
- Adds new BuildLine model
v120 -> 2023-06-12 : https://github.com/inventree/InvenTree/pull/4804
- Adds 'project_code' field to build order API endpoints
v119 -> 2023-06-01 : https://github.com/inventree/InvenTree/pull/4898
- Add Metadata to: Part test templates, Part parameters, Part category parameter templates, BOM item substitute, Part relateds, Stock item test result
- Add Metadata to: Part test templates, Part parameters, Part category parameter templates, BOM item substitute, Related Parts, Stock item test result
v118 -> 2023-06-01 : https://github.com/inventree/InvenTree/pull/4935
- Adds extra fields for the PartParameterTemplate model
@ -30,6 +36,7 @@ v115 - > 2023-05-18 : https://github.com/inventree/InvenTree/pull/4846
v114 -> 2023-05-16 : https://github.com/inventree/InvenTree/pull/4825
- Adds "delivery_date" to shipments
>>>>>>> inventree/master
v113 -> 2023-05-13 : https://github.com/inventree/InvenTree/pull/4800
- Adds API endpoints for scrapping a build output

View File

@ -14,7 +14,6 @@ from allauth_2fa.middleware import (AllauthTwoFactorMiddleware,
from error_report.middleware import ExceptionProcessor
from rest_framework.authtoken.models import Token
from common.models import InvenTreeSetting
from InvenTree.urls import frontendpatterns
logger = logging.getLogger("inventree")
@ -123,6 +122,9 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware):
"""Check if user is required to have MFA enabled."""
def require_2fa(self, request):
"""Use setting to check if MFA should be enforced for frontend page."""
from common.models import InvenTreeSetting
try:
if url_matcher.resolve(request.path[1:]):
return InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA')

View File

@ -21,7 +21,7 @@ from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta
from taggit.serializers import TaggitSerializer
from common.models import InvenTreeSetting
import common.models as common_models
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers_model import download_image_from_url
@ -724,7 +724,7 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
if not url:
return
if not InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL'):
if not common_models.InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL'):
raise ValidationError(_("Downloading images from remote URL is not enabled"))
try:

View File

@ -233,6 +233,11 @@ class ExchangeRateMixin:
Rate.objects.bulk_create(items)
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
"""Testcase with user setup buildin."""
pass
class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Base class for running InvenTree API tests."""
@ -408,8 +413,3 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
data.append(entry)
return data
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
"""Testcase with user setup buildin."""
pass

View File

@ -31,8 +31,8 @@ from allauth_2fa.views import TwoFactorRemove
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from user_sessions.views import SessionDeleteOtherView, SessionDeleteView
from common.models import ColorTheme, InvenTreeSetting
from common.settings import currency_code_default, currency_codes
import common.models as common_models
import common.settings as common_settings
from part.models import PartCategory
from users.models import RuleSet, check_user_role
@ -514,10 +514,10 @@ class SettingsView(TemplateView):
"""Add data for template."""
ctx = super().get_context_data(**kwargs).copy()
ctx['settings'] = InvenTreeSetting.objects.all().order_by('key')
ctx['settings'] = common_models.InvenTreeSetting.objects.all().order_by('key')
ctx["base_currency"] = currency_code_default()
ctx["currencies"] = currency_codes
ctx["base_currency"] = common_settings.currency_code_default()
ctx["currencies"] = common_settings.currency_codes
ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange")
@ -622,8 +622,8 @@ class AppearanceSelectView(RedirectView):
def get_user_theme(self):
"""Get current user color theme."""
try:
user_theme = ColorTheme.objects.filter(user=self.request.user).get()
except ColorTheme.DoesNotExist:
user_theme = common_models.ColorTheme.objects.filter(user=self.request.user).get()
except common_models.ColorTheme.DoesNotExist:
user_theme = None
return user_theme
@ -637,7 +637,7 @@ class AppearanceSelectView(RedirectView):
# Create theme entry if user did not select one yet
if not user_theme:
user_theme = ColorTheme()
user_theme = common_models.ColorTheme()
user_theme.user = request.user
user_theme.name = theme

View File

@ -16,6 +16,7 @@ from InvenTree.helpers import str2bool, isNull, DownloadFile
from InvenTree.status_codes import BuildStatus, BuildStatusGroups
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
import common.models
import build.admin
import build.serializers
from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment
@ -89,6 +90,21 @@ class BuildFilter(rest_filters.FilterSet):
lookup_expr="iexact"
)
project_code = rest_filters.ModelChoiceFilter(
queryset=common.models.ProjectCode.objects.all(),
field_name='project_code'
)
has_project_code = rest_filters.BooleanFilter(label='has_project_code', method='filter_has_project_code')
def filter_has_project_code(self, queryset, name, value):
"""Filter by whether or not the order has a project code"""
if str2bool(value):
return queryset.exclude(project_code=None)
else:
return queryset.filter(project_code=None)
class BuildList(APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of Build objects.
@ -114,11 +130,13 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
'completed',
'issued_by',
'responsible',
'project_code',
'priority',
]
ordering_field_aliases = {
'reference': ['reference_int', 'reference'],
'project_code': ['project_code__code'],
}
ordering = '-reference'
@ -129,6 +147,7 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
'part__name',
'part__IPN',
'part__description',
'project_code__code',
'priority',
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.19 on 2023-05-14 09:22
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('common', '0019_projectcode_metadata'),
('build', '0047_auto_20230606_1058'),
]
operations = [
migrations.AddField(
model_name='build',
name='project_code',
field=models.ForeignKey(blank=True, help_text='Project code for this build order', null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.projectcode', verbose_name='Project Code'),
),
]

View File

@ -32,9 +32,10 @@ import InvenTree.models
import InvenTree.ready
import InvenTree.tasks
import common.models
from common.notifications import trigger_notification
from plugin.events import trigger_event
import common.notifications
import part.models
import stock.models
import users.models
@ -301,6 +302,14 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
help_text=_('Priority of this build order')
)
project_code = models.ForeignKey(
common.models.ProjectCode,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Project Code'),
help_text=_('Project code for this build order'),
)
def sub_builds(self, cascade=True):
"""Return all Build Order objects under this one."""
if cascade:
@ -547,7 +556,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
}
}
common.notifications.trigger_notification(
trigger_notification(
build,
'build.completed',
targets=targets,

View File

@ -23,6 +23,7 @@ from InvenTree.status_codes import StockStatus
from stock.models import generate_batch_code, StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer
from common.serializers import ProjectCodeSerializer
import part.filters
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
from users.serializers import OwnerSerializer
@ -49,6 +50,8 @@ class BuildSerializer(InvenTreeModelSerializer):
'parent',
'part',
'part_detail',
'project_code',
'project_code_detail',
'overdue',
'reference',
'sales_order',
@ -90,6 +93,8 @@ class BuildSerializer(InvenTreeModelSerializer):
barcode_hash = serializers.CharField(read_only=True)
project_code_detail = ProjectCodeSerializer(source='project_code', many=False, read_only=True)
@staticmethod
def annotate_queryset(queryset):
"""Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible.

View File

@ -108,6 +108,7 @@ src="{% static 'img/blank_image.png' %}"
<td>{% trans "Build Description" %}</td>
<td>{{ build.title }}</td>
</tr>
{% include "project_code_data.html" with instance=build %}
{% include "barcode_data.html" with instance=build %}
</table>

View File

@ -19,22 +19,15 @@ class TestForwardMigrations(MigratorTestCase):
name='Widget',
description='Buildable Part',
active=True,
level=0, lft=0, rght=0, tree_id=0,
)
with self.assertRaises(TypeError):
# Cannot set the 'assembly' field as it hasn't been added to the db schema
Part.objects.create(
name='Blorb',
description='ABCDE',
assembly=True
)
Build = self.old_state.apps.get_model('build', 'build')
Build.objects.create(
part=buildable_part,
title='A build of some stuff',
quantity=50
quantity=50,
)
def test_items_exist(self):
@ -67,7 +60,8 @@ class TestReferenceMigration(MigratorTestCase):
part = Part.objects.create(
name='Part',
description='A test part'
description='A test part',
level=0, lft=0, rght=0, tree_id=0,
)
Build = self.old_state.apps.get_model('build', 'build')

View File

@ -525,7 +525,7 @@ settings_api_urls = [
path(r'<int:pk>/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'),
# Notification Settings List
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'),
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notification-setting-list'),
])),
# Global settings

View File

@ -8,8 +8,8 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.utils.translation import gettext_lazy as _
import common.models
import InvenTree.helpers
from common.models import NotificationEntry, NotificationMessage
from InvenTree.ready import isImportingData
from plugin import registry
from plugin.models import NotificationUserSetting, PluginConfig
@ -247,7 +247,7 @@ class UIMessageNotification(SingleNotificationMethod):
def send(self, target):
"""Send a UI notification to a user."""
NotificationMessage.objects.create(
common.models.NotificationMessage.objects.create(
target_object=self.obj,
source_object=target,
user=target,
@ -338,7 +338,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
# Check if we have notified recently...
delta = timedelta(days=1)
if NotificationEntry.check_recent(category, obj_ref_value, delta):
if common.models.NotificationEntry.check_recent(category, obj_ref_value, delta):
logger.info(f"Notification '{category}' has recently been sent for '{str(obj)}' - SKIPPING")
return
@ -398,7 +398,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
logger.error(error)
# Set delivery flag
NotificationEntry.notify(category, obj_ref_value)
common.models.NotificationEntry.notify(category, obj_ref_value)
else:
logger.info(f"No possible users for notification '{category}'")

View File

@ -6,9 +6,7 @@ from django.urls import reverse
from flags.state import flag_state
from rest_framework import serializers
from common.models import (InvenTreeSetting, InvenTreeUserSetting,
NewsFeedEntry, NotesImage, NotificationMessage,
ProjectCode)
import common.models as common_models
from InvenTree.helpers import get_objectreference
from InvenTree.helpers_model import construct_absolute_url
from InvenTree.serializers import (InvenTreeImageSerializerField,
@ -64,7 +62,7 @@ class GlobalSettingsSerializer(SettingsSerializer):
class Meta:
"""Meta options for GlobalSettingsSerializer."""
model = InvenTreeSetting
model = common_models.InvenTreeSetting
fields = [
'pk',
'key',
@ -85,7 +83,7 @@ class UserSettingsSerializer(SettingsSerializer):
class Meta:
"""Meta options for UserSettingsSerializer."""
model = InvenTreeUserSetting
model = common_models.InvenTreeUserSetting
fields = [
'pk',
'key',
@ -148,7 +146,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
class Meta:
"""Meta options for NotificationMessageSerializer."""
model = NotificationMessage
model = common_models.NotificationMessage
fields = [
'pk',
'target',
@ -209,7 +207,7 @@ class NewsFeedEntrySerializer(InvenTreeModelSerializer):
class Meta:
"""Meta options for NewsFeedEntrySerializer."""
model = NewsFeedEntry
model = common_models.NewsFeedEntry
fields = [
'pk',
'feed_id',
@ -243,7 +241,7 @@ class NotesImageSerializer(InvenTreeModelSerializer):
class Meta:
"""Meta options for NotesImageSerializer."""
model = NotesImage
model = common_models.NotesImage
fields = [
'pk',
'image',
@ -265,7 +263,7 @@ class ProjectCodeSerializer(InvenTreeModelSerializer):
class Meta:
"""Meta options for ProjectCodeSerializer."""
model = ProjectCode
model = common_models.ProjectCode
fields = [
'pk',
'code',

View File

@ -226,7 +226,7 @@ class SettingsTest(InvenTreeTestCase):
cache.clear()
# Generate a number of new usesr
# Generate a number of new users
for idx in range(5):
get_user_model().objects.create(
username=f"User_{idx}",
@ -417,7 +417,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase):
self.assertTrue(str2bool(response.data['value']))
# Assign some falsey values
# Assign some false(ish) values
for v in ['false', False, '0', 'n', 'FalSe']:
self.patch(
url,
@ -535,7 +535,7 @@ class NotificationUserSettingsApiTest(InvenTreeAPITestCase):
def test_api_list(self):
"""Test list URL."""
url = reverse('api-notifcation-setting-list')
url = reverse('api-notification-setting-list')
self.get(url, expected_code=200)
@ -583,7 +583,7 @@ class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase):
# Failure mode tests
# Non - exsistant plugin
# Non-existent plugin
url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'doesnotexist', 'key': 'doesnotmatter'})
response = self.get(url, expected_code=404)
self.assertIn("Plugin 'doesnotexist' not installed", str(response.data))
@ -729,7 +729,7 @@ class WebhookMessageTests(TestCase):
class NotificationTest(InvenTreeAPITestCase):
"""Tests for NotificationEntriy."""
"""Tests for NotificationEntry."""
fixtures = [
'users',
@ -785,7 +785,7 @@ class NotificationTest(InvenTreeAPITestCase):
messages = NotificationMessage.objects.all()
# As there are three staff users (including the 'test' user) we expect 30 notifications
# However, one user is marked as i nactive
# However, one user is marked as inactive
self.assertEqual(messages.count(), 20)
# Only 10 messages related to *this* user

View File

@ -468,7 +468,7 @@ class SupplierPartTest(InvenTreeAPITestCase):
self.assertIsNone(sp.availability_updated)
self.assertEqual(sp.available, 0)
# Now, *update* the availabile quantity via the API
# Now, *update* the available quantity via the API
self.patch(
reverse('api-supplier-part-detail', kwargs={'pk': sp.pk}),
{

View File

@ -48,7 +48,8 @@ class TestManufacturerField(MigratorTestCase):
# Create an initial part
part = Part.objects.create(
name='Screw',
description='A single screw'
description='A single screw',
level=0, tree_id=0, lft=0, rght=0
)
# Create a company to act as the supplier

View File

@ -13,7 +13,7 @@ from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from common.models import InvenTreeSetting, ProjectCode
import common.models as common_models
from common.settings import settings
from company.models import SupplierPart
from generic.states import StatusView
@ -139,7 +139,7 @@ class OrderFilter(rest_filters.FilterSet):
return queryset.exclude(status__in=self.Meta.model.get_status_class().OPEN)
project_code = rest_filters.ModelChoiceFilter(
queryset=ProjectCode.objects.all(),
queryset=common_models.ProjectCode.objects.all(),
field_name='project_code'
)
@ -1457,7 +1457,7 @@ class OrderCalendarExport(ICalFeed):
else:
ordertype_title = _('Unknown')
return f'{InvenTreeSetting.get_setting("INVENTREE_COMPANY_NAME")} {ordertype_title}'
return f'{common_models.InvenTreeSetting.get_setting("INVENTREE_COMPANY_NAME")} {ordertype_title}'
def product_id(self, obj):
"""Return calendar product id."""

View File

@ -22,6 +22,7 @@ from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from mptt.models import TreeForeignKey
import common.models as common_models
import InvenTree.helpers
import InvenTree.ready
import InvenTree.tasks
@ -29,7 +30,6 @@ import InvenTree.validators
import order.validators
import stock.models
import users.models as UserModels
from common.models import ProjectCode
from common.notifications import InvenTreeNotificationBodies
from common.settings import currency_code_default
from company.models import Company, Contact, SupplierPart
@ -231,7 +231,11 @@ class Order(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, Reference
description = models.CharField(max_length=250, blank=True, verbose_name=_('Description'), help_text=_('Order description (optional)'))
project_code = models.ForeignKey(ProjectCode, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Project Code'), help_text=_('Select project code for this order'))
project_code = models.ForeignKey(
common_models.ProjectCode, on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Project Code'), help_text=_('Select project code for this order')
)
link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page'))

View File

@ -13,11 +13,11 @@ from rest_framework import serializers
from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount
import common.serializers
import order.models
import part.filters
import stock.models
import stock.serializers
from common.serializers import ProjectCodeSerializer
from company.serializers import (CompanyBriefSerializer, ContactSerializer,
SupplierPartSerializer)
from InvenTree.helpers import (extract_serial_numbers, hash_barcode, normalize,
@ -73,7 +73,7 @@ class AbstractOrderSerializer(serializers.Serializer):
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
# Detail for project code field
project_code_detail = common.serializers.ProjectCodeSerializer(source='project_code', read_only=True, many=False)
project_code_detail = ProjectCodeSerializer(source='project_code', read_only=True, many=False)
# Boolean field indicating if this order is overdue (Note: must be annotated)
overdue = serializers.BooleanField(required=False, read_only=True)

View File

@ -54,7 +54,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
).data
self.assertEqual(data['success'], True)
# valid - github url and packagename
# valid - github url and package name
data = self.post(
url,
{

View File

@ -14,6 +14,7 @@
FullCalendar,
getFormFieldValue,
getTableData,
global_settings,
handleFormErrors,
handleFormSuccess,
imageHoverIcon,
@ -64,7 +65,7 @@
function buildFormFields() {
return {
let fields = {
reference: {
icon: 'fa-hashtag',
},
@ -76,6 +77,9 @@ function buildFormFields() {
},
title: {},
quantity: {},
project_code: {
icon: 'fa-list',
},
priority: {},
parent: {
filters: {
@ -111,6 +115,12 @@ function buildFormFields() {
icon: 'fa-users',
},
};
if (!global_settings.PROJECT_CODES_ENABLED) {
delete fields.project_code;
}
return fields;
}
/*
@ -2020,6 +2030,18 @@ function loadBuildTable(table, options) {
title: '{% trans "Description" %}',
switchable: true,
},
{
field: 'project_code',
title: '{% trans "Project Code" %}',
sortable: true,
switchable: global_settings.PROJECT_CODES_ENABLED,
visible: global_settings.PROJECT_CODES_ENABLED,
formatter: function(value, row) {
if (row.project_code_detail) {
return `<span title='${row.project_code_detail.description}'>${row.project_code_detail.code}</span>`;
}
}
},
{
field: 'priority',
title: '{% trans "Priority" %}',

View File

@ -440,7 +440,7 @@ function getPluginTableFilters() {
// Return a dictionary of filters for the "build" table
function getBuildTableFilters() {
return {
let filters = {
status: {
title: '{% trans "Build status" %}',
options: buildCodes,
@ -477,6 +477,13 @@ function getBuildTableFilters() {
},
},
};
if (global_settings.PROJECT_CODES_ENABLED) {
filters['has_project_code'] = constructHasProjectCodeFilter();
filters['project_code'] = constructProjectCodeFilter();
}
return filters;
}