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