Single table for file attachments (#7420)

* Add basic model for handling generic attachments

* Refactor migration

* Data migration to convert old files across

* Admin updates

* Increase comment field max_length

* Adjust field name

* Remove legacy serializer classes / endpoints

* Expose new model to API

* Admin site list filters

* Remove legacy attachment models

- Add new mixin class to designate which models can have attachments

* Update data migration

- Ensure other apps are at the correct migration state beforehand

* Add migrations to remove legacy attachment tables

* Fix for "rename_attachment" callback

* Refactor model_type field

- ContentType does not allow easy API serialization

* Set allowed options for admin

* Update model verbose names

* Fix logic for file upload

* Add choices for serializer

* Add API filtering

* Fix for API filter

* Fix for attachment tables in PUI

- Still not solved permission issues

* Bump API version

* Record user when uploading attachment via API

* Refactor <AttachmentTable /> for PUI

* Display 'file_size' in PUI attachment table

* Fix company migrations

* Include permission informtion in roles API endpoint

* Read user permissions in PUI

* Simplify permission checks for <AttachmentTable />

* Automatically clean up old content types

* Cleanup PUI

* Fix typo in data migration

* Add reverse data migration

* Update unit tests

* Use InMemoryStorage for media files in test mode

* Data migration unit test

* Fix "model_type" field

- It is a required field after all

* Add permission check for serializer

* Fix permission check for CUI

* Fix PUI import

* Test python lib against specific branch

- Will be reverted once code is merged

* Revert STORAGES setting

- Might be worth looking into again

* Fix part unit test

* Fix unit test for sales order

* Use 'get_global_setting'

* Use 'get_global_setting'

* Update setting getter

* Unit tests

* Tweaks

* Revert change to settings.py

* More updates for get_global_setting

* Relax API query count requirement

* remove illegal chars and add unit tests

* Fix unit tests

* Fix frontend unit tests

* settings management updates

* Prevent db write under more conditions

* Simplify settings code

* Pop values before creating filters

* Prevent settings write under certain conditions

* Add debug msg

* Clear db on record import

* Refactor permissions checks

- Allows extension / customization of permission checks at a later date

* Unit test updates

* Prevent delete of attachment without correct permissions

* Adjust odcker.yaml

* Cleanup data migrations

* Tweak migration tests for build app

* Update data migration

- Handle case with missing data

* Prevent debug shell in TESTING mode

* Update migration dependencies

- Ensure all apps are "up to date" before removing legacy tables

* add file size test

* Update migration tests

* Revert some settings caching changes

* Fix incorrect logic in migration

* Update unit tests

* Prevent create on CURRENCY_CODES

- Seems to play havoc with bootup sequence

* Fix unit test

* Some refactoring

- Use get_global_setting

* Fix typo

* Revert change

* Add "tags" and "metadata"

* Include "tags" field in API serializer

* add "metadata" endpoint for attachments
This commit is contained in:
Oliver 2024-06-19 14:38:46 +10:00 committed by GitHub
parent b8b79b2b2d
commit 432e0c622c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
111 changed files with 1549 additions and 1232 deletions

View File

@ -13,5 +13,5 @@ runs:
invoke export-records -f data.json
python3 ./src/backend/InvenTree/manage.py flush --noinput
invoke migrate
invoke import-records -f data.json
invoke import-records -f data.json
invoke import-records -c -f data.json
invoke import-records -c -f data.json

View File

@ -115,9 +115,10 @@ jobs:
- name: Run Unit Tests
run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> contrib/container/docker.dev.env
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke test --disable-pty
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke test --migrations --disable-pty
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml down
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke test --disable-pty
- name: Run Migration Tests
run: |
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke test --migrations
- name: Clean up test folder
run: |
rm -rf InvenTree/_testfolder

View File

@ -419,22 +419,6 @@ class APIDownloadMixin:
raise NotImplementedError('download_queryset method not implemented!')
class AttachmentMixin:
"""Mixin for creating attachment objects, and ensuring the user information is saved correctly."""
permission_classes = [permissions.IsAuthenticated, RolePermission]
filter_backends = SEARCH_ORDER_FILTER
search_fields = ['attachment', 'comment', 'link']
def perform_create(self, serializer):
"""Save the user information when a file is uploaded."""
attachment = serializer.save()
attachment.user = self.request.user
attachment.save()
class APISearchViewSerializer(serializers.Serializer):
"""Serializer for the APISearchView."""

View File

@ -1,11 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 206
INVENTREE_API_VERSION = 207
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v207 - 2024-06-09 : https://github.com/inventree/InvenTree/pull/7420
- Moves all "Attachment" models into a single table
- All "Attachment" operations are now performed at /api/attachment/
- Add permissions information to /api/user/roles/ endpoint
v206 - 2024-06-08 : https://github.com/inventree/InvenTree/pull/7417
- Adds "choices" field to the PartTestTemplate model

View File

@ -8,6 +8,7 @@ from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from common.currency import currency_code_default, currency_codes
from common.settings import get_global_setting
logger = logging.getLogger('inventree')
@ -22,14 +23,13 @@ class InvenTreeExchange(SimpleExchangeBackend):
def get_rates(self, **kwargs) -> dict:
"""Set the requested currency codes and get rates."""
from common.models import InvenTreeSetting
from plugin import registry
base_currency = kwargs.get('base_currency', currency_code_default())
symbols = kwargs.get('symbols', currency_codes())
# Find the selected exchange rate plugin
slug = InvenTreeSetting.get_setting('CURRENCY_UPDATE_PLUGIN', '', create=False)
slug = get_global_setting('CURRENCY_UPDATE_PLUGIN', create=False)
if slug:
plugin = registry.get_plugin(slug)

View File

@ -33,7 +33,7 @@ class InvenTreeRestURLField(RestURLField):
def run_validation(self, data=empty):
"""Override default validation behaviour for this field type."""
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', True, cache=False)
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
if not strict_urls and data is not empty and '://' not in data:
# Validate as if there were a schema provided

View File

@ -12,6 +12,7 @@ from django.urls import Resolver404, include, path, resolve, reverse_lazy
from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware
from error_report.middleware import ExceptionProcessor
from common.settings import get_global_setting
from InvenTree.urls import frontendpatterns
from users.models import ApiToken
@ -153,11 +154,9 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware):
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')
return get_global_setting('LOGIN_ENFORCE_MFA')
except Resolver404:
pass
return False

View File

@ -1,9 +1,7 @@
"""Generic models which provide extra functionality over base Django model types."""
import logging
import os
from datetime import datetime
from io import BytesIO
from django.conf import settings
from django.contrib.auth import get_user_model
@ -20,11 +18,11 @@ from error_report.models import Error
from mptt.exceptions import InvalidMove
from mptt.models import MPTTModel, TreeForeignKey
import common.settings
import InvenTree.fields
import InvenTree.format
import InvenTree.helpers
import InvenTree.helpers_model
from InvenTree.sanitizer import sanitize_svg
logger = logging.getLogger('inventree')
@ -304,10 +302,7 @@ class ReferenceIndexingMixin(models.Model):
if cls.REFERENCE_PATTERN_SETTING is None:
return ''
# import at function level to prevent cyclic imports
from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting(
return common.settings.get_global_setting(
cls.REFERENCE_PATTERN_SETTING, create=False
).strip()
@ -503,200 +498,64 @@ class InvenTreeMetadataModel(MetadataMixin, InvenTreeModel):
abstract = True
def rename_attachment(instance, filename):
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
Args:
instance: Instance of a PartAttachment object
filename: name of uploaded file
Returns:
path to store file, format: '<subdir>/<id>/filename'
"""
# Construct a path to store a file attachment for a given model type
return os.path.join(instance.getSubdir(), filename)
class InvenTreeAttachment(InvenTreeModel):
class InvenTreeAttachmentMixin:
"""Provides an abstracted class for managing file attachments.
An attachment can be either an uploaded file, or an external URL
Links the implementing model to the common.models.Attachment table,
and provides the following methods:
Attributes:
attachment: Upload file
link: External URL
comment: String descriptor for the attachment
user: User associated with file upload
upload_date: Date the file was uploaded
- attachments: Return a queryset containing all attachments for this model
"""
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
def delete(self):
"""Handle the deletion of a model instance.
abstract = True
def getSubdir(self):
"""Return the subdirectory under which attachments should be stored.
Note: Re-implement this for each subclass of InvenTreeAttachment
Before deleting the model instance, delete any associated attachments.
"""
return 'attachments'
def save(self, *args, **kwargs):
"""Provide better validation error."""
# Either 'attachment' or 'link' must be specified!
if not self.attachment and not self.link:
raise ValidationError({
'attachment': _('Missing file'),
'link': _('Missing external link'),
})
if self.attachment and self.attachment.name.lower().endswith('.svg'):
self.attachment.file.file = self.clean_svg(self.attachment)
super().save(*args, **kwargs)
def clean_svg(self, field):
"""Sanitize SVG file before saving."""
cleaned = sanitize_svg(field.file.read())
return BytesIO(bytes(cleaned, 'utf8'))
def __str__(self):
"""Human name for attachment."""
if self.attachment is not None:
return os.path.basename(self.attachment.name)
return str(self.link)
attachment = models.FileField(
upload_to=rename_attachment,
verbose_name=_('Attachment'),
help_text=_('Select file to attach'),
blank=True,
null=True,
)
link = InvenTree.fields.InvenTreeURLField(
blank=True,
null=True,
verbose_name=_('Link'),
help_text=_('Link to external URL'),
)
comment = models.CharField(
blank=True,
max_length=100,
verbose_name=_('Comment'),
help_text=_('File comment'),
)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name=_('User'),
help_text=_('User'),
)
upload_date = models.DateField(
auto_now_add=True, null=True, blank=True, verbose_name=_('upload date')
)
self.attachments.all().delete()
super().delete()
@property
def basename(self):
"""Base name/path for attachment."""
if self.attachment:
return os.path.basename(self.attachment.name)
return None
def attachments(self):
"""Return a queryset containing all attachments for this model."""
return self.attachments_for_model().filter(model_id=self.pk)
@basename.setter
def basename(self, fn):
"""Function to rename the attachment file.
@classmethod
def check_attachment_permission(cls, permission, user) -> bool:
"""Check if the user has permission to perform the specified action on the attachment.
- Filename cannot be empty
- Filename cannot contain illegal characters
- Filename must specify an extension
- Filename cannot match an existing file
The default implementation runs a permission check against *this* model class,
but this can be overridden in the implementing class if required.
Arguments:
permission: The permission to check (add / change / view / delete)
user: The user to check against
Returns:
bool: True if the user has permission, False otherwise
"""
fn = fn.strip()
perm = f'{cls._meta.app_label}.{permission}_{cls._meta.model_name}'
return user.has_perm(perm)
if len(fn) == 0:
raise ValidationError(_('Filename must not be empty'))
def attachments_for_model(self):
"""Return all attachments for this model class."""
from common.models import Attachment
attachment_dir = settings.MEDIA_ROOT.joinpath(self.getSubdir())
old_file = settings.MEDIA_ROOT.joinpath(self.attachment.name)
new_file = settings.MEDIA_ROOT.joinpath(self.getSubdir(), fn).resolve()
model_type = self.__class__.__name__.lower()
# Check that there are no directory tricks going on...
if new_file.parent != attachment_dir:
logger.error(
"Attempted to rename attachment outside valid directory: '%s'", new_file
)
raise ValidationError(_('Invalid attachment directory'))
return Attachment.objects.filter(model_type=model_type)
# Ignore further checks if the filename is not actually being renamed
if new_file == old_file:
return
def create_attachment(self, attachment=None, link=None, comment='', **kwargs):
"""Create an attachment / link for this model."""
from common.models import Attachment
forbidden = [
"'",
'"',
'#',
'@',
'!',
'&',
'^',
'<',
'>',
':',
';',
'/',
'\\',
'|',
'?',
'*',
'%',
'~',
'`',
]
kwargs['attachment'] = attachment
kwargs['link'] = link
kwargs['comment'] = comment
kwargs['model_type'] = self.__class__.__name__.lower()
kwargs['model_id'] = self.pk
for c in forbidden:
if c in fn:
raise ValidationError(_(f"Filename contains illegal character '{c}'"))
if len(fn.split('.')) < 2:
raise ValidationError(_('Filename missing extension'))
if not old_file.exists():
logger.error(
"Trying to rename attachment '%s' which does not exist", old_file
)
return
if new_file.exists():
raise ValidationError(_('Attachment with this filename already exists'))
try:
os.rename(old_file, new_file)
self.attachment.name = os.path.join(self.getSubdir(), fn)
self.save()
except Exception:
raise ValidationError(_('Error renaming file'))
def fully_qualified_url(self):
"""Return a 'fully qualified' URL for this attachment.
- If the attachment is a link to an external resource, return the link
- If the attachment is an uploaded file, return the fully qualified media URL
"""
if self.link:
return self.link
if self.attachment:
media_url = InvenTree.helpers.getMediaUrl(self.attachment.url)
return InvenTree.helpers_model.construct_absolute_url(media_url)
return ''
Attachment.objects.create(**kwargs)
class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):

View File

@ -509,43 +509,6 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
return os.path.join(str(settings.MEDIA_URL), str(value))
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
"""Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
The only real addition here is that we support "renaming" of the attachment file.
"""
@staticmethod
def attachment_fields(extra_fields=None):
"""Default set of fields for an attachment serializer."""
fields = [
'pk',
'attachment',
'filename',
'link',
'comment',
'upload_date',
'user',
'user_detail',
]
if extra_fields:
fields += extra_fields
return fields
user_detail = UserSerializer(source='user', read_only=True, many=False)
attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=False)
# The 'filename' field must be present in the serializer
filename = serializers.CharField(
label=_('Filename'), required=False, source='basename', allow_blank=False
)
upload_date = serializers.DateField(read_only=True)
class InvenTreeImageSerializerField(serializers.ImageField):
"""Custom image serializer.

View File

@ -296,6 +296,7 @@ ADMIN_SHELL_IMPORT_MODELS = False
if (
DEBUG
and INVENTREE_ADMIN_ENABLED
and not TESTING
and get_boolean_setting('INVENTREE_DEBUG_SHELL', 'debug_shell', False)
): # noqa
try:

View File

@ -152,6 +152,17 @@ class UserMixin:
"""Lougout current user."""
self.client.logout()
@classmethod
def clearRoles(cls):
"""Remove all user roles from the registered user."""
for ruleset in cls.group.rule_sets.all():
ruleset.can_view = False
ruleset.can_change = False
ruleset.can_delete = False
ruleset.can_add = False
ruleset.save()
@classmethod
def assignRole(cls, role=None, assign_all: bool = False, group=None):
"""Set the user roles for the registered user.
@ -267,7 +278,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
) # pragma: no cover
if verbose:
if verbose or n >= value:
msg = '\r\n%s' % json.dumps(
context.captured_queries, indent=4
) # pragma: no cover
@ -296,7 +307,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
if hasattr(response, 'content'):
print('content:', response.content)
self.assertEqual(expected_code, response.status_code)
self.assertEqual(response.status_code, expected_code)
def getActions(self, url):
"""Return a dict of the 'actions' available at a given endpoint.
@ -314,17 +325,17 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
if data is None:
data = {}
expected_code = kwargs.pop('expected_code', None)
kwargs['format'] = kwargs.get('format', 'json')
max_queries = kwargs.get('max_query_count', self.MAX_QUERY_COUNT)
max_query_time = kwargs.get('max_query_time', self.MAX_QUERY_TIME)
expected_code = kwargs.pop('expected_code', None)
max_queries = kwargs.pop('max_query_count', self.MAX_QUERY_COUNT)
max_query_time = kwargs.pop('max_query_time', self.MAX_QUERY_TIME)
t1 = time.time()
with self.assertNumQueriesLessThan(max_queries, url=url):
response = method(url, data, **kwargs)
t2 = time.time()
dt = t2 - t1

View File

@ -13,6 +13,7 @@ from jinja2 import Template
from moneyed import CURRENCIES
import InvenTree.conversion
from common.settings import get_global_setting
def validate_physical_units(unit):
@ -63,14 +64,10 @@ class AllowedURLValidator(validators.URLValidator):
def __call__(self, value):
"""Validate the URL."""
import common.models
self.schemes = allowable_url_schemes()
# Determine if 'strict' URL validation is required (i.e. if the URL must have a schema prefix)
strict_urls = common.models.InvenTreeSetting.get_setting(
'INVENTREE_STRICT_URLS', True, cache=False
)
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
if not strict_urls:
# Allow URLs which do not have a provided schema

View File

@ -53,13 +53,13 @@ def checkMinPythonVersion():
def inventreeInstanceName():
"""Returns the InstanceName settings for the current database."""
return get_global_setting('INVENTREE_INSTANCE', '')
return get_global_setting('INVENTREE_INSTANCE')
def inventreeInstanceTitle():
"""Returns the InstanceTitle for the current database."""
if get_global_setting('INVENTREE_INSTANCE_TITLE', False):
return get_global_setting('INVENTREE_INSTANCE', 'InvenTree')
if get_global_setting('INVENTREE_INSTANCE_TITLE'):
return get_global_setting('INVENTREE_INSTANCE')
return 'InvenTree'

View File

@ -11,7 +11,7 @@ from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.api import APIDownloadMixin, MetadataView
from generic.states.api import StatusView
from InvenTree.helpers import str2bool, isNull, DownloadFile
from build.status_codes import BuildStatus, BuildStatusGroups
@ -20,7 +20,7 @@ from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
import common.models
import build.admin
import build.serializers
from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment
from build.models import Build, BuildLine, BuildItem
import part.models
from users.models import Owner
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
@ -614,32 +614,8 @@ class BuildItemList(ListCreateAPI):
]
class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
queryset = BuildOrderAttachment.objects.all()
serializer_class = build.serializers.BuildAttachmentSerializer
filterset_fields = [
'build',
]
class BuildAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for a BuildOrderAttachment object."""
queryset = BuildOrderAttachment.objects.all()
serializer_class = build.serializers.BuildAttachmentSerializer
build_api_urls = [
# Attachments
path('attachment/', include([
path('<int:pk>/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
path('', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
])),
# Build lines
path('line/', include([
path('<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),

View File

@ -18,7 +18,7 @@ class Migration(migrations.Migration):
name='BuildOrderAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')),
('comment', models.CharField(blank=True, help_text='File comment', max_length=100)),
('upload_date', models.DateField(auto_now_add=True, null=True)),
('build', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='build.Build')),

View File

@ -65,7 +65,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='buildorderattachment',
name='attachment',
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'),
),
migrations.AlterField(
model_name='buildorderattachment',

View File

@ -20,6 +20,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='buildorderattachment',
name='attachment',
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.12 on 2024-06-09 09:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('build', '0050_auto_20240508_0138'),
('common', '0026_auto_20240608_1238'),
('company', '0069_company_active'),
('order', '0099_alter_salesorder_status'),
('part', '0123_parttesttemplate_choices'),
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
]
operations = [
migrations.DeleteModel(
name='BuildOrderAttachment',
),
]

View File

@ -50,6 +50,7 @@ logger = logging.getLogger('inventree')
class Build(
report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.MetadataMixin,
@ -1322,16 +1323,6 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
instance.update_build_line_items()
class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a BuildOrder object."""
def getSubdir(self):
"""Return the media file subdirectory for storing BuildOrder attachments"""
return os.path.join('bo_files', str(self.build.id))
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel):
"""A BuildLine object links a BOMItem to a Build.

View File

@ -13,8 +13,7 @@ from django.db.models.functions import Coalesce
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import UserSerializer
from InvenTree.serializers import InvenTreeModelSerializer, UserSerializer
import InvenTree.helpers
from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin
@ -30,7 +29,7 @@ import part.filters
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
from users.serializers import OwnerSerializer
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
from .models import Build, BuildLine, BuildItem
class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
@ -1311,15 +1310,3 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
return queryset
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for a BuildAttachment."""
class Meta:
"""Serializer metaclass"""
model = BuildOrderAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields([
'build',
])

View File

@ -326,18 +326,7 @@ onPanelLoad('children', function() {
});
onPanelLoad('attachments', function() {
loadAttachmentTable('{% url "api-build-attachment-list" %}', {
filters: {
build: {{ build.pk }},
},
fields: {
build: {
value: {{ build.pk }},
hidden: true,
}
}
});
loadAttachmentTable('build', {{ build.pk }});
});
onPanelLoad('notes', function() {

View File

@ -19,7 +19,6 @@ class TestForwardMigrations(MigratorTestCase):
name='Widget',
description='Buildable Part',
active=True,
level=0, lft=0, rght=0, tree_id=0,
)
Build = self.old_state.apps.get_model('build', 'build')
@ -61,7 +60,6 @@ class TestReferenceMigration(MigratorTestCase):
part = Part.objects.create(
name='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

@ -5,6 +5,34 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
import common.models
import common.validators
@admin.register(common.models.Attachment)
class AttachmentAdmin(admin.ModelAdmin):
"""Admin interface for Attachment objects."""
def formfield_for_dbfield(self, db_field, request, **kwargs):
"""Provide custom choices for 'model_type' field."""
if db_field.name == 'model_type':
db_field.choices = common.validators.attachment_model_options()
return super().formfield_for_dbfield(db_field, request, **kwargs)
list_display = (
'model_type',
'model_id',
'attachment',
'link',
'upload_user',
'upload_date',
)
list_filter = ['model_type', 'upload_user']
readonly_fields = ['file_size', 'upload_date', 'upload_user']
search_fields = ('content_type', 'comment')
@admin.register(common.models.ProjectCode)
@ -16,6 +44,7 @@ class ProjectCodeAdmin(ImportExportModelAdmin):
search_fields = ('code', 'description')
@admin.register(common.models.InvenTreeSetting)
class SettingsAdmin(ImportExportModelAdmin):
"""Admin settings for InvenTreeSetting."""
@ -28,6 +57,7 @@ class SettingsAdmin(ImportExportModelAdmin):
return []
@admin.register(common.models.InvenTreeUserSetting)
class UserSettingsAdmin(ImportExportModelAdmin):
"""Admin settings for InvenTreeUserSetting."""
@ -40,18 +70,21 @@ class UserSettingsAdmin(ImportExportModelAdmin):
return []
@admin.register(common.models.WebhookEndpoint)
class WebhookAdmin(ImportExportModelAdmin):
"""Admin settings for Webhook."""
list_display = ('endpoint_id', 'name', 'active', 'user')
@admin.register(common.models.NotificationEntry)
class NotificationEntryAdmin(admin.ModelAdmin):
"""Admin settings for NotificationEntry."""
list_display = ('key', 'uid', 'updated')
@admin.register(common.models.NotificationMessage)
class NotificationMessageAdmin(admin.ModelAdmin):
"""Admin settings for NotificationMessage."""
@ -70,16 +103,11 @@ class NotificationMessageAdmin(admin.ModelAdmin):
search_fields = ('name', 'category', 'message')
@admin.register(common.models.NewsFeedEntry)
class NewsFeedEntryAdmin(admin.ModelAdmin):
"""Admin settings for NewsFeedEntry."""
list_display = ('title', 'author', 'published', 'summary')
admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)
admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin)
admin.site.register(common.models.NewsFeedEntry, NewsFeedEntryAdmin)

View File

@ -4,18 +4,21 @@ import json
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.http.response import HttpResponse
from django.urls import include, path, re_path
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
import django_q.models
from django_filters import rest_framework as rest_filters
from django_q.tasks import async_task
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import OpenApiResponse, extend_schema
from error_report.models import Error
from rest_framework import permissions, serializers
from rest_framework.exceptions import NotAcceptable, NotFound
from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from rest_framework.views import APIView
@ -674,6 +677,71 @@ class ContentTypeModelDetail(ContentTypeDetail):
raise NotFound()
class AttachmentFilter(rest_filters.FilterSet):
"""Filterset for the AttachmentList API endpoint."""
class Meta:
"""Metaclass options."""
model = common.models.Attachment
fields = ['model_type', 'model_id', 'upload_user']
is_link = rest_filters.BooleanFilter(label=_('Is Link'), method='filter_is_link')
def filter_is_link(self, queryset, name, value):
"""Filter attachments based on whether they are a link or not."""
if value:
return queryset.exclude(link=None).exclude(link='')
return queryset.filter(Q(link=None) | Q(link='')).distinct()
is_file = rest_filters.BooleanFilter(label=_('Is File'), method='filter_is_file')
def filter_is_file(self, queryset, name, value):
"""Filter attachments based on whether they are a file or not."""
if value:
return queryset.exclude(attachment=None).exclude(attachment='')
return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct()
class AttachmentList(ListCreateAPI):
"""List API endpoint for Attachment objects."""
queryset = common.models.Attachment.objects.all()
serializer_class = common.serializers.AttachmentSerializer
permission_classes = [permissions.IsAuthenticated]
filter_backends = SEARCH_ORDER_FILTER
filterset_class = AttachmentFilter
ordering_fields = ['model_id', 'model_type', 'upload_date', 'file_size']
search_fields = ['comment', 'model_id', 'model_type']
def perform_create(self, serializer):
"""Save the user information when a file is uploaded."""
attachment = serializer.save()
attachment.upload_user = self.request.user
attachment.save()
class AttachmentDetail(RetrieveUpdateDestroyAPI):
"""Detail API endpoint for Attachment objects."""
queryset = common.models.Attachment.objects.all()
serializer_class = common.serializers.AttachmentSerializer
permission_classes = [permissions.IsAuthenticated]
def destroy(self, request, *args, **kwargs):
"""Check user permissions before deleting an attachment."""
attachment = self.get_object()
if not attachment.check_permission('delete', request.user):
raise PermissionDenied(
_('User does not have permission to delete this attachment')
)
return super().destroy(request, *args, **kwargs)
settings_api_urls = [
# User settings
path(
@ -742,6 +810,25 @@ common_api_urls = [
path('', BackgroundTaskOverview.as_view(), name='api-task-overview'),
]),
),
# Attachments
path(
'attachment/',
include([
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(),
{'model': common.models.Attachment},
name='api-attachment-metadata',
),
path('', AttachmentDetail.as_view(), name='api-attachment-detail'),
]),
),
path('', AttachmentList.as_view(), name='api-attachment-list'),
]),
),
path(
'error-report/',
include([

View File

@ -28,9 +28,7 @@ def currency_code_default():
return cached_value
try:
code = get_global_setting(
'INVENTREE_DEFAULT_CURRENCY', backup_value='', create=True, cache=True
)
code = get_global_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True)
except Exception: # pragma: no cover
# Database may not yet be ready, no need to throw an error here
code = ''
@ -61,7 +59,7 @@ def currency_codes() -> list:
"""Returns the current currency codes."""
from common.settings import get_global_setting
codes = get_global_setting('CURRENCY_CODES', '', create=False).strip()
codes = get_global_setting('CURRENCY_CODES', create=False).strip()
if not codes:
codes = currency_codes_default_list()

View File

@ -0,0 +1,43 @@
# Generated by Django 4.2.12 on 2024-06-08 12:37
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
import common.models
import common.validators
import InvenTree.fields
import InvenTree.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
('common', '0024_notesimage_model_id_notesimage_model_type'),
]
operations = [
migrations.CreateModel(
name='Attachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('model_id', models.PositiveIntegerField()),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=common.models.rename_attachment, verbose_name='Attachment')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
('comment', models.CharField(blank=True, help_text='Attachment comment', max_length=250, verbose_name='Comment')),
('upload_date', models.DateField(auto_now_add=True, help_text='Date the file was uploaded', null=True, verbose_name='Upload date')),
('file_size', models.PositiveIntegerField(default=0, help_text='File size in bytes', verbose_name='File size')),
('model_type', models.CharField(help_text='Target model type for this image', max_length=100, validators=[common.validators.validate_attachment_model_type])),
('upload_user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')),
('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'))
],
bases=(InvenTree.models.PluginValidationMixin, models.Model),
options={
'verbose_name': 'Attachment',
}
),
]

