mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
b8b79b2b2d
commit
432e0c622c
4
.github/actions/migration/action.yaml
vendored
4
.github/actions/migration/action.yaml
vendored
@ -13,5 +13,5 @@ runs:
|
||||
invoke export-records -f data.json
|
||||
python3 ./src/backend/InvenTree/manage.py flush --noinput
|
||||
invoke migrate
|
||||
invoke import-records -f data.json
|
||||
invoke import-records -f data.json
|
||||
invoke import-records -c -f data.json
|
||||
invoke import-records -c -f data.json
|
||||
|
7
.github/workflows/docker.yaml
vendored
7
.github/workflows/docker.yaml
vendored
@ -115,9 +115,10 @@ jobs:
|
||||
- name: Run Unit Tests
|
||||
run: |
|
||||
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> contrib/container/docker.dev.env
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke test --disable-pty
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke test --migrations --disable-pty
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml down
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke test --disable-pty
|
||||
- name: Run Migration Tests
|
||||
run: |
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke test --migrations
|
||||
- name: Clean up test folder
|
||||
run: |
|
||||
rm -rf InvenTree/_testfolder
|
||||
|
@ -419,22 +419,6 @@ class APIDownloadMixin:
|
||||
raise NotImplementedError('download_queryset method not implemented!')
|
||||
|
||||
|
||||
class AttachmentMixin:
|
||||
"""Mixin for creating attachment objects, and ensuring the user information is saved correctly."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, RolePermission]
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
search_fields = ['attachment', 'comment', 'link']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Save the user information when a file is uploaded."""
|
||||
attachment = serializer.save()
|
||||
attachment.user = self.request.user
|
||||
attachment.save()
|
||||
|
||||
|
||||
class APISearchViewSerializer(serializers.Serializer):
|
||||
"""Serializer for the APISearchView."""
|
||||
|
||||
|
@ -1,11 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 206
|
||||
INVENTREE_API_VERSION = 207
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
v207 - 2024-06-09 : https://github.com/inventree/InvenTree/pull/7420
|
||||
- Moves all "Attachment" models into a single table
|
||||
- All "Attachment" operations are now performed at /api/attachment/
|
||||
- Add permissions information to /api/user/roles/ endpoint
|
||||
|
||||
v206 - 2024-06-08 : https://github.com/inventree/InvenTree/pull/7417
|
||||
- Adds "choices" field to the PartTestTemplate model
|
||||
|
||||
|
@ -8,6 +8,7 @@ from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
|
||||
from common.currency import currency_code_default, currency_codes
|
||||
from common.settings import get_global_setting
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@ -22,14 +23,13 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
||||
|
||||
def get_rates(self, **kwargs) -> dict:
|
||||
"""Set the requested currency codes and get rates."""
|
||||
from common.models import InvenTreeSetting
|
||||
from plugin import registry
|
||||
|
||||
base_currency = kwargs.get('base_currency', currency_code_default())
|
||||
symbols = kwargs.get('symbols', currency_codes())
|
||||
|
||||
# Find the selected exchange rate plugin
|
||||
slug = InvenTreeSetting.get_setting('CURRENCY_UPDATE_PLUGIN', '', create=False)
|
||||
slug = get_global_setting('CURRENCY_UPDATE_PLUGIN', create=False)
|
||||
|
||||
if slug:
|
||||
plugin = registry.get_plugin(slug)
|
||||
|
@ -33,7 +33,7 @@ class InvenTreeRestURLField(RestURLField):
|
||||
|
||||
def run_validation(self, data=empty):
|
||||
"""Override default validation behaviour for this field type."""
|
||||
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', True, cache=False)
|
||||
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
|
||||
|
||||
if not strict_urls and data is not empty and '://' not in data:
|
||||
# Validate as if there were a schema provided
|
||||
|
@ -12,6 +12,7 @@ from django.urls import Resolver404, include, path, resolve, reverse_lazy
|
||||
from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware
|
||||
from error_report.middleware import ExceptionProcessor
|
||||
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.urls import frontendpatterns
|
||||
from users.models import ApiToken
|
||||
|
||||
@ -153,11 +154,9 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware):
|
||||
|
||||
def require_2fa(self, request):
|
||||
"""Use setting to check if MFA should be enforced for frontend page."""
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
try:
|
||||
if url_matcher.resolve(request.path[1:]):
|
||||
return InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA')
|
||||
return get_global_setting('LOGIN_ENFORCE_MFA')
|
||||
except Resolver404:
|
||||
pass
|
||||
return False
|
||||
|
@ -1,9 +1,7 @@
|
||||
"""Generic models which provide extra functionality over base Django model types."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -20,11 +18,11 @@ from error_report.models import Error
|
||||
from mptt.exceptions import InvalidMove
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
import common.settings
|
||||
import InvenTree.fields
|
||||
import InvenTree.format
|
||||
import InvenTree.helpers
|
||||
import InvenTree.helpers_model
|
||||
from InvenTree.sanitizer import sanitize_svg
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@ -304,10 +302,7 @@ class ReferenceIndexingMixin(models.Model):
|
||||
if cls.REFERENCE_PATTERN_SETTING is None:
|
||||
return ''
|
||||
|
||||
# import at function level to prevent cyclic imports
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
return InvenTreeSetting.get_setting(
|
||||
return common.settings.get_global_setting(
|
||||
cls.REFERENCE_PATTERN_SETTING, create=False
|
||||
).strip()
|
||||
|
||||
@ -503,200 +498,64 @@ class InvenTreeMetadataModel(MetadataMixin, InvenTreeModel):
|
||||
abstract = True
|
||||
|
||||
|
||||
def rename_attachment(instance, filename):
|
||||
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
|
||||
|
||||
Args:
|
||||
instance: Instance of a PartAttachment object
|
||||
filename: name of uploaded file
|
||||
|
||||
Returns:
|
||||
path to store file, format: '<subdir>/<id>/filename'
|
||||
"""
|
||||
# Construct a path to store a file attachment for a given model type
|
||||
return os.path.join(instance.getSubdir(), filename)
|
||||
|
||||
|
||||
class InvenTreeAttachment(InvenTreeModel):
|
||||
class InvenTreeAttachmentMixin:
|
||||
"""Provides an abstracted class for managing file attachments.
|
||||
|
||||
An attachment can be either an uploaded file, or an external URL
|
||||
Links the implementing model to the common.models.Attachment table,
|
||||
and provides the following methods:
|
||||
|
||||
Attributes:
|
||||
attachment: Upload file
|
||||
link: External URL
|
||||
comment: String descriptor for the attachment
|
||||
user: User associated with file upload
|
||||
upload_date: Date the file was uploaded
|
||||
- attachments: Return a queryset containing all attachments for this model
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options. Abstract ensures no database table is created."""
|
||||
def delete(self):
|
||||
"""Handle the deletion of a model instance.
|
||||
|
||||
abstract = True
|
||||
|
||||
def getSubdir(self):
|
||||
"""Return the subdirectory under which attachments should be stored.
|
||||
|
||||
Note: Re-implement this for each subclass of InvenTreeAttachment
|
||||
Before deleting the model instance, delete any associated attachments.
|
||||
"""
|
||||
return 'attachments'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Provide better validation error."""
|
||||
# Either 'attachment' or 'link' must be specified!
|
||||
if not self.attachment and not self.link:
|
||||
raise ValidationError({
|
||||
'attachment': _('Missing file'),
|
||||
'link': _('Missing external link'),
|
||||
})
|
||||
|
||||
if self.attachment and self.attachment.name.lower().endswith('.svg'):
|
||||
self.attachment.file.file = self.clean_svg(self.attachment)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean_svg(self, field):
|
||||
"""Sanitize SVG file before saving."""
|
||||
cleaned = sanitize_svg(field.file.read())
|
||||
return BytesIO(bytes(cleaned, 'utf8'))
|
||||
|
||||
def __str__(self):
|
||||
"""Human name for attachment."""
|
||||
if self.attachment is not None:
|
||||
return os.path.basename(self.attachment.name)
|
||||
return str(self.link)
|
||||
|
||||
attachment = models.FileField(
|
||||
upload_to=rename_attachment,
|
||||
verbose_name=_('Attachment'),
|
||||
help_text=_('Select file to attach'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
link = InvenTree.fields.InvenTreeURLField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Link'),
|
||||
help_text=_('Link to external URL'),
|
||||
)
|
||||
|
||||
comment = models.CharField(
|
||||
blank=True,
|
||||
max_length=100,
|
||||
verbose_name=_('Comment'),
|
||||
help_text=_('File comment'),
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('User'),
|
||||
help_text=_('User'),
|
||||
)
|
||||
|
||||
upload_date = models.DateField(
|
||||
auto_now_add=True, null=True, blank=True, verbose_name=_('upload date')
|
||||
)
|
||||
self.attachments.all().delete()
|
||||
super().delete()
|
||||
|
||||
@property
|
||||
def basename(self):
|
||||
"""Base name/path for attachment."""
|
||||
if self.attachment:
|
||||
return os.path.basename(self.attachment.name)
|
||||
return None
|
||||
def attachments(self):
|
||||
"""Return a queryset containing all attachments for this model."""
|
||||
return self.attachments_for_model().filter(model_id=self.pk)
|
||||
|
||||
@basename.setter
|
||||
def basename(self, fn):
|
||||
"""Function to rename the attachment file.
|
||||
@classmethod
|
||||
def check_attachment_permission(cls, permission, user) -> bool:
|
||||
"""Check if the user has permission to perform the specified action on the attachment.
|
||||
|
||||
- Filename cannot be empty
|
||||
- Filename cannot contain illegal characters
|
||||
- Filename must specify an extension
|
||||
- Filename cannot match an existing file
|
||||
The default implementation runs a permission check against *this* model class,
|
||||
but this can be overridden in the implementing class if required.
|
||||
|
||||
Arguments:
|
||||
permission: The permission to check (add / change / view / delete)
|
||||
user: The user to check against
|
||||
|
||||
Returns:
|
||||
bool: True if the user has permission, False otherwise
|
||||
"""
|
||||
fn = fn.strip()
|
||||
perm = f'{cls._meta.app_label}.{permission}_{cls._meta.model_name}'
|
||||
return user.has_perm(perm)
|
||||
|
||||
if len(fn) == 0:
|
||||
raise ValidationError(_('Filename must not be empty'))
|
||||
def attachments_for_model(self):
|
||||
"""Return all attachments for this model class."""
|
||||
from common.models import Attachment
|
||||
|
||||
attachment_dir = settings.MEDIA_ROOT.joinpath(self.getSubdir())
|
||||
old_file = settings.MEDIA_ROOT.joinpath(self.attachment.name)
|
||||
new_file = settings.MEDIA_ROOT.joinpath(self.getSubdir(), fn).resolve()
|
||||
model_type = self.__class__.__name__.lower()
|
||||
|
||||
# Check that there are no directory tricks going on...
|
||||
if new_file.parent != attachment_dir:
|
||||
logger.error(
|
||||
"Attempted to rename attachment outside valid directory: '%s'", new_file
|
||||
)
|
||||
raise ValidationError(_('Invalid attachment directory'))
|
||||
return Attachment.objects.filter(model_type=model_type)
|
||||
|
||||
# Ignore further checks if the filename is not actually being renamed
|
||||
if new_file == old_file:
|
||||
return
|
||||
def create_attachment(self, attachment=None, link=None, comment='', **kwargs):
|
||||
"""Create an attachment / link for this model."""
|
||||
from common.models import Attachment
|
||||
|
||||
forbidden = [
|
||||
"'",
|
||||
'"',
|
||||
'#',
|
||||
'@',
|
||||
'!',
|
||||
'&',
|
||||
'^',
|
||||
'<',
|
||||
'>',
|
||||
':',
|
||||
';',
|
||||
'/',
|
||||
'\\',
|
||||
'|',
|
||||
'?',
|
||||
'*',
|
||||
'%',
|
||||
'~',
|
||||
'`',
|
||||
]
|
||||
kwargs['attachment'] = attachment
|
||||
kwargs['link'] = link
|
||||
kwargs['comment'] = comment
|
||||
kwargs['model_type'] = self.__class__.__name__.lower()
|
||||
kwargs['model_id'] = self.pk
|
||||
|
||||
for c in forbidden:
|
||||
if c in fn:
|
||||
raise ValidationError(_(f"Filename contains illegal character '{c}'"))
|
||||
|
||||
if len(fn.split('.')) < 2:
|
||||
raise ValidationError(_('Filename missing extension'))
|
||||
|
||||
if not old_file.exists():
|
||||
logger.error(
|
||||
"Trying to rename attachment '%s' which does not exist", old_file
|
||||
)
|
||||
return
|
||||
|
||||
if new_file.exists():
|
||||
raise ValidationError(_('Attachment with this filename already exists'))
|
||||
|
||||
try:
|
||||
os.rename(old_file, new_file)
|
||||
self.attachment.name = os.path.join(self.getSubdir(), fn)
|
||||
self.save()
|
||||
except Exception:
|
||||
raise ValidationError(_('Error renaming file'))
|
||||
|
||||
def fully_qualified_url(self):
|
||||
"""Return a 'fully qualified' URL for this attachment.
|
||||
|
||||
- If the attachment is a link to an external resource, return the link
|
||||
- If the attachment is an uploaded file, return the fully qualified media URL
|
||||
"""
|
||||
if self.link:
|
||||
return self.link
|
||||
|
||||
if self.attachment:
|
||||
media_url = InvenTree.helpers.getMediaUrl(self.attachment.url)
|
||||
return InvenTree.helpers_model.construct_absolute_url(media_url)
|
||||
|
||||
return ''
|
||||
Attachment.objects.create(**kwargs)
|
||||
|
||||
|
||||
class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
|
||||
|
@ -509,43 +509,6 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
|
||||
return os.path.join(str(settings.MEDIA_URL), str(value))
|
||||
|
||||
|
||||
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
|
||||
"""Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
|
||||
|
||||
The only real addition here is that we support "renaming" of the attachment file.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def attachment_fields(extra_fields=None):
|
||||
"""Default set of fields for an attachment serializer."""
|
||||
fields = [
|
||||
'pk',
|
||||
'attachment',
|
||||
'filename',
|
||||
'link',
|
||||
'comment',
|
||||
'upload_date',
|
||||
'user',
|
||||
'user_detail',
|
||||
]
|
||||
|
||||
if extra_fields:
|
||||
fields += extra_fields
|
||||
|
||||
return fields
|
||||
|
||||
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
||||
|
||||
attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=False)
|
||||
|
||||
# The 'filename' field must be present in the serializer
|
||||
filename = serializers.CharField(
|
||||
label=_('Filename'), required=False, source='basename', allow_blank=False
|
||||
)
|
||||
|
||||
upload_date = serializers.DateField(read_only=True)
|
||||
|
||||
|
||||
class InvenTreeImageSerializerField(serializers.ImageField):
|
||||
"""Custom image serializer.
|
||||
|
||||
|
@ -296,6 +296,7 @@ ADMIN_SHELL_IMPORT_MODELS = False
|
||||
if (
|
||||
DEBUG
|
||||
and INVENTREE_ADMIN_ENABLED
|
||||
and not TESTING
|
||||
and get_boolean_setting('INVENTREE_DEBUG_SHELL', 'debug_shell', False)
|
||||
): # noqa
|
||||
try:
|
||||
|
@ -152,6 +152,17 @@ class UserMixin:
|
||||
"""Lougout current user."""
|
||||
self.client.logout()
|
||||
|
||||
@classmethod
|
||||
def clearRoles(cls):
|
||||
"""Remove all user roles from the registered user."""
|
||||
for ruleset in cls.group.rule_sets.all():
|
||||
ruleset.can_view = False
|
||||
ruleset.can_change = False
|
||||
ruleset.can_delete = False
|
||||
ruleset.can_add = False
|
||||
|
||||
ruleset.save()
|
||||
|
||||
@classmethod
|
||||
def assignRole(cls, role=None, assign_all: bool = False, group=None):
|
||||
"""Set the user roles for the registered user.
|
||||
@ -267,7 +278,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
|
||||
) # pragma: no cover
|
||||
|
||||
if verbose:
|
||||
if verbose or n >= value:
|
||||
msg = '\r\n%s' % json.dumps(
|
||||
context.captured_queries, indent=4
|
||||
) # pragma: no cover
|
||||
@ -296,7 +307,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
if hasattr(response, 'content'):
|
||||
print('content:', response.content)
|
||||
|
||||
self.assertEqual(expected_code, response.status_code)
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
def getActions(self, url):
|
||||
"""Return a dict of the 'actions' available at a given endpoint.
|
||||
@ -314,17 +325,17 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
expected_code = kwargs.pop('expected_code', None)
|
||||
|
||||
kwargs['format'] = kwargs.get('format', 'json')
|
||||
|
||||
max_queries = kwargs.get('max_query_count', self.MAX_QUERY_COUNT)
|
||||
max_query_time = kwargs.get('max_query_time', self.MAX_QUERY_TIME)
|
||||
expected_code = kwargs.pop('expected_code', None)
|
||||
max_queries = kwargs.pop('max_query_count', self.MAX_QUERY_COUNT)
|
||||
max_query_time = kwargs.pop('max_query_time', self.MAX_QUERY_TIME)
|
||||
|
||||
t1 = time.time()
|
||||
|
||||
with self.assertNumQueriesLessThan(max_queries, url=url):
|
||||
response = method(url, data, **kwargs)
|
||||
|
||||
t2 = time.time()
|
||||
dt = t2 - t1
|
||||
|
||||
|
@ -13,6 +13,7 @@ from jinja2 import Template
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
import InvenTree.conversion
|
||||
from common.settings import get_global_setting
|
||||
|
||||
|
||||
def validate_physical_units(unit):
|
||||
@ -63,14 +64,10 @@ class AllowedURLValidator(validators.URLValidator):
|
||||
|
||||
def __call__(self, value):
|
||||
"""Validate the URL."""
|
||||
import common.models
|
||||
|
||||
self.schemes = allowable_url_schemes()
|
||||
|
||||
# Determine if 'strict' URL validation is required (i.e. if the URL must have a schema prefix)
|
||||
strict_urls = common.models.InvenTreeSetting.get_setting(
|
||||
'INVENTREE_STRICT_URLS', True, cache=False
|
||||
)
|
||||
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
|
||||
|
||||
if not strict_urls:
|
||||
# Allow URLs which do not have a provided schema
|
||||
|
@ -53,13 +53,13 @@ def checkMinPythonVersion():
|
||||
|
||||
def inventreeInstanceName():
|
||||
"""Returns the InstanceName settings for the current database."""
|
||||
return get_global_setting('INVENTREE_INSTANCE', '')
|
||||
return get_global_setting('INVENTREE_INSTANCE')
|
||||
|
||||
|
||||
def inventreeInstanceTitle():
|
||||
"""Returns the InstanceTitle for the current database."""
|
||||
if get_global_setting('INVENTREE_INSTANCE_TITLE', False):
|
||||
return get_global_setting('INVENTREE_INSTANCE', 'InvenTree')
|
||||
if get_global_setting('INVENTREE_INSTANCE_TITLE'):
|
||||
return get_global_setting('INVENTREE_INSTANCE')
|
||||
|
||||
return 'InvenTree'
|
||||
|
||||
|
@ -11,7 +11,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters import rest_framework as rest_filters
|
||||
|
||||
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
|
||||
from InvenTree.api import APIDownloadMixin, MetadataView
|
||||
from generic.states.api import StatusView
|
||||
from InvenTree.helpers import str2bool, isNull, DownloadFile
|
||||
from build.status_codes import BuildStatus, BuildStatusGroups
|
||||
@ -20,7 +20,7 @@ from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
|
||||
import common.models
|
||||
import build.admin
|
||||
import build.serializers
|
||||
from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment
|
||||
from build.models import Build, BuildLine, BuildItem
|
||||
import part.models
|
||||
from users.models import Owner
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
|
||||
@ -614,32 +614,8 @@ class BuildItemList(ListCreateAPI):
|
||||
]
|
||||
|
||||
|
||||
class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
|
||||
|
||||
queryset = BuildOrderAttachment.objects.all()
|
||||
serializer_class = build.serializers.BuildAttachmentSerializer
|
||||
|
||||
filterset_fields = [
|
||||
'build',
|
||||
]
|
||||
|
||||
|
||||
class BuildAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for a BuildOrderAttachment object."""
|
||||
|
||||
queryset = BuildOrderAttachment.objects.all()
|
||||
serializer_class = build.serializers.BuildAttachmentSerializer
|
||||
|
||||
|
||||
build_api_urls = [
|
||||
|
||||
# Attachments
|
||||
path('attachment/', include([
|
||||
path('<int:pk>/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
|
||||
path('', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
|
||||
])),
|
||||
|
||||
# Build lines
|
||||
path('line/', include([
|
||||
path('<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),
|
||||
|
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
|
||||
name='BuildOrderAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
|
||||
('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')),
|
||||
('comment', models.CharField(blank=True, help_text='File comment', max_length=100)),
|
||||
('upload_date', models.DateField(auto_now_add=True, null=True)),
|
||||
('build', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='build.Build')),
|
||||
|
@ -65,7 +65,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='buildorderattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='buildorderattachment',
|
||||
|
@ -20,6 +20,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='buildorderattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'),
|
||||
),
|
||||
]
|
||||
|
@ -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',
|
||||
),
|
||||
]
|
@ -50,6 +50,7 @@ logger = logging.getLogger('inventree')
|
||||
|
||||
class Build(
|
||||
report.mixins.InvenTreeReportMixin,
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
InvenTree.models.MetadataMixin,
|
||||
@ -1322,16 +1323,6 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
||||
instance.update_build_line_items()
|
||||
|
||||
|
||||
class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
"""Model for storing file attachments against a BuildOrder object."""
|
||||
|
||||
def getSubdir(self):
|
||||
"""Return the media file subdirectory for storing BuildOrder attachments"""
|
||||
return os.path.join('bo_files', str(self.build.id))
|
||||
|
||||
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
|
||||
|
||||
|
||||
class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel):
|
||||
"""A BuildLine object links a BOMItem to a Build.
|
||||
|
||||
|
@ -13,8 +13,7 @@ from django.db.models.functions import Coalesce
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
||||
from InvenTree.serializers import UserSerializer
|
||||
from InvenTree.serializers import InvenTreeModelSerializer, UserSerializer
|
||||
|
||||
import InvenTree.helpers
|
||||
from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin
|
||||
@ -30,7 +29,7 @@ import part.filters
|
||||
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
|
||||
from users.serializers import OwnerSerializer
|
||||
|
||||
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
|
||||
from .models import Build, BuildLine, BuildItem
|
||||
|
||||
|
||||
class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
|
||||
@ -1311,15 +1310,3 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer for a BuildAttachment."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
model = BuildOrderAttachment
|
||||
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields([
|
||||
'build',
|
||||
])
|
||||
|
@ -326,18 +326,7 @@ onPanelLoad('children', function() {
|
||||
});
|
||||
|
||||
onPanelLoad('attachments', function() {
|
||||
|
||||
loadAttachmentTable('{% url "api-build-attachment-list" %}', {
|
||||
filters: {
|
||||
build: {{ build.pk }},
|
||||
},
|
||||
fields: {
|
||||
build: {
|
||||
value: {{ build.pk }},
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
});
|
||||
loadAttachmentTable('build', {{ build.pk }});
|
||||
});
|
||||
|
||||
onPanelLoad('notes', function() {
|
||||
|
@ -19,7 +19,6 @@ class TestForwardMigrations(MigratorTestCase):
|
||||
name='Widget',
|
||||
description='Buildable Part',
|
||||
active=True,
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
Build = self.old_state.apps.get_model('build', 'build')
|
||||
@ -61,7 +60,6 @@ class TestReferenceMigration(MigratorTestCase):
|
||||
part = Part.objects.create(
|
||||
name='Part',
|
||||
description='A test part',
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
Build = self.old_state.apps.get_model('build', 'build')
|
||||
|
@ -5,6 +5,34 @@ from django.contrib import admin
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
import common.models
|
||||
import common.validators
|
||||
|
||||
|
||||
@admin.register(common.models.Attachment)
|
||||
class AttachmentAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for Attachment objects."""
|
||||
|
||||
def formfield_for_dbfield(self, db_field, request, **kwargs):
|
||||
"""Provide custom choices for 'model_type' field."""
|
||||
if db_field.name == 'model_type':
|
||||
db_field.choices = common.validators.attachment_model_options()
|
||||
|
||||
return super().formfield_for_dbfield(db_field, request, **kwargs)
|
||||
|
||||
list_display = (
|
||||
'model_type',
|
||||
'model_id',
|
||||
'attachment',
|
||||
'link',
|
||||
'upload_user',
|
||||
'upload_date',
|
||||
)
|
||||
|
||||
list_filter = ['model_type', 'upload_user']
|
||||
|
||||
readonly_fields = ['file_size', 'upload_date', 'upload_user']
|
||||
|
||||
search_fields = ('content_type', 'comment')
|
||||
|
||||
|
||||
@admin.register(common.models.ProjectCode)
|
||||
@ -16,6 +44,7 @@ class ProjectCodeAdmin(ImportExportModelAdmin):
|
||||
search_fields = ('code', 'description')
|
||||
|
||||
|
||||
@admin.register(common.models.InvenTreeSetting)
|
||||
class SettingsAdmin(ImportExportModelAdmin):
|
||||
"""Admin settings for InvenTreeSetting."""
|
||||
|
||||
@ -28,6 +57,7 @@ class SettingsAdmin(ImportExportModelAdmin):
|
||||
return []
|
||||
|
||||
|
||||
@admin.register(common.models.InvenTreeUserSetting)
|
||||
class UserSettingsAdmin(ImportExportModelAdmin):
|
||||
"""Admin settings for InvenTreeUserSetting."""
|
||||
|
||||
@ -40,18 +70,21 @@ class UserSettingsAdmin(ImportExportModelAdmin):
|
||||
return []
|
||||
|
||||
|
||||
@admin.register(common.models.WebhookEndpoint)
|
||||
class WebhookAdmin(ImportExportModelAdmin):
|
||||
"""Admin settings for Webhook."""
|
||||
|
||||
list_display = ('endpoint_id', 'name', 'active', 'user')
|
||||
|
||||
|
||||
@admin.register(common.models.NotificationEntry)
|
||||
class NotificationEntryAdmin(admin.ModelAdmin):
|
||||
"""Admin settings for NotificationEntry."""
|
||||
|
||||
list_display = ('key', 'uid', 'updated')
|
||||
|
||||
|
||||
@admin.register(common.models.NotificationMessage)
|
||||
class NotificationMessageAdmin(admin.ModelAdmin):
|
||||
"""Admin settings for NotificationMessage."""
|
||||
|
||||
@ -70,16 +103,11 @@ class NotificationMessageAdmin(admin.ModelAdmin):
|
||||
search_fields = ('name', 'category', 'message')
|
||||
|
||||
|
||||
@admin.register(common.models.NewsFeedEntry)
|
||||
class NewsFeedEntryAdmin(admin.ModelAdmin):
|
||||
"""Admin settings for NewsFeedEntry."""
|
||||
|
||||
list_display = ('title', 'author', 'published', 'summary')
|
||||
|
||||
|
||||
admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
|
||||
admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
|
||||
admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
|
||||
admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
|
||||
admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)
|
||||
admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin)
|
||||
admin.site.register(common.models.NewsFeedEntry, NewsFeedEntryAdmin)
|
||||
|
@ -4,18 +4,21 @@ import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.http.response import HttpResponse
|
||||
from django.urls import include, path, re_path
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
import django_q.models
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from django_q.tasks import async_task
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from error_report.models import Error
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework.exceptions import NotAcceptable, NotFound
|
||||
from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
@ -674,6 +677,71 @@ class ContentTypeModelDetail(ContentTypeDetail):
|
||||
raise NotFound()
|
||||
|
||||
|
||||
class AttachmentFilter(rest_filters.FilterSet):
|
||||
"""Filterset for the AttachmentList API endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = common.models.Attachment
|
||||
fields = ['model_type', 'model_id', 'upload_user']
|
||||
|
||||
is_link = rest_filters.BooleanFilter(label=_('Is Link'), method='filter_is_link')
|
||||
|
||||
def filter_is_link(self, queryset, name, value):
|
||||
"""Filter attachments based on whether they are a link or not."""
|
||||
if value:
|
||||
return queryset.exclude(link=None).exclude(link='')
|
||||
return queryset.filter(Q(link=None) | Q(link='')).distinct()
|
||||
|
||||
is_file = rest_filters.BooleanFilter(label=_('Is File'), method='filter_is_file')
|
||||
|
||||
def filter_is_file(self, queryset, name, value):
|
||||
"""Filter attachments based on whether they are a file or not."""
|
||||
if value:
|
||||
return queryset.exclude(attachment=None).exclude(attachment='')
|
||||
return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct()
|
||||
|
||||
|
||||
class AttachmentList(ListCreateAPI):
|
||||
"""List API endpoint for Attachment objects."""
|
||||
|
||||
queryset = common.models.Attachment.objects.all()
|
||||
serializer_class = common.serializers.AttachmentSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
filterset_class = AttachmentFilter
|
||||
|
||||
ordering_fields = ['model_id', 'model_type', 'upload_date', 'file_size']
|
||||
search_fields = ['comment', 'model_id', 'model_type']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Save the user information when a file is uploaded."""
|
||||
attachment = serializer.save()
|
||||
attachment.upload_user = self.request.user
|
||||
attachment.save()
|
||||
|
||||
|
||||
class AttachmentDetail(RetrieveUpdateDestroyAPI):
|
||||
"""Detail API endpoint for Attachment objects."""
|
||||
|
||||
queryset = common.models.Attachment.objects.all()
|
||||
serializer_class = common.serializers.AttachmentSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Check user permissions before deleting an attachment."""
|
||||
attachment = self.get_object()
|
||||
|
||||
if not attachment.check_permission('delete', request.user):
|
||||
raise PermissionDenied(
|
||||
_('User does not have permission to delete this attachment')
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
settings_api_urls = [
|
||||
# User settings
|
||||
path(
|
||||
@ -742,6 +810,25 @@ common_api_urls = [
|
||||
path('', BackgroundTaskOverview.as_view(), name='api-task-overview'),
|
||||
]),
|
||||
),
|
||||
# Attachments
|
||||
path(
|
||||
'attachment/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path(
|
||||
'metadata/',
|
||||
MetadataView.as_view(),
|
||||
{'model': common.models.Attachment},
|
||||
name='api-attachment-metadata',
|
||||
),
|
||||
path('', AttachmentDetail.as_view(), name='api-attachment-detail'),
|
||||
]),
|
||||
),
|
||||
path('', AttachmentList.as_view(), name='api-attachment-list'),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'error-report/',
|
||||
include([
|
||||
|
@ -28,9 +28,7 @@ def currency_code_default():
|
||||
return cached_value
|
||||
|
||||
try:
|
||||
code = get_global_setting(
|
||||
'INVENTREE_DEFAULT_CURRENCY', backup_value='', create=True, cache=True
|
||||
)
|
||||
code = get_global_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True)
|
||||
except Exception: # pragma: no cover
|
||||
# Database may not yet be ready, no need to throw an error here
|
||||
code = ''
|
||||
@ -61,7 +59,7 @@ def currency_codes() -> list:
|
||||
"""Returns the current currency codes."""
|
||||
from common.settings import get_global_setting
|
||||
|
||||
codes = get_global_setting('CURRENCY_CODES', '', create=False).strip()
|
||||
codes = get_global_setting('CURRENCY_CODES', create=False).strip()
|
||||
|
||||
if not codes:
|
||||
codes = currency_codes_default_list()
|
||||
|
43
src/backend/InvenTree/common/migrations/0025_attachment.py
Normal file
43
src/backend/InvenTree/common/migrations/0025_attachment.py
Normal 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',
|
||||
}
|
||||
),
|
||||
]
|
@ -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),
|
||||
]
|
@ -12,6 +12,7 @@ import os
|
||||
import uuid
|
||||
from datetime import timedelta, timezone
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
from secrets import compare_digest
|
||||
from typing import Any, Callable, TypedDict, Union
|
||||
|
||||
@ -23,6 +24,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.humanize.templatetags.humanize import naturaltime
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator
|
||||
from django.db import models, transaction
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
@ -35,6 +37,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
import build.validators
|
||||
import common.currency
|
||||
@ -48,6 +51,7 @@ import InvenTree.validators
|
||||
import order.validators
|
||||
import report.helpers
|
||||
import users.models
|
||||
from InvenTree.sanitizer import sanitize_svg
|
||||
from plugin import registry
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@ -549,25 +553,25 @@ class BaseInvenTreeSetting(models.Model):
|
||||
"""
|
||||
key = str(key).strip().upper()
|
||||
|
||||
filters = {
|
||||
'key__iexact': key,
|
||||
# Optionally filter by other keys
|
||||
**cls.get_filters(**kwargs),
|
||||
}
|
||||
|
||||
# Unless otherwise specified, attempt to create the setting
|
||||
create = kwargs.pop('create', True)
|
||||
|
||||
# Specify if cache lookup should be performed
|
||||
do_cache = kwargs.pop('cache', django_settings.GLOBAL_CACHE_ENABLED)
|
||||
|
||||
# Prevent saving to the database during data import
|
||||
if InvenTree.ready.isImportingData():
|
||||
create = False
|
||||
do_cache = False
|
||||
filters = {
|
||||
'key__iexact': key,
|
||||
# Optionally filter by other keys
|
||||
**cls.get_filters(**kwargs),
|
||||
}
|
||||
|
||||
# Prevent saving to the database during migrations
|
||||
if InvenTree.ready.isRunningMigrations():
|
||||
# Prevent saving to the database during certain operations
|
||||
if (
|
||||
InvenTree.ready.isImportingData()
|
||||
or InvenTree.ready.isRunningMigrations()
|
||||
or InvenTree.ready.isRebuildingData()
|
||||
or InvenTree.ready.isRunningBackup()
|
||||
):
|
||||
create = False
|
||||
do_cache = False
|
||||
|
||||
@ -594,21 +598,9 @@ class BaseInvenTreeSetting(models.Model):
|
||||
setting = None
|
||||
|
||||
# Setting does not exist! (Try to create it)
|
||||
if not setting:
|
||||
# Prevent creation of new settings objects when importing data
|
||||
if (
|
||||
InvenTree.ready.isImportingData()
|
||||
or not InvenTree.ready.canAppAccessDatabase(
|
||||
allow_test=True, allow_shell=True
|
||||
)
|
||||
):
|
||||
create = False
|
||||
|
||||
if create:
|
||||
if not setting and create:
|
||||
# Attempt to create a new settings object
|
||||
|
||||
default_value = cls.get_setting_default(key, **kwargs)
|
||||
|
||||
setting = cls(key=key, value=default_value, **kwargs)
|
||||
|
||||
try:
|
||||
@ -694,6 +686,15 @@ class BaseInvenTreeSetting(models.Model):
|
||||
if change_user is not None and not change_user.is_staff:
|
||||
return
|
||||
|
||||
# Do not write to the database under certain conditions
|
||||
if (
|
||||
InvenTree.ready.isImportingData()
|
||||
or InvenTree.ready.isRunningMigrations()
|
||||
or InvenTree.ready.isRebuildingData()
|
||||
or InvenTree.ready.isRunningBackup()
|
||||
):
|
||||
return
|
||||
|
||||
attempts = int(kwargs.get('attempts', 3))
|
||||
|
||||
filters = {
|
||||
@ -3062,3 +3063,184 @@ def after_custom_unit_updated(sender, instance, **kwargs):
|
||||
from InvenTree.conversion import reload_unit_registry
|
||||
|
||||
reload_unit_registry()
|
||||
|
||||
|
||||
def rename_attachment(instance, filename):
|
||||
"""Callback function to rename an uploaded attachment file.
|
||||
|
||||
Arguments:
|
||||
- instance: The Attachment instance
|
||||
- filename: The original filename of the uploaded file
|
||||
|
||||
Returns:
|
||||
- The new filename for the uploaded file, e.g. 'attachments/<model_type>/<model_id>/<filename>'
|
||||
"""
|
||||
# Remove any illegal characters from the filename
|
||||
illegal_chars = '\'"\\`~#|!@#$%^&*()[]{}<>?;:+=,'
|
||||
|
||||
for c in illegal_chars:
|
||||
filename = filename.replace(c, '')
|
||||
|
||||
filename = os.path.basename(filename)
|
||||
|
||||
# Generate a new filename for the attachment
|
||||
return os.path.join(
|
||||
'attachments', str(instance.model_type), str(instance.model_id), filename
|
||||
)
|
||||
|
||||
|
||||
class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
"""Class which represents an uploaded file attachment.
|
||||
|
||||
An attachment can be either an uploaded file, or an external URL.
|
||||
|
||||
Attributes:
|
||||
attachment: The uploaded file
|
||||
url: An external URL
|
||||
comment: A comment or description for the attachment
|
||||
user: The user who uploaded the attachment
|
||||
upload_date: The date the attachment was uploaded
|
||||
file_size: The size of the uploaded file
|
||||
metadata: Arbitrary metadata for the attachment (inherit from MetadataMixin)
|
||||
tags: Tags for the attachment
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
verbose_name = _('Attachment')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Custom 'save' method for the Attachment model.
|
||||
|
||||
- Record the file size of the uploaded attachment (if applicable)
|
||||
- Ensure that the 'content_type' and 'object_id' fields are set
|
||||
- Run extra validations
|
||||
"""
|
||||
# Either 'attachment' or 'link' must be specified!
|
||||
if not self.attachment and not self.link:
|
||||
raise ValidationError({
|
||||
'attachment': _('Missing file'),
|
||||
'link': _('Missing external link'),
|
||||
})
|
||||
|
||||
if self.attachment:
|
||||
if self.attachment.name.lower().endswith('.svg'):
|
||||
self.attachment.file.file = self.clean_svg(self.attachment)
|
||||
else:
|
||||
self.file_size = 0
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update file size
|
||||
if self.file_size == 0 and self.attachment:
|
||||
# Get file size
|
||||
if default_storage.exists(self.attachment.name):
|
||||
try:
|
||||
self.file_size = default_storage.size(self.attachment.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.file_size != 0:
|
||||
super().save()
|
||||
|
||||
def clean_svg(self, field):
|
||||
"""Sanitize SVG file before saving."""
|
||||
cleaned = sanitize_svg(field.file.read())
|
||||
return BytesIO(bytes(cleaned, 'utf8'))
|
||||
|
||||
def __str__(self):
|
||||
"""Human name for attachment."""
|
||||
if self.attachment is not None:
|
||||
return os.path.basename(self.attachment.name)
|
||||
return str(self.link)
|
||||
|
||||
model_type = models.CharField(
|
||||
max_length=100,
|
||||
validators=[common.validators.validate_attachment_model_type],
|
||||
help_text=_('Target model type for this image'),
|
||||
)
|
||||
|
||||
model_id = models.PositiveIntegerField()
|
||||
|
||||
attachment = models.FileField(
|
||||
upload_to=rename_attachment,
|
||||
verbose_name=_('Attachment'),
|
||||
help_text=_('Select file to attach'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
link = InvenTree.fields.InvenTreeURLField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Link'),
|
||||
help_text=_('Link to external URL'),
|
||||
)
|
||||
|
||||
comment = models.CharField(
|
||||
blank=True,
|
||||
max_length=250,
|
||||
verbose_name=_('Comment'),
|
||||
help_text=_('Attachment comment'),
|
||||
)
|
||||
|
||||
upload_user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('User'),
|
||||
help_text=_('User'),
|
||||
)
|
||||
|
||||
upload_date = models.DateField(
|
||||
auto_now_add=True,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_('Upload date'),
|
||||
help_text=_('Date the file was uploaded'),
|
||||
)
|
||||
|
||||
file_size = models.PositiveIntegerField(
|
||||
default=0, verbose_name=_('File size'), help_text=_('File size in bytes')
|
||||
)
|
||||
|
||||
tags = TaggableManager(blank=True)
|
||||
|
||||
@property
|
||||
def basename(self):
|
||||
"""Base name/path for attachment."""
|
||||
if self.attachment:
|
||||
return os.path.basename(self.attachment.name)
|
||||
return None
|
||||
|
||||
def fully_qualified_url(self):
|
||||
"""Return a 'fully qualified' URL for this attachment.
|
||||
|
||||
- If the attachment is a link to an external resource, return the link
|
||||
- If the attachment is an uploaded file, return the fully qualified media URL
|
||||
"""
|
||||
if self.link:
|
||||
return self.link
|
||||
|
||||
if self.attachment:
|
||||
import InvenTree.helpers_model
|
||||
|
||||
media_url = InvenTree.helpers.getMediaUrl(self.attachment.url)
|
||||
return InvenTree.helpers_model.construct_absolute_url(media_url)
|
||||
|
||||
return ''
|
||||
|
||||
def check_permission(self, permission, user):
|
||||
"""Check if the user has the required permission for this attachment."""
|
||||
from InvenTree.models import InvenTreeAttachmentMixin
|
||||
|
||||
model_class = common.validators.attachment_model_class_from_label(
|
||||
self.model_type
|
||||
)
|
||||
|
||||
if not issubclass(model_class, InvenTreeAttachmentMixin):
|
||||
raise ValueError(_('Invalid model type specified for attachment'))
|
||||
|
||||
return model_class.check_attachment_permission(permission, user)
|
||||
|
@ -9,13 +9,18 @@ import django_q.models
|
||||
from error_report.models import Error
|
||||
from flags.state import flag_state
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import common.models as common_models
|
||||
import common.validators
|
||||
from InvenTree.helpers import get_objectreference
|
||||
from InvenTree.helpers_model import construct_absolute_url
|
||||
from InvenTree.serializers import (
|
||||
InvenTreeAttachmentSerializerField,
|
||||
InvenTreeImageSerializerField,
|
||||
InvenTreeModelSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
from plugin import registry as plugin_registry
|
||||
from users.serializers import OwnerSerializer
|
||||
@ -474,3 +479,85 @@ class FailedTaskSerializer(InvenTreeModelSerializer):
|
||||
pk = serializers.CharField(source='id', read_only=True)
|
||||
|
||||
result = serializers.CharField()
|
||||
|
||||
|
||||
class AttachmentSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer class for the Attachment model."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass."""
|
||||
|
||||
model = common_models.Attachment
|
||||
fields = [
|
||||
'pk',
|
||||
'attachment',
|
||||
'filename',
|
||||
'link',
|
||||
'comment',
|
||||
'upload_date',
|
||||
'upload_user',
|
||||
'user_detail',
|
||||
'file_size',
|
||||
'model_type',
|
||||
'model_id',
|
||||
'tags',
|
||||
]
|
||||
|
||||
read_only_fields = ['pk', 'file_size', 'upload_date', 'upload_user', 'filename']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Override the model_type field to provide dynamic choices."""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if len(self.fields['model_type'].choices) == 0:
|
||||
self.fields[
|
||||
'model_type'
|
||||
].choices = common.validators.attachment_model_options()
|
||||
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
user_detail = UserSerializer(source='upload_user', read_only=True, many=False)
|
||||
|
||||
attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=True)
|
||||
|
||||
# The 'filename' field must be present in the serializer
|
||||
filename = serializers.CharField(
|
||||
label=_('Filename'), required=False, source='basename', allow_blank=False
|
||||
)
|
||||
|
||||
upload_date = serializers.DateField(read_only=True)
|
||||
|
||||
# Note: The choices are overridden at run-time on class initialization
|
||||
model_type = serializers.ChoiceField(
|
||||
label=_('Model Type'),
|
||||
choices=common.validators.attachment_model_options(),
|
||||
required=True,
|
||||
allow_blank=False,
|
||||
allow_null=False,
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""Override the save method to handle the model_type field."""
|
||||
from InvenTree.models import InvenTreeAttachmentMixin
|
||||
|
||||
model_type = self.validated_data.get('model_type', None)
|
||||
|
||||
# Ensure that the user has permission to attach files to the specified model
|
||||
user = self.context.get('request').user
|
||||
|
||||
target_model_class = common.validators.attachment_model_class_from_label(
|
||||
model_type
|
||||
)
|
||||
|
||||
if not issubclass(target_model_class, InvenTreeAttachmentMixin):
|
||||
raise PermissionDenied(_('Invalid model type specified for attachment'))
|
||||
|
||||
# Check that the user has the required permissions to attach files to the target model
|
||||
if not target_model_class.check_attachment_permission('change', user):
|
||||
raise PermissionDenied(
|
||||
_(
|
||||
'User does not have permission to create or edit attachments for this model'
|
||||
)
|
||||
)
|
||||
|
||||
return super().save()
|
||||
|
@ -5,6 +5,7 @@ def get_global_setting(key, backup_value=None, **kwargs):
|
||||
"""Return the value of a global setting using the provided key."""
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
if backup_value is not None:
|
||||
kwargs['backup_value'] = backup_value
|
||||
|
||||
return InvenTreeSetting.get_setting(key, **kwargs)
|
||||
@ -25,6 +26,8 @@ def get_user_setting(key, user, backup_value=None, **kwargs):
|
||||
from common.models import InvenTreeUserSetting
|
||||
|
||||
kwargs['user'] = user
|
||||
|
||||
if backup_value is not None:
|
||||
kwargs['backup_value'] = backup_value
|
||||
|
||||
return InvenTreeUserSetting.get_setting(key, **kwargs)
|
||||
|
210
src/backend/InvenTree/common/test_migrations.py
Normal file
210
src/backend/InvenTree/common/test_migrations.py
Normal 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)
|
@ -1 +0,0 @@
|
||||
"""Unit tests for the views associated with the 'common' app."""
|
@ -11,6 +11,8 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.test.utils import override_settings
|
||||
@ -21,11 +23,13 @@ import PIL
|
||||
from common.settings import get_global_setting, set_global_setting
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, PluginMixin
|
||||
from part.models import Part
|
||||
from plugin import registry
|
||||
from plugin.models import NotificationUserSetting
|
||||
|
||||
from .api import WebhookView
|
||||
from .models import (
|
||||
Attachment,
|
||||
ColorTheme,
|
||||
CustomUnit,
|
||||
InvenTreeSetting,
|
||||
@ -41,6 +45,131 @@ from .models import (
|
||||
CONTENT_TYPE_JSON = 'application/json'
|
||||
|
||||
|
||||
class AttachmentTest(InvenTreeAPITestCase):
|
||||
"""Unit tests for the 'Attachment' model."""
|
||||
|
||||
fixtures = ['part', 'category', 'location']
|
||||
|
||||
def generate_file(self, fn: str):
|
||||
"""Generate an attachment file object."""
|
||||
file_object = io.StringIO('Some dummy data')
|
||||
file_object.seek(0)
|
||||
|
||||
return ContentFile(file_object.getvalue(), fn)
|
||||
|
||||
def test_filename_validation(self):
|
||||
"""Test that the filename validation works as expected.
|
||||
|
||||
The django file-upload mechanism should sanitize filenames correctly.
|
||||
"""
|
||||
part = Part.objects.first()
|
||||
|
||||
filenames = {
|
||||
'test.txt': 'test.txt',
|
||||
'r####at.mp4': 'rat.mp4',
|
||||
'../../../win32.dll': 'win32.dll',
|
||||
'ABC!@#$%^&&&&&&&)-XYZ-(**&&&\\/QqQ.sqlite': 'QqQ.sqlite',
|
||||
'/var/log/inventree.log': 'inventree.log',
|
||||
'c:\\Users\\admin\\passwd.txt': 'cUsersadminpasswd.txt',
|
||||
'8&&&8.txt': '88.txt',
|
||||
}
|
||||
|
||||
for fn, expected in filenames.items():
|
||||
attachment = Attachment.objects.create(
|
||||
attachment=self.generate_file(fn),
|
||||
comment=f'Testing filename: {fn}',
|
||||
model_type='part',
|
||||
model_id=part.pk,
|
||||
)
|
||||
|
||||
expected_path = f'attachments/part/{part.pk}/{expected}'
|
||||
self.assertEqual(attachment.attachment.name, expected_path)
|
||||
self.assertEqual(attachment.file_size, 15)
|
||||
|
||||
self.assertEqual(part.attachments.count(), len(filenames.keys()))
|
||||
|
||||
# Delete any attachments after the test is completed
|
||||
for attachment in part.attachments.all():
|
||||
path = attachment.attachment.name
|
||||
attachment.delete()
|
||||
|
||||
# Remove uploaded files to prevent them sticking around
|
||||
if default_storage.exists(path):
|
||||
default_storage.delete(path)
|
||||
|
||||
self.assertEqual(
|
||||
Attachment.objects.filter(model_type='part', model_id=part.pk).count(), 0
|
||||
)
|
||||
|
||||
def test_mixin(self):
|
||||
"""Test that the mixin class works as expected."""
|
||||
part = Part.objects.first()
|
||||
|
||||
self.assertEqual(part.attachments.count(), 0)
|
||||
|
||||
part.create_attachment(
|
||||
attachment=self.generate_file('test.txt'), comment='Hello world'
|
||||
)
|
||||
|
||||
self.assertEqual(part.attachments.count(), 1)
|
||||
|
||||
attachment = part.attachments.first()
|
||||
|
||||
self.assertEqual(attachment.comment, 'Hello world')
|
||||
self.assertIn(f'attachments/part/{part.pk}/test', attachment.attachment.name)
|
||||
|
||||
def test_upload_via_api(self):
|
||||
"""Test that we can upload attachments via the API."""
|
||||
part = Part.objects.first()
|
||||
url = reverse('api-attachment-list')
|
||||
|
||||
data = {
|
||||
'model_type': 'part',
|
||||
'model_id': part.pk,
|
||||
'link': 'https://www.google.com',
|
||||
'comment': 'Some appropriate comment',
|
||||
}
|
||||
|
||||
# Start without appropriate permissions
|
||||
# User must have 'part.change' to upload an attachment against a Part instance
|
||||
self.logout()
|
||||
self.user.is_staff = False
|
||||
self.user.is_superuser = False
|
||||
self.user.save()
|
||||
self.clearRoles()
|
||||
|
||||
# Check without login (401)
|
||||
response = self.post(url, data, expected_code=401)
|
||||
|
||||
self.login()
|
||||
|
||||
response = self.post(url, data, expected_code=403)
|
||||
|
||||
self.assertIn(
|
||||
'User does not have permission to create or edit attachments for this model',
|
||||
str(response.data['detail']),
|
||||
)
|
||||
|
||||
# Add the required permission
|
||||
self.assignRole('part.change')
|
||||
|
||||
# Upload should now work!
|
||||
response = self.post(url, data, expected_code=201)
|
||||
|
||||
# Try to delete the attachment via API (should fail)
|
||||
attachment = part.attachments.first()
|
||||
url = reverse('api-attachment-detail', kwargs={'pk': attachment.pk})
|
||||
response = self.delete(url, expected_code=403)
|
||||
self.assertIn(
|
||||
'User does not have permission to delete this attachment',
|
||||
str(response.data['detail']),
|
||||
)
|
||||
|
||||
# Assign 'delete' permission to 'part' model
|
||||
self.assignRole('part.delete')
|
||||
response = self.delete(url, expected_code=204)
|
||||
|
||||
|
||||
class SettingsTest(InvenTreeTestCase):
|
||||
"""Tests for the 'settings' model."""
|
||||
|
||||
|
@ -8,6 +8,41 @@ from django.utils.translation import gettext_lazy as _
|
||||
from common.settings import get_global_setting
|
||||
|
||||
|
||||
def attachment_model_types():
|
||||
"""Return a list of valid attachment model choices."""
|
||||
import InvenTree.models
|
||||
|
||||
return list(
|
||||
InvenTree.helpers_model.getModelsWithMixin(
|
||||
InvenTree.models.InvenTreeAttachmentMixin
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def attachment_model_options():
|
||||
"""Return a list of options for models which support attachments."""
|
||||
return [
|
||||
(model.__name__.lower(), model._meta.verbose_name)
|
||||
for model in attachment_model_types()
|
||||
]
|
||||
|
||||
|
||||
def attachment_model_class_from_label(label: str):
|
||||
"""Return the model class for the given label."""
|
||||
for model in attachment_model_types():
|
||||
if model.__name__.lower() == label.lower():
|
||||
return model
|
||||
|
||||
raise ValueError(f'Invalid attachment model label: {label}')
|
||||
|
||||
|
||||
def validate_attachment_model_type(value):
|
||||
"""Ensure that the provided attachment model is valid."""
|
||||
model_names = [el[0] for el in attachment_model_options()]
|
||||
if value not in model_names:
|
||||
raise ValidationError(f'Model type does not support attachments')
|
||||
|
||||
|
||||
def validate_notes_model_type(value):
|
||||
"""Ensure that the provided model type is valid.
|
||||
|
||||
|
@ -14,7 +14,6 @@ from .models import (
|
||||
Company,
|
||||
Contact,
|
||||
ManufacturerPart,
|
||||
ManufacturerPartAttachment,
|
||||
ManufacturerPartParameter,
|
||||
SupplierPart,
|
||||
SupplierPriceBreak,
|
||||
@ -120,15 +119,6 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
|
||||
autocomplete_fields = ('part', 'manufacturer')
|
||||
|
||||
|
||||
@admin.register(ManufacturerPartAttachment)
|
||||
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for ManufacturerPartAttachment model."""
|
||||
|
||||
list_display = ('manufacturer_part', 'attachment', 'comment')
|
||||
|
||||
autocomplete_fields = ('manufacturer_part',)
|
||||
|
||||
|
||||
class ManufacturerPartParameterResource(InvenTreeResource):
|
||||
"""Class for managing ManufacturerPartParameter data import/export."""
|
||||
|
||||
|
@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_filters import rest_framework as rest_filters
|
||||
|
||||
import part.models
|
||||
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView, MetadataView
|
||||
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
|
||||
from InvenTree.filters import (
|
||||
ORDER_FILTER,
|
||||
SEARCH_ORDER_FILTER,
|
||||
@ -19,20 +19,16 @@ from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||
from .models import (
|
||||
Address,
|
||||
Company,
|
||||
CompanyAttachment,
|
||||
Contact,
|
||||
ManufacturerPart,
|
||||
ManufacturerPartAttachment,
|
||||
ManufacturerPartParameter,
|
||||
SupplierPart,
|
||||
SupplierPriceBreak,
|
||||
)
|
||||
from .serializers import (
|
||||
AddressSerializer,
|
||||
CompanyAttachmentSerializer,
|
||||
CompanySerializer,
|
||||
ContactSerializer,
|
||||
ManufacturerPartAttachmentSerializer,
|
||||
ManufacturerPartParameterSerializer,
|
||||
ManufacturerPartSerializer,
|
||||
SupplierPartSerializer,
|
||||
@ -88,22 +84,6 @@ class CompanyDetail(RetrieveUpdateDestroyAPI):
|
||||
return queryset
|
||||
|
||||
|
||||
class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing, creating and bulk deleting a CompanyAttachment."""
|
||||
|
||||
queryset = CompanyAttachment.objects.all()
|
||||
serializer_class = CompanyAttachmentSerializer
|
||||
|
||||
filterset_fields = ['company']
|
||||
|
||||
|
||||
class CompanyAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for CompanyAttachment model."""
|
||||
|
||||
queryset = CompanyAttachment.objects.all()
|
||||
serializer_class = CompanyAttachmentSerializer
|
||||
|
||||
|
||||
class ContactList(ListCreateDestroyAPIView):
|
||||
"""API endpoint for list view of Company model."""
|
||||
|
||||
@ -227,22 +207,6 @@ class ManufacturerPartDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = ManufacturerPartSerializer
|
||||
|
||||
|
||||
class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing, creating and bulk deleting a ManufacturerPartAttachment (file upload)."""
|
||||
|
||||
queryset = ManufacturerPartAttachment.objects.all()
|
||||
serializer_class = ManufacturerPartAttachmentSerializer
|
||||
|
||||
filterset_fields = ['manufacturer_part']
|
||||
|
||||
|
||||
class ManufacturerPartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpooint for ManufacturerPartAttachment model."""
|
||||
|
||||
queryset = ManufacturerPartAttachment.objects.all()
|
||||
serializer_class = ManufacturerPartAttachmentSerializer
|
||||
|
||||
|
||||
class ManufacturerPartParameterFilter(rest_filters.FilterSet):
|
||||
"""Custom filterset for the ManufacturerPartParameterList API endpoint."""
|
||||
|
||||
@ -509,22 +473,6 @@ class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI):
|
||||
|
||||
|
||||
manufacturer_part_api_urls = [
|
||||
# Base URL for ManufacturerPartAttachment API endpoints
|
||||
path(
|
||||
'attachment/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
ManufacturerPartAttachmentDetail.as_view(),
|
||||
name='api-manufacturer-part-attachment-detail',
|
||||
),
|
||||
path(
|
||||
'',
|
||||
ManufacturerPartAttachmentList.as_view(),
|
||||
name='api-manufacturer-part-attachment-list',
|
||||
),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'parameter/',
|
||||
include([
|
||||
@ -611,19 +559,6 @@ company_api_urls = [
|
||||
path('', CompanyDetail.as_view(), name='api-company-detail'),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'attachment/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
CompanyAttachmentDetail.as_view(),
|
||||
name='api-company-attachment-detail',
|
||||
),
|
||||
path(
|
||||
'', CompanyAttachmentList.as_view(), name='api-company-attachment-list'
|
||||
),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'contact/',
|
||||
include([
|
||||
|
@ -31,6 +31,9 @@ class Migration(migrations.Migration):
|
||||
('is_customer', models.BooleanField(default=False, help_text='Do you sell items to this company?')),
|
||||
('is_supplier', models.BooleanField(default=True, help_text='Do you purchase items from this company?')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Company',
|
||||
}
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Contact',
|
||||
@ -60,6 +63,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'db_table': 'part_supplierpart',
|
||||
'verbose_name': 'Supplier Part',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
|
@ -12,6 +12,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='company',
|
||||
options={'ordering': ['name']},
|
||||
options={'ordering': ['name'], 'verbose_name': 'Company'},
|
||||
),
|
||||
]
|
||||
|
@ -22,6 +22,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'unique_together': {('part', 'manufacturer', 'MPN')},
|
||||
'verbose_name': 'Manufacturer Part',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
@ -12,6 +12,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='company',
|
||||
options={'ordering': ['name'], 'verbose_name_plural': 'Companies'},
|
||||
options={'ordering': ['name'], 'verbose_name': 'Company', 'verbose_name_plural': 'Companies'},
|
||||
),
|
||||
]
|
||||
|
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
|
||||
name='ManufacturerPartAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment')),
|
||||
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
|
||||
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
|
||||
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
|
||||
|
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
|
||||
name='CompanyAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment')),
|
||||
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
|
||||
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
|
||||
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
|
||||
|
@ -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',
|
||||
),
|
||||
]
|
@ -60,7 +60,9 @@ def rename_company_image(instance, filename):
|
||||
|
||||
|
||||
class Company(
|
||||
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeMetadataModel
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
InvenTree.models.InvenTreeMetadataModel,
|
||||
):
|
||||
"""A Company object represents an external company.
|
||||
|
||||
@ -95,7 +97,8 @@ class Company(
|
||||
constraints = [
|
||||
UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair')
|
||||
]
|
||||
verbose_name_plural = 'Companies'
|
||||
verbose_name = _('Company')
|
||||
verbose_name_plural = _('Companies')
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
@ -255,26 +258,6 @@ class Company(
|
||||
).distinct()
|
||||
|
||||
|
||||
class CompanyAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
"""Model for storing file or URL attachments against a Company object."""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with this model."""
|
||||
return reverse('api-company-attachment-list')
|
||||
|
||||
def getSubdir(self):
|
||||
"""Return the subdirectory where these attachments are uploaded."""
|
||||
return os.path.join('company_files', str(self.company.pk))
|
||||
|
||||
company = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_('Company'),
|
||||
related_name='attachments',
|
||||
)
|
||||
|
||||
|
||||
class Contact(InvenTree.models.InvenTreeMetadataModel):
|
||||
"""A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
|
||||
|
||||
@ -460,7 +443,9 @@ class Address(InvenTree.models.InvenTreeModel):
|
||||
|
||||
|
||||
class ManufacturerPart(
|
||||
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeMetadataModel
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeMetadataModel,
|
||||
):
|
||||
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
|
||||
|
||||
@ -475,6 +460,7 @@ class ManufacturerPart(
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options."""
|
||||
|
||||
verbose_name = _('Manufacturer Part')
|
||||
unique_together = ('part', 'manufacturer', 'MPN')
|
||||
|
||||
@staticmethod
|
||||
@ -563,26 +549,6 @@ class ManufacturerPart(
|
||||
return s
|
||||
|
||||
|
||||
class ManufacturerPartAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
"""Model for storing file attachments against a ManufacturerPart object."""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the ManufacturerPartAttachment model."""
|
||||
return reverse('api-manufacturer-part-attachment-list')
|
||||
|
||||
def getSubdir(self):
|
||||
"""Return the subdirectory where attachment files for the ManufacturerPart model are located."""
|
||||
return os.path.join('manufacturer_part_files', str(self.manufacturer_part.id))
|
||||
|
||||
manufacturer_part = models.ForeignKey(
|
||||
ManufacturerPart,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_('Manufacturer Part'),
|
||||
related_name='attachments',
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerPartParameter(InvenTree.models.InvenTreeModel):
|
||||
"""A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
|
||||
|
||||
@ -679,6 +645,8 @@ class SupplierPart(
|
||||
|
||||
unique_together = ('part', 'supplier', 'SKU')
|
||||
|
||||
verbose_name = _('Supplier Part')
|
||||
|
||||
# This model was moved from the 'Part' app
|
||||
db_table = 'part_supplierpart'
|
||||
|
||||
|
@ -11,7 +11,6 @@ from taggit.serializers import TagListSerializerField
|
||||
|
||||
import part.filters
|
||||
from InvenTree.serializers import (
|
||||
InvenTreeAttachmentSerializer,
|
||||
InvenTreeCurrencySerializer,
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeImageSerializerField,
|
||||
@ -26,10 +25,8 @@ from part.serializers import PartBriefSerializer
|
||||
from .models import (
|
||||
Address,
|
||||
Company,
|
||||
CompanyAttachment,
|
||||
Contact,
|
||||
ManufacturerPart,
|
||||
ManufacturerPartAttachment,
|
||||
ManufacturerPartParameter,
|
||||
SupplierPart,
|
||||
SupplierPriceBreak,
|
||||
@ -186,17 +183,6 @@ class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSeriali
|
||||
return self.instance
|
||||
|
||||
|
||||
class CompanyAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer for the CompanyAttachment class."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines serializer options."""
|
||||
|
||||
model = CompanyAttachment
|
||||
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields(['company'])
|
||||
|
||||
|
||||
class ContactSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer class for the Contact model."""
|
||||
|
||||
@ -260,17 +246,6 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer for the ManufacturerPartAttachment class."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = ManufacturerPartAttachment
|
||||
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields(['manufacturer_part'])
|
||||
|
||||
|
||||
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for the ManufacturerPartParameter model."""
|
||||
|
||||
|
@ -244,17 +244,7 @@
|
||||
{{ block.super }}
|
||||
|
||||
onPanelLoad("attachments", function() {
|
||||
loadAttachmentTable('{% url "api-company-attachment-list" %}', {
|
||||
filters: {
|
||||
company: {{ company.pk }},
|
||||
},
|
||||
fields: {
|
||||
company: {
|
||||
value: {{ company.pk }},
|
||||
hidden: true
|
||||
}
|
||||
}
|
||||
});
|
||||
loadAttachmentTable('company', {{ company.pk }});
|
||||
});
|
||||
|
||||
// Callback function when the 'contacts' panel is loaded
|
||||
|
@ -177,17 +177,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{{ block.super }}
|
||||
|
||||
onPanelLoad("attachments", function() {
|
||||
loadAttachmentTable('{% url "api-manufacturer-part-attachment-list" %}', {
|
||||
filters: {
|
||||
manufacturer_part: {{ part.pk }},
|
||||
},
|
||||
fields: {
|
||||
manufacturer_part: {
|
||||
value: {{ part.pk }},
|
||||
hidden: true
|
||||
}
|
||||
}
|
||||
});
|
||||
loadAttachmentTable('manufacturerpart', {{ part.pk }});
|
||||
});
|
||||
|
||||
$('#parameter-create').click(function() {
|
||||
|
@ -45,14 +45,7 @@ class TestManufacturerField(MigratorTestCase):
|
||||
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
|
||||
|
||||
# Create an initial part
|
||||
part = Part.objects.create(
|
||||
name='Screw',
|
||||
description='A single screw',
|
||||
level=0,
|
||||
tree_id=0,
|
||||
lft=0,
|
||||
rght=0,
|
||||
)
|
||||
part = Part.objects.create(name='Screw', description='A single screw')
|
||||
|
||||
# Create a company to act as the supplier
|
||||
supplier = Company.objects.create(
|
||||
|
@ -17,15 +17,11 @@ from rest_framework import status
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
import common.models as common_models
|
||||
from company.models import SupplierPart
|
||||
import common.models
|
||||
import common.settings
|
||||
import company.models
|
||||
from generic.states.api import StatusView
|
||||
from InvenTree.api import (
|
||||
APIDownloadMixin,
|
||||
AttachmentMixin,
|
||||
ListCreateDestroyAPIView,
|
||||
MetadataView,
|
||||
)
|
||||
from InvenTree.api import APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
|
||||
from InvenTree.helpers import DownloadFile, str2bool
|
||||
from InvenTree.helpers_model import construct_absolute_url, get_base_url
|
||||
@ -135,7 +131,7 @@ class OrderFilter(rest_filters.FilterSet):
|
||||
return queryset.exclude(status__in=self.Meta.model.get_status_class().OPEN)
|
||||
|
||||
project_code = rest_filters.ModelChoiceFilter(
|
||||
queryset=common_models.ProjectCode.objects.all(), field_name='project_code'
|
||||
queryset=common.models.ProjectCode.objects.all(), field_name='project_code'
|
||||
)
|
||||
|
||||
has_project_code = rest_filters.BooleanFilter(
|
||||
@ -306,11 +302,13 @@ class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI):
|
||||
|
||||
if supplier_part is not None:
|
||||
try:
|
||||
supplier_part = SupplierPart.objects.get(pk=supplier_part)
|
||||
supplier_part = company.models.SupplierPart.objects.get(
|
||||
pk=supplier_part
|
||||
)
|
||||
queryset = queryset.filter(
|
||||
id__in=[p.id for p in supplier_part.purchase_orders()]
|
||||
)
|
||||
except (ValueError, SupplierPart.DoesNotExist):
|
||||
except (ValueError, company.models.SupplierPart.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by 'date range'
|
||||
@ -449,7 +447,9 @@ class PurchaseOrderLineItemFilter(LineItemFilter):
|
||||
return queryset.exclude(order__status=PurchaseOrderStatus.COMPLETE.value)
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=SupplierPart.objects.all(), field_name='part', label=_('Supplier Part')
|
||||
queryset=company.models.SupplierPart.objects.all(),
|
||||
field_name='part',
|
||||
label=_('Supplier Part'),
|
||||
)
|
||||
|
||||
base_part = rest_filters.ModelChoiceFilter(
|
||||
@ -648,22 +648,6 @@ class PurchaseOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = serializers.PurchaseOrderExtraLineSerializer
|
||||
|
||||
|
||||
class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing, creating and bulk deleting a SalesOrderAttachment (file upload)."""
|
||||
|
||||
queryset = models.SalesOrderAttachment.objects.all()
|
||||
serializer_class = serializers.SalesOrderAttachmentSerializer
|
||||
|
||||
filterset_fields = ['order']
|
||||
|
||||
|
||||
class SalesOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for SalesOrderAttachment."""
|
||||
|
||||
queryset = models.SalesOrderAttachment.objects.all()
|
||||
serializer_class = serializers.SalesOrderAttachmentSerializer
|
||||
|
||||
|
||||
class SalesOrderFilter(OrderFilter):
|
||||
"""Custom API filters for the SalesOrderList endpoint."""
|
||||
|
||||
@ -1150,22 +1134,6 @@ class SalesOrderShipmentComplete(CreateAPI):
|
||||
return ctx
|
||||
|
||||
|
||||
class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing, creating and bulk deleting) a PurchaseOrderAttachment (file upload)."""
|
||||
|
||||
queryset = models.PurchaseOrderAttachment.objects.all()
|
||||
serializer_class = serializers.PurchaseOrderAttachmentSerializer
|
||||
|
||||
filterset_fields = ['order']
|
||||
|
||||
|
||||
class PurchaseOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for a PurchaseOrderAttachment."""
|
||||
|
||||
queryset = models.PurchaseOrderAttachment.objects.all()
|
||||
serializer_class = serializers.PurchaseOrderAttachmentSerializer
|
||||
|
||||
|
||||
class ReturnOrderFilter(OrderFilter):
|
||||
"""Custom API filters for the ReturnOrderList endpoint."""
|
||||
|
||||
@ -1416,22 +1384,6 @@ class ReturnOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = serializers.ReturnOrderExtraLineSerializer
|
||||
|
||||
|
||||
class ReturnOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing, creating and bulk deleting a ReturnOrderAttachment (file upload)."""
|
||||
|
||||
queryset = models.ReturnOrderAttachment.objects.all()
|
||||
serializer_class = serializers.ReturnOrderAttachmentSerializer
|
||||
|
||||
filterset_fields = ['order']
|
||||
|
||||
|
||||
class ReturnOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for the ReturnOrderAttachment model."""
|
||||
|
||||
queryset = models.ReturnOrderAttachment.objects.all()
|
||||
serializer_class = serializers.ReturnOrderAttachmentSerializer
|
||||
|
||||
|
||||
class OrderCalendarExport(ICalFeed):
|
||||
"""Calendar export for Purchase/Sales Orders.
|
||||
|
||||
@ -1514,7 +1466,9 @@ class OrderCalendarExport(ICalFeed):
|
||||
else:
|
||||
ordertype_title = _('Unknown')
|
||||
|
||||
return f'{common_models.InvenTreeSetting.get_setting("INVENTREE_COMPANY_NAME")} {ordertype_title}'
|
||||
company_name = common.settings.get_global_setting('INVENTREE_COMPANY_NAME')
|
||||
|
||||
return f'{company_name} {ordertype_title}'
|
||||
|
||||
def product_id(self, obj):
|
||||
"""Return calendar product id."""
|
||||
@ -1597,22 +1551,6 @@ order_api_urls = [
|
||||
path(
|
||||
'po/',
|
||||
include([
|
||||
# Purchase order attachments
|
||||
path(
|
||||
'attachment/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
PurchaseOrderAttachmentDetail.as_view(),
|
||||
name='api-po-attachment-detail',
|
||||
),
|
||||
path(
|
||||
'',
|
||||
PurchaseOrderAttachmentList.as_view(),
|
||||
name='api-po-attachment-list',
|
||||
),
|
||||
]),
|
||||
),
|
||||
# Individual purchase order detail URLs
|
||||
path(
|
||||
'<int:pk>/',
|
||||
@ -1704,21 +1642,6 @@ order_api_urls = [
|
||||
path(
|
||||
'so/',
|
||||
include([
|
||||
path(
|
||||
'attachment/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
SalesOrderAttachmentDetail.as_view(),
|
||||
name='api-so-attachment-detail',
|
||||
),
|
||||
path(
|
||||
'',
|
||||
SalesOrderAttachmentList.as_view(),
|
||||
name='api-so-attachment-list',
|
||||
),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'shipment/',
|
||||
include([
|
||||
@ -1854,21 +1777,6 @@ order_api_urls = [
|
||||
path(
|
||||
'ro/',
|
||||
include([
|
||||
path(
|
||||
'attachment/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
ReturnOrderAttachmentDetail.as_view(),
|
||||
name='api-return-order-attachment-detail',
|
||||
),
|
||||
path(
|
||||
'',
|
||||
ReturnOrderAttachmentList.as_view(),
|
||||
name='api-return-order-attachment-list',
|
||||
),
|
||||
]),
|
||||
),
|
||||
# Return Order detail endpoints
|
||||
path(
|
||||
'<int:pk>/',
|
||||
|
@ -16,7 +16,7 @@ class Migration(migrations.Migration):
|
||||
name='PurchaseOrderAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
|
||||
('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')),
|
||||
('comment', models.CharField(help_text='File comment', max_length=100)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.PurchaseOrder')),
|
||||
],
|
||||
|
@ -65,7 +65,7 @@ class Migration(migrations.Migration):
|
||||
name='SalesOrderAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
|
||||
('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')),
|
||||
('comment', models.CharField(help_text='File comment', max_length=100)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.SalesOrder')),
|
||||
],
|
||||
|
@ -67,7 +67,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderattachment',
|
||||
@ -187,7 +187,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='salesorderattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderattachment',
|
||||
|
@ -25,11 +25,11 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'),
|
||||
),
|
||||
]
|
||||
|
@ -51,7 +51,7 @@ class Migration(migrations.Migration):
|
||||
name='ReturnOrderAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment')),
|
||||
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
|
||||
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
|
||||
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
|
||||
|
@ -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',
|
||||
),
|
||||
]
|
@ -184,6 +184,7 @@ class TotalPriceMixin(models.Model):
|
||||
|
||||
class Order(
|
||||
StateTransitionMixin,
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
report.mixins.InvenTreeReportMixin,
|
||||
@ -1236,40 +1237,6 @@ def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs
|
||||
notify_responsible(instance, sender, exclude=instance.created_by)
|
||||
|
||||
|
||||
class PurchaseOrderAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
"""Model for storing file attachments against a PurchaseOrder object."""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the PurchaseOrderAttachment model."""
|
||||
return reverse('api-po-attachment-list')
|
||||
|
||||
def getSubdir(self):
|
||||
"""Return the directory path where PurchaseOrderAttachment files are located."""
|
||||
return os.path.join('po_files', str(self.order.id))
|
||||
|
||||
order = models.ForeignKey(
|
||||
PurchaseOrder, on_delete=models.CASCADE, related_name='attachments'
|
||||
)
|
||||
|
||||
|
||||
class SalesOrderAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
"""Model for storing file attachments against a SalesOrder object."""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the SalesOrderAttachment class."""
|
||||
return reverse('api-so-attachment-list')
|
||||
|
||||
def getSubdir(self):
|
||||
"""Return the directory path where SalesOrderAttachment files are located."""
|
||||
return os.path.join('so_files', str(self.order.id))
|
||||
|
||||
order = models.ForeignKey(
|
||||
SalesOrder, on_delete=models.CASCADE, related_name='attachments'
|
||||
)
|
||||
|
||||
|
||||
class OrderLineItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
"""Abstract model for an order line item.
|
||||
|
||||
@ -2315,20 +2282,3 @@ class ReturnOrderExtraLine(OrderExtraLine):
|
||||
verbose_name=_('Order'),
|
||||
help_text=_('Return Order'),
|
||||
)
|
||||
|
||||
|
||||
class ReturnOrderAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
"""Model for storing file attachments against a ReturnOrder object."""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the ReturnOrderAttachment class."""
|
||||
return reverse('api-return-order-attachment-list')
|
||||
|
||||
def getSubdir(self):
|
||||
"""Return the directory path where ReturnOrderAttachment files are located."""
|
||||
return os.path.join('return_files', str(self.order.id))
|
||||
|
||||
order = models.ForeignKey(
|
||||
ReturnOrder, on_delete=models.CASCADE, related_name='attachments'
|
||||
)
|
||||
|
@ -1,6 +1,5 @@
|
||||
"""JSON serializers for the Order API."""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
@ -42,7 +41,6 @@ from InvenTree.helpers import (
|
||||
str2bool,
|
||||
)
|
||||
from InvenTree.serializers import (
|
||||
InvenTreeAttachmentSerializer,
|
||||
InvenTreeCurrencySerializer,
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeModelSerializer,
|
||||
@ -757,17 +755,6 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
|
||||
class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializers for the PurchaseOrderAttachment model."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.PurchaseOrderAttachment
|
||||
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields(['order'])
|
||||
|
||||
|
||||
class SalesOrderSerializer(
|
||||
NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
|
||||
):
|
||||
@ -1525,17 +1512,6 @@ class SalesOrderExtraLineSerializer(
|
||||
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
|
||||
|
||||
|
||||
class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializers for the SalesOrderAttachment model."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.SalesOrderAttachment
|
||||
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields(['order'])
|
||||
|
||||
|
||||
class ReturnOrderSerializer(
|
||||
NotesFieldMixin, AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer
|
||||
):
|
||||
@ -1778,14 +1754,3 @@ class ReturnOrderExtraLineSerializer(
|
||||
model = order.models.ReturnOrderExtraLine
|
||||
|
||||
order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True)
|
||||
|
||||
|
||||
class ReturnOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer for the ReturnOrderAttachment model."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.ReturnOrderAttachment
|
||||
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields(['order'])
|
||||
|
@ -132,17 +132,7 @@
|
||||
});
|
||||
|
||||
onPanelLoad('order-attachments', function() {
|
||||
loadAttachmentTable('{% url "api-po-attachment-list" %}', {
|
||||
filters: {
|
||||
order: {{ order.pk }},
|
||||
},
|
||||
fields: {
|
||||
order: {
|
||||
value: {{ order.pk }},
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
});
|
||||
loadAttachmentTable('purchaseorder', {{ order.pk }});
|
||||
});
|
||||
|
||||
loadStockTable($("#stock-table"), {
|
||||
|
@ -189,17 +189,7 @@ onPanelLoad('order-notes', function() {
|
||||
// Callback function when the 'attachments' panel is loaded
|
||||
onPanelLoad('order-attachments', function() {
|
||||
|
||||
loadAttachmentTable('{% url "api-return-order-attachment-list" %}', {
|
||||
filters: {
|
||||
order: {{ order.pk }},
|
||||
},
|
||||
fields: {
|
||||
order: {
|
||||
value: {{ order.pk }},
|
||||
hidden: true,
|
||||
},
|
||||
}
|
||||
});
|
||||
loadAttachmentTable('returnorder', {{ order.pk }});
|
||||
});
|
||||
|
||||
enableSidebar('returnorder');
|
||||
|
@ -203,17 +203,7 @@
|
||||
|
||||
onPanelLoad('order-attachments', function() {
|
||||
|
||||
loadAttachmentTable('{% url "api-so-attachment-list" %}', {
|
||||
filters: {
|
||||
order: {{ order.pk }},
|
||||
},
|
||||
fields: {
|
||||
order: {
|
||||
value: {{ order.pk }},
|
||||
hidden: true,
|
||||
},
|
||||
}
|
||||
});
|
||||
loadAttachmentTable('salesorder', {{ order.pk }});
|
||||
});
|
||||
|
||||
loadBuildTable($("#builds-table"), {
|
||||
|
@ -258,9 +258,9 @@ class PurchaseOrderTest(OrderTest):
|
||||
|
||||
def test_po_attachments(self):
|
||||
"""Test the list endpoint for the PurchaseOrderAttachment model."""
|
||||
url = reverse('api-po-attachment-list')
|
||||
url = reverse('api-attachment-list')
|
||||
|
||||
response = self.get(url)
|
||||
response = self.get(url, {'model_type': 'purchaseorder'})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@ -1260,9 +1260,12 @@ class SalesOrderTest(OrderTest):
|
||||
|
||||
def test_so_attachments(self):
|
||||
"""Test the list endpoint for the SalesOrderAttachment model."""
|
||||
url = reverse('api-so-attachment-list')
|
||||
url = reverse('api-attachment-list')
|
||||
|
||||
self.get(url)
|
||||
# Filter by 'salesorder'
|
||||
self.get(
|
||||
url, data={'model_type': 'salesorder', 'model_id': 1}, expected_code=200
|
||||
)
|
||||
|
||||
def test_so_operations(self):
|
||||
"""Test that we can create / edit and delete a SalesOrder via the API."""
|
||||
|
@ -353,14 +353,6 @@ class PartRelatedAdmin(admin.ModelAdmin):
|
||||
autocomplete_fields = ('part_1', 'part_2')
|
||||
|
||||
|
||||
class PartAttachmentAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the PartAttachment model."""
|
||||
|
||||
list_display = ('part', 'attachment', 'comment')
|
||||
|
||||
autocomplete_fields = ('part',)
|
||||
|
||||
|
||||
class PartTestTemplateAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the PartTestTemplate model."""
|
||||
|
||||
@ -607,7 +599,6 @@ class PartInternalPriceBreakAdmin(admin.ModelAdmin):
|
||||
admin.site.register(models.Part, PartAdmin)
|
||||
admin.site.register(models.PartCategory, PartCategoryAdmin)
|
||||
admin.site.register(models.PartRelated, PartRelatedAdmin)
|
||||
admin.site.register(models.PartAttachment, PartAttachmentAdmin)
|
||||
admin.site.register(models.BomItem, BomItemAdmin)
|
||||
admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin)
|
||||
admin.site.register(models.PartParameter, ParameterAdmin)
|
||||
|
@ -19,12 +19,7 @@ import order.models
|
||||
import part.filters
|
||||
from build.models import Build, BuildItem
|
||||
from build.status_codes import BuildStatusGroups
|
||||
from InvenTree.api import (
|
||||
APIDownloadMixin,
|
||||
AttachmentMixin,
|
||||
ListCreateDestroyAPIView,
|
||||
MetadataView,
|
||||
)
|
||||
from InvenTree.api import APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
|
||||
from InvenTree.filters import (
|
||||
ORDER_FILTER,
|
||||
ORDER_FILTER_ALIAS,
|
||||
@ -56,7 +51,6 @@ from .models import (
|
||||
BomItem,
|
||||
BomItemSubstitute,
|
||||
Part,
|
||||
PartAttachment,
|
||||
PartCategory,
|
||||
PartCategoryParameterTemplate,
|
||||
PartInternalPriceBreak,
|
||||
@ -404,22 +398,6 @@ class PartInternalPriceList(ListCreateAPI):
|
||||
ordering = 'quantity'
|
||||
|
||||
|
||||
class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing, creating and bulk deleting a PartAttachment (file upload)."""
|
||||
|
||||
queryset = PartAttachment.objects.all()
|
||||
serializer_class = part_serializers.PartAttachmentSerializer
|
||||
|
||||
filterset_fields = ['part']
|
||||
|
||||
|
||||
class PartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for PartAttachment model."""
|
||||
|
||||
queryset = PartAttachment.objects.all()
|
||||
serializer_class = part_serializers.PartAttachmentSerializer
|
||||
|
||||
|
||||
class PartTestTemplateFilter(rest_filters.FilterSet):
|
||||
"""Custom filterset class for the PartTestTemplateList endpoint."""
|
||||
|
||||
@ -2059,18 +2037,6 @@ part_api_urls = [
|
||||
),
|
||||
]),
|
||||
),
|
||||
# Base URL for PartAttachment API endpoints
|
||||
path(
|
||||
'attachment/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
PartAttachmentDetail.as_view(),
|
||||
name='api-part-attachment-detail',
|
||||
),
|
||||
path('', PartAttachmentList.as_view(), name='api-part-attachment-list'),
|
||||
]),
|
||||
),
|
||||
# Base URL for part sale pricing
|
||||
path(
|
||||
'sale-price/',
|
||||
|
@ -14,6 +14,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='partattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment),
|
||||
field=models.FileField(help_text='Select file to attach', upload_to='attachments'),
|
||||
),
|
||||
]
|
||||
|
@ -98,7 +98,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='partattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='partattachment',
|
||||
|
@ -20,6 +20,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='partattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'),
|
||||
),
|
||||
]
|
||||
|
@ -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',
|
||||
),
|
||||
]
|
@ -341,6 +341,7 @@ class PartManager(TreeManager):
|
||||
|
||||
@cleanup.ignore
|
||||
class Part(
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
report.mixins.InvenTreeReportMixin,
|
||||
@ -2208,24 +2209,6 @@ class Part(
|
||||
required=True, enabled=enabled, include_parent=include_parent
|
||||
)
|
||||
|
||||
@property
|
||||
def attachment_count(self):
|
||||
"""Count the number of attachments for this part.
|
||||
|
||||
If the part is a variant of a template part,
|
||||
include the number of attachments for the template part.
|
||||
"""
|
||||
return self.part_attachments.count()
|
||||
|
||||
@property
|
||||
def part_attachments(self):
|
||||
"""Return *all* attachments for this part, potentially including attachments for template parts above this one."""
|
||||
ancestors = self.get_ancestors(include_self=True)
|
||||
|
||||
attachments = PartAttachment.objects.filter(part__in=ancestors)
|
||||
|
||||
return attachments
|
||||
|
||||
def sales_orders(self):
|
||||
"""Return a list of sales orders which reference this part."""
|
||||
orders = []
|
||||
@ -3299,26 +3282,6 @@ class PartStocktakeReport(models.Model):
|
||||
)
|
||||
|
||||
|
||||
class PartAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
"""Model for storing file attachments against a Part object."""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the list API endpoint URL associated with the PartAttachment model."""
|
||||
return reverse('api-part-attachment-list')
|
||||
|
||||
def getSubdir(self):
|
||||
"""Returns the media subdirectory where part attachments are stored."""
|
||||
return os.path.join('part_files', str(self.part.id))
|
||||
|
||||
part = models.ForeignKey(
|
||||
Part,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_('Part'),
|
||||
related_name='attachments',
|
||||
)
|
||||
|
||||
|
||||
class PartSellPriceBreak(common.models.PriceBreak):
|
||||
"""Represents a price break for selling this part."""
|
||||
|
||||
|
@ -22,7 +22,6 @@ from sql_util.utils import SubqueryCount, SubquerySum
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import common.currency
|
||||
import common.models
|
||||
import common.settings
|
||||
import company.models
|
||||
import InvenTree.helpers
|
||||
@ -41,7 +40,6 @@ from .models import (
|
||||
BomItem,
|
||||
BomItemSubstitute,
|
||||
Part,
|
||||
PartAttachment,
|
||||
PartCategory,
|
||||
PartCategoryParameterTemplate,
|
||||
PartInternalPriceBreak,
|
||||
@ -147,19 +145,6 @@ class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
return queryset.annotate(subcategories=part.filters.annotate_sub_categories())
|
||||
|
||||
|
||||
class PartAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
|
||||
"""Serializer for the PartAttachment class."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields."""
|
||||
|
||||
model = PartAttachment
|
||||
|
||||
fields = InvenTree.serializers.InvenTreeAttachmentSerializer.attachment_fields([
|
||||
'part'
|
||||
])
|
||||
|
||||
|
||||
class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""Serializer for the PartTestTemplate class."""
|
||||
|
||||
@ -1171,7 +1156,7 @@ class PartStocktakeReportGenerateSerializer(serializers.Serializer):
|
||||
def validate(self, data):
|
||||
"""Custom validation for this serializer."""
|
||||
# Stocktake functionality must be enabled
|
||||
if not common.models.InvenTreeSetting.get_setting('STOCKTAKE_ENABLE', False):
|
||||
if not common.settings.get_global_setting('STOCKTAKE_ENABLE'):
|
||||
raise serializers.ValidationError(
|
||||
_('Stocktake functionality is not enabled')
|
||||
)
|
||||
|
@ -803,17 +803,7 @@
|
||||
});
|
||||
|
||||
onPanelLoad("part-attachments", function() {
|
||||
loadAttachmentTable('{% url "api-part-attachment-list" %}', {
|
||||
filters: {
|
||||
part: {{ part.pk }},
|
||||
},
|
||||
fields: {
|
||||
part: {
|
||||
value: {{ part.pk }},
|
||||
hidden: true
|
||||
}
|
||||
}
|
||||
});
|
||||
loadAttachmentTable('part', {{ part.pk }});
|
||||
});
|
||||
|
||||
onPanelLoad('pricing', function() {
|
||||
|
@ -2513,22 +2513,28 @@ class PartAttachmentTest(InvenTreeAPITestCase):
|
||||
|
||||
def test_add_attachment(self):
|
||||
"""Test that we can create a new PartAttachment via the API."""
|
||||
url = reverse('api-part-attachment-list')
|
||||
url = reverse('api-attachment-list')
|
||||
|
||||
# Upload without permission
|
||||
response = self.post(url, {}, expected_code=403)
|
||||
response = self.post(
|
||||
url, {'model_id': 1, 'model_type': 'part'}, expected_code=403
|
||||
)
|
||||
|
||||
# Add required permission
|
||||
self.assignRole('part.add')
|
||||
self.assignRole('part.change')
|
||||
|
||||
# Upload without specifying part (will fail)
|
||||
response = self.post(url, {'comment': 'Hello world'}, expected_code=400)
|
||||
|
||||
self.assertIn('This field is required', str(response.data['part']))
|
||||
self.assertIn('This field is required', str(response.data['model_id']))
|
||||
self.assertIn('This field is required', str(response.data['model_type']))
|
||||
|
||||
# Upload without file OR link (will fail)
|
||||
response = self.post(
|
||||
url, {'part': 1, 'comment': 'Hello world'}, expected_code=400
|
||||
url,
|
||||
{'model_id': 1, 'model_type': 'part', 'comment': 'Hello world'},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('Missing file', str(response.data['attachment']))
|
||||
@ -2536,7 +2542,9 @@ class PartAttachmentTest(InvenTreeAPITestCase):
|
||||
|
||||
# Upload an invalid link (will fail)
|
||||
response = self.post(
|
||||
url, {'part': 1, 'link': 'not-a-link.py'}, expected_code=400
|
||||
url,
|
||||
{'model_id': 1, 'model_type': 'part', 'link': 'not-a-link.py'},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('Enter a valid URL', str(response.data['link']))
|
||||
@ -2545,12 +2553,20 @@ class PartAttachmentTest(InvenTreeAPITestCase):
|
||||
|
||||
# Upload a valid link (will pass)
|
||||
response = self.post(
|
||||
url, {'part': 1, 'link': link, 'comment': 'Hello world'}, expected_code=201
|
||||
url,
|
||||
{
|
||||
'model_id': 1,
|
||||
'model_type': 'part',
|
||||
'link': link,
|
||||
'comment': 'Hello world',
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
data = response.data
|
||||
|
||||
self.assertEqual(data['part'], 1)
|
||||
self.assertEqual(data['model_type'], 'part')
|
||||
self.assertEqual(data['model_id'], 1)
|
||||
self.assertEqual(data['link'], link)
|
||||
self.assertEqual(data['comment'], 'Hello world')
|
||||
|
||||
|
@ -5,6 +5,7 @@ import logging
|
||||
from django.conf import settings
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
from common.settings import get_global_setting
|
||||
from plugin.helpers import MixinImplementationError
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@ -58,16 +59,12 @@ class ScheduleMixin:
|
||||
@classmethod
|
||||
def _activate_mixin(cls, registry, plugins, *args, **kwargs):
|
||||
"""Activate schedules from plugins with the ScheduleMixin."""
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
logger.debug('Activating plugin tasks')
|
||||
|
||||
# List of tasks we have activated
|
||||
task_keys = []
|
||||
|
||||
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting(
|
||||
'ENABLE_PLUGINS_SCHEDULE'
|
||||
):
|
||||
if settings.PLUGIN_TESTING or get_global_setting('ENABLE_PLUGINS_SCHEDULE'):
|
||||
for _key, plugin in plugins:
|
||||
if plugin.mixin_enabled('schedule') and plugin.is_active():
|
||||
# Only active tasks for plugins which are enabled
|
||||
|
@ -786,7 +786,7 @@ class PluginsRegistry:
|
||||
|
||||
for k in self.plugin_settings_keys():
|
||||
try:
|
||||
val = get_global_setting(k, False, create=False)
|
||||
val = get_global_setting(k)
|
||||
msg = f'{k}-{val}'
|
||||
|
||||
data.update(msg.encode())
|
||||
|
@ -3,20 +3,18 @@
|
||||
from django.conf import settings
|
||||
from django.urls import include, re_path
|
||||
|
||||
from common.validators import get_global_setting
|
||||
|
||||
PLUGIN_BASE = 'plugin' # Constant for links
|
||||
|
||||
|
||||
def get_plugin_urls():
|
||||
"""Returns a urlpattern that can be integrated into the global urls."""
|
||||
from common.models import InvenTreeSetting
|
||||
from plugin.registry import registry
|
||||
|
||||
urls = []
|
||||
|
||||
if (
|
||||
InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL', False)
|
||||
or settings.PLUGIN_TESTING_SETUP
|
||||
):
|
||||
if get_global_setting('ENABLE_PLUGINS_URL', False) or settings.PLUGIN_TESTING_SETUP:
|
||||
for plugin in registry.plugins.values():
|
||||
if plugin.mixin_enabled('urls'):
|
||||
urls.append(plugin.urlpatterns)
|
||||
|
@ -70,7 +70,7 @@ def page_size(page_code):
|
||||
def report_page_size_default():
|
||||
"""Returns the default page size for PDF reports."""
|
||||
try:
|
||||
page_size = get_global_setting('REPORT_DEFAULT_PAGE_SIZE', 'A4')
|
||||
page_size = get_global_setting('REPORT_DEFAULT_PAGE_SIZE', 'A4', create=False)
|
||||
except Exception as exc:
|
||||
logger.exception('Error getting default page size: %s', str(exc))
|
||||
page_size = 'A4'
|
||||
|
@ -21,6 +21,7 @@ import InvenTree.helpers
|
||||
import InvenTree.models
|
||||
import report.helpers
|
||||
import report.validators
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.helpers_model import get_base_url
|
||||
from InvenTree.models import MetadataMixin
|
||||
from plugin.registry import registry
|
||||
@ -311,8 +312,8 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
||||
def get_report_size(self):
|
||||
"""Return the printable page size for this report."""
|
||||
try:
|
||||
page_size_default = common.models.InvenTreeSetting.get_setting(
|
||||
'REPORT_DEFAULT_PAGE_SIZE', 'A4'
|
||||
page_size_default = get_global_setting(
|
||||
'REPORT_DEFAULT_PAGE_SIZE', 'A4', create=False
|
||||
)
|
||||
except Exception:
|
||||
page_size_default = 'A4'
|
||||
|
@ -15,14 +15,14 @@ from PIL import Image
|
||||
|
||||
import report.models as report_models
|
||||
from build.models import Build
|
||||
from common.models import InvenTreeSetting
|
||||
from common.models import Attachment, InvenTreeSetting
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from order.models import ReturnOrder, SalesOrder
|
||||
from plugin.registry import registry
|
||||
from report.models import LabelTemplate, ReportTemplate
|
||||
from report.templatetags import barcode as barcode_tags
|
||||
from report.templatetags import report as report_tags
|
||||
from stock.models import StockItem, StockItemAttachment
|
||||
from stock.models import StockItem
|
||||
|
||||
|
||||
class ReportTagTest(TestCase):
|
||||
@ -502,7 +502,7 @@ class PrintTestMixins:
|
||||
},
|
||||
expected_code=201,
|
||||
max_query_time=15,
|
||||
max_query_count=1000, # TODO: Should look into this
|
||||
max_query_count=500 * len(qs),
|
||||
)
|
||||
|
||||
|
||||
@ -548,7 +548,9 @@ class TestReportTest(PrintTestMixins, ReportTest):
|
||||
self.assertEqual(response.data['output'].startswith('/media/report/'), True)
|
||||
|
||||
# By default, this should *not* have created an attachment against this stockitem
|
||||
self.assertFalse(StockItemAttachment.objects.filter(stock_item=item).exists())
|
||||
self.assertFalse(
|
||||
Attachment.objects.filter(model_id=item.pk, model_type='stockitem').exists()
|
||||
)
|
||||
|
||||
return
|
||||
# TODO @matmair - Re-add this test after https://github.com/inventree/InvenTree/pull/7074/files#r1600694356 is resolved
|
||||
@ -563,7 +565,9 @@ class TestReportTest(PrintTestMixins, ReportTest):
|
||||
self.assertEqual(response.data['output'].startswith('/media/report/'), True)
|
||||
|
||||
# Check that a report has been uploaded
|
||||
attachment = StockItemAttachment.objects.filter(stock_item=item).first()
|
||||
attachment = Attachment.objects.filter(
|
||||
model_id=item.pk, model_type='stockitem'
|
||||
).first()
|
||||
self.assertIsNotNone(attachment)
|
||||
|
||||
def test_mdl_build(self):
|
||||
|
@ -16,7 +16,6 @@ from part.models import Part
|
||||
|
||||
from .models import (
|
||||
StockItem,
|
||||
StockItemAttachment,
|
||||
StockItemTestResult,
|
||||
StockItemTracking,
|
||||
StockLocation,
|
||||
@ -301,15 +300,6 @@ class StockItemAdmin(ImportExportModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
@admin.register(StockItemAttachment)
|
||||
class StockAttachmentAdmin(admin.ModelAdmin):
|
||||
"""Admin class for StockAttachment."""
|
||||
|
||||
list_display = ('stock_item', 'attachment', 'comment')
|
||||
|
||||
autocomplete_fields = ['stock_item']
|
||||
|
||||
|
||||
@admin.register(StockItemTracking)
|
||||
class StockTrackingAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for StockTracking."""
|
||||
|
@ -28,12 +28,7 @@ from build.serializers import BuildSerializer
|
||||
from company.models import Company, SupplierPart
|
||||
from company.serializers import CompanySerializer
|
||||
from generic.states.api import StatusView
|
||||
from InvenTree.api import (
|
||||
APIDownloadMixin,
|
||||
AttachmentMixin,
|
||||
ListCreateDestroyAPIView,
|
||||
MetadataView,
|
||||
)
|
||||
from InvenTree.api import APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
|
||||
from InvenTree.filters import (
|
||||
ORDER_FILTER_ALIAS,
|
||||
SEARCH_ORDER_FILTER,
|
||||
@ -68,7 +63,6 @@ from stock.admin import LocationResource, StockItemResource
|
||||
from stock.generators import generate_batch_code, generate_serial_number
|
||||
from stock.models import (
|
||||
StockItem,
|
||||
StockItemAttachment,
|
||||
StockItemTestResult,
|
||||
StockItemTracking,
|
||||
StockLocation,
|
||||
@ -1221,22 +1215,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
|
||||
]
|
||||
|
||||
|
||||
class StockAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing, creating and bulk deleting a StockItemAttachment (file upload)."""
|
||||
|
||||
queryset = StockItemAttachment.objects.all()
|
||||
serializer_class = StockSerializers.StockItemAttachmentSerializer
|
||||
|
||||
filterset_fields = ['stock_item']
|
||||
|
||||
|
||||
class StockAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for StockItemAttachment."""
|
||||
|
||||
queryset = StockItemAttachment.objects.all()
|
||||
serializer_class = StockSerializers.StockItemAttachmentSerializer
|
||||
|
||||
|
||||
class StockItemTestResultMixin:
|
||||
"""Mixin class for the StockItemTestResult API endpoints."""
|
||||
|
||||
@ -1609,18 +1587,6 @@ stock_api_urls = [
|
||||
path('assign/', StockAssign.as_view(), name='api-stock-assign'),
|
||||
path('merge/', StockMerge.as_view(), name='api-stock-merge'),
|
||||
path('change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'),
|
||||
# StockItemAttachment API endpoints
|
||||
path(
|
||||
'attachment/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
StockAttachmentDetail.as_view(),
|
||||
name='api-stock-attachment-detail',
|
||||
),
|
||||
path('', StockAttachmentList.as_view(), name='api-stock-attachment-list'),
|
||||
]),
|
||||
),
|
||||
# StockItemTestResult API endpoints
|
||||
path(
|
||||
'test/',
|
||||
|
@ -16,7 +16,7 @@ class Migration(migrations.Migration):
|
||||
name='StockItemAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
|
||||
('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')),
|
||||
('comment', models.CharField(help_text='File comment', max_length=100)),
|
||||
('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='stock.StockItem')),
|
||||
],
|
||||
|
@ -32,7 +32,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='stockitemattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stockitemattachment',
|
||||
|
@ -20,6 +20,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='stockitemattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'),
|
||||
),
|
||||
]
|
||||
|
@ -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',
|
||||
),
|
||||
]
|
@ -316,6 +316,7 @@ def default_delete_on_deplete():
|
||||
|
||||
|
||||
class StockItem(
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
report.mixins.InvenTreeReportMixin,
|
||||
@ -2255,23 +2256,6 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
|
||||
instance.part.schedule_pricing_update(create=True)
|
||||
|
||||
|
||||
class StockItemAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
"""Model for storing file attachments against a StockItem object."""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return API url."""
|
||||
return reverse('api-stock-attachment-list')
|
||||
|
||||
def getSubdir(self):
|
||||
"""Override attachment location."""
|
||||
return os.path.join('stock_files', str(self.stock_item.id))
|
||||
|
||||
stock_item = models.ForeignKey(
|
||||
StockItem, on_delete=models.CASCADE, related_name='attachments'
|
||||
)
|
||||
|
||||
|
||||
class StockItemTracking(InvenTree.models.InvenTreeModel):
|
||||
"""Stock tracking entry - used for tracking history of a particular StockItem.
|
||||
|
||||
|
@ -31,7 +31,6 @@ from part.serializers import PartBriefSerializer, PartTestTemplateSerializer
|
||||
|
||||
from .models import (
|
||||
StockItem,
|
||||
StockItemAttachment,
|
||||
StockItemTestResult,
|
||||
StockItemTracking,
|
||||
StockLocation,
|
||||
@ -1101,21 +1100,6 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class StockItemAttachmentSerializer(
|
||||
InvenTree.serializers.InvenTreeAttachmentSerializer
|
||||
):
|
||||
"""Serializer for StockItemAttachment model."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = StockItemAttachment
|
||||
|
||||
fields = InvenTree.serializers.InvenTreeAttachmentSerializer.attachment_fields([
|
||||
'stock_item'
|
||||
])
|
||||
|
||||
|
||||
class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""Serializer for StockItemTracking model."""
|
||||
|
||||
|
@ -220,17 +220,7 @@
|
||||
});
|
||||
|
||||
onPanelLoad('attachments', function() {
|
||||
loadAttachmentTable('{% url "api-stock-attachment-list" %}', {
|
||||
filters: {
|
||||
stock_item: {{ item.pk }},
|
||||
},
|
||||
fields: {
|
||||
stock_item: {
|
||||
value: {{ item.pk }},
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
});
|
||||
loadAttachmentTable('stockitem', {{ item.pk }});
|
||||
});
|
||||
|
||||
{% settings_value "TEST_STATION_DATA" as test_station_fields %}
|
||||
|
@ -885,13 +885,6 @@ class StockItemListTest(StockAPITestCase):
|
||||
|
||||
def test_query_count(self):
|
||||
"""Test that the number of queries required to fetch stock items is reasonable."""
|
||||
|
||||
def get_stock(data, expected_status=200):
|
||||
"""Helper function to fetch stock items."""
|
||||
response = self.client.get(self.list_url, data=data)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
return response.data
|
||||
|
||||
# Create a bunch of StockItem objects
|
||||
prt = Part.objects.first()
|
||||
|
||||
@ -901,20 +894,18 @@ class StockItemListTest(StockAPITestCase):
|
||||
])
|
||||
|
||||
# List *all* stock items
|
||||
with self.assertNumQueriesLessThan(25):
|
||||
get_stock({})
|
||||
self.get(self.list_url, {}, max_query_count=35)
|
||||
|
||||
# List all stock items, with part detail
|
||||
with self.assertNumQueriesLessThan(20):
|
||||
get_stock({'part_detail': True})
|
||||
self.get(self.list_url, {'part_detail': True}, max_query_count=35)
|
||||
|
||||
# List all stock items, with supplier_part detail
|
||||
with self.assertNumQueriesLessThan(20):
|
||||
get_stock({'supplier_part_detail': True})
|
||||
self.get(self.list_url, {'supplier_part_detail': True}, max_query_count=35)
|
||||
|
||||
# List all stock items, with 'location' and 'tests' detail
|
||||
with self.assertNumQueriesLessThan(20):
|
||||
get_stock({'location_detail': True, 'tests': True})
|
||||
self.get(
|
||||
self.list_url, {'location_detail': True, 'tests': True}, max_query_count=35
|
||||
)
|
||||
|
||||
|
||||
class StockItemTest(StockAPITestCase):
|
||||
|
@ -214,34 +214,41 @@ function makeAttachmentActions(permissions, options) {
|
||||
/* Load a table of attachments against a specific model.
|
||||
* Note that this is a 'generic' table which is used for multiple attachment model classes
|
||||
*/
|
||||
function loadAttachmentTable(url, options) {
|
||||
function loadAttachmentTable(model_type, model_id, options={}) {
|
||||
|
||||
var table = options.table || '#attachment-table';
|
||||
const url = '{% url "api-attachment-list" %}';
|
||||
const table = options.table || '#attachment-table';
|
||||
|
||||
var permissions = {};
|
||||
let filters = {
|
||||
model_type: model_type,
|
||||
model_id: model_id,
|
||||
};
|
||||
|
||||
// First we determine which permissions the user has for this attachment table
|
||||
let permissions = {
|
||||
delete: false,
|
||||
add: false,
|
||||
change: false,
|
||||
};
|
||||
|
||||
// Request the permissions for the current user
|
||||
$.ajax({
|
||||
url: url,
|
||||
url: '{% url "api-user-roles" %}',
|
||||
async: false,
|
||||
type: 'OPTIONS',
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
accepts: {
|
||||
json: 'application/json',
|
||||
},
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
if (response.actions.DELETE) {
|
||||
if (response.is_superuser) {
|
||||
permissions.delete = true;
|
||||
permissions.add = true;
|
||||
permissions.change = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.actions.POST) {
|
||||
permissions.change = true;
|
||||
permissions.add = true;
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
showApiError(xhr, url);
|
||||
let model_permissions = response?.permissions[model_type] ?? {};
|
||||
|
||||
permissions.delete = "delete" in model_permissions;
|
||||
permissions.add = "add" in model_permissions;
|
||||
permissions.change = "change" in model_permissions;
|
||||
}
|
||||
});
|
||||
|
||||
@ -261,7 +268,19 @@ function loadAttachmentTable(url, options) {
|
||||
});
|
||||
|
||||
if (permissions.add) {
|
||||
addAttachmentButtonCallbacks(url, options.fields || {});
|
||||
addAttachmentButtonCallbacks(
|
||||
url,
|
||||
{
|
||||
model_type: {
|
||||
value: model_type,
|
||||
hidden: true,
|
||||
},
|
||||
model_id: {
|
||||
value: model_id,
|
||||
hidden: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Hide the buttons
|
||||
$('#new-attachment').hide();
|
||||
@ -276,7 +295,7 @@ function loadAttachmentTable(url, options) {
|
||||
},
|
||||
sortable: true,
|
||||
search: true,
|
||||
queryParams: options.filters || {},
|
||||
queryParams: filters,
|
||||
uniqueId: 'pk',
|
||||
sidePagination: 'server',
|
||||
onPostBody: function() {
|
||||
@ -386,7 +405,10 @@ function loadAttachmentTable(url, options) {
|
||||
'#attachment-dropzone',
|
||||
url,
|
||||
{
|
||||
data: options.filters,
|
||||
data: {
|
||||
model_type: model_type,
|
||||
model_id: model_id,
|
||||
},
|
||||
label: 'attachment',
|
||||
method: 'POST',
|
||||
success: function() {
|
||||
|
@ -4,7 +4,8 @@ import datetime
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user, login, logout
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
from django.db.models import Q
|
||||
from django.urls import include, path, re_path
|
||||
from django.views.generic.base import RedirectView
|
||||
|
||||
@ -137,10 +138,29 @@ class RoleDetails(APIView):
|
||||
else:
|
||||
roles[role] = None # pragma: no cover
|
||||
|
||||
# Extract individual permissions for the user
|
||||
if user.is_superuser:
|
||||
permissions = Permission.objects.all()
|
||||
else:
|
||||
permissions = Permission.objects.filter(
|
||||
Q(user=user) | Q(group__user=user)
|
||||
).distinct()
|
||||
|
||||
perms = {}
|
||||
|
||||
for permission in permissions:
|
||||
perm, model = permission.codename.split('_')
|
||||
|
||||
if model not in perms:
|
||||
perms[model] = []
|
||||
|
||||
perms[model].append(perm)
|
||||
|
||||
data = {
|
||||
'user': user.pk,
|
||||
'username': user.username,
|
||||
'roles': roles,
|
||||
'permissions': perms,
|
||||
'is_staff': user.is_staff,
|
||||
'is_superuser': user.is_superuser,
|
||||
}
|
||||
|
@ -9,6 +9,10 @@ from django.db import migrations
|
||||
def clear_sessions(apps, schema_editor):
|
||||
"""Clear all user sessions."""
|
||||
|
||||
# Ignore in test mode
|
||||
if settings.TESTING:
|
||||
return
|
||||
|
||||
try:
|
||||
engine = import_module(settings.SESSION_ENGINE)
|
||||
engine.SessionStore.clear_expired()
|
||||
|
@ -258,7 +258,6 @@ class RuleSet(models.Model):
|
||||
'part_partpricing',
|
||||
'part_bomitem',
|
||||
'part_bomitemsubstitute',
|
||||
'part_partattachment',
|
||||
'part_partsellpricebreak',
|
||||
'part_partinternalpricebreak',
|
||||
'part_parttesttemplate',
|
||||
@ -270,13 +269,11 @@ class RuleSet(models.Model):
|
||||
'company_supplierpart',
|
||||
'company_manufacturerpart',
|
||||
'company_manufacturerpartparameter',
|
||||
'company_manufacturerpartattachment',
|
||||
],
|
||||
'stocktake': ['part_partstocktake', 'part_partstocktakereport'],
|
||||
'stock_location': ['stock_stocklocation', 'stock_stocklocationtype'],
|
||||
'stock': [
|
||||
'stock_stockitem',
|
||||
'stock_stockitemattachment',
|
||||
'stock_stockitemtracking',
|
||||
'stock_stockitemtestresult',
|
||||
],
|
||||
@ -288,13 +285,11 @@ class RuleSet(models.Model):
|
||||
'build_build',
|
||||
'build_builditem',
|
||||
'build_buildline',
|
||||
'build_buildorderattachment',
|
||||
'stock_stockitem',
|
||||
'stock_stocklocation',
|
||||
],
|
||||
'purchase_order': [
|
||||
'company_company',
|
||||
'company_companyattachment',
|
||||
'company_contact',
|
||||
'company_address',
|
||||
'company_manufacturerpart',
|
||||
@ -302,31 +297,26 @@ class RuleSet(models.Model):
|
||||
'company_supplierpart',
|
||||
'company_supplierpricebreak',
|
||||
'order_purchaseorder',
|
||||
'order_purchaseorderattachment',
|
||||
'order_purchaseorderlineitem',
|
||||
'order_purchaseorderextraline',
|
||||
],
|
||||
'sales_order': [
|
||||
'company_company',
|
||||
'company_companyattachment',
|
||||
'company_contact',
|
||||
'company_address',
|
||||
'order_salesorder',
|
||||
'order_salesorderallocation',
|
||||
'order_salesorderattachment',
|
||||
'order_salesorderlineitem',
|
||||
'order_salesorderextraline',
|
||||
'order_salesordershipment',
|
||||
],
|
||||
'return_order': [
|
||||
'company_company',
|
||||
'company_companyattachment',
|
||||
'company_contact',
|
||||
'company_address',
|
||||
'order_returnorder',
|
||||
'order_returnorderlineitem',
|
||||
'order_returnorderextraline',
|
||||
'order_returnorderattachment',
|
||||
],
|
||||
}
|
||||
|
||||
@ -344,6 +334,7 @@ class RuleSet(models.Model):
|
||||
'admin_logentry',
|
||||
'contenttypes_contenttype',
|
||||
# Models which currently do not require permissions
|
||||
'common_attachment',
|
||||
'common_colortheme',
|
||||
'common_customunit',
|
||||
'common_inventreesetting',
|
||||
|
@ -117,7 +117,23 @@ export function formatPriceRange(
|
||||
)}`;
|
||||
}
|
||||
|
||||
interface RenderDateOptionsInterface {
|
||||
/*
|
||||
* Format a file size (in bytes) into a human-readable format
|
||||
*/
|
||||
export function formatFileSize(size: number) {
|
||||
const suffixes: string[] = ['B', 'KB', 'MB', 'GB'];
|
||||
|
||||
let idx = 0;
|
||||
|
||||
while (size > 1024 && idx < suffixes.length) {
|
||||
size /= 1024;
|
||||
idx++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${suffixes[idx]}`;
|
||||
}
|
||||
|
||||
interface FormatDateOptionsInterface {
|
||||
showTime?: boolean;
|
||||
showSeconds?: boolean;
|
||||
}
|
||||
@ -128,9 +144,9 @@ interface RenderDateOptionsInterface {
|
||||
* The provided "date" variable is a string, nominally ISO format e.g. 2022-02-22
|
||||
* The user-configured setting DATE_DISPLAY_FORMAT determines how the date should be displayed.
|
||||
*/
|
||||
export function renderDate(
|
||||
export function formatDate(
|
||||
date: string,
|
||||
options: RenderDateOptionsInterface = {}
|
||||
options: FormatDateOptionsInterface = {}
|
||||
) {
|
||||
if (!date) {
|
||||
return '-';
|
||||
|
@ -57,7 +57,6 @@ export enum ApiEndpoints {
|
||||
build_output_complete = 'build/:id/complete/',
|
||||
build_output_scrap = 'build/:id/scrap-outputs/',
|
||||
build_output_delete = 'build/:id/delete-outputs/',
|
||||
build_order_attachment_list = 'build/attachment/',
|
||||
build_line_list = 'build/line/',
|
||||
|
||||
bom_list = 'bom/',
|
||||
@ -76,18 +75,15 @@ export enum ApiEndpoints {
|
||||
category_tree = 'part/category/tree/',
|
||||
category_parameter_list = 'part/category/parameters/',
|
||||
related_part_list = 'part/related/',
|
||||
part_attachment_list = 'part/attachment/',
|
||||
part_test_template_list = 'part/test-template/',
|
||||
|
||||
// Company API endpoints
|
||||
company_list = 'company/',
|
||||
contact_list = 'company/contact/',
|
||||
address_list = 'company/address/',
|
||||
company_attachment_list = 'company/attachment/',
|
||||
supplier_part_list = 'company/part/',
|
||||
supplier_part_pricing_list = 'company/price-break/',
|
||||
manufacturer_part_list = 'company/part/manufacturer/',
|
||||
manufacturer_part_attachment_list = 'company/part/manufacturer/attachment/',
|
||||
manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/',
|
||||
|
||||
// Stock API endpoints
|
||||
@ -96,7 +92,6 @@ export enum ApiEndpoints {
|
||||
stock_location_list = 'stock/location/',
|
||||
stock_location_type_list = 'stock/location-type/',
|
||||
stock_location_tree = 'stock/location/tree/',
|
||||
stock_attachment_list = 'stock/attachment/',
|
||||
stock_test_result_list = 'stock/test/',
|
||||
stock_transfer = 'stock/transfer/',
|
||||
stock_remove = 'stock/remove/',
|
||||
@ -115,16 +110,13 @@ export enum ApiEndpoints {
|
||||
// Order API endpoints
|
||||
purchase_order_list = 'order/po/',
|
||||
purchase_order_line_list = 'order/po-line/',
|
||||
purchase_order_attachment_list = 'order/po/attachment/',
|
||||
purchase_order_receive = 'order/po/:id/receive/',
|
||||
|
||||
sales_order_list = 'order/so/',
|
||||
sales_order_line_list = 'order/so-line/',
|
||||
sales_order_attachment_list = 'order/so/attachment/',
|
||||
sales_order_shipment_list = 'order/so/shipment/',
|
||||
|
||||
return_order_list = 'order/ro/',
|
||||
return_order_attachment_list = 'order/ro/attachment/',
|
||||
|
||||
// Template API endpoints
|
||||
label_list = 'label/template/',
|
||||
@ -155,6 +147,7 @@ export enum ApiEndpoints {
|
||||
machine_setting_detail = 'machine/:machine/settings/:config_type/',
|
||||
|
||||
// Miscellaneous API endpoints
|
||||
attachment_list = 'attachment/',
|
||||
error_report_list = 'error-report/',
|
||||
project_code_list = 'project-code/',
|
||||
custom_unit_list = 'units/',
|
||||
|
@ -56,20 +56,6 @@ function ApiFormsPlayground() {
|
||||
fields: editPartFields
|
||||
});
|
||||
|
||||
const newAttachment = useCreateApiFormModal({
|
||||
url: ApiEndpoints.part_attachment_list,
|
||||
title: 'Create Attachment',
|
||||
fields: {
|
||||
part: {},
|
||||
attachment: {},
|
||||
comment: {}
|
||||
},
|
||||
initialData: {
|
||||
part: 1
|
||||
},
|
||||
successMessage: 'Attachment uploaded'
|
||||
});
|
||||
|
||||
const [active, setActive] = useState(true);
|
||||
const [name, setName] = useState('Hello');
|
||||
|
||||
@ -130,9 +116,6 @@ function ApiFormsPlayground() {
|
||||
<Button onClick={() => editCategory.open()}>Edit Category</Button>
|
||||
{editCategory.modal}
|
||||
|
||||
<Button onClick={() => newAttachment.open()}>Create Attachment</Button>
|
||||
{newAttachment.modal}
|
||||
|
||||
<Button onClick={() => openCreatePart()}>Create Part new Modal</Button>
|
||||
{createPartModal}
|
||||
</Group>
|
||||
|
@ -295,11 +295,7 @@ export default function BuildDetail() {
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
endpoint={ApiEndpoints.build_order_attachment_list}
|
||||
model="build"
|
||||
pk={Number(id)}
|
||||
/>
|
||||
<AttachmentTable model_type={ModelType.build} model_id={Number(id)} />
|
||||
)
|
||||
},
|
||||
{
|
||||
|
@ -256,9 +256,8 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
endpoint={ApiEndpoints.company_attachment_list}
|
||||
model="company"
|
||||
pk={company.pk ?? -1}
|
||||
model_type={ModelType.company}
|
||||
model_id={company.pk}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
@ -173,9 +173,8 @@ export default function ManufacturerPartDetail() {
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
endpoint={ApiEndpoints.manufacturer_part_attachment_list}
|
||||
model="manufacturer_part"
|
||||
pk={manufacturerPart?.pk}
|
||||
model_type={ModelType.manufacturerpart}
|
||||
model_id={manufacturerPart?.pk}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -618,11 +618,7 @@ export default function PartDetail() {
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
endpoint={ApiEndpoints.part_attachment_list}
|
||||
model="part"
|
||||
pk={part.pk ?? -1}
|
||||
/>
|
||||
<AttachmentTable model_type={ModelType.part} model_id={part?.pk} />
|
||||
)
|
||||
},
|
||||
{
|
||||
|
@ -22,7 +22,7 @@ import { DataTable } from 'mantine-datatable';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
|
||||
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
|
||||
import { formatCurrency, renderDate } from '../../../defaults/formatters';
|
||||
import { formatCurrency, formatDate } from '../../../defaults/formatters';
|
||||
import { panelOptions } from '../PartPricingPanel';
|
||||
|
||||
interface PricingOverviewEntry {
|
||||
@ -173,7 +173,7 @@ export default function PricingOverviewPanel({
|
||||
{pricing?.updated && (
|
||||
<Paper p="xs">
|
||||
<Alert color="blue" title={t`Last Updated`}>
|
||||
<Text>{renderDate(pricing.updated)}</Text>
|
||||
<Text>{formatDate(pricing.updated)}</Text>
|
||||
</Alert>
|
||||
</Paper>
|
||||
)}
|
||||
|
@ -3,7 +3,7 @@ import { BarChart } from '@mantine/charts';
|
||||
import { Group, SimpleGrid, Text } from '@mantine/core';
|
||||
import { ReactNode, useCallback, useMemo } from 'react';
|
||||
|
||||
import { formatCurrency, renderDate } from '../../../defaults/formatters';
|
||||
import { formatCurrency, formatDate } from '../../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
|
||||
import { useTable } from '../../../hooks/UseTable';
|
||||
import { apiUrl } from '../../../states/ApiState';
|
||||
@ -40,7 +40,7 @@ export default function PurchaseHistoryPanel({
|
||||
title: t`Date`,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => renderDate(record.order_detail.complete_date)
|
||||
render: (record: any) => formatDate(record.order_detail.complete_date)
|
||||
},
|
||||
{
|
||||
accessor: 'purchase_price',
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user