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 invoke export-records -f data.json
python3 ./src/backend/InvenTree/manage.py flush --noinput python3 ./src/backend/InvenTree/manage.py flush --noinput
invoke migrate invoke migrate
invoke import-records -f data.json invoke import-records -c -f data.json
invoke import-records -f data.json invoke import-records -c -f data.json

View File

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

View File

@ -419,22 +419,6 @@ class APIDownloadMixin:
raise NotImplementedError('download_queryset method not implemented!') 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): class APISearchViewSerializer(serializers.Serializer):
"""Serializer for the APISearchView.""" """Serializer for the APISearchView."""

View File

@ -1,11 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v206 - 2024-06-08 : https://github.com/inventree/InvenTree/pull/7417
- Adds "choices" field to the PartTestTemplate model - 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 djmoney.contrib.exchange.models import ExchangeBackend, Rate
from common.currency import currency_code_default, currency_codes from common.currency import currency_code_default, currency_codes
from common.settings import get_global_setting
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -22,14 +23,13 @@ class InvenTreeExchange(SimpleExchangeBackend):
def get_rates(self, **kwargs) -> dict: def get_rates(self, **kwargs) -> dict:
"""Set the requested currency codes and get rates.""" """Set the requested currency codes and get rates."""
from common.models import InvenTreeSetting
from plugin import registry from plugin import registry
base_currency = kwargs.get('base_currency', currency_code_default()) base_currency = kwargs.get('base_currency', currency_code_default())
symbols = kwargs.get('symbols', currency_codes()) symbols = kwargs.get('symbols', currency_codes())
# Find the selected exchange rate plugin # 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: if slug:
plugin = registry.get_plugin(slug) plugin = registry.get_plugin(slug)

View File