View File

@ -0,0 +1,122 @@
# Generated by Django 4.2.12 on 2024-06-08 12:38
from django.db import migrations
from django.core.files.storage import default_storage
def get_legacy_models():
"""Return a set of legacy attachment models."""
# Legacy attachment types to convert:
# app_label, table name, target model, model ref
return [
('build', 'BuildOrderAttachment', 'build', 'build'),
('company', 'CompanyAttachment', 'company', 'company'),
('company', 'ManufacturerPartAttachment', 'manufacturerpart', 'manufacturer_part'),
('order', 'PurchaseOrderAttachment', 'purchaseorder', 'order'),
('order', 'SalesOrderAttachment', 'salesorder', 'order'),
('order', 'ReturnOrderAttachment', 'returnorder', 'order'),
('part', 'PartAttachment', 'part', 'part'),
('stock', 'StockItemAttachment', 'stockitem', 'stock_item')
]
def update_attachments(apps, schema_editor):
"""Migrate any existing attachment models to the new attachment table."""
Attachment = apps.get_model('common', 'attachment')
N = 0
for app, model, target_model, model_ref in get_legacy_models():
LegacyAttachmentModel = apps.get_model(app, model)
if LegacyAttachmentModel.objects.count() == 0:
continue
to_create = []
for attachment in LegacyAttachmentModel.objects.all():
# Find the size of the file (if exists)
if attachment.attachment and default_storage.exists(attachment.attachment.name):
try:
file_size = default_storage.size(attachment.attachment.name)
except NotImplementedError:
file_size = 0
else:
file_size = 0
to_create.append(
Attachment(
model_type=target_model,
model_id=getattr(attachment, model_ref).pk,
attachment=attachment.attachment,
link=attachment.link,
comment=attachment.comment,
upload_date=attachment.upload_date,
upload_user=attachment.user,
file_size=file_size
)
)
if len(to_create) > 0:
print(f"Migrating {len(to_create)} attachments for the legacy '{model}' model.")
Attachment.objects.bulk_create(to_create)
N += len(to_create)
# Check the correct number of Attachment objects has been created
assert(N == Attachment.objects.count())
def reverse_attachments(apps, schema_editor):
"""Reverse data migration, and map new Attachment model back to legacy models."""
Attachment = apps.get_model('common', 'attachment')
N = 0
for app, model, target_model, model_ref in get_legacy_models():
LegacyAttachmentModel = apps.get_model(app, model)
to_create = []
for attachment in Attachment.objects.filter(model_type=target_model):
TargetModel = apps.get_model(app, target_model)
data = {
'attachment': attachment.attachment,
'link': attachment.link,
'comment': attachment.comment,
'upload_date': attachment.upload_date,
'user': attachment.upload_user,
model_ref: TargetModel.objects.get(pk=attachment.model_id)
}
to_create.append(LegacyAttachmentModel(**data))
if len(to_create) > 0:
print(f"Reversing {len(to_create)} attachments for the legacy '{model}' model.")
LegacyAttachmentModel.objects.bulk_create(to_create)
N += len(to_create)
# Check the correct number of LegacyAttachmentModel objects has been created
assert(N == Attachment.objects.count())
class Migration(migrations.Migration):
dependencies = [
('build', '0050_auto_20240508_0138'),
('common', '0025_attachment'),
('company', '0069_company_active'),
('order', '0099_alter_salesorder_status'),
('part', '0123_parttesttemplate_choices'),
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
]
operations = [
migrations.RunPython(update_attachments, reverse_code=reverse_attachments),
]

View File

@ -12,6 +12,7 @@ import os
import uuid
from datetime import timedelta, timezone
from enum import Enum
from io import BytesIO
from secrets import compare_digest
from typing import Any, Callable, TypedDict, Union
@ -23,6 +24,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator
from django.db import models, transaction
from django.db.models.signals import post_delete, post_save
@ -35,6 +37,7 @@ from django.utils.translation import gettext_lazy as _
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from rest_framework.exceptions import PermissionDenied
from taggit.managers import TaggableManager
import build.validators
import common.currency
@ -48,6 +51,7 @@ import InvenTree.validators
import order.validators
import report.helpers
import users.models
from InvenTree.sanitizer import sanitize_svg
from plugin import registry
logger = logging.getLogger('inventree')
@ -549,25 +553,25 @@ class BaseInvenTreeSetting(models.Model):
"""
key = str(key).strip().upper()
filters = {
'key__iexact': key,
# Optionally filter by other keys
**cls.get_filters(**kwargs),
}
# Unless otherwise specified, attempt to create the setting
create = kwargs.pop('create', True)
# Specify if cache lookup should be performed
do_cache = kwargs.pop('cache', django_settings.GLOBAL_CACHE_ENABLED)
# Prevent saving to the database during data import
if InvenTree.ready.isImportingData():
create = False
do_cache = False
filters = {
'key__iexact': key,
# Optionally filter by other keys
**cls.get_filters(**kwargs),
}
# Prevent saving to the database during migrations
if InvenTree.ready.isRunningMigrations():
# Prevent saving to the database during certain operations
if (
InvenTree.ready.isImportingData()
or InvenTree.ready.isRunningMigrations()
or InvenTree.ready.isRebuildingData()
or InvenTree.ready.isRunningBackup()
):
create = False
do_cache = False
@ -594,21 +598,9 @@ class BaseInvenTreeSetting(models.Model):
setting = None
# Setting does not exist! (Try to create it)
if not setting:
# Prevent creation of new settings objects when importing data
if (
InvenTree.ready.isImportingData()
or not InvenTree.ready.canAppAccessDatabase(
allow_test=True, allow_shell=True
)
):
create = False
if create:
if not setting and create:
# Attempt to create a new settings object
default_value = cls.get_setting_default(key, **kwargs)
setting = cls(key=key, value=default_value, **kwargs)
try:
@ -694,6 +686,15 @@ class BaseInvenTreeSetting(models.Model):
if change_user is not None and not change_user.is_staff:
return
# Do not write to the database under certain conditions
if (
InvenTree.ready.isImportingData()
or InvenTree.ready.isRunningMigrations()
or InvenTree.ready.isRebuildingData()
or InvenTree.ready.isRunningBackup()
):
return
attempts = int(kwargs.get('attempts', 3))
filters = {
@ -3062,3 +3063,184 @@ def after_custom_unit_updated(sender, instance, **kwargs):
from InvenTree.conversion import reload_unit_registry
reload_unit_registry()
def rename_attachment(instance, filename):
"""Callback function to rename an uploaded attachment file.
Arguments:
- instance: The Attachment instance
- filename: The original filename of the uploaded file
Returns:
- The new filename for the uploaded file, e.g. 'attachments/<model_type>/<model_id>/<filename>'
"""
# Remove any illegal characters from the filename
illegal_chars = '\'"\\`~#|!@#$%^&*()[]{}<>?;:+=,'
for c in illegal_chars:
filename = filename.replace(c, '')
filename = os.path.basename(filename)
# Generate a new filename for the attachment
return os.path.join(
'attachments', str(instance.model_type), str(instance.model_id), filename
)
class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
"""Class which represents an uploaded file attachment.
An attachment can be either an uploaded file, or an external URL.
Attributes:
attachment: The uploaded file
url: An external URL
comment: A comment or description for the attachment
user: The user who uploaded the attachment
upload_date: The date the attachment was uploaded
file_size: The size of the uploaded file
metadata: Arbitrary metadata for the attachment (inherit from MetadataMixin)
tags: Tags for the attachment
"""
class Meta:
"""Metaclass options."""
verbose_name = _('Attachment')
def save(self, *args, **kwargs):
"""Custom 'save' method for the Attachment model.
- Record the file size of the uploaded attachment (if applicable)
- Ensure that the 'content_type' and 'object_id' fields are set
- Run extra validations
"""
# Either 'attachment' or 'link' must be specified!
if not self.attachment and not self.link:
raise ValidationError({
'attachment': _('Missing file'),
'link': _('Missing external link'),
})
if self.attachment:
if self.attachment.name.lower().endswith('.svg'):
self.attachment.file.file = self.clean_svg(self.attachment)
else:
self.file_size = 0
super().save(*args, **kwargs)
# Update file size
if self.file_size == 0 and self.attachment:
# Get file size
if default_storage.exists(self.attachment.name):
try:
self.file_size = default_storage.size(self.attachment.name)
except Exception:
pass
if self.file_size != 0:
super().save()
def clean_svg(self, field):
"""Sanitize SVG file before saving."""
cleaned = sanitize_svg(field.file.read())
return BytesIO(bytes(cleaned, 'utf8'))
def __str__(self):
"""Human name for attachment."""
if self.attachment is not None:
return os.path.basename(self.attachment.name)
return str(self.link)
model_type = models.CharField(
max_length=100,
validators=[common.validators.validate_attachment_model_type],
help_text=_('Target model type for this image'),
)
model_id = models.PositiveIntegerField()
attachment = models.FileField(
upload_to=rename_attachment,
verbose_name=_('Attachment'),
help_text=_('Select file to attach'),
blank=True,
null=True,
)
link = InvenTree.fields.InvenTreeURLField(
blank=True,
null=True,
verbose_name=_('Link'),
help_text=_('Link to external URL'),
)
comment = models.CharField(
blank=True,
max_length=250,
verbose_name=_('Comment'),
help_text=_('Attachment comment'),
)
upload_user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name=_('User'),
help_text=_('User'),
)
upload_date = models.DateField(
auto_now_add=True,
null=True,
blank=True,
verbose_name=_('Upload date'),
help_text=_('Date the file was uploaded'),
)
file_size = models.PositiveIntegerField(
default=0, verbose_name=_('File size'), help_text=_('File size in bytes')
)
tags = TaggableManager(blank=True)
@property
def basename(self):
"""Base name/path for attachment."""
if self.attachment:
return os.path.basename(self.attachment.name)
return None
def fully_qualified_url(self):
"""Return a 'fully qualified' URL for this attachment.
- If the attachment is a link to an external resource, return the link
- If the attachment is an uploaded file, return the fully qualified media URL
"""
if self.link:
return self.link
if self.attachment:
import InvenTree.helpers_model
media_url = InvenTree.helpers.getMediaUrl(self.attachment.url)
return InvenTree.helpers_model.construct_absolute_url(media_url)
return ''
def check_permission(self, permission, user):
"""Check if the user has the required permission for this attachment."""
from InvenTree.models import InvenTreeAttachmentMixin
model_class = common.validators.attachment_model_class_from_label(
self.model_type
)
if not issubclass(model_class, InvenTreeAttachmentMixin):
raise ValueError(_('Invalid model type specified for attachment'))
return model_class.check_attachment_permission(permission, user)

View File

@ -9,13 +9,18 @@ import django_q.models
from error_report.models import Error
from flags.state import flag_state
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from taggit.serializers import TagListSerializerField
import common.models as common_models
import common.validators
from InvenTree.helpers import get_objectreference
from InvenTree.helpers_model import construct_absolute_url
from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
UserSerializer,
)
from plugin import registry as plugin_registry
from users.serializers import OwnerSerializer
@ -474,3 +479,85 @@ class FailedTaskSerializer(InvenTreeModelSerializer):
pk = serializers.CharField(source='id', read_only=True)
result = serializers.CharField()
class AttachmentSerializer(InvenTreeModelSerializer):
"""Serializer class for the Attachment model."""
class Meta:
"""Serializer metaclass."""
model = common_models.Attachment
fields = [
'pk',
'attachment',
'filename',
'link',
'comment',
'upload_date',
'upload_user',
'user_detail',
'file_size',
'model_type',
'model_id',
'tags',
]
read_only_fields = ['pk', 'file_size', 'upload_date', 'upload_user', 'filename']
def __init__(self, *args, **kwargs):
"""Override the model_type field to provide dynamic choices."""
super().__init__(*args, **kwargs)
if len(self.fields['model_type'].choices) == 0:
self.fields[
'model_type'
].choices = common.validators.attachment_model_options()
tags = TagListSerializerField(required=False)
user_detail = UserSerializer(source='upload_user', read_only=True, many=False)
attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=True)
# The 'filename' field must be present in the serializer
filename = serializers.CharField(
label=_('Filename'), required=False, source='basename', allow_blank=False
)
upload_date = serializers.DateField(read_only=True)
# Note: The choices are overridden at run-time on class initialization
model_type = serializers.ChoiceField(
label=_('Model Type'),
choices=common.validators.attachment_model_options(),
required=True,
allow_blank=False,
allow_null=False,
)
def save(self):
"""Override the save method to handle the model_type field."""
from InvenTree.models import InvenTreeAttachmentMixin
model_type = self.validated_data.get('model_type', None)
# Ensure that the user has permission to attach files to the specified model
user = self.context.get('request').user
target_model_class = common.validators.attachment_model_class_from_label(
model_type
)
if not issubclass(target_model_class, InvenTreeAttachmentMixin):
raise PermissionDenied(_('Invalid model type specified for attachment'))
# Check that the user has the required permissions to attach files to the target model
if not target_model_class.check_attachment_permission('change', user):
raise PermissionDenied(
_(
'User does not have permission to create or edit attachments for this model'
)
)
return super().save()

View File

@ -5,6 +5,7 @@ def get_global_setting(key, backup_value=None, **kwargs):
"""Return the value of a global setting using the provided key."""
from common.models import InvenTreeSetting
if backup_value is not None:
kwargs['backup_value'] = backup_value
return InvenTreeSetting.get_setting(key, **kwargs)
@ -25,6 +26,8 @@ def get_user_setting(key, user, backup_value=None, **kwargs):
from common.models import InvenTreeUserSetting
kwargs['user'] = user
if backup_value is not None:
kwargs['backup_value'] = backup_value
return InvenTreeUserSetting.get_setting(key, **kwargs)

View File

@ -0,0 +1,210 @@
"""Data migration unit tests for the 'common' app."""
import io
from django.core.files.base import ContentFile
from django_test_migrations.contrib.unittest_case import MigratorTestCase
from InvenTree import unit_test
def get_legacy_models():
"""Return a set of legacy attachment models."""
# Legacy attachment types to convert:
# app_label, table name, target model, model ref
return [
('build', 'BuildOrderAttachment', 'build', 'build'),
('company', 'CompanyAttachment', 'company', 'company'),
(
'company',
'ManufacturerPartAttachment',
'manufacturerpart',
'manufacturer_part',
),
('order', 'PurchaseOrderAttachment', 'purchaseorder', 'order'),
('order', 'SalesOrderAttachment', 'salesorder', 'order'),
('order', 'ReturnOrderAttachment', 'returnorder', 'order'),
('part', 'PartAttachment', 'part', 'part'),
('stock', 'StockItemAttachment', 'stockitem', 'stock_item'),
]
def generate_attachment():
"""Generate a file attachment object for test upload."""
file_object = io.StringIO('Some dummy data')
file_object.seek(0)
return ContentFile(file_object.getvalue(), 'test.txt')
class TestForwardMigrations(MigratorTestCase):
"""Test entire schema migration sequence for the common app."""
migrate_from = ('common', '0024_notesimage_model_id_notesimage_model_type')
migrate_to = ('common', unit_test.getNewestMigrationFile('common'))
def prepare(self):
"""Create initial data.
Legacy attachment model types are:
- BuildOrderAttachment
- CompanyAttachment
- ManufacturerPartAttachment
- PurchaseOrderAttachment
- SalesOrderAttachment
- ReturnOrderAttachment
- PartAttachment
- StockItemAttachment
"""
# Dummy MPPT data
tree = {'tree_id': 0, 'level': 0, 'lft': 0, 'rght': 0}
# BuildOrderAttachment
Part = self.old_state.apps.get_model('part', 'Part')
Build = self.old_state.apps.get_model('build', 'Build')
part = Part.objects.create(
name='Test Part',
description='Test Part Description',
active=True,
assembly=True,
purchaseable=True,
**tree,
)
build = Build.objects.create(part=part, title='Test Build', quantity=10, **tree)
PartAttachment = self.old_state.apps.get_model('part', 'PartAttachment')
PartAttachment.objects.create(
part=part, attachment=generate_attachment(), comment='Test file attachment'
)
PartAttachment.objects.create(
part=part, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(PartAttachment.objects.count(), 2)
BuildOrderAttachment = self.old_state.apps.get_model(
'build', 'BuildOrderAttachment'
)
BuildOrderAttachment.objects.create(
build=build, link='http://example.com', comment='Test comment'
)
BuildOrderAttachment.objects.create(
build=build, attachment=generate_attachment(), comment='a test file'
)
self.assertEqual(BuildOrderAttachment.objects.count(), 2)
StockItem = self.old_state.apps.get_model('stock', 'StockItem')
StockItemAttachment = self.old_state.apps.get_model(
'stock', 'StockItemAttachment'
)
item = StockItem.objects.create(part=part, quantity=10, **tree)
StockItemAttachment.objects.create(
stock_item=item,
attachment=generate_attachment(),
comment='Test file attachment',
)
StockItemAttachment.objects.create(
stock_item=item, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(StockItemAttachment.objects.count(), 2)
Company = self.old_state.apps.get_model('company', 'Company')
CompanyAttachment = self.old_state.apps.get_model(
'company', 'CompanyAttachment'
)
company = Company.objects.create(
name='Test Company',
description='Test Company Description',
is_customer=True,
is_manufacturer=True,
is_supplier=True,
)
CompanyAttachment.objects.create(
company=company,
attachment=generate_attachment(),
comment='Test file attachment',
)
CompanyAttachment.objects.create(
company=company, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(CompanyAttachment.objects.count(), 2)
PurchaseOrder = self.old_state.apps.get_model('order', 'PurchaseOrder')
PurchaseOrderAttachment = self.old_state.apps.get_model(
'order', 'PurchaseOrderAttachment'
)
po = PurchaseOrder.objects.create(
reference='PO-12345',
supplier=company,
description='Test Purchase Order Description',
)
PurchaseOrderAttachment.objects.create(
order=po, attachment=generate_attachment(), comment='Test file attachment'
)
PurchaseOrderAttachment.objects.create(
order=po, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(PurchaseOrderAttachment.objects.count(), 2)
SalesOrder = self.old_state.apps.get_model('order', 'SalesOrder')
SalesOrderAttachment = self.old_state.apps.get_model(
'order', 'SalesOrderAttachment'
)
so = SalesOrder.objects.create(
reference='SO-12345',
customer=company,
description='Test Sales Order Description',
)
SalesOrderAttachment.objects.create(
order=so, attachment=generate_attachment(), comment='Test file attachment'
)
SalesOrderAttachment.objects.create(
order=so, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(SalesOrderAttachment.objects.count(), 2)
ReturnOrder = self.old_state.apps.get_model('order', 'ReturnOrder')
ReturnOrderAttachment = self.old_state.apps.get_model(
'order', 'ReturnOrderAttachment'
)
ro = ReturnOrder.objects.create(
reference='RO-12345',
customer=company,
description='Test Return Order Description',
)
ReturnOrderAttachment.objects.create(
order=ro, attachment=generate_attachment(), comment='Test file attachment'
)
ReturnOrderAttachment.objects.create(
order=ro, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(ReturnOrderAttachment.objects.count(), 2)
def test_items_exist(self):
"""Test to ensure that the attachments are correctly migrated."""
Attachment = self.new_state.apps.get_model('common', 'Attachment')
self.assertEqual(Attachment.objects.count(), 14)
for model in [
'build',
'company',
'purchaseorder',
'returnorder',
'salesorder',
'part',
'stockitem',
]:
self.assertEqual(Attachment.objects.filter(model_type=model).count(), 2)

View File

@ -1 +0,0 @@
"""Unit tests for the views associated with the 'common' app."""

View File

@ -11,6 +11,8 @@ from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.test.utils import override_settings
@ -21,11 +23,13 @@ import PIL
from common.settings import get_global_setting, set_global_setting
from InvenTree.helpers import str2bool
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, PluginMixin
from part.models import Part
from plugin import registry
from plugin.models import NotificationUserSetting
from .api import WebhookView
from .models import (
Attachment,
ColorTheme,
CustomUnit,
InvenTreeSetting,
@ -41,6 +45,131 @@ from .models import (
CONTENT_TYPE_JSON = 'application/json'
class AttachmentTest(InvenTreeAPITestCase):
"""Unit tests for the 'Attachment' model."""
fixtures = ['part', 'category', 'location']
def generate_file(self, fn: str):
"""Generate an attachment file object."""
file_object = io.StringIO('Some dummy data')
file_object.seek(0)
return ContentFile(file_object.getvalue(), fn)
def test_filename_validation(self):
"""Test that the filename validation works as expected.
The django file-upload mechanism should sanitize filenames correctly.
"""
part = Part.objects.first()
filenames = {
'test.txt': 'test.txt',
'r####at.mp4': 'rat.mp4',
'../../../win32.dll': 'win32.dll',
'ABC!@#$%^&&&&&&&)-XYZ-(**&&&\\/QqQ.sqlite': 'QqQ.sqlite',
'/var/log/inventree.log': 'inventree.log',
'c:\\Users\\admin\\passwd.txt': 'cUsersadminpasswd.txt',
'8&&&8.txt': '88.txt',
}
for fn, expected in filenames.items():
attachment = Attachment.objects.create(
attachment=self.generate_file(fn),
comment=f'Testing filename: {fn}',
model_type='part',
model_id=part.pk,
)
expected_path = f'attachments/part/{part.pk}/{expected}'
self.assertEqual(attachment.attachment.name, expected_path)
self.assertEqual(attachment.file_size, 15)
self.assertEqual(part.attachments.count(), len(filenames.keys()))
# Delete any attachments after the test is completed
for attachment in part.attachments.all():
path = attachment.attachment.name
attachment.delete()
# Remove uploaded files to prevent them sticking around
if default_storage.exists(path):
default_storage.delete(path)
self.assertEqual(
Attachment.objects.filter(model_type='part', model_id=part.pk).count(), 0
)
def test_mixin(self):
"""Test that the mixin class works as expected."""
part = Part.objects.first()
self.assertEqual(part.attachments.count(), 0)
part.create_attachment(
attachment=self.generate_file('test.txt'), comment='Hello world'
)
self.assertEqual(part.attachments.count(), 1)
attachment = part.attachments.first()
self.assertEqual(attachment.comment, 'Hello world')
self.assertIn(f'attachments/part/{part.pk}/test', attachment.attachment.name)
def test_upload_via_api(self):
"""Test that we can upload attachments via the API."""
part = Part.objects.first()
url = reverse('api-attachment-list')
data = {
'model_type': 'part',
'model_id': part.pk,
'link': 'https://www.google.com',
'comment': 'Some appropriate comment',
}
# Start without appropriate permissions
# User must have 'part.change' to upload an attachment against a Part instance
self.logout()
self.user.is_staff = False
self.user.is_superuser = False
self.user.save()
self.clearRoles()
# Check without login (401)
response = self.post(url, data, expected_code=401)
self.login()
response = self.post(url, data, expected_code=403)
self.assertIn(
'User does not have permission to create or edit attachments for this model',
str(response.data['detail']),
)
# Add the required permission
self.assignRole('part.change')
# Upload should now work!
response = self.post(url, data, expected_code=201)
# Try to delete the attachment via API (should fail)
attachment = part.attachments.first()
url = reverse('api-attachment-detail', kwargs={'pk': attachment.pk})
response = self.delete(url, expected_code=403)
self.assertIn(
'User does not have permission to delete this attachment',
str(response.data['detail']),
)
# Assign 'delete' permission to 'part' model
self.assignRole('part.delete')
response = self.delete(url, expected_code=204)
class SettingsTest(InvenTreeTestCase):
"""Tests for the 'settings' model."""

View File

@ -8,6 +8,41 @@ from django.utils.translation import gettext_lazy as _
from common.settings import get_global_setting
def attachment_model_types():
"""Return a list of valid attachment model choices."""
import InvenTree.models
return list(
InvenTree.helpers_model.getModelsWithMixin(
InvenTree.models.InvenTreeAttachmentMixin
)
)
def attachment_model_options():
"""Return a list of options for models which support attachments."""
return [
(model.__name__.lower(), model._meta.verbose_name)
for model in attachment_model_types()
]
def attachment_model_class_from_label(label: str):
"""Return the model class for the given label."""
for model in attachment_model_types():
if model.__name__.lower() == label.lower():
return model
raise ValueError(f'Invalid attachment model label: {label}')
def validate_attachment_model_type(value):
"""Ensure that the provided attachment model is valid."""
model_names = [el[0] for el in attachment_model_options()]
if value not in model_names:
raise ValidationError(f'Model type does not support attachments')
def validate_notes_model_type(value):
"""Ensure that the provided model type is valid.

View File