@ -33,7 +33,7 @@ class InvenTreeRestURLField(RestURLField):
def run_validation(self, data=empty): def run_validation(self, data=empty):
"""Override default validation behaviour for this field type.""" """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: if not strict_urls and data is not empty and '://' not in data:
# Validate as if there were a schema provided # 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 allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware
from error_report.middleware import ExceptionProcessor from error_report.middleware import ExceptionProcessor
from common.settings import get_global_setting
from InvenTree.urls import frontendpatterns from InvenTree.urls import frontendpatterns
from users.models import ApiToken from users.models import ApiToken
@ -153,11 +154,9 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware):
def require_2fa(self, request): def require_2fa(self, request):
"""Use setting to check if MFA should be enforced for frontend page.""" """Use setting to check if MFA should be enforced for frontend page."""
from common.models import InvenTreeSetting
try: try:
if url_matcher.resolve(request.path[1:]): if url_matcher.resolve(request.path[1:]):
return InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA') return get_global_setting('LOGIN_ENFORCE_MFA')
except Resolver404: except Resolver404:
pass pass
return False return False

View File

@ -1,9 +1,7 @@
"""Generic models which provide extra functionality over base Django model types.""" """Generic models which provide extra functionality over base Django model types."""
import logging import logging
import os
from datetime import datetime from datetime import datetime
from io import BytesIO
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model 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.exceptions import InvalidMove
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
import common.settings
import InvenTree.fields import InvenTree.fields
import InvenTree.format import InvenTree.format
import InvenTree.helpers import InvenTree.helpers
import InvenTree.helpers_model import InvenTree.helpers_model
from InvenTree.sanitizer import sanitize_svg
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -304,10 +302,7 @@ class ReferenceIndexingMixin(models.Model):
if cls.REFERENCE_PATTERN_SETTING is None: if cls.REFERENCE_PATTERN_SETTING is None:
return '' return ''
# import at function level to prevent cyclic imports return common.settings.get_global_setting(
from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting(
cls.REFERENCE_PATTERN_SETTING, create=False cls.REFERENCE_PATTERN_SETTING, create=False
).strip() ).strip()
@ -503,200 +498,64 @@ class InvenTreeMetadataModel(MetadataMixin, InvenTreeModel):
abstract = True abstract = True
def rename_attachment(instance, filename): class InvenTreeAttachmentMixin:
"""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):
"""Provides an abstracted class for managing file attachments. """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: - attachments: Return a queryset containing all attachments for this model
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
""" """
class Meta: def delete(self):
"""Metaclass options. Abstract ensures no database table is created.""" """Handle the deletion of a model instance.
abstract = True Before deleting the model instance, delete any associated attachments.
def getSubdir(self):
"""Return the subdirectory under which attachments should be stored.
Note: Re-implement this for each subclass of InvenTreeAttachment
""" """
return 'attachments' self.attachments.all().delete()
super().delete()
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')
)
@property @property
def basename(self): def attachments(self):
"""Base name/path for attachment.""" """Return a queryset containing all attachments for this model."""
if self.attachment: return self.attachments_for_model().filter(model_id=self.pk)
return os.path.basename(self.attachment.name)
return None
@basename.setter @classmethod
def basename(self, fn): def check_attachment_permission(cls, permission, user) -> bool:
"""Function to rename the attachment file. """Check if the user has permission to perform the specified action on the attachment.
- Filename cannot be empty The default implementation runs a permission check against *this* model class,
- Filename cannot contain illegal characters but this can be overridden in the implementing class if required.
- Filename must specify an extension
- Filename cannot match an existing file 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: def attachments_for_model(self):
raise ValidationError(_('Filename must not be empty')) """Return all attachments for this model class."""
from common.models import Attachment
attachment_dir = settings.MEDIA_ROOT.joinpath(self.getSubdir()) model_type = self.__class__.__name__.lower()
old_file = settings.MEDIA_ROOT.joinpath(self.attachment.name)
new_file = settings.MEDIA_ROOT.joinpath(self.getSubdir(), fn).resolve()
# Check that there are no directory tricks going on... return Attachment.objects.filter(model_type=model_type)
if new_file.parent != attachment_dir:
logger.error(
"Attempted to rename attachment outside valid directory: '%s'", new_file
)
raise ValidationError(_('Invalid attachment directory'))
# Ignore further checks if the filename is not actually being renamed def create_attachment(self, attachment=None, link=None, comment='', **kwargs):
if new_file == old_file: """Create an attachment / link for this model."""
return 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: Attachment.objects.create(**kwargs)
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 ''
class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): 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)) 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): class InvenTreeImageSerializerField(serializers.ImageField):
"""Custom image serializer. """Custom image serializer.

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters 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 generic.states.api import StatusView
from InvenTree.helpers import str2bool, isNull, DownloadFile from InvenTree.helpers import str2bool, isNull, DownloadFile
from build.status_codes import BuildStatus, BuildStatusGroups from build.status_codes import BuildStatus, BuildStatusGroups
@ -20,7 +20,7 @@ from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
import common.models import common.models
import build.admin import build.admin
import build.serializers import build.serializers
from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment from build.models import Build, BuildLine, BuildItem
import part.models import part.models
from users.models import Owner from users.models import Owner
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS 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 = [ 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 # Build lines
path('line/', include([ path('line/', include([
path('<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'), path('<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),

View File

@ -18,7 +18,7 @@ class Migration(migrations.Migration):
name='BuildOrderAttachment', name='BuildOrderAttachment',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('comment', models.CharField(blank=True, help_text='File comment', max_length=100)),
('upload_date', models.DateField(auto_now_add=True, null=True)), ('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')), ('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( migrations.AlterField(
model_name='buildorderattachment', model_name='buildorderattachment',
name='attachment', 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( migrations.AlterField(
model_name='buildorderattachment', model_name='buildorderattachment',

View File

@ -20,6 +20,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='buildorderattachment', model_name='buildorderattachment',
name='attachment', 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( class Build(
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.MetadataMixin, InvenTree.models.MetadataMixin,
@ -1322,16 +1323,6 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
instance.update_build_line_items() 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): class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel):
"""A BuildLine object links a BOMItem to a Build. """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 import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeModelSerializer, UserSerializer
from InvenTree.serializers import UserSerializer
import InvenTree.helpers import InvenTree.helpers
from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin
@ -30,7 +29,7 @@ import part.filters
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
from users.serializers import OwnerSerializer from users.serializers import OwnerSerializer
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment from .models import Build, BuildLine, BuildItem
class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer): class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
@ -1311,15 +1310,3 @@ class BuildLineSerializer(InvenTreeModelSerializer):
) )
return queryset 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() { onPanelLoad('attachments', function() {
loadAttachmentTable('build', {{ build.pk }});
loadAttachmentTable('{% url "api-build-attachment-list" %}', {
filters: {
build: {{ build.pk }},
},
fields: {
build: {
value: {{ build.pk }},
hidden: true,
}
}
});
}); });
onPanelLoad('notes', function() { onPanelLoad('notes', function() {

View File

@ -19,7 +19,6 @@ class TestForwardMigrations(MigratorTestCase):
name='Widget', name='Widget',
description='Buildable Part', description='Buildable Part',
active=True, active=True,
level=0, lft=0, rght=0, tree_id=0,
) )
Build = self.old_state.apps.get_model('build', 'build') Build = self.old_state.apps.get_model('build', 'build')
@ -61,7 +60,6 @@ class TestReferenceMigration(MigratorTestCase):
part = Part.objects.create( part = Part.objects.create(
name='Part', name='Part',
description='A test part', description='A test part',
level=0, lft=0, rght=0, tree_id=0,
) )
Build = self.old_state.apps.get_model('build', 'build') 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 from import_export.admin import ImportExportModelAdmin
import common.models 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) @admin.register(common.models.ProjectCode)
@ -16,6 +44,7 @@ class ProjectCodeAdmin(ImportExportModelAdmin):
search_fields = ('code', 'description') search_fields = ('code', 'description')
@admin.register(common.models.InvenTreeSetting)
class SettingsAdmin(ImportExportModelAdmin): class SettingsAdmin(ImportExportModelAdmin):
"""Admin settings for InvenTreeSetting.""" """Admin settings for InvenTreeSetting."""
@ -28,6 +57,7 @@ class SettingsAdmin(ImportExportModelAdmin):
return [] return []
@admin.register(common.models.InvenTreeUserSetting)
class UserSettingsAdmin(ImportExportModelAdmin): class UserSettingsAdmin(ImportExportModelAdmin):
"""Admin settings for InvenTreeUserSetting.""" """Admin settings for InvenTreeUserSetting."""
@ -40,18 +70,21 @@ class UserSettingsAdmin(ImportExportModelAdmin):
return [] return []
@admin.register(common.models.WebhookEndpoint)
class WebhookAdmin(ImportExportModelAdmin): class WebhookAdmin(ImportExportModelAdmin):
"""Admin settings for Webhook.""" """Admin settings for Webhook."""
list_display = ('endpoint_id', 'name', 'active', 'user') list_display = ('endpoint_id', 'name', 'active', 'user')
@admin.register(common.models.NotificationEntry)
class NotificationEntryAdmin(admin.ModelAdmin): class NotificationEntryAdmin(admin.ModelAdmin):
"""Admin settings for NotificationEntry.""" """Admin settings for NotificationEntry."""
list_display = ('key', 'uid', 'updated') list_display = ('key', 'uid', 'updated')
@admin.register(common.models.NotificationMessage)
class NotificationMessageAdmin(admin.ModelAdmin): class NotificationMessageAdmin(admin.ModelAdmin):
"""Admin settings for NotificationMessage.""" """Admin settings for NotificationMessage."""
@ -70,16 +103,11 @@ class NotificationMessageAdmin(admin.ModelAdmin):
search_fields = ('name', 'category', 'message') search_fields = ('name', 'category', 'message')
@admin.register(common.models.NewsFeedEntry)
class NewsFeedEntryAdmin(admin.ModelAdmin): class NewsFeedEntryAdmin(admin.ModelAdmin):
"""Admin settings for NewsFeedEntry.""" """Admin settings for NewsFeedEntry."""
list_display = ('title', 'author', 'published', 'summary') 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.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.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.http.response import HttpResponse from django.http.response import HttpResponse
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
import django_q.models import django_q.models
from django_filters import rest_framework as rest_filters
from django_q.tasks import async_task from django_q.tasks import async_task
from djmoney.contrib.exchange.models import ExchangeBackend, Rate from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
from error_report.models import Error from error_report.models import Error
from rest_framework import permissions, serializers 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.permissions import IsAdminUser
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@ -674,6 +677,71 @@ class ContentTypeModelDetail(ContentTypeDetail):
raise NotFound() 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 = [ settings_api_urls = [
# User settings # User settings
path( path(
@ -742,6 +810,25 @@ common_api_urls = [
path('', BackgroundTaskOverview.as_view(), name='api-task-overview'), 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( path(
'error-report/', 'error-report/',
include([ include([

View File

@ -28,9 +28,7 @@ def currency_code_default():
return cached_value return cached_value
try: try:
code = get_global_setting( code = get_global_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True)
'INVENTREE_DEFAULT_CURRENCY', backup_value='', create=True, cache=True
)
except Exception: # pragma: no cover except Exception: # pragma: no cover
# Database may not yet be ready, no need to throw an error here # Database may not yet be ready, no need to throw an error here
code = '' code = ''
@ -61,7 +59,7 @@ def currency_codes() -> list:
"""Returns the current currency codes.""" """Returns the current currency codes."""
from common.settings import get_global_setting 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: if not codes:
codes = currency_codes_default_list() 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 import uuid
from datetime import timedelta, timezone from datetime import timedelta, timezone
from enum import Enum from enum import Enum
from io import BytesIO
from secrets import compare_digest from secrets import compare_digest
from typing import Any, Callable, TypedDict, Union 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.contrib.humanize.templatetags.humanize import naturaltime
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models.signals import post_delete, post_save 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.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from taggit.managers import TaggableManager
import build.validators import build.validators
import common.currency import common.currency
@ -48,6 +51,7 @@ import InvenTree.validators
import order.validators import order.validators
import report.helpers import report.helpers
import users.models import users.models
from InvenTree.sanitizer import sanitize_svg
from plugin import registry from plugin import registry
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -549,25 +553,25 @@ class BaseInvenTreeSetting(models.Model):
""" """
key = str(key).strip().upper() 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 # Unless otherwise specified, attempt to create the setting
create = kwargs.pop('create', True) create = kwargs.pop('create', True)
# Specify if cache lookup should be performed # Specify if cache lookup should be performed
do_cache = kwargs.pop('cache', django_settings.GLOBAL_CACHE_ENABLED) do_cache = kwargs.pop('cache', django_settings.GLOBAL_CACHE_ENABLED)
# Prevent saving to the database during data import filters = {
if InvenTree.ready.isImportingData(): 'key__iexact': key,
create = False # Optionally filter by other keys
do_cache = False **cls.get_filters(**kwargs),
}
# Prevent saving to the database during migrations # Prevent saving to the database during certain operations
if InvenTree.ready.isRunningMigrations(): if (
InvenTree.ready.isImportingData()
or InvenTree.ready.isRunningMigrations()
or InvenTree.ready.isRebuildingData()
or InvenTree.ready.isRunningBackup()
):
create = False create = False
do_cache = False do_cache = False
@ -594,33 +598,21 @@ class BaseInvenTreeSetting(models.Model):
setting = None setting = None
# Setting does not exist! (Try to create it) # Setting does not exist! (Try to create it)
if not setting: if not setting and create:
# Prevent creation of new settings objects when importing data # Attempt to create a new settings object
if ( default_value = cls.get_setting_default(key, **kwargs)
InvenTree.ready.isImportingData() setting = cls(key=key, value=default_value, **kwargs)
or not InvenTree.ready.canAppAccessDatabase(
allow_test=True, allow_shell=True
)
):
create = False
if create: try:
# Attempt to create a new settings object # Wrap this statement in "atomic", so it can be rolled back if it fails
with transaction.atomic():
default_value = cls.get_setting_default(key, **kwargs) setting.save(**kwargs)
except (IntegrityError, OperationalError, ProgrammingError):
setting = cls(key=key, value=default_value, **kwargs) # It might be the case that the database isn't created yet
pass
try: except ValidationError:
# Wrap this statement in "atomic", so it can be rolled back if it fails # The setting failed validation - might be due to duplicate keys
with transaction.atomic(): pass
setting.save(**kwargs)
except (IntegrityError, OperationalError, ProgrammingError):
# It might be the case that the database isn't created yet
pass
except ValidationError:
# The setting failed validation - might be due to duplicate keys
pass
if setting and do_cache: if setting and do_cache:
# Cache this setting object # Cache this setting object
@ -694,6 +686,15 @@ class BaseInvenTreeSetting(models.Model):
if change_user is not None and not change_user.is_staff: if change_user is not None and not change_user.is_staff:
return 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)) attempts = int(kwargs.get('attempts', 3))
filters = { filters = {
@ -3062,3 +3063,184 @@ def after_custom_unit_updated(sender, instance, **kwargs):
from InvenTree.conversion import reload_unit_registry from InvenTree.conversion import reload_unit_registry
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 error_report.models import Error
from flags.state import flag_state from flags.state import flag_state
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from taggit.serializers import TagListSerializerField
import common.models as common_models import common.models as common_models
import common.validators
from InvenTree.helpers import get_objectreference from InvenTree.helpers import get_objectreference
from InvenTree.helpers_model import construct_absolute_url from InvenTree.helpers_model import construct_absolute_url
from InvenTree.serializers import ( from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField, InvenTreeImageSerializerField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
UserSerializer,
) )
from plugin import registry as plugin_registry from plugin import registry as plugin_registry
from users.serializers import OwnerSerializer from users.serializers import OwnerSerializer
@ -474,3 +479,85 @@ class FailedTaskSerializer(InvenTreeModelSerializer):
pk = serializers.CharField(source='id', read_only=True) pk = serializers.CharField(source='id', read_only=True)
result = serializers.CharField() 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,7 +5,8 @@ def get_global_setting(key, backup_value=None, **kwargs):
"""Return the value of a global setting using the provided key.""" """Return the value of a global setting using the provided key."""
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
kwargs['backup_value'] = backup_value if backup_value is not None:
kwargs['backup_value'] = backup_value
return InvenTreeSetting.get_setting(key, **kwargs) return InvenTreeSetting.get_setting(key, **kwargs)
@ -25,7 +26,9 @@ def get_user_setting(key, user, backup_value=None, **kwargs):
from common.models import InvenTreeUserSetting from common.models import InvenTreeUserSetting
kwargs['user'] = user kwargs['user'] = user
kwargs['backup_value'] = backup_value
if backup_value is not None:
kwargs['backup_value'] = backup_value
return InvenTreeUserSetting.get_setting(key, **kwargs) 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.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError 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.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
@ -21,11 +23,13 @@ import PIL
from common.settings import get_global_setting, set_global_setting from common.settings import get_global_setting, set_global_setting
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, PluginMixin from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, PluginMixin
from part.models import Part
from plugin import registry from plugin import registry
from plugin.models import NotificationUserSetting from plugin.models import NotificationUserSetting
from .api import WebhookView from .api import WebhookView
from .models import ( from .models import (
Attachment,
ColorTheme, ColorTheme,
CustomUnit, CustomUnit,
InvenTreeSetting, InvenTreeSetting,
@ -41,6 +45,131 @@ from .models import (
CONTENT_TYPE_JSON = 'application/json' 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): class SettingsTest(InvenTreeTestCase):
"""Tests for the 'settings' model.""" """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 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): def validate_notes_model_type(value):
"""Ensure that the provided model type is valid. """Ensure that the provided model type is valid.

View File

@ -14,7 +14,6 @@ from .models import (
Company, Company,
Contact, Contact,
ManufacturerPart, ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter, ManufacturerPartParameter,
SupplierPart, SupplierPart,
SupplierPriceBreak, SupplierPriceBreak,
@ -120,15 +119,6 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part', 'manufacturer') 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 ManufacturerPartParameterResource(InvenTreeResource):
"""Class for managing ManufacturerPartParameter data import/export.""" """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 from django_filters import rest_framework as rest_filters
import part.models import part.models
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView, MetadataView from InvenTree.api import ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import ( from InvenTree.filters import (
ORDER_FILTER, ORDER_FILTER,
SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER,
@ -19,20 +19,16 @@ from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from .models import ( from .models import (
Address, Address,
Company, Company,
CompanyAttachment,
Contact, Contact,
ManufacturerPart, ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter, ManufacturerPartParameter,
SupplierPart, SupplierPart,
SupplierPriceBreak, SupplierPriceBreak,
) )
from .serializers import ( from .serializers import (
AddressSerializer, AddressSerializer,
CompanyAttachmentSerializer,
CompanySerializer, CompanySerializer,
ContactSerializer, ContactSerializer,
ManufacturerPartAttachmentSerializer,
ManufacturerPartParameterSerializer, ManufacturerPartParameterSerializer,
ManufacturerPartSerializer, ManufacturerPartSerializer,
SupplierPartSerializer, SupplierPartSerializer,
@ -88,22 +84,6 @@ class CompanyDetail(RetrieveUpdateDestroyAPI):
return queryset 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): class ContactList(ListCreateDestroyAPIView):
"""API endpoint for list view of Company model.""" """API endpoint for list view of Company model."""
@ -227,22 +207,6 @@ class ManufacturerPartDetail(RetrieveUpdateDestroyAPI):
serializer_class = ManufacturerPartSerializer 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): class ManufacturerPartParameterFilter(rest_filters.FilterSet):
"""Custom filterset for the ManufacturerPartParameterList API endpoint.""" """Custom filterset for the ManufacturerPartParameterList API endpoint."""
@ -509,22 +473,6 @@ class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI):
manufacturer_part_api_urls = [ 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( path(
'parameter/', 'parameter/',
include([ include([
@ -611,19 +559,6 @@ company_api_urls = [
path('', CompanyDetail.as_view(), name='api-company-detail'), 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( path(
'contact/', 'contact/',
include([ 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_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?')), ('is_supplier', models.BooleanField(default=True, help_text='Do you purchase items from this company?')),
], ],
options={
'verbose_name': 'Company',
}
), ),
migrations.CreateModel( migrations.CreateModel(
name='Contact', name='Contact',
@ -60,6 +63,7 @@ class Migration(migrations.Migration):
], ],
options={ options={
'db_table': 'part_supplierpart', 'db_table': 'part_supplierpart',
'verbose_name': 'Supplier Part',
}, },
), ),
migrations.CreateModel( migrations.CreateModel(

View File

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

View File

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

View File

@ -12,6 +12,6 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='company', 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', name='ManufacturerPartAttachment',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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')), ('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')), ('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')), ('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', name='CompanyAttachment',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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')), ('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')), ('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')), ('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( class Company(
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeMetadataModel InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeMetadataModel,
): ):
"""A Company object represents an external company. """A Company object represents an external company.
@ -95,7 +97,8 @@ class Company(
constraints = [ constraints = [
UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair') UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair')
] ]
verbose_name_plural = 'Companies' verbose_name = _('Company')
verbose_name_plural = _('Companies')
@staticmethod @staticmethod
def get_api_url(): def get_api_url():
@ -255,26 +258,6 @@ class Company(
).distinct() ).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): 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. """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( 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. """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: class Meta:
"""Metaclass defines extra model options.""" """Metaclass defines extra model options."""
verbose_name = _('Manufacturer Part')
unique_together = ('part', 'manufacturer', 'MPN') unique_together = ('part', 'manufacturer', 'MPN')
@staticmethod @staticmethod
@ -563,26 +549,6 @@ class ManufacturerPart(
return s 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): class ManufacturerPartParameter(InvenTree.models.InvenTreeModel):
"""A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart. """A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
@ -679,6 +645,8 @@ class SupplierPart(
unique_together = ('part', 'supplier', 'SKU') unique_together = ('part', 'supplier', 'SKU')
verbose_name = _('Supplier Part')
# This model was moved from the 'Part' app # This model was moved from the 'Part' app
db_table = 'part_supplierpart' db_table = 'part_supplierpart'

View File

@ -11,7 +11,6 @@ from taggit.serializers import TagListSerializerField
import part.filters import part.filters
from InvenTree.serializers import ( from InvenTree.serializers import (
InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer, InvenTreeCurrencySerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeImageSerializerField, InvenTreeImageSerializerField,
@ -26,10 +25,8 @@ from part.serializers import PartBriefSerializer
from .models import ( from .models import (
Address, Address,
Company, Company,
CompanyAttachment,
Contact, Contact,
ManufacturerPart, ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter, ManufacturerPartParameter,
SupplierPart, SupplierPart,
SupplierPriceBreak, SupplierPriceBreak,
@ -186,17 +183,6 @@ class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSeriali
return self.instance 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): class ContactSerializer(InvenTreeModelSerializer):
"""Serializer class for the Contact model.""" """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): class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
"""Serializer for the ManufacturerPartParameter model.""" """Serializer for the ManufacturerPartParameter model."""

View File

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

View File

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

View File

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

View File

@ -17,15 +17,11 @@ from rest_framework import status
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.response import Response from rest_framework.response import Response
import common.models as common_models import common.models
from company.models import SupplierPart import common.settings
import company.models
from generic.states.api import StatusView from generic.states.api import StatusView
from InvenTree.api import ( from InvenTree.api import APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
APIDownloadMixin,
AttachmentMixin,
ListCreateDestroyAPIView,
MetadataView,
)
from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
from InvenTree.helpers import DownloadFile, str2bool from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.helpers_model import construct_absolute_url, get_base_url 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) return queryset.exclude(status__in=self.Meta.model.get_status_class().OPEN)
project_code = rest_filters.ModelChoiceFilter( 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( has_project_code = rest_filters.BooleanFilter(
@ -306,11 +302,13 @@ class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI):
if supplier_part is not None: if supplier_part is not None:
try: try:
supplier_part = SupplierPart.objects.get(pk=supplier_part) supplier_part = company.models.SupplierPart.objects.get(
pk=supplier_part
)
queryset = queryset.filter( queryset = queryset.filter(
id__in=[p.id for p in supplier_part.purchase_orders()] id__in=[p.id for p in supplier_part.purchase_orders()]
) )
except (ValueError, SupplierPart.DoesNotExist): except (ValueError, company.models.SupplierPart.DoesNotExist):
pass pass
# Filter by 'date range' # Filter by 'date range'
@ -449,7 +447,9 @@ class PurchaseOrderLineItemFilter(LineItemFilter):
return queryset.exclude(order__status=PurchaseOrderStatus.COMPLETE.value) return queryset.exclude(order__status=PurchaseOrderStatus.COMPLETE.value)
part = rest_filters.ModelChoiceFilter( 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( base_part = rest_filters.ModelChoiceFilter(
@ -648,22 +648,6 @@ class PurchaseOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
serializer_class = serializers.PurchaseOrderExtraLineSerializer 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): class SalesOrderFilter(OrderFilter):
"""Custom API filters for the SalesOrderList endpoint.""" """Custom API filters for the SalesOrderList endpoint."""
@ -1150,22 +1134,6 @@ class SalesOrderShipmentComplete(CreateAPI):
return ctx 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): class ReturnOrderFilter(OrderFilter):
"""Custom API filters for the ReturnOrderList endpoint.""" """Custom API filters for the ReturnOrderList endpoint."""
@ -1416,22 +1384,6 @@ class ReturnOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
serializer_class = serializers.ReturnOrderExtraLineSerializer 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): class OrderCalendarExport(ICalFeed):
"""Calendar export for Purchase/Sales Orders. """Calendar export for Purchase/Sales Orders.
@ -1514,7 +1466,9 @@ class OrderCalendarExport(ICalFeed):
else: else:
ordertype_title = _('Unknown') 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): def product_id(self, obj):
"""Return calendar product id.""" """Return calendar product id."""
@ -1597,22 +1551,6 @@ order_api_urls = [
path( path(
'po/', 'po/',
include([ 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 # Individual purchase order detail URLs
path( path(
'<int:pk>/', '<int:pk>/',
@ -1704,21 +1642,6 @@ order_api_urls = [
path( path(
'so/', 'so/',
include([ 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( path(
'shipment/', 'shipment/',
include([ include([
@ -1854,21 +1777,6 @@ order_api_urls = [
path( path(
'ro/', 'ro/',
include([ 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 # Return Order detail endpoints
path( path(
'<int:pk>/', '<int:pk>/',

View File

@ -16,7 +16,7 @@ class Migration(migrations.Migration):
name='PurchaseOrderAttachment', name='PurchaseOrderAttachment',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('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')), ('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', name='SalesOrderAttachment',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('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')), ('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( migrations.AlterField(
model_name='purchaseorderattachment', model_name='purchaseorderattachment',
name='attachment', 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( migrations.AlterField(
model_name='purchaseorderattachment', model_name='purchaseorderattachment',
@ -187,7 +187,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='salesorderattachment', model_name='salesorderattachment',
name='attachment', 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( migrations.AlterField(
model_name='salesorderattachment', model_name='salesorderattachment',

View File

@ -25,11 +25,11 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='purchaseorderattachment', model_name='purchaseorderattachment',
name='attachment', 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( migrations.AlterField(
model_name='salesorderattachment', model_name='salesorderattachment',
name='attachment', 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', name='ReturnOrderAttachment',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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')), ('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')), ('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')), ('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( class Order(
StateTransitionMixin, StateTransitionMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin, 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) 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): class OrderLineItem(InvenTree.models.InvenTreeMetadataModel):
"""Abstract model for an order line item. """Abstract model for an order line item.
@ -2315,20 +2282,3 @@ class ReturnOrderExtraLine(OrderExtraLine):
verbose_name=_('Order'), verbose_name=_('Order'),
help_text=_('Return 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.""" """JSON serializers for the Order API."""
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
@ -42,7 +41,6 @@ from InvenTree.helpers import (
str2bool, str2bool,
) )
from InvenTree.serializers import ( from InvenTree.serializers import (
InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer, InvenTreeCurrencySerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
@ -757,17 +755,6 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
raise ValidationError(detail=serializers.as_serializer_error(exc)) 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( class SalesOrderSerializer(
NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
): ):
@ -1525,17 +1512,6 @@ class SalesOrderExtraLineSerializer(
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) 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( class ReturnOrderSerializer(
NotesFieldMixin, AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer NotesFieldMixin, AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer
): ):
@ -1778,14 +1754,3 @@ class ReturnOrderExtraLineSerializer(
model = order.models.ReturnOrderExtraLine model = order.models.ReturnOrderExtraLine
order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True) 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() { onPanelLoad('order-attachments', function() {
loadAttachmentTable('{% url "api-po-attachment-list" %}', { loadAttachmentTable('purchaseorder', {{ order.pk }});
filters: {
order: {{ order.pk }},
},
fields: {
order: {
value: {{ order.pk }},
hidden: true,
}
}
});
}); });
loadStockTable($("#stock-table"), { loadStockTable($("#stock-table"), {

View File

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

View File

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

View File

@ -258,9 +258,9 @@ class PurchaseOrderTest(OrderTest):
def test_po_attachments(self): def test_po_attachments(self):
"""Test the list endpoint for the PurchaseOrderAttachment model.""" """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) self.assertEqual(response.status_code, status.HTTP_200_OK)
@ -1260,9 +1260,12 @@ class SalesOrderTest(OrderTest):
def test_so_attachments(self): def test_so_attachments(self):
"""Test the list endpoint for the SalesOrderAttachment model.""" """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): def test_so_operations(self):
"""Test that we can create / edit and delete a SalesOrder via the API.""" """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') 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): class PartTestTemplateAdmin(admin.ModelAdmin):
"""Admin class for the PartTestTemplate model.""" """Admin class for the PartTestTemplate model."""
@ -607,7 +599,6 @@ class PartInternalPriceBreakAdmin(admin.ModelAdmin):
admin.site.register(models.Part, PartAdmin) admin.site.register(models.Part, PartAdmin)
admin.site.register(models.PartCategory, PartCategoryAdmin) admin.site.register(models.PartCategory, PartCategoryAdmin)
admin.site.register(models.PartRelated, PartRelatedAdmin) admin.site.register(models.PartRelated, PartRelatedAdmin)
admin.site.register(models.PartAttachment, PartAttachmentAdmin)
admin.site.register(models.BomItem, BomItemAdmin) admin.site.register(models.BomItem, BomItemAdmin)
admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin) admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin)
admin.site.register(models.PartParameter, ParameterAdmin) admin.site.register(models.PartParameter, ParameterAdmin)

View File

@ -19,12 +19,7 @@ import order.models
import part.filters import part.filters
from build.models import Build, BuildItem from build.models import Build, BuildItem
from build.status_codes import BuildStatusGroups from build.status_codes import BuildStatusGroups
from InvenTree.api import ( from InvenTree.api import APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
APIDownloadMixin,
AttachmentMixin,
ListCreateDestroyAPIView,
MetadataView,
)
from InvenTree.filters import ( from InvenTree.filters import (
ORDER_FILTER, ORDER_FILTER,
ORDER_FILTER_ALIAS, ORDER_FILTER_ALIAS,
@ -56,7 +51,6 @@ from .models import (
BomItem, BomItem,
BomItemSubstitute, BomItemSubstitute,
Part, Part,
PartAttachment,
PartCategory, PartCategory,
PartCategoryParameterTemplate, PartCategoryParameterTemplate,
PartInternalPriceBreak, PartInternalPriceBreak,
@ -404,22 +398,6 @@ class PartInternalPriceList(ListCreateAPI):
ordering = 'quantity' 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): class PartTestTemplateFilter(rest_filters.FilterSet):
"""Custom filterset class for the PartTestTemplateList endpoint.""" """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 # Base URL for part sale pricing
path( path(
'sale-price/', 'sale-price/',

View File

@ -14,6 +14,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='partattachment', model_name='partattachment',
name='attachment', 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( migrations.AlterField(
model_name='partattachment', model_name='partattachment',
name='attachment', 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( migrations.AlterField(
model_name='partattachment', model_name='partattachment',

View File

@ -20,6 +20,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='partattachment', model_name='partattachment',
name='attachment', 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 @cleanup.ignore
class Part( class Part(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
@ -2208,24 +2209,6 @@ class Part(
required=True, enabled=enabled, include_parent=include_parent 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): def sales_orders(self):
"""Return a list of sales orders which reference this part.""" """Return a list of sales orders which reference this part."""
orders = [] 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): class PartSellPriceBreak(common.models.PriceBreak):
"""Represents a price break for selling this part.""" """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 from taggit.serializers import TagListSerializerField
import common.currency import common.currency
import common.models
import common.settings import common.settings
import company.models import company.models
import InvenTree.helpers import InvenTree.helpers
@ -41,7 +40,6 @@ from .models import (
BomItem, BomItem,
BomItemSubstitute, BomItemSubstitute,
Part, Part,
PartAttachment,
PartCategory, PartCategory,
PartCategoryParameterTemplate, PartCategoryParameterTemplate,
PartInternalPriceBreak, PartInternalPriceBreak,
@ -147,19 +145,6 @@ class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
return queryset.annotate(subcategories=part.filters.annotate_sub_categories()) 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): class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the PartTestTemplate class.""" """Serializer for the PartTestTemplate class."""
@ -1171,7 +1156,7 @@ class PartStocktakeReportGenerateSerializer(serializers.Serializer):
def validate(self, data): def validate(self, data):
"""Custom validation for this serializer.""" """Custom validation for this serializer."""
# Stocktake functionality must be enabled # 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( raise serializers.ValidationError(
_('Stocktake functionality is not enabled') _('Stocktake functionality is not enabled')
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -70,7 +70,7 @@ def page_size(page_code):
def report_page_size_default(): def report_page_size_default():
"""Returns the default page size for PDF reports.""" """Returns the default page size for PDF reports."""
try: 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: except Exception as exc:
logger.exception('Error getting default page size: %s', str(exc)) logger.exception('Error getting default page size: %s', str(exc))
page_size = 'A4' page_size = 'A4'

View File

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

View File

@ -15,14 +15,14 @@ from PIL import Image
import report.models as report_models import report.models as report_models
from build.models import Build from build.models import Build
from common.models import InvenTreeSetting from common.models import Attachment, InvenTreeSetting
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from order.models import ReturnOrder, SalesOrder from order.models import ReturnOrder, SalesOrder
from plugin.registry import registry from plugin.registry import registry
from report.models import LabelTemplate, ReportTemplate from report.models import LabelTemplate, ReportTemplate
from report.templatetags import barcode as barcode_tags from report.templatetags import barcode as barcode_tags
from report.templatetags import report as report_tags from report.templatetags import report as report_tags
from stock.models import StockItem, StockItemAttachment from stock.models import StockItem
class ReportTagTest(TestCase): class ReportTagTest(TestCase):
@ -502,7 +502,7 @@ class PrintTestMixins:
}, },
expected_code=201, expected_code=201,
max_query_time=15, 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) self.assertEqual(response.data['output'].startswith('/media/report/'), True)
# By default, this should *not* have created an attachment against this stockitem # 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 return
# TODO @matmair - Re-add this test after https://github.com/inventree/InvenTree/pull/7074/files#r1600694356 is resolved # 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) self.assertEqual(response.data['output'].startswith('/media/report/'), True)
# Check that a report has been uploaded # 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) self.assertIsNotNone(attachment)
def test_mdl_build(self): def test_mdl_build(self):

View File

@ -16,7 +16,6 @@ from part.models import Part
from .models import ( from .models import (
StockItem, StockItem,
StockItemAttachment,
StockItemTestResult, StockItemTestResult,
StockItemTracking, StockItemTracking,
StockLocation, 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) @admin.register(StockItemTracking)
class StockTrackingAdmin(ImportExportModelAdmin): class StockTrackingAdmin(ImportExportModelAdmin):
"""Admin class for StockTracking.""" """Admin class for StockTracking."""

View File

@ -28,12 +28,7 @@ from build.serializers import BuildSerializer
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from company.serializers import CompanySerializer from company.serializers import CompanySerializer
from generic.states.api import StatusView from generic.states.api import StatusView
from InvenTree.api import ( from InvenTree.api import APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
APIDownloadMixin,
AttachmentMixin,
ListCreateDestroyAPIView,
MetadataView,
)
from InvenTree.filters import ( from InvenTree.filters import (
ORDER_FILTER_ALIAS, ORDER_FILTER_ALIAS,
SEARCH_ORDER_FILTER, 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.generators import generate_batch_code, generate_serial_number
from stock.models import ( from stock.models import (
StockItem, StockItem,
StockItemAttachment,
StockItemTestResult, StockItemTestResult,
StockItemTracking, StockItemTracking,
StockLocation, 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: class StockItemTestResultMixin:
"""Mixin class for the StockItemTestResult API endpoints.""" """Mixin class for the StockItemTestResult API endpoints."""
@ -1609,18 +1587,6 @@ stock_api_urls = [
path('assign/', StockAssign.as_view(), name='api-stock-assign'), path('assign/', StockAssign.as_view(), name='api-stock-assign'),
path('merge/', StockMerge.as_view(), name='api-stock-merge'), path('merge/', StockMerge.as_view(), name='api-stock-merge'),
path('change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'), 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 # StockItemTestResult API endpoints
path( path(
'test/', 'test/',

View File

@ -16,7 +16,7 @@ class Migration(migrations.Migration):
name='StockItemAttachment', name='StockItemAttachment',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('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')), ('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( migrations.AlterField(
model_name='stockitemattachment', model_name='stockitemattachment',
name='attachment', 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( migrations.AlterField(
model_name='stockitemattachment', model_name='stockitemattachment',

View File

@ -20,6 +20,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='stockitemattachment', model_name='stockitemattachment',
name='attachment', 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( class StockItem(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
@ -2255,23 +2256,6 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
instance.part.schedule_pricing_update(create=True) 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): class StockItemTracking(InvenTree.models.InvenTreeModel):
"""Stock tracking entry - used for tracking history of a particular StockItem. """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 ( from .models import (
StockItem, StockItem,
StockItemAttachment,
StockItemTestResult, StockItemTestResult,
StockItemTracking, StockItemTracking,
StockLocation, 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): class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for StockItemTracking model.""" """Serializer for StockItemTracking model."""

View File

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

View File

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

View File

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

View File

@ -4,7 +4,8 @@ import datetime
import logging import logging
from django.contrib.auth import get_user, login, logout 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.urls import include, path, re_path
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
@ -137,10 +138,29 @@ class RoleDetails(APIView):
else: else:
roles[role] = None # pragma: no cover 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 = { data = {
'user': user.pk, 'user': user.pk,
'username': user.username, 'username': user.username,
'roles': roles, 'roles': roles,
'permissions': perms,
'is_staff': user.is_staff, 'is_staff': user.is_staff,
'is_superuser': user.is_superuser, 'is_superuser': user.is_superuser,
} }

View File

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

View File

@ -258,7 +258,6 @@ class RuleSet(models.Model):
'part_partpricing', 'part_partpricing',
'part_bomitem', 'part_bomitem',
'part_bomitemsubstitute', 'part_bomitemsubstitute',
'part_partattachment',
'part_partsellpricebreak', 'part_partsellpricebreak',
'part_partinternalpricebreak', 'part_partinternalpricebreak',
'part_parttesttemplate', 'part_parttesttemplate',
@ -270,13 +269,11 @@ class RuleSet(models.Model):
'company_supplierpart', 'company_supplierpart',
'company_manufacturerpart', 'company_manufacturerpart',
'company_manufacturerpartparameter', 'company_manufacturerpartparameter',
'company_manufacturerpartattachment',
], ],
'stocktake': ['part_partstocktake', 'part_partstocktakereport'], 'stocktake': ['part_partstocktake', 'part_partstocktakereport'],
'stock_location': ['stock_stocklocation', 'stock_stocklocationtype'], 'stock_location': ['stock_stocklocation', 'stock_stocklocationtype'],
'stock': [ 'stock': [
'stock_stockitem', 'stock_stockitem',
'stock_stockitemattachment',
'stock_stockitemtracking', 'stock_stockitemtracking',
'stock_stockitemtestresult', 'stock_stockitemtestresult',
], ],
@ -288,13 +285,11 @@ class RuleSet(models.Model):
'build_build', 'build_build',
'build_builditem', 'build_builditem',
'build_buildline', 'build_buildline',
'build_buildorderattachment',
'stock_stockitem', 'stock_stockitem',
'stock_stocklocation', 'stock_stocklocation',
], ],
'purchase_order': [ 'purchase_order': [
'company_company', 'company_company',
'company_companyattachment',
'company_contact', 'company_contact',
'company_address', 'company_address',
'company_manufacturerpart', 'company_manufacturerpart',
@ -302,31 +297,26 @@ class RuleSet(models.Model):
'company_supplierpart', 'company_supplierpart',
'company_supplierpricebreak', 'company_supplierpricebreak',
'order_purchaseorder', 'order_purchaseorder',
'order_purchaseorderattachment',
'order_purchaseorderlineitem', 'order_purchaseorderlineitem',
'order_purchaseorderextraline', 'order_purchaseorderextraline',
], ],
'sales_order': [ 'sales_order': [
'company_company', 'company_company',
'company_companyattachment',
'company_contact', 'company_contact',
'company_address', 'company_address',
'order_salesorder', 'order_salesorder',
'order_salesorderallocation', 'order_salesorderallocation',
'order_salesorderattachment',
'order_salesorderlineitem', 'order_salesorderlineitem',
'order_salesorderextraline', 'order_salesorderextraline',
'order_salesordershipment', 'order_salesordershipment',
], ],
'return_order': [ 'return_order': [
'company_company', 'company_company',
'company_companyattachment',
'company_contact', 'company_contact',
'company_address', 'company_address',
'order_returnorder', 'order_returnorder',
'order_returnorderlineitem', 'order_returnorderlineitem',
'order_returnorderextraline', 'order_returnorderextraline',
'order_returnorderattachment',
], ],
} }
@ -344,6 +334,7 @@ class RuleSet(models.Model):
'admin_logentry', 'admin_logentry',
'contenttypes_contenttype', 'contenttypes_contenttype',
# Models which currently do not require permissions # Models which currently do not require permissions
'common_attachment',
'common_colortheme', 'common_colortheme',
'common_customunit', 'common_customunit',
'common_inventreesetting', '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; showTime?: boolean;
showSeconds?: 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 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. * The user-configured setting DATE_DISPLAY_FORMAT determines how the date should be displayed.
*/ */
export function renderDate( export function formatDate(
date: string, date: string,
options: RenderDateOptionsInterface = {} options: FormatDateOptionsInterface = {}
) { ) {
if (!date) { if (!date) {
return '-'; return '-';

View File

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

View File

@ -56,20 +56,6 @@ function ApiFormsPlayground() {
fields: editPartFields 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 [active, setActive] = useState(true);
const [name, setName] = useState('Hello'); const [name, setName] = useState('Hello');
@ -130,9 +116,6 @@ function ApiFormsPlayground() {
<Button onClick={() => editCategory.open()}>Edit Category</Button> <Button onClick={() => editCategory.open()}>Edit Category</Button>
{editCategory.modal} {editCategory.modal}
<Button onClick={() => newAttachment.open()}>Create Attachment</Button>
{newAttachment.modal}
<Button onClick={() => openCreatePart()}>Create Part new Modal</Button> <Button onClick={() => openCreatePart()}>Create Part new Modal</Button>
{createPartModal} {createPartModal}
</Group> </Group>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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