@ -14,7 +14,6 @@ from .models import (
Company,
Contact,
ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter,
SupplierPart,
SupplierPriceBreak,
@ -120,15 +119,6 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part', 'manufacturer')
@admin.register(ManufacturerPartAttachment)
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
"""Admin class for ManufacturerPartAttachment model."""
list_display = ('manufacturer_part', 'attachment', 'comment')
autocomplete_fields = ('manufacturer_part',)
class ManufacturerPartParameterResource(InvenTreeResource):
"""Class for managing ManufacturerPartParameter data import/export."""

View File

@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters
import part.models
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import (
ORDER_FILTER,
SEARCH_ORDER_FILTER,
@ -19,20 +19,16 @@ from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from .models import (
Address,
Company,
CompanyAttachment,
Contact,
ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter,
SupplierPart,
SupplierPriceBreak,
)
from .serializers import (
AddressSerializer,
CompanyAttachmentSerializer,
CompanySerializer,
ContactSerializer,
ManufacturerPartAttachmentSerializer,
ManufacturerPartParameterSerializer,
ManufacturerPartSerializer,
SupplierPartSerializer,
@ -88,22 +84,6 @@ class CompanyDetail(RetrieveUpdateDestroyAPI):
return queryset
class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing, creating and bulk deleting a CompanyAttachment."""
queryset = CompanyAttachment.objects.all()
serializer_class = CompanyAttachmentSerializer
filterset_fields = ['company']
class CompanyAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for CompanyAttachment model."""
queryset = CompanyAttachment.objects.all()
serializer_class = CompanyAttachmentSerializer
class ContactList(ListCreateDestroyAPIView):
"""API endpoint for list view of Company model."""
@ -227,22 +207,6 @@ class ManufacturerPartDetail(RetrieveUpdateDestroyAPI):
serializer_class = ManufacturerPartSerializer
class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing, creating and bulk deleting a ManufacturerPartAttachment (file upload)."""
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer
filterset_fields = ['manufacturer_part']
class ManufacturerPartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpooint for ManufacturerPartAttachment model."""
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer
class ManufacturerPartParameterFilter(rest_filters.FilterSet):
"""Custom filterset for the ManufacturerPartParameterList API endpoint."""
@ -509,22 +473,6 @@ class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI):
manufacturer_part_api_urls = [
# Base URL for ManufacturerPartAttachment API endpoints
path(
'attachment/',
include([
path(
'<int:pk>/',
ManufacturerPartAttachmentDetail.as_view(),
name='api-manufacturer-part-attachment-detail',
),
path(
'',
ManufacturerPartAttachmentList.as_view(),
name='api-manufacturer-part-attachment-list',
),
]),
),
path(
'parameter/',
include([
@ -611,19 +559,6 @@ company_api_urls = [
path('', CompanyDetail.as_view(), name='api-company-detail'),
]),
),
path(
'attachment/',
include([
path(
'<int:pk>/',
CompanyAttachmentDetail.as_view(),
name='api-company-attachment-detail',
),
path(
'', CompanyAttachmentList.as_view(), name='api-company-attachment-list'
),
]),
),
path(
'contact/',
include([

View File

@ -31,6 +31,9 @@ class Migration(migrations.Migration):
('is_customer', models.BooleanField(default=False, help_text='Do you sell items to this company?')),
('is_supplier', models.BooleanField(default=True, help_text='Do you purchase items from this company?')),
],
options={
'verbose_name': 'Company',
}
),
migrations.CreateModel(
name='Contact',
@ -60,6 +63,7 @@ class Migration(migrations.Migration):
],
options={
'db_table': 'part_supplierpart',
'verbose_name': 'Supplier Part',
},
),
migrations.CreateModel(

View File

@ -12,6 +12,6 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='company',
options={'ordering': ['name']},
options={'ordering': ['name'], 'verbose_name': 'Company'},
),
]

View File

@ -22,6 +22,7 @@ class Migration(migrations.Migration):
],
options={
'unique_together': {('part', 'manufacturer', 'MPN')},
'verbose_name': 'Manufacturer Part',
},
),
]

View File

@ -12,6 +12,6 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='company',
options={'ordering': ['name'], 'verbose_name_plural': 'Companies'},
options={'ordering': ['name'], 'verbose_name': 'Company', 'verbose_name_plural': 'Companies'},
),
]

View File

@ -19,7 +19,7 @@ class Migration(migrations.Migration):
name='ManufacturerPartAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),

View File

@ -19,7 +19,7 @@ class Migration(migrations.Migration):
name='CompanyAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.12 on 2024-06-09 09:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('build', '0050_auto_20240508_0138'),
('common', '0026_auto_20240608_1238'),
('company', '0069_company_active'),
('order', '0099_alter_salesorder_status'),
('part', '0123_parttesttemplate_choices'),
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
]
operations = [
migrations.DeleteModel(
name='CompanyAttachment',
),
migrations.DeleteModel(
name='ManufacturerPartAttachment',
),
]

View File

@ -60,7 +60,9 @@ def rename_company_image(instance, filename):
class Company(
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeMetadataModel
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeMetadataModel,
):
"""A Company object represents an external company.
@ -95,7 +97,8 @@ class Company(
constraints = [
UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair')
]
verbose_name_plural = 'Companies'
verbose_name = _('Company')
verbose_name_plural = _('Companies')
@staticmethod
def get_api_url():
@ -255,26 +258,6 @@ class Company(
).distinct()
class CompanyAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file or URL attachments against a Company object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with this model."""
return reverse('api-company-attachment-list')
def getSubdir(self):
"""Return the subdirectory where these attachments are uploaded."""
return os.path.join('company_files', str(self.company.pk))
company = models.ForeignKey(
Company,
on_delete=models.CASCADE,
verbose_name=_('Company'),
related_name='attachments',
)
class Contact(InvenTree.models.InvenTreeMetadataModel):
"""A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
@ -460,7 +443,9 @@ class Address(InvenTree.models.InvenTreeModel):
class ManufacturerPart(
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeMetadataModel
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeMetadataModel,
):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
@ -475,6 +460,7 @@ class ManufacturerPart(
class Meta:
"""Metaclass defines extra model options."""
verbose_name = _('Manufacturer Part')
unique_together = ('part', 'manufacturer', 'MPN')
@staticmethod
@ -563,26 +549,6 @@ class ManufacturerPart(
return s
class ManufacturerPartAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a ManufacturerPart object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the ManufacturerPartAttachment model."""
return reverse('api-manufacturer-part-attachment-list')
def getSubdir(self):
"""Return the subdirectory where attachment files for the ManufacturerPart model are located."""
return os.path.join('manufacturer_part_files', str(self.manufacturer_part.id))
manufacturer_part = models.ForeignKey(
ManufacturerPart,
on_delete=models.CASCADE,
verbose_name=_('Manufacturer Part'),
related_name='attachments',
)
class ManufacturerPartParameter(InvenTree.models.InvenTreeModel):
"""A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
@ -679,6 +645,8 @@ class SupplierPart(
unique_together = ('part', 'supplier', 'SKU')
verbose_name = _('Supplier Part')
# This model was moved from the 'Part' app
db_table = 'part_supplierpart'

View File

@ -11,7 +11,6 @@ from taggit.serializers import TagListSerializerField
import part.filters
from InvenTree.serializers import (
InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
InvenTreeImageSerializerField,
@ -26,10 +25,8 @@ from part.serializers import PartBriefSerializer
from .models import (
Address,
Company,
CompanyAttachment,
Contact,
ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter,
SupplierPart,
SupplierPriceBreak,
@ -186,17 +183,6 @@ class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSeriali
return self.instance
class CompanyAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for the CompanyAttachment class."""
class Meta:
"""Metaclass defines serializer options."""
model = CompanyAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields(['company'])
class ContactSerializer(InvenTreeModelSerializer):
"""Serializer class for the Contact model."""
@ -260,17 +246,6 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
)
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for the ManufacturerPartAttachment class."""
class Meta:
"""Metaclass options."""
model = ManufacturerPartAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields(['manufacturer_part'])
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
"""Serializer for the ManufacturerPartParameter model."""

View File

@ -244,17 +244,7 @@
{{ block.super }}
onPanelLoad("attachments", function() {
loadAttachmentTable('{% url "api-company-attachment-list" %}', {
filters: {
company: {{ company.pk }},
},
fields: {
company: {
value: {{ company.pk }},
hidden: true
}
}
});
loadAttachmentTable('company', {{ company.pk }});
});
// Callback function when the 'contacts' panel is loaded

View File

@ -177,17 +177,7 @@ src="{% static 'img/blank_image.png' %}"
{{ block.super }}
onPanelLoad("attachments", function() {
loadAttachmentTable('{% url "api-manufacturer-part-attachment-list" %}', {
filters: {
manufacturer_part: {{ part.pk }},
},
fields: {
manufacturer_part: {
value: {{ part.pk }},
hidden: true
}
}
});
loadAttachmentTable('manufacturerpart', {{ part.pk }});
});
$('#parameter-create').click(function() {

View File

@ -45,14 +45,7 @@ class TestManufacturerField(MigratorTestCase):
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
# Create an initial part
part = Part.objects.create(
name='Screw',
description='A single screw',
level=0,
tree_id=0,
lft=0,
rght=0,
)
part = Part.objects.create(name='Screw', description='A single screw')
# Create a company to act as the supplier
supplier = Company.objects.create(

View File

@ -17,15 +17,11 @@ from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
import common.models as common_models
from company.models import SupplierPart
import common.models
import common.settings
import company.models
from generic.states.api import StatusView
from InvenTree.api import (
APIDownloadMixin,
AttachmentMixin,
ListCreateDestroyAPIView,
MetadataView,
)
from InvenTree.api import APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.helpers_model import construct_absolute_url, get_base_url
@ -135,7 +131,7 @@ class OrderFilter(rest_filters.FilterSet):
return queryset.exclude(status__in=self.Meta.model.get_status_class().OPEN)
project_code = rest_filters.ModelChoiceFilter(
queryset=common_models.ProjectCode.objects.all(), field_name='project_code'
queryset=common.models.ProjectCode.objects.all(), field_name='project_code'
)
has_project_code = rest_filters.BooleanFilter(
@ -306,11 +302,13 @@ class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI):
if supplier_part is not None:
try:
supplier_part = SupplierPart.objects.get(pk=supplier_part)
supplier_part = company.models.SupplierPart.objects.get(
pk=supplier_part
)
queryset = queryset.filter(
id__in=[p.id for p in supplier_part.purchase_orders()]
)
except (ValueError, SupplierPart.DoesNotExist):
except (ValueError, company.models.SupplierPart.DoesNotExist):
pass
# Filter by 'date range'
@ -449,7 +447,9 @@ class PurchaseOrderLineItemFilter(LineItemFilter):
return queryset.exclude(order__status=PurchaseOrderStatus.COMPLETE.value)
part = rest_filters.ModelChoiceFilter(
queryset=SupplierPart.objects.all(), field_name='part', label=_('Supplier Part')
queryset=company.models.SupplierPart.objects.all(),
field_name='part',
label=_('Supplier Part'),
)
base_part = rest_filters.ModelChoiceFilter(
@ -648,22 +648,6 @@ class PurchaseOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
serializer_class = serializers.PurchaseOrderExtraLineSerializer
class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing, creating and bulk deleting a SalesOrderAttachment (file upload)."""
queryset = models.SalesOrderAttachment.objects.all()
serializer_class = serializers.SalesOrderAttachmentSerializer
filterset_fields = ['order']
class SalesOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for SalesOrderAttachment."""
queryset = models.SalesOrderAttachment.objects.all()
serializer_class = serializers.SalesOrderAttachmentSerializer
class SalesOrderFilter(OrderFilter):
"""Custom API filters for the SalesOrderList endpoint."""
@ -1150,22 +1134,6 @@ class SalesOrderShipmentComplete(CreateAPI):
return ctx
class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing, creating and bulk deleting) a PurchaseOrderAttachment (file upload)."""
queryset = models.PurchaseOrderAttachment.objects.all()
serializer_class = serializers.PurchaseOrderAttachmentSerializer
filterset_fields = ['order']
class PurchaseOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for a PurchaseOrderAttachment."""
queryset = models.PurchaseOrderAttachment.objects.all()
serializer_class = serializers.PurchaseOrderAttachmentSerializer
class ReturnOrderFilter(OrderFilter):
"""Custom API filters for the ReturnOrderList endpoint."""
@ -1416,22 +1384,6 @@ class ReturnOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
serializer_class = serializers.ReturnOrderExtraLineSerializer
class ReturnOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing, creating and bulk deleting a ReturnOrderAttachment (file upload)."""
queryset = models.ReturnOrderAttachment.objects.all()
serializer_class = serializers.ReturnOrderAttachmentSerializer
filterset_fields = ['order']
class ReturnOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for the ReturnOrderAttachment model."""
queryset = models.ReturnOrderAttachment.objects.all()
serializer_class = serializers.ReturnOrderAttachmentSerializer
class OrderCalendarExport(ICalFeed):
"""Calendar export for Purchase/Sales Orders.
@ -1514,7 +1466,9 @@ class OrderCalendarExport(ICalFeed):
else:
ordertype_title = _('Unknown')
return f'{common_models.InvenTreeSetting.get_setting("INVENTREE_COMPANY_NAME")} {ordertype_title}'
company_name = common.settings.get_global_setting('INVENTREE_COMPANY_NAME')
return f'{company_name} {ordertype_title}'
def product_id(self, obj):
"""Return calendar product id."""
@ -1597,22 +1551,6 @@ order_api_urls = [
path(
'po/',
include([
# Purchase order attachments
path(
'attachment/',
include([
path(
'<int:pk>/',
PurchaseOrderAttachmentDetail.as_view(),
name='api-po-attachment-detail',
),
path(
'',
PurchaseOrderAttachmentList.as_view(),
name='api-po-attachment-list',
),
]),
),
# Individual purchase order detail URLs
path(
'<int:pk>/',
@ -1704,21 +1642,6 @@ order_api_urls = [
path(
'so/',
include([
path(
'attachment/',
include([
path(
'<int:pk>/',
SalesOrderAttachmentDetail.as_view(),
name='api-so-attachment-detail',
),
path(
'',
SalesOrderAttachmentList.as_view(),
name='api-so-attachment-list',
),
]),
),
path(
'shipment/',
include([
@ -1854,21 +1777,6 @@ order_api_urls = [
path(
'ro/',
include([
path(
'attachment/',
include([
path(
'<int:pk>/',
ReturnOrderAttachmentDetail.as_view(),
name='api-return-order-attachment-detail',
),
path(
'',
ReturnOrderAttachmentList.as_view(),
name='api-return-order-attachment-list',
),
]),
),
# Return Order detail endpoints
path(
'<int:pk>/',

View File

@ -16,7 +16,7 @@ class Migration(migrations.Migration):
name='PurchaseOrderAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')),
('comment', models.CharField(help_text='File comment', max_length=100)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.PurchaseOrder')),
],

View File

@ -65,7 +65,7 @@ class Migration(migrations.Migration):
name='SalesOrderAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')),
('comment', models.CharField(help_text='File comment', max_length=100)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.SalesOrder')),
],

View File

@ -67,7 +67,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='purchaseorderattachment',
name='attachment',
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'),
),
migrations.AlterField(
model_name='purchaseorderattachment',
@ -187,7 +187,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='salesorderattachment',
name='attachment',
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'),
),
migrations.AlterField(
model_name='salesorderattachment',

View File

@ -25,11 +25,11 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='purchaseorderattachment',
name='attachment',
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'),
),
migrations.AlterField(
model_name='salesorderattachment',
name='attachment',
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'),
),
]

View File

@ -51,7 +51,7 @@ class Migration(migrations.Migration):
name='ReturnOrderAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),

View File

@ -0,0 +1,27 @@
# Generated by Django 4.2.12 on 2024-06-09 09:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('build', '0050_auto_20240508_0138'),
('common', '0026_auto_20240608_1238'),
('company', '0069_company_active'),
('order', '0099_alter_salesorder_status'),
('part', '0123_parttesttemplate_choices'),
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
]
operations = [
migrations.DeleteModel(
name='PurchaseOrderAttachment',
),
migrations.DeleteModel(
name='ReturnOrderAttachment',
),
migrations.DeleteModel(
name='SalesOrderAttachment',
),
]

View File

@ -184,6 +184,7 @@ class TotalPriceMixin(models.Model):
class Order(
StateTransitionMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin,
@ -1236,40 +1237,6 @@ def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs
notify_responsible(instance, sender, exclude=instance.created_by)
class PurchaseOrderAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a PurchaseOrder object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the PurchaseOrderAttachment model."""
return reverse('api-po-attachment-list')
def getSubdir(self):
"""Return the directory path where PurchaseOrderAttachment files are located."""
return os.path.join('po_files', str(self.order.id))
order = models.ForeignKey(
PurchaseOrder, on_delete=models.CASCADE, related_name='attachments'
)
class SalesOrderAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a SalesOrder object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the SalesOrderAttachment class."""
return reverse('api-so-attachment-list')
def getSubdir(self):
"""Return the directory path where SalesOrderAttachment files are located."""
return os.path.join('so_files', str(self.order.id))
order = models.ForeignKey(
SalesOrder, on_delete=models.CASCADE, related_name='attachments'
)
class OrderLineItem(InvenTree.models.InvenTreeMetadataModel):
"""Abstract model for an order line item.
@ -2315,20 +2282,3 @@ class ReturnOrderExtraLine(OrderExtraLine):
verbose_name=_('Order'),
help_text=_('Return Order'),
)
class ReturnOrderAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a ReturnOrder object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the ReturnOrderAttachment class."""
return reverse('api-return-order-attachment-list')
def getSubdir(self):
"""Return the directory path where ReturnOrderAttachment files are located."""
return os.path.join('return_files', str(self.order.id))
order = models.ForeignKey(
ReturnOrder, on_delete=models.CASCADE, related_name='attachments'
)

View File

@ -1,6 +1,5 @@
"""JSON serializers for the Order API."""
from datetime import datetime
from decimal import Decimal
from django.core.exceptions import ValidationError as DjangoValidationError
@ -42,7 +41,6 @@ from InvenTree.helpers import (
str2bool,
)
from InvenTree.serializers import (
InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
InvenTreeModelSerializer,
@ -757,17 +755,6 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
raise ValidationError(detail=serializers.as_serializer_error(exc))
class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializers for the PurchaseOrderAttachment model."""
class Meta:
"""Metaclass options."""
model = order.models.PurchaseOrderAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields(['order'])
class SalesOrderSerializer(
NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
):
@ -1525,17 +1512,6 @@ class SalesOrderExtraLineSerializer(
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializers for the SalesOrderAttachment model."""
class Meta:
"""Metaclass options."""
model = order.models.SalesOrderAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields(['order'])
class ReturnOrderSerializer(
NotesFieldMixin, AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer
):
@ -1778,14 +1754,3 @@ class ReturnOrderExtraLineSerializer(
model = order.models.ReturnOrderExtraLine
order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True)
class ReturnOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for the ReturnOrderAttachment model."""
class Meta:
"""Metaclass options."""
model = order.models.ReturnOrderAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields(['order'])

View File

@ -132,17 +132,7 @@
});
onPanelLoad('order-attachments', function() {
loadAttachmentTable('{% url "api-po-attachment-list" %}', {
filters: {
order: {{ order.pk }},
},
fields: {
order: {
value: {{ order.pk }},
hidden: true,
}
}
});
loadAttachmentTable('purchaseorder', {{ order.pk }});
});
loadStockTable($("#stock-table"), {

View File

@ -189,17 +189,7 @@ onPanelLoad('order-notes', function() {
// Callback function when the 'attachments' panel is loaded
onPanelLoad('order-attachments', function() {
loadAttachmentTable('{% url "api-return-order-attachment-list" %}', {
filters: {
order: {{ order.pk }},
},
fields: {
order: {
value: {{ order.pk }},
hidden: true,
},
}
});
loadAttachmentTable('returnorder', {{ order.pk }});
});
enableSidebar('returnorder');

View File

@ -203,17 +203,7 @@
onPanelLoad('order-attachments', function() {
loadAttachmentTable('{% url "api-so-attachment-list" %}', {
filters: {
order: {{ order.pk }},
},
fields: {
order: {
value: {{ order.pk }},
hidden: true,
},
}
});
loadAttachmentTable('salesorder', {{ order.pk }});
});
loadBuildTable($("#builds-table"), {

View File

@ -258,9 +258,9 @@ class PurchaseOrderTest(OrderTest):
def test_po_attachments(self):
"""Test the list endpoint for the PurchaseOrderAttachment model."""
url = reverse('api-po-attachment-list')
url = reverse('api-attachment-list')
response = self.get(url)
response = self.get(url, {'model_type': 'purchaseorder'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
@ -1260,9 +1260,12 @@ class SalesOrderTest(OrderTest):
def test_so_attachments(self):
"""Test the list endpoint for the SalesOrderAttachment model."""
url = reverse('api-so-attachment-list')
url = reverse('api-attachment-list')
self.get(url)
# Filter by 'salesorder'
self.get(
url, data={'model_type': 'salesorder', 'model_id': 1}, expected_code=200
)
def test_so_operations(self):
"""Test that we can create / edit and delete a SalesOrder via the API."""

View File

@ -353,14 +353,6 @@ class PartRelatedAdmin(admin.ModelAdmin):
autocomplete_fields = ('part_1', 'part_2')
class PartAttachmentAdmin(admin.ModelAdmin):
"""Admin class for the PartAttachment model."""
list_display = ('part', 'attachment', 'comment')
autocomplete_fields = ('part',)
class PartTestTemplateAdmin(admin.ModelAdmin):
"""Admin class for the PartTestTemplate model."""
@ -607,7 +599,6 @@ class PartInternalPriceBreakAdmin(admin.ModelAdmin):
admin.site.register(models.Part, PartAdmin)
admin.site.register(models.PartCategory, PartCategoryAdmin)
admin.site.register(models.PartRelated, PartRelatedAdmin)
admin.site.register(models.PartAttachment, PartAttachmentAdmin)
admin.site.register(models.BomItem, BomItemAdmin)
admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin)
admin.site.register(models.PartParameter, ParameterAdmin)

View File

@ -19,12 +19,7 @@ import order.models
import part.filters
from build.models import Build, BuildItem
from build.status_codes import BuildStatusGroups
from InvenTree.api import (
APIDownloadMixin,
AttachmentMixin,
ListCreateDestroyAPIView,
MetadataView,
)
from InvenTree.api import APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import (
ORDER_FILTER,
ORDER_FILTER_ALIAS,
@ -56,7 +51,6 @@ from .models import (
BomItem,
BomItemSubstitute,
Part,
PartAttachment,
PartCategory,
PartCategoryParameterTemplate,
PartInternalPriceBreak,
@ -404,22 +398,6 @@ class PartInternalPriceList(ListCreateAPI):
ordering = 'quantity'
class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing, creating and bulk deleting a PartAttachment (file upload)."""
queryset = PartAttachment.objects.all()
serializer_class = part_serializers.PartAttachmentSerializer
filterset_fields = ['part']
class PartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for PartAttachment model."""
queryset = PartAttachment.objects.all()
serializer_class = part_serializers.PartAttachmentSerializer
class PartTestTemplateFilter(rest_filters.FilterSet):
"""Custom filterset class for the PartTestTemplateList endpoint."""
@ -2059,18 +2037,6 @@ part_api_urls = [
),
]),
),
# Base URL for PartAttachment API endpoints
path(
'attachment/',
include([
path(
'<int:pk>/',
PartAttachmentDetail.as_view(),
name='api-part-attachment-detail',
),
path('', PartAttachmentList.as_view(), name='api-part-attachment-list'),
]),
),
# Base URL for part sale pricing
path(
'sale-price/',

View File

@ -14,6 +14,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='partattachment',
name='attachment',
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment),
field=models.FileField(help_text='Select file to attach', upload_to='attachments'),
),
]

View File

@ -98,7 +98,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='partattachment',
name='attachment',
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'),
),
migrations.AlterField(
model_name='partattachment',

View File

@ -20,6 +20,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='partattachment',
name='attachment',
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.12 on 2024-06-09 09:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('build', '0050_auto_20240508_0138'),
('common', '0026_auto_20240608_1238'),
('company', '0069_company_active'),
('order', '0099_alter_salesorder_status'),
('part', '0123_parttesttemplate_choices'),
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
]
operations = [
migrations.DeleteModel(
name='PartAttachment',
),
]

View File

@ -341,6 +341,7 @@ class PartManager(TreeManager):
@cleanup.ignore
class Part(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin,
@ -2208,24 +2209,6 @@ class Part(
required=True, enabled=enabled, include_parent=include_parent
)
@property
def attachment_count(self):
"""Count the number of attachments for this part.
If the part is a variant of a template part,
include the number of attachments for the template part.
"""
return self.part_attachments.count()
@property
def part_attachments(self):
"""Return *all* attachments for this part, potentially including attachments for template parts above this one."""
ancestors = self.get_ancestors(include_self=True)
attachments = PartAttachment.objects.filter(part__in=ancestors)
return attachments
def sales_orders(self):
"""Return a list of sales orders which reference this part."""
orders = []
@ -3299,26 +3282,6 @@ class PartStocktakeReport(models.Model):
)
class PartAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a Part object."""
@staticmethod
def get_api_url():
"""Return the list API endpoint URL associated with the PartAttachment model."""
return reverse('api-part-attachment-list')
def getSubdir(self):
"""Returns the media subdirectory where part attachments are stored."""
return os.path.join('part_files', str(self.part.id))
part = models.ForeignKey(
Part,
on_delete=models.CASCADE,
verbose_name=_('Part'),
related_name='attachments',
)
class PartSellPriceBreak(common.models.PriceBreak):
"""Represents a price break for selling this part."""

View File

@ -22,7 +22,6 @@ from sql_util.utils import SubqueryCount, SubquerySum
from taggit.serializers import TagListSerializerField
import common.currency
import common.models
import common.settings
import company.models
import InvenTree.helpers
@ -41,7 +40,6 @@ from .models import (
BomItem,
BomItemSubstitute,
Part,
PartAttachment,
PartCategory,
PartCategoryParameterTemplate,
PartInternalPriceBreak,
@ -147,19 +145,6 @@ class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
return queryset.annotate(subcategories=part.filters.annotate_sub_categories())
class PartAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
"""Serializer for the PartAttachment class."""
class Meta:
"""Metaclass defining serializer fields."""
model = PartAttachment
fields = InvenTree.serializers.InvenTreeAttachmentSerializer.attachment_fields([
'part'
])
class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the PartTestTemplate class."""
@ -1171,7 +1156,7 @@ class PartStocktakeReportGenerateSerializer(serializers.Serializer):
def validate(self, data):
"""Custom validation for this serializer."""
# Stocktake functionality must be enabled
if not common.models.InvenTreeSetting.get_setting('STOCKTAKE_ENABLE', False):
if not common.settings.get_global_setting('STOCKTAKE_ENABLE'):
raise serializers.ValidationError(
_('Stocktake functionality is not enabled')
)

View File

@ -803,17 +803,7 @@
});
onPanelLoad("part-attachments", function() {
loadAttachmentTable('{% url "api-part-attachment-list" %}', {
filters: {
part: {{ part.pk }},
},
fields: {
part: {
value: {{ part.pk }},
hidden: true
}
}
});
loadAttachmentTable('part', {{ part.pk }});
});
onPanelLoad('pricing', function() {

View File

@ -2513,22 +2513,28 @@ class PartAttachmentTest(InvenTreeAPITestCase):
def test_add_attachment(self):
"""Test that we can create a new PartAttachment via the API."""
url = reverse('api-part-attachment-list')
url = reverse('api-attachment-list')
# Upload without permission
response = self.post(url, {}, expected_code=403)
response = self.post(
url, {'model_id': 1, 'model_type': 'part'}, expected_code=403
)
# Add required permission
self.assignRole('part.add')
self.assignRole('part.change')
# Upload without specifying part (will fail)
response = self.post(url, {'comment': 'Hello world'}, expected_code=400)
self.assertIn('This field is required', str(response.data['part']))
self.assertIn('This field is required', str(response.data['model_id']))
self.assertIn('This field is required', str(response.data['model_type']))
# Upload without file OR link (will fail)
response = self.post(
url, {'part': 1, 'comment': 'Hello world'}, expected_code=400
url,
{'model_id': 1, 'model_type': 'part', 'comment': 'Hello world'},
expected_code=400,
)
self.assertIn('Missing file', str(response.data['attachment']))
@ -2536,7 +2542,9 @@ class PartAttachmentTest(InvenTreeAPITestCase):
# Upload an invalid link (will fail)
response = self.post(
url, {'part': 1, 'link': 'not-a-link.py'}, expected_code=400
url,
{'model_id': 1, 'model_type': 'part', 'link': 'not-a-link.py'},
expected_code=400,
)
self.assertIn('Enter a valid URL', str(response.data['link']))
@ -2545,12 +2553,20 @@ class PartAttachmentTest(InvenTreeAPITestCase):
# Upload a valid link (will pass)
response = self.post(
url, {'part': 1, 'link': link, 'comment': 'Hello world'}, expected_code=201
url,
{
'model_id': 1,
'model_type': 'part',
'link': link,
'comment': 'Hello world',
},
expected_code=201,
)
data = response.data
self.assertEqual(data['part'], 1)
self.assertEqual(data['model_type'], 'part')
self.assertEqual(data['model_id'], 1)
self.assertEqual(data['link'], link)
self.assertEqual(data['comment'], 'Hello world')

View File

@ -5,6 +5,7 @@ import logging
from django.conf import settings
from django.db.utils import OperationalError, ProgrammingError
from common.settings import get_global_setting
from plugin.helpers import MixinImplementationError
logger = logging.getLogger('inventree')
@ -58,16 +59,12 @@ class ScheduleMixin:
@classmethod
def _activate_mixin(cls, registry, plugins, *args, **kwargs):
"""Activate schedules from plugins with the ScheduleMixin."""
from common.models import InvenTreeSetting
logger.debug('Activating plugin tasks')
# List of tasks we have activated
task_keys = []
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting(
'ENABLE_PLUGINS_SCHEDULE'
):
if settings.PLUGIN_TESTING or get_global_setting('ENABLE_PLUGINS_SCHEDULE'):
for _key, plugin in plugins:
if plugin.mixin_enabled('schedule') and plugin.is_active():
# Only active tasks for plugins which are enabled

View File

@ -786,7 +786,7 @@ class PluginsRegistry:
for k in self.plugin_settings_keys():
try:
val = get_global_setting(k, False, create=False)
val = get_global_setting(k)
msg = f'{k}-{val}'
data.update(msg.encode())

View File

@ -3,20 +3,18 @@
from django.conf import settings
from django.urls import include, re_path
from common.validators import get_global_setting
PLUGIN_BASE = 'plugin' # Constant for links
def get_plugin_urls():
"""Returns a urlpattern that can be integrated into the global urls."""
from common.models import InvenTreeSetting
from plugin.registry import registry
urls = []
if (
InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL', False)
or settings.PLUGIN_TESTING_SETUP
):
if get_global_setting('ENABLE_PLUGINS_URL', False) or settings.PLUGIN_TESTING_SETUP:
for plugin in registry.plugins.values():
if plugin.mixin_enabled('urls'):
urls.append(plugin.urlpatterns)

View File

@ -70,7 +70,7 @@ def page_size(page_code):
def report_page_size_default():
"""Returns the default page size for PDF reports."""
try:
page_size = get_global_setting('REPORT_DEFAULT_PAGE_SIZE', 'A4')
page_size = get_global_setting('REPORT_DEFAULT_PAGE_SIZE', 'A4', create=False)
except Exception as exc:
logger.exception('Error getting default page size: %s', str(exc))
page_size = 'A4'

View File

@ -21,6 +21,7 @@ import InvenTree.helpers
import InvenTree.models
import report.helpers
import report.validators
from common.settings import get_global_setting
from InvenTree.helpers_model import get_base_url
from InvenTree.models import MetadataMixin
from plugin.registry import registry
@ -311,8 +312,8 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
def get_report_size(self):
"""Return the printable page size for this report."""
try:
page_size_default = common.models.InvenTreeSetting.get_setting(
'REPORT_DEFAULT_PAGE_SIZE', 'A4'
page_size_default = get_global_setting(
'REPORT_DEFAULT_PAGE_SIZE', 'A4', create=False
)
except Exception:
page_size_default = 'A4'

View File

@ -15,14 +15,14 @@ from PIL import Image
import report.models as report_models
from build.models import Build
from common.models import InvenTreeSetting
from common.models import Attachment, InvenTreeSetting
from InvenTree.unit_test import InvenTreeAPITestCase
from order.models import ReturnOrder, SalesOrder
from plugin.registry import registry
from report.models import LabelTemplate, ReportTemplate
from report.templatetags import barcode as barcode_tags
from report.templatetags import report as report_tags
from stock.models import StockItem, StockItemAttachment
from stock.models import StockItem
class ReportTagTest(TestCase):
@ -502,7 +502,7 @@ class PrintTestMixins:
},
expected_code=201,
max_query_time=15,
max_query_count=1000, # TODO: Should look into this
max_query_count=500 * len(qs),
)
@ -548,7 +548,9 @@ class TestReportTest(PrintTestMixins, ReportTest):
self.assertEqual(response.data['output'].startswith('/media/report/'), True)
# By default, this should *not* have created an attachment against this stockitem
self.assertFalse(StockItemAttachment.objects.filter(stock_item=item).exists())
self.assertFalse(
Attachment.objects.filter(model_id=item.pk, model_type='stockitem').exists()
)
return
# TODO @matmair - Re-add this test after https://github.com/inventree/InvenTree/pull/7074/files#r1600694356 is resolved
@ -563,7 +565,9 @@ class TestReportTest(PrintTestMixins, ReportTest):
self.assertEqual(response.data['output'].startswith('/media/report/'), True)
# Check that a report has been uploaded
attachment = StockItemAttachment.objects.filter(stock_item=item).first()
attachment = Attachment.objects.filter(
model_id=item.pk, model_type='stockitem'
).first()
self.assertIsNotNone(attachment)
def test_mdl_build(self):

View File

@ -16,7 +16,6 @@ from part.models import Part
from .models import (
StockItem,
StockItemAttachment,
StockItemTestResult,
StockItemTracking,
StockLocation,
@ -301,15 +300,6 @@ class StockItemAdmin(ImportExportModelAdmin):
]
@admin.register(StockItemAttachment)
class StockAttachmentAdmin(admin.ModelAdmin):
"""Admin class for StockAttachment."""
list_display = ('stock_item', 'attachment', 'comment')
autocomplete_fields = ['stock_item']
@admin.register(StockItemTracking)
class StockTrackingAdmin(ImportExportModelAdmin):
"""Admin class for StockTracking."""

View File

@ -28,12 +28,7 @@ from build.serializers import BuildSerializer
from company.models import Company, SupplierPart
from company.serializers import CompanySerializer
from generic.states.api import StatusView
from InvenTree.api import (
APIDownloadMixin,
AttachmentMixin,
ListCreateDestroyAPIView,
MetadataView,
)
from InvenTree.api import APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import (
ORDER_FILTER_ALIAS,
SEARCH_ORDER_FILTER,
@ -68,7 +63,6 @@ from stock.admin import LocationResource, StockItemResource
from stock.generators import generate_batch_code, generate_serial_number
from stock.models import (
StockItem,
StockItemAttachment,
StockItemTestResult,
StockItemTracking,
StockLocation,
@ -1221,22 +1215,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
]
class StockAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing, creating and bulk deleting a StockItemAttachment (file upload)."""
queryset = StockItemAttachment.objects.all()
serializer_class = StockSerializers.StockItemAttachmentSerializer
filterset_fields = ['stock_item']
class StockAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for StockItemAttachment."""
queryset = StockItemAttachment.objects.all()
serializer_class = StockSerializers.StockItemAttachmentSerializer
class StockItemTestResultMixin:
"""Mixin class for the StockItemTestResult API endpoints."""
@ -1609,18 +1587,6 @@ stock_api_urls = [
path('assign/', StockAssign.as_view(), name='api-stock-assign'),
path('merge/', StockMerge.as_view(), name='api-stock-merge'),
path('change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'),
# StockItemAttachment API endpoints
path(
'attachment/',
include([
path(
'<int:pk>/',
StockAttachmentDetail.as_view(),
name='api-stock-attachment-detail',
),
path('', StockAttachmentList.as_view(), name='api-stock-attachment-list'),
]),
),
# StockItemTestResult API endpoints
path(
'test/',

View File

@ -16,7 +16,7 @@ class Migration(migrations.Migration):
name='StockItemAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')),
('comment', models.CharField(help_text='File comment', max_length=100)),
('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='stock.StockItem')),
],

View File

@ -32,7 +32,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='stockitemattachment',
name='attachment',
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'),
),
migrations.AlterField(
model_name='stockitemattachment',

View File

@ -20,6 +20,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='stockitemattachment',
name='attachment',
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.12 on 2024-06-09 09:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('build', '0050_auto_20240508_0138'),
('common', '0026_auto_20240608_1238'),
('company', '0069_company_active'),
('order', '0099_alter_salesorder_status'),
('part', '0123_parttesttemplate_choices'),
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
]
operations = [
migrations.DeleteModel(
name='StockItemAttachment',
),
]

View File

@ -316,6 +316,7 @@ def default_delete_on_deplete():
class StockItem(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin,
@ -2255,23 +2256,6 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
instance.part.schedule_pricing_update(create=True)
class StockItemAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a StockItem object."""
@staticmethod
def get_api_url():
"""Return API url."""
return reverse('api-stock-attachment-list')
def getSubdir(self):
"""Override attachment location."""
return os.path.join('stock_files', str(self.stock_item.id))
stock_item = models.ForeignKey(
StockItem, on_delete=models.CASCADE, related_name='attachments'
)
class StockItemTracking(InvenTree.models.InvenTreeModel):
"""Stock tracking entry - used for tracking history of a particular StockItem.

View File

@ -31,7 +31,6 @@ from part.serializers import PartBriefSerializer, PartTestTemplateSerializer
from .models import (
StockItem,
StockItemAttachment,
StockItemTestResult,
StockItemTracking,
StockLocation,
@ -1101,21 +1100,6 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
)
class StockItemAttachmentSerializer(
InvenTree.serializers.InvenTreeAttachmentSerializer
):
"""Serializer for StockItemAttachment model."""
class Meta:
"""Metaclass options."""
model = StockItemAttachment
fields = InvenTree.serializers.InvenTreeAttachmentSerializer.attachment_fields([
'stock_item'
])
class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for StockItemTracking model."""

View File

@ -220,17 +220,7 @@
});
onPanelLoad('attachments', function() {
loadAttachmentTable('{% url "api-stock-attachment-list" %}', {
filters: {
stock_item: {{ item.pk }},
},
fields: {
stock_item: {
value: {{ item.pk }},
hidden: true,
}
}
});
loadAttachmentTable('stockitem', {{ item.pk }});
});
{% settings_value "TEST_STATION_DATA" as test_station_fields %}

View File

@ -885,13 +885,6 @@ class StockItemListTest(StockAPITestCase):
def test_query_count(self):
"""Test that the number of queries required to fetch stock items is reasonable."""
def get_stock(data, expected_status=200):
"""Helper function to fetch stock items."""
response = self.client.get(self.list_url, data=data)
self.assertEqual(response.status_code, expected_status)
return response.data
# Create a bunch of StockItem objects
prt = Part.objects.first()
@ -901,20 +894,18 @@ class StockItemListTest(StockAPITestCase):
])
# List *all* stock items
with self.assertNumQueriesLessThan(25):
get_stock({})
self.get(self.list_url, {}, max_query_count=35)
# List all stock items, with part detail
with self.assertNumQueriesLessThan(20):
get_stock({'part_detail': True})
self.get(self.list_url, {'part_detail': True}, max_query_count=35)
# List all stock items, with supplier_part detail
with self.assertNumQueriesLessThan(20):
get_stock({'supplier_part_detail': True})
self.get(self.list_url, {'supplier_part_detail': True}, max_query_count=35)
# List all stock items, with 'location' and 'tests' detail
with self.assertNumQueriesLessThan(20):
get_stock({'location_detail': True, 'tests': True})
self.get(
self.list_url, {'location_detail': True, 'tests': True}, max_query_count=35
)
class StockItemTest(StockAPITestCase):

View File

@ -214,34 +214,41 @@ function makeAttachmentActions(permissions, options) {
/* Load a table of attachments against a specific model.
* Note that this is a 'generic' table which is used for multiple attachment model classes
*/
function loadAttachmentTable(url, options) {
function loadAttachmentTable(model_type, model_id, options={}) {
var table = options.table || '#attachment-table';
const url = '{% url "api-attachment-list" %}';
const table = options.table || '#attachment-table';
var permissions = {};
let filters = {
model_type: model_type,
model_id: model_id,
};
// First we determine which permissions the user has for this attachment table
let permissions = {
delete: false,
add: false,
change: false,
};
// Request the permissions for the current user
$.ajax({
url: url,
url: '{% url "api-user-roles" %}',
async: false,
type: 'OPTIONS',
contentType: 'application/json',
dataType: 'json',
accepts: {
json: 'application/json',
},
contentType: 'application/json',
success: function(response) {
if (response.actions.DELETE) {
if (response.is_superuser) {
permissions.delete = true;
permissions.add = true;
permissions.change = true;
return;
}
if (response.actions.POST) {
permissions.change = true;
permissions.add = true;
}
},
error: function(xhr) {
showApiError(xhr, url);
let model_permissions = response?.permissions[model_type] ?? {};
permissions.delete = "delete" in model_permissions;
permissions.add = "add" in model_permissions;
permissions.change = "change" in model_permissions;
}
});
@ -261,7 +268,19 @@ function loadAttachmentTable(url, options) {
});
if (permissions.add) {
addAttachmentButtonCallbacks(url, options.fields || {});
addAttachmentButtonCallbacks(
url,
{
model_type: {
value: model_type,
hidden: true,
},
model_id: {
value: model_id,
hidden: true,
},
}
);
} else {
// Hide the buttons
$('#new-attachment').hide();
@ -276,7 +295,7 @@ function loadAttachmentTable(url, options) {
},
sortable: true,
search: true,
queryParams: options.filters || {},
queryParams: filters,
uniqueId: 'pk',
sidePagination: 'server',
onPostBody: function() {
@ -386,7 +405,10 @@ function loadAttachmentTable(url, options) {
'#attachment-dropzone',
url,
{
data: options.filters,
data: {
model_type: model_type,
model_id: model_id,
},
label: 'attachment',
method: 'POST',
success: function() {

View File

@ -4,7 +4,8 @@ import datetime
import logging
from django.contrib.auth import get_user, login, logout
from django.contrib.auth.models import Group, User
from django.contrib.auth.models import Group, Permission, User
from django.db.models import Q
from django.urls import include, path, re_path
from django.views.generic.base import RedirectView
@ -137,10 +138,29 @@ class RoleDetails(APIView):
else:
roles[role] = None # pragma: no cover
# Extract individual permissions for the user
if user.is_superuser:
permissions = Permission.objects.all()
else:
permissions = Permission.objects.filter(
Q(user=user) | Q(group__user=user)
).distinct()
perms = {}
for permission in permissions:
perm, model = permission.codename.split('_')
if model not in perms:
perms[model] = []
perms[model].append(perm)
data = {
'user': user.pk,
'username': user.username,
'roles': roles,
'permissions': perms,
'is_staff': user.is_staff,
'is_superuser': user.is_superuser,
}

View File

@ -9,6 +9,10 @@ from django.db import migrations
def clear_sessions(apps, schema_editor):
"""Clear all user sessions."""
# Ignore in test mode
if settings.TESTING:
return
try:
engine = import_module(settings.SESSION_ENGINE)
engine.SessionStore.clear_expired()

View File

@ -258,7 +258,6 @@ class RuleSet(models.Model):
'part_partpricing',
'part_bomitem',
'part_bomitemsubstitute',
'part_partattachment',
'part_partsellpricebreak',
'part_partinternalpricebreak',
'part_parttesttemplate',
@ -270,13 +269,11 @@ class RuleSet(models.Model):
'company_supplierpart',
'company_manufacturerpart',
'company_manufacturerpartparameter',
'company_manufacturerpartattachment',
],
'stocktake': ['part_partstocktake', 'part_partstocktakereport'],
'stock_location': ['stock_stocklocation', 'stock_stocklocationtype'],
'stock': [
'stock_stockitem',
'stock_stockitemattachment',
'stock_stockitemtracking',
'stock_stockitemtestresult',
],
@ -288,13 +285,11 @@ class RuleSet(models.Model):
'build_build',
'build_builditem',
'build_buildline',
'build_buildorderattachment',
'stock_stockitem',
'stock_stocklocation',
],
'purchase_order': [
'company_company',
'company_companyattachment',
'company_contact',
'company_address',
'company_manufacturerpart',
@ -302,31 +297,26 @@ class RuleSet(models.Model):
'company_supplierpart',
'company_supplierpricebreak',
'order_purchaseorder',
'order_purchaseorderattachment',
'order_purchaseorderlineitem',
'order_purchaseorderextraline',
],
'sales_order': [
'company_company',
'company_companyattachment',
'company_contact',
'company_address',
'order_salesorder',
'order_salesorderallocation',
'order_salesorderattachment',
'order_salesorderlineitem',
'order_salesorderextraline',
'order_salesordershipment',
],
'return_order': [
'company_company',
'company_companyattachment',
'company_contact',
'company_address',
'order_returnorder',
'order_returnorderlineitem',
'order_returnorderextraline',
'order_returnorderattachment',
],
}
@ -344,6 +334,7 @@ class RuleSet(models.Model):
'admin_logentry',
'contenttypes_contenttype',
# Models which currently do not require permissions
'common_attachment',
'common_colortheme',
'common_customunit',
'common_inventreesetting',

View File

@ -117,7 +117,23 @@ export function formatPriceRange(
)}`;
}
interface RenderDateOptionsInterface {
/*
* Format a file size (in bytes) into a human-readable format
*/
export function formatFileSize(size: number) {
const suffixes: string[] = ['B', 'KB', 'MB', 'GB'];
let idx = 0;
while (size > 1024 && idx < suffixes.length) {
size /= 1024;
idx++;
}
return `${size.toFixed(2)} ${suffixes[idx]}`;
}
interface FormatDateOptionsInterface {
showTime?: boolean;
showSeconds?: boolean;
}
@ -128,9 +144,9 @@ interface RenderDateOptionsInterface {
* The provided "date" variable is a string, nominally ISO format e.g. 2022-02-22
* The user-configured setting DATE_DISPLAY_FORMAT determines how the date should be displayed.
*/
export function renderDate(
export function formatDate(
date: string,
options: RenderDateOptionsInterface = {}
options: FormatDateOptionsInterface = {}
) {
if (!date) {
return '-';

View File

@ -57,7 +57,6 @@ export enum ApiEndpoints {
build_output_complete = 'build/:id/complete/',
build_output_scrap = 'build/:id/scrap-outputs/',
build_output_delete = 'build/:id/delete-outputs/',
build_order_attachment_list = 'build/attachment/',
build_line_list = 'build/line/',
bom_list = 'bom/',
@ -76,18 +75,15 @@ export enum ApiEndpoints {
category_tree = 'part/category/tree/',
category_parameter_list = 'part/category/parameters/',
related_part_list = 'part/related/',
part_attachment_list = 'part/attachment/',
part_test_template_list = 'part/test-template/',
// Company API endpoints
company_list = 'company/',
contact_list = 'company/contact/',
address_list = 'company/address/',
company_attachment_list = 'company/attachment/',
supplier_part_list = 'company/part/',
supplier_part_pricing_list = 'company/price-break/',
manufacturer_part_list = 'company/part/manufacturer/',
manufacturer_part_attachment_list = 'company/part/manufacturer/attachment/',
manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/',
// Stock API endpoints
@ -96,7 +92,6 @@ export enum ApiEndpoints {
stock_location_list = 'stock/location/',
stock_location_type_list = 'stock/location-type/',
stock_location_tree = 'stock/location/tree/',
stock_attachment_list = 'stock/attachment/',
stock_test_result_list = 'stock/test/',
stock_transfer = 'stock/transfer/',
stock_remove = 'stock/remove/',
@ -115,16 +110,13 @@ export enum ApiEndpoints {
// Order API endpoints
purchase_order_list = 'order/po/',
purchase_order_line_list = 'order/po-line/',
purchase_order_attachment_list = 'order/po/attachment/',
purchase_order_receive = 'order/po/:id/receive/',
sales_order_list = 'order/so/',
sales_order_line_list = 'order/so-line/',
sales_order_attachment_list = 'order/so/attachment/',
sales_order_shipment_list = 'order/so/shipment/',
return_order_list = 'order/ro/',
return_order_attachment_list = 'order/ro/attachment/',
// Template API endpoints
label_list = 'label/template/',
@ -155,6 +147,7 @@ export enum ApiEndpoints {
machine_setting_detail = 'machine/:machine/settings/:config_type/',
// Miscellaneous API endpoints
attachment_list = 'attachment/',
error_report_list = 'error-report/',
project_code_list = 'project-code/',
custom_unit_list = 'units/',

View File

@ -56,20 +56,6 @@ function ApiFormsPlayground() {
fields: editPartFields
});
const newAttachment = useCreateApiFormModal({
url: ApiEndpoints.part_attachment_list,
title: 'Create Attachment',
fields: {
part: {},
attachment: {},
comment: {}
},
initialData: {
part: 1
},
successMessage: 'Attachment uploaded'
});
const [active, setActive] = useState(true);
const [name, setName] = useState('Hello');
@ -130,9 +116,6 @@ function ApiFormsPlayground() {
<Button onClick={() => editCategory.open()}>Edit Category</Button>
{editCategory.modal}
<Button onClick={() => newAttachment.open()}>Create Attachment</Button>
{newAttachment.modal}
<Button onClick={() => openCreatePart()}>Create Part new Modal</Button>
{createPartModal}
</Group>

View File

@ -295,11 +295,7 @@ export default function BuildDetail() {
label: t`Attachments`,
icon: <IconPaperclip />,
content: (
<AttachmentTable
endpoint={ApiEndpoints.build_order_attachment_list}
model="build"
pk={Number(id)}
/>
<AttachmentTable model_type={ModelType.build} model_id={Number(id)} />
)
},
{

View File

@ -256,9 +256,8 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
icon: <IconPaperclip />,
content: (
<AttachmentTable
endpoint={ApiEndpoints.company_attachment_list}
model="company"
pk={company.pk ?? -1}
model_type={ModelType.company}
model_id={company.pk}
/>
)
},

View File

@ -173,9 +173,8 @@ export default function ManufacturerPartDetail() {
icon: <IconPaperclip />,
content: (
<AttachmentTable
endpoint={ApiEndpoints.manufacturer_part_attachment_list}
model="manufacturer_part"
pk={manufacturerPart?.pk}
model_type={ModelType.manufacturerpart}
model_id={manufacturerPart?.pk}
/>
)
}

View File

@ -618,11 +618,7 @@ export default function PartDetail() {
label: t`Attachments`,
icon: <IconPaperclip />,
content: (
<AttachmentTable
endpoint={ApiEndpoints.part_attachment_list}
model="part"
pk={part.pk ?? -1}
/>
<AttachmentTable model_type={ModelType.part} model_id={part?.pk} />
)
},
{

View File

@ -22,7 +22,7 @@ import { DataTable } from 'mantine-datatable';
import { ReactNode, useMemo } from 'react';
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
import { formatCurrency, renderDate } from '../../../defaults/formatters';
import { formatCurrency, formatDate } from '../../../defaults/formatters';
import { panelOptions } from '../PartPricingPanel';
interface PricingOverviewEntry {
@ -173,7 +173,7 @@ export default function PricingOverviewPanel({
{pricing?.updated && (
<Paper p="xs">
<Alert color="blue" title={t`Last Updated`}>
<Text>{renderDate(pricing.updated)}</Text>
<Text>{formatDate(pricing.updated)}</Text>
</Alert>
</Paper>
)}

View File

@ -3,7 +3,7 @@ import { BarChart } from '@mantine/charts';
import { Group, SimpleGrid, Text } from '@mantine/core';
import { ReactNode, useCallback, useMemo } from 'react';
import { formatCurrency, renderDate } from '../../../defaults/formatters';
import { formatCurrency, formatDate } from '../../../defaults/formatters';
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState';
@ -40,7 +40,7 @@ export default function PurchaseHistoryPanel({
title: t`Date`,
sortable: true,
switchable: true,
render: (record: any) => renderDate(record.order_detail.complete_date)
render: (record: any) => formatDate(record.order_detail.complete_date)
},
{
accessor: 'purchase_price',

Some files were not shown because too many files have changed in this diff Show